Skip to content
Merged
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
Binary file added src-tauri/icons/tray/unread.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
52 changes: 50 additions & 2 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Image<'_>> {
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.
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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);
});
}
3 changes: 2 additions & 1 deletion src/core/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -168,6 +168,7 @@ export class Poller {
private async fire(notices: Notice[]): Promise<void> {
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);
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,6 +104,7 @@ async function boot(): Promise<void> {
};
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);
Expand Down
5 changes: 5 additions & 0 deletions src/platform/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading