Unread counts, a window margin, blurred dialogs, softer group names
Each rise in an app's unread count while you are elsewhere adds to a tally beside its name, and a dot on its icon in the rail. Looking at the app is the only thing that clears it. The window has no frame of its own, so the shell now keeps a 6px margin in a normal window - the only part of it that is not a web page, and so the only place left to grab. Full screen gives the room back. Dialogs get the app blurred behind them. An app's webview paints above the shell, so it has to be moved aside before a dialog can be seen at all, and once moved there is nothing left to blur - a still taken on the way out is the only way to keep the background there. It runs on a blocking worker: its completion handler is on the main thread, and waiting there deadlocks until the timeout and returns nothing. Settings is much larger, the cog moved to the foot of the nav, group names read as names rather than shouted headings, and "Refresh icons" bumps a version every favicon URL carries, for the ones that cache wrong.
This commit is contained in:
@@ -242,6 +242,33 @@ screen raises a banner; a fall is you reading things, and is not news.
|
|||||||
|
|
||||||
The site's own notifications still work when they fire — both paths feed the same channel.
|
The site's own notifications still work when they fire — both paths feed the same channel.
|
||||||
|
|
||||||
|
### Counting what was missed
|
||||||
|
|
||||||
|
Each rise in an app's unread count while you are elsewhere adds to a per-app tally shown
|
||||||
|
against its name in the nav — and on the rail, as a dot on the icon. Looking at an app is
|
||||||
|
the only thing that clears it; nothing else does, because nothing else means you have seen
|
||||||
|
it. Counts live in memory rather than `apps.json`: a restart reloads every app anyway, and
|
||||||
|
a number that survived would be a claim the app can no longer support.
|
||||||
|
|
||||||
|
## The window
|
||||||
|
|
||||||
|
There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin
|
||||||
|
around itself. That margin is the only part of the window that is not a web page, and
|
||||||
|
therefore the only place left to grab it by. Full screen has no use for it and gets the
|
||||||
|
room back.
|
||||||
|
|
||||||
|
## Dialogs
|
||||||
|
|
||||||
|
An app's webview is a native view that paints above the shell, so a dialog cannot simply
|
||||||
|
sit on a higher z-index — the app has to be moved out of the way first. Once it is moved
|
||||||
|
there is nothing left behind the dialog to look at, so a still is taken on the way out with
|
||||||
|
`takeSnapshotWithConfiguration` and shown blurred behind it.
|
||||||
|
|
||||||
|
The snapshot is deliberately 640px wide: it is going behind a 16px blur. It is also taken
|
||||||
|
on a blocking worker rather than the calling thread — its completion handler runs on the
|
||||||
|
main thread, and waiting for it *there* deadlocks until the timeout and returns nothing
|
||||||
|
every time.
|
||||||
|
|
||||||
## Zoom
|
## Zoom
|
||||||
|
|
||||||
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
|
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
|
||||||
|
|||||||
Generated
+25
@@ -2205,9 +2205,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-core-video",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2227,6 +2235,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -2287,6 +2296,19 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-video"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -4868,8 +4890,11 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
|||||||
name = "work-app"
|
name = "work-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block2",
|
||||||
"mac-notification-sys",
|
"mac-notification-sys",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences"] }
|
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "block2"] }
|
||||||
|
objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] }
|
||||||
|
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError"] }
|
||||||
|
block2 = "0.6"
|
||||||
# Notifications are raised here rather than through the plugin, which offers no
|
# Notifications are raised here rather than through the plugin, which offers no
|
||||||
# way to learn that one was clicked.
|
# way to learn that one was clicked.
|
||||||
mac-notification-sys = "0.6"
|
mac-notification-sys = "0.6"
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub struct AppState {
|
|||||||
pub diag: Mutex<std::collections::HashMap<String, String>>,
|
pub diag: Mutex<std::collections::HashMap<String, String>>,
|
||||||
/// The last notification a page raised, and what macOS did with it.
|
/// The last notification a page raised, and what macOS did with it.
|
||||||
pub last_notification: Mutex<String>,
|
pub last_notification: Mutex<String>,
|
||||||
|
/// How much has arrived in each app since you last looked at it.
|
||||||
|
pub unread: Mutex<std::collections::HashMap<String, u32>>,
|
||||||
|
/// Size of the last dialog backdrop still, or why there wasn't one.
|
||||||
|
pub last_snapshot: Mutex<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -67,6 +71,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
|||||||
booted: Mutex::new(false),
|
booted: Mutex::new(false),
|
||||||
diag: Mutex::new(std::collections::HashMap::new()),
|
diag: Mutex::new(std::collections::HashMap::new()),
|
||||||
last_notification: Mutex::new(String::new()),
|
last_notification: Mutex::new(String::new()),
|
||||||
|
unread: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
last_snapshot: Mutex::new(String::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +161,67 @@ pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
|
|||||||
let stage = *state.stage.lock().unwrap();
|
let stage = *state.stage.lock().unwrap();
|
||||||
*state.active.lock().unwrap() = Some(app_id.clone());
|
*state.active.lock().unwrap() = Some(app_id.clone());
|
||||||
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
webviews::show_only(&app, Some(&app_id), &cfg, stage);
|
||||||
|
|
||||||
|
// Looking at an app is what clears its count. Nothing else does.
|
||||||
|
state.unread.lock().unwrap().remove(&app_id);
|
||||||
|
let _ = app.emit("unread-changed", unread_list(&state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every app's count, in the shape the nav wants.
|
||||||
|
pub fn unread_list(state: &AppState) -> Vec<(String, u32)> {
|
||||||
|
state
|
||||||
|
.unread
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), *v))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> {
|
||||||
|
unread_list(&state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bumps the version every favicon URL carries, so a wrong one is refetched.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn refresh_favicons(state: State<'_, AppState>) -> Result<Config, String> {
|
||||||
|
{
|
||||||
|
let mut cfg = state.config.lock().unwrap();
|
||||||
|
cfg.settings.favicon_version = cfg.settings.favicon_version.wrapping_add(1);
|
||||||
|
}
|
||||||
|
state.persist()?;
|
||||||
|
Ok(state.cfg())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hides every app, so a dialog is not painted over by a native view.
|
/// Hides every app, so a dialog is not painted over by a native view.
|
||||||
|
/// A still of the app on screen, taken before a dialog covers it.
|
||||||
|
///
|
||||||
|
/// Async, and the wait happens on a blocking worker: the snapshot's completion
|
||||||
|
/// handler runs on the main thread, so waiting for it *on* the main thread
|
||||||
|
/// deadlocks until the timeout and returns nothing every time.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stage_snapshot(app: AppHandle) -> Option<String> {
|
||||||
|
let id = {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let active = state.active.lock().unwrap();
|
||||||
|
active.clone()?
|
||||||
|
};
|
||||||
|
let handle = app.clone();
|
||||||
|
let shot = tauri::async_runtime::spawn_blocking(move || webviews::snapshot(&app, &id))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
// Recorded so the diagnostic can say whether the still was taken at all,
|
||||||
|
// rather than leaving a flat backdrop to be interpreted by eye.
|
||||||
|
*handle.state::<AppState>().last_snapshot.lock().unwrap() = match &shot {
|
||||||
|
Some(d) => format!("{} bytes", d.len()),
|
||||||
|
None => "none".into(),
|
||||||
|
};
|
||||||
|
shot
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
|
||||||
let stage = *state.stage.lock().unwrap();
|
let stage = *state.stage.lock().unwrap();
|
||||||
@@ -443,7 +507,9 @@ pub fn notification_status(app: AppHandle) -> String {
|
|||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let last = app_state.last_notification.lock().unwrap().clone();
|
let last = app_state.last_notification.lock().unwrap().clone();
|
||||||
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
||||||
format!("permission: {state} · direct: {raised} · from page: {from_page}")
|
let shot = app_state.last_snapshot.lock().unwrap().clone();
|
||||||
|
let shot = if shot.is_empty() { "not taken".into() } else { shot };
|
||||||
|
format!("permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks macOS for notification permission, and claims this app's identity.
|
/// Asks macOS for notification permission, and claims this app's identity.
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ pub struct Settings {
|
|||||||
pub nav_collapsed: bool,
|
pub nav_collapsed: bool,
|
||||||
#[serde(default = "default_theme")]
|
#[serde(default = "default_theme")]
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
|
/// Bumped to defeat a cached favicon that came back wrong.
|
||||||
|
#[serde(default)]
|
||||||
|
pub favicon_version: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_theme() -> String {
|
fn default_theme() -> String {
|
||||||
@@ -88,6 +91,7 @@ impl Default for Settings {
|
|||||||
Self {
|
Self {
|
||||||
nav_collapsed: false,
|
nav_collapsed: false,
|
||||||
theme: default_theme(),
|
theme: default_theme(),
|
||||||
|
favicon_version: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ pub fn run() {
|
|||||||
commands::set_stage,
|
commands::set_stage,
|
||||||
commands::set_active,
|
commands::set_active,
|
||||||
commands::hide_stage,
|
commands::hide_stage,
|
||||||
|
commands::stage_snapshot,
|
||||||
commands::show_stage,
|
commands::show_stage,
|
||||||
commands::navigate_app,
|
commands::navigate_app,
|
||||||
commands::history_go,
|
commands::history_go,
|
||||||
@@ -103,6 +104,8 @@ pub fn run() {
|
|||||||
commands::delete_group,
|
commands::delete_group,
|
||||||
commands::set_nav_collapsed,
|
commands::set_nav_collapsed,
|
||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
|
commands::unread_counts,
|
||||||
|
commands::refresh_favicons,
|
||||||
commands::focus_window,
|
commands::focus_window,
|
||||||
commands::notification_click,
|
commands::notification_click,
|
||||||
commands::set_zoom,
|
commands::set_zoom,
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ fn handle_sentinel(
|
|||||||
.map(|a| a.name.clone())
|
.map(|a| a.name.clone())
|
||||||
.unwrap_or_else(|| "Work".into());
|
.unwrap_or_else(|| "Work".into());
|
||||||
let n: u32 = delta.parse().unwrap_or(1);
|
let n: u32 = delta.parse().unwrap_or(1);
|
||||||
|
|
||||||
|
{
|
||||||
|
let state = handle.state::<crate::commands::AppState>();
|
||||||
|
let mut counts = state.unread.lock().unwrap();
|
||||||
|
*counts.entry(from.clone()).or_insert(0) += n;
|
||||||
|
}
|
||||||
|
let state = handle.state::<crate::commands::AppState>();
|
||||||
|
let _ = handle.emit("unread-changed", crate::commands::unread_list(&state));
|
||||||
|
|
||||||
let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
|
let title = if n == 1 { "1 new".to_string() } else { format!("{n} new") };
|
||||||
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new());
|
||||||
}
|
}
|
||||||
@@ -340,6 +349,76 @@ fn keep_running_while_covered(handle: &AppHandle, app_id: &str) {
|
|||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn keep_running_while_covered(_: &AppHandle, _: &str) {}
|
fn keep_running_while_covered(_: &AppHandle, _: &str) {}
|
||||||
|
|
||||||
|
/// A still of one app, as a PNG data URL, for the shell to blur behind a dialog.
|
||||||
|
///
|
||||||
|
/// A dialog is drawn by the shell webview, and every app's webview is a native
|
||||||
|
/// view that paints above it — so an app has to be moved out of the way before
|
||||||
|
/// a dialog can be seen at all, and once it is moved there is nothing left to
|
||||||
|
/// blur. A still taken on the way out is the only way to keep the background
|
||||||
|
/// there. Deliberately small: it is going behind a blur.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn snapshot(handle: &AppHandle, app_id: &str) -> Option<String> {
|
||||||
|
use block2::RcBlock;
|
||||||
|
use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep, NSImage};
|
||||||
|
use objc2_foundation::{
|
||||||
|
MainThreadMarker, NSDataBase64EncodingOptions, NSDictionary, NSError, NSNumber,
|
||||||
|
};
|
||||||
|
use objc2_web_kit::{WKSnapshotConfiguration, WKWebView};
|
||||||
|
|
||||||
|
let wv = handle.get_webview(&label_for(app_id))?;
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Option<String>>();
|
||||||
|
|
||||||
|
let sent = wv.with_webview(move |platform| unsafe {
|
||||||
|
let ptr = platform.inner() as *const WKWebView;
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else {
|
||||||
|
let _ = tx.send(None);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if ptr.is_null() {
|
||||||
|
let _ = tx.send(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let webview: &WKWebView = &*ptr;
|
||||||
|
|
||||||
|
let config = WKSnapshotConfiguration::new(mtm);
|
||||||
|
config.setSnapshotWidth(Some(&NSNumber::new_f64(640.0)));
|
||||||
|
|
||||||
|
let handler = RcBlock::new(move |image: *mut NSImage, _error: *mut NSError| {
|
||||||
|
let encoded = (|| {
|
||||||
|
let image = image.as_ref()?;
|
||||||
|
let tiff = image.TIFFRepresentation()?;
|
||||||
|
let rep = NSBitmapImageRep::imageRepWithData(&tiff)?;
|
||||||
|
let png = rep.representationUsingType_properties(
|
||||||
|
NSBitmapImageFileType::PNG,
|
||||||
|
&NSDictionary::new(),
|
||||||
|
)?;
|
||||||
|
Some(
|
||||||
|
png.base64EncodedStringWithOptions(NSDataBase64EncodingOptions::empty())
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})();
|
||||||
|
let _ = tx.send(encoded);
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.takeSnapshotWithConfiguration_completionHandler(Some(&config), &handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
if sent.is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A snapshot that takes longer than this is not worth making someone wait
|
||||||
|
// for; the dialog opens over a plain backdrop instead.
|
||||||
|
rx.recv_timeout(std::time::Duration::from_millis(2500))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|b64| format!("data:image/png;base64,{b64}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub fn snapshot(_: &AppHandle, _: &str) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Turns on WKWebView's two-finger back and forward swipes.
|
/// Turns on WKWebView's two-finger back and forward swipes.
|
||||||
///
|
///
|
||||||
/// wry supports it but Tauri does not expose it, so it is set on the native
|
/// wry supports it but Tauri does not expose it, so it is set on the native
|
||||||
|
|||||||
+70
-5
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
import Nav from "./components/Nav";
|
import Nav from "./components/Nav";
|
||||||
@@ -14,6 +15,9 @@ export default function App() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
const [focusHidden, setFocusHidden] = useState<string | null>(null);
|
||||||
const [theme, setTheme] = useAppearance("system");
|
const [theme, setTheme] = useAppearance("system");
|
||||||
|
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||||
|
|
||||||
const stageRef = useRef<HTMLDivElement>(null);
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
const booted = useRef(false);
|
const booted = useRef(false);
|
||||||
@@ -24,6 +28,20 @@ export default function App() {
|
|||||||
|
|
||||||
const collapsed = config?.settings.navCollapsed ?? false;
|
const collapsed = config?.settings.navCollapsed ?? false;
|
||||||
|
|
||||||
|
/* The window has no frame of its own, so in a normal window the shell keeps
|
||||||
|
a margin around itself: somewhere to grab that is not a web page, and the
|
||||||
|
only way to move or place the window by hand. Full screen has no use for
|
||||||
|
it and gives the room back. */
|
||||||
|
useEffect(() => {
|
||||||
|
const w = getCurrentWindow();
|
||||||
|
const check = () => void w.isFullscreen().then(setFullscreen);
|
||||||
|
check();
|
||||||
|
const un = w.onResized(check);
|
||||||
|
return () => {
|
||||||
|
void un.then((f) => f());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getConfig().then((c) => {
|
api.getConfig().then((c) => {
|
||||||
setConfig(c);
|
setConfig(c);
|
||||||
@@ -103,17 +121,37 @@ export default function App() {
|
|||||||
listen<[string, number]>("zoom-changed", () => {
|
listen<[string, number]>("zoom-changed", () => {
|
||||||
void api.getConfig().then(setConfig);
|
void api.getConfig().then(setConfig);
|
||||||
}),
|
}),
|
||||||
|
listen<[string, number][]>("unread-changed", (e) => {
|
||||||
|
setUnread(Object.fromEntries(e.payload));
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.forEach((p) => p.then((f) => f()));
|
unlisten.forEach((p) => p.then((f) => f()));
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// A native view paints over anything the shell draws, so a dialog needs the
|
/* A native view paints over anything the shell draws, so a dialog needs the
|
||||||
// stage out of the way rather than merely on a higher z-index.
|
app moved out of the way rather than merely a higher z-index — and once it
|
||||||
|
is moved there is nothing left behind the dialog to look at. A still taken
|
||||||
|
on the way out, blurred, puts the background back. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
void (settingsOpen ? api.hideStage() : api.showStage());
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (settingsOpen) {
|
||||||
|
void (async () => {
|
||||||
|
const shot = await api.stageSnapshot().catch(() => null);
|
||||||
|
if (cancelled) return;
|
||||||
|
setBackdrop(shot);
|
||||||
|
await api.hideStage();
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
setBackdrop(null);
|
||||||
|
void api.showStage();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [settingsOpen, config]);
|
}, [settingsOpen, config]);
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
@@ -137,8 +175,20 @@ export default function App() {
|
|||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
|
const frame = fullscreen ? 0 : 6;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex h-screen bg-slate-200 dark:bg-black"
|
||||||
|
style={{ padding: frame }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex min-w-0 flex-1 overflow-hidden " +
|
||||||
|
(fullscreen ? "" : "rounded-xl border border-slate-300 dark:border-slate-800")
|
||||||
|
}
|
||||||
|
>
|
||||||
<Nav
|
<Nav
|
||||||
config={config}
|
config={config}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
@@ -150,11 +200,24 @@ export default function App() {
|
|||||||
onBack={() => activeId && api.historyGo(activeId, -1)}
|
onBack={() => activeId && api.historyGo(activeId, -1)}
|
||||||
onForward={() => activeId && api.historyGo(activeId, 1)}
|
onForward={() => activeId && api.historyGo(activeId, 1)}
|
||||||
onReload={() => activeId && api.historyGo(activeId, 0)}
|
onReload={() => activeId && api.historyGo(activeId, 0)}
|
||||||
|
unread={unread}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* The hole an app's native webview is positioned into. It stays empty
|
{/* The hole an app's native webview is positioned into. It stays empty
|
||||||
on purpose — anything drawn here would be painted over. */}
|
on purpose — anything drawn here would be painted over. */}
|
||||||
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="relative min-w-0 flex-1 overflow-hidden bg-slate-100 dark:bg-slate-950"
|
||||||
|
>
|
||||||
|
{backdrop && (
|
||||||
|
<img
|
||||||
|
src={backdrop}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
/* Scaled up so the blur has no soft edge to give itself away. */
|
||||||
|
className="absolute inset-0 h-full w-full scale-110 object-cover blur-[16px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{config.apps.length === 0 && (
|
{config.apps.length === 0 && (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||||
<p className="text-[13px] text-slate-500 dark:text-slate-400">
|
<p className="text-[13px] text-slate-500 dark:text-slate-400">
|
||||||
@@ -167,6 +230,8 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Settings
|
<Settings
|
||||||
config={config}
|
config={config}
|
||||||
|
|||||||
@@ -50,3 +50,7 @@ export const notificationClick = (appId: string, notificationId: string) =>
|
|||||||
export const focusWindow = () => invoke<void>("focus_window");
|
export const focusWindow = () => invoke<void>("focus_window");
|
||||||
export const probeApps = () => invoke<void>("probe_apps");
|
export const probeApps = () => invoke<void>("probe_apps");
|
||||||
export const appReports = () => invoke<[string, string][]>("app_reports");
|
export const appReports = () => invoke<[string, string][]>("app_reports");
|
||||||
|
|
||||||
|
export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
|
||||||
|
export const refreshFavicons = () => invoke<Config>("refresh_favicons");
|
||||||
|
export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
|
||||||
|
|||||||
+56
-17
@@ -1,11 +1,13 @@
|
|||||||
import type { Config, Group, WorkApp } from "../types";
|
import type { Config, Group, WorkApp } from "../types";
|
||||||
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
|
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
|
||||||
import { Favicon, HEADING, ICON_CHROME } from "./ui";
|
import { Favicon, GROUP_LABEL, ICON_CHROME } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: Config;
|
config: Config;
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
/** How much has arrived in each app since it was last looked at. */
|
||||||
|
unread: Record<string, number>;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
@@ -27,10 +29,11 @@ const TITLEBAR = 36;
|
|||||||
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
||||||
|
|
||||||
export default function Nav({
|
export default function Nav({
|
||||||
config, activeId, collapsed,
|
config, activeId, collapsed, unread,
|
||||||
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
||||||
onBack, onForward, onReload,
|
onBack, onForward, onReload,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const iconV = config.settings.faviconVersion ?? 0;
|
||||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||||
const inGroup = (id: string | null) =>
|
const inGroup = (id: string | null) =>
|
||||||
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
|
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
|
||||||
@@ -55,12 +58,36 @@ export default function Nav({
|
|||||||
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
|
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
|
||||||
<Reload />
|
<Reload />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
|
||||||
<Cog />
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Settings lives at the foot of both layouts: it is the thing you reach for
|
||||||
|
least, and putting it there leaves the top for what you use constantly. */
|
||||||
|
const settingsButton = (
|
||||||
|
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||||
|
<Cog />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* A count of what arrived while you were elsewhere. Absent, rather than
|
||||||
|
zero, when there is nothing — a row of noughts is just noise. */
|
||||||
|
const badge = (id: string, active: boolean) => {
|
||||||
|
const n = unread[id] ?? 0;
|
||||||
|
if (n === 0) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={`${n} new since you last looked`}
|
||||||
|
className={
|
||||||
|
"ml-auto min-w-[18px] shrink-0 rounded-full px-1.5 py-0.5 text-center " +
|
||||||
|
"font-mono text-[10px] font-semibold tabular-nums " +
|
||||||
|
(active ? "bg-white/25 text-white dark:bg-slate-900/20 dark:text-slate-900" : "bg-sky-500 text-white")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{n > 99 ? "99+" : n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------ icon rail
|
// ------------------------------------------------------------ icon rail
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
const railBtn = (app: WorkApp) => {
|
const railBtn = (app: WorkApp) => {
|
||||||
@@ -79,7 +106,16 @@ export default function Nav({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||||
<Favicon url={app.url} name={app.name} size={20} />
|
<Favicon url={app.url} name={app.name} size={20} version={iconV} />
|
||||||
|
{(unread[app.id] ?? 0) > 0 && (
|
||||||
|
<span
|
||||||
|
title={`${unread[app.id]} new since you last looked`}
|
||||||
|
className="absolute -right-0.5 -top-0.5 min-w-[15px] rounded-full bg-sky-500
|
||||||
|
px-1 text-center font-mono text-[9px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
{unread[app.id] > 99 ? "99+" : unread[app.id]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -87,13 +123,10 @@ export default function Nav({
|
|||||||
return (
|
return (
|
||||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
||||||
{/* Only settings and the expander survive the rail. Back and forward
|
{/* Only the expander survives the rail's header. Back and forward are
|
||||||
are a two-finger swipe, reload is ⌘R, and five buttons across 72px
|
a two-finger swipe and reload is ⌘R, so a toolbar here would be
|
||||||
is clutter standing in for a toolbar nobody asked for. */}
|
clutter standing in for something nobody asked for. */}
|
||||||
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
|
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
|
||||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
|
||||||
<Cog />
|
|
||||||
</button>
|
|
||||||
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
||||||
<Collapse open={false} />
|
<Collapse open={false} />
|
||||||
</button>
|
</button>
|
||||||
@@ -114,6 +147,9 @@ export default function Nav({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800">
|
||||||
|
{settingsButton}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,8 +168,9 @@ export default function Nav({
|
|||||||
title={app.url}
|
title={app.url}
|
||||||
className={`${row} ${app.id === activeId ? active : inactive}`}
|
className={`${row} ${app.id === activeId ? active : inactive}`}
|
||||||
>
|
>
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon url={app.url} name={app.name} version={iconV} />
|
||||||
<span className="truncate">{app.name}</span>
|
<span className="truncate">{app.name}</span>
|
||||||
|
{badge(app.id, app.id === activeId)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -161,7 +198,7 @@ export default function Nav({
|
|||||||
<section key={g.id} className="pt-3 first:pt-0">
|
<section key={g.id} className="pt-3 first:pt-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleGroup(g)}
|
onClick={() => onToggleGroup(g)}
|
||||||
className={`${HEADING} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
|
className={`${GROUP_LABEL} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
|
||||||
hover:text-slate-700 dark:hover:text-slate-200`}
|
hover:text-slate-700 dark:hover:text-slate-200`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -172,9 +209,7 @@ export default function Nav({
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="truncate">{g.name}</span>
|
<span className="truncate">{g.name}</span>
|
||||||
<span className="ml-auto font-mono text-[10px] tracking-normal opacity-60">
|
<span className="ml-auto font-mono text-[10px] opacity-60">{apps.length}</span>
|
||||||
{apps.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
|
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
|
||||||
</section>
|
</section>
|
||||||
@@ -187,6 +222,10 @@ export default function Nav({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<footer className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800">
|
||||||
|
{settingsButton}
|
||||||
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,16 @@ export default function Settings({
|
|||||||
|
|
||||||
{/* ----------------------------------------------------- apps */}
|
{/* ----------------------------------------------------- apps */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<SectionHeading>Apps</SectionHeading>
|
<div className="flex items-center justify-between">
|
||||||
|
<SectionHeading>Apps</SectionHeading>
|
||||||
|
<button
|
||||||
|
onClick={() => run(() => api.refreshFavicons())}
|
||||||
|
title="Fetch every icon again — one that cached wrong will not fix itself"
|
||||||
|
className={BTN}
|
||||||
|
>
|
||||||
|
Refresh icons
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
|
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
|
||||||
{orderedApps.map((app) => (
|
{orderedApps.map((app) => (
|
||||||
@@ -107,7 +116,7 @@ export default function Settings({
|
|||||||
key={app.id}
|
key={app.id}
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
||||||
>
|
>
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
||||||
<input
|
<input
|
||||||
value={app.name}
|
value={app.name}
|
||||||
onChange={(e) => patch(app, { name: e.target.value })}
|
onChange={(e) => patch(app, { name: e.target.value })}
|
||||||
@@ -183,7 +192,7 @@ export default function Settings({
|
|||||||
{withHidden.map((app) => (
|
{withHidden.map((app) => (
|
||||||
<div key={app.id} className={SUBPANEL}>
|
<div key={app.id} className={SUBPANEL}>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
||||||
<span className="text-[12px] font-medium">{app.name}</span>
|
<span className="text-[12px] font-medium">{app.name}</span>
|
||||||
<Badge tone="accent">{app.hidden.length}</Badge>
|
<Badge tone="accent">{app.hidden.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +240,7 @@ export default function Settings({
|
|||||||
key={app.id}
|
key={app.id}
|
||||||
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
||||||
>
|
>
|
||||||
<Favicon url={app.url} name={app.name} />
|
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
||||||
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|||||||
+16
-5
@@ -16,6 +16,9 @@ export const ICON_BTN =
|
|||||||
|
|
||||||
export const HEADING =
|
export const HEADING =
|
||||||
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
|
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
|
||||||
|
/** A group's own name, so it reads as a label rather than a shouted heading. */
|
||||||
|
export const GROUP_LABEL =
|
||||||
|
"text-[11px] font-normal text-slate-500 dark:text-slate-400";
|
||||||
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
|
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
|
||||||
export const HELP = "text-[11px] leading-snug text-slate-500 dark:text-slate-400";
|
export const HELP = "text-[11px] leading-snug text-slate-500 dark:text-slate-400";
|
||||||
export const SECTION = "border-b border-slate-200 px-4 py-4 dark:border-slate-800";
|
export const SECTION = "border-b border-slate-200 px-4 py-4 dark:border-slate-800";
|
||||||
@@ -157,16 +160,21 @@ export function Dialog({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
|
/* Light enough that the blurred still behind it still reads as the app
|
||||||
|
you were in, dark enough that the dialog is unambiguously in front. */
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/45 p-5"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={`w-full ${wide ? "max-w-2xl max-h-[82vh] overflow-y-auto" : "max-w-sm"}
|
className={`w-full ${
|
||||||
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
|
wide
|
||||||
dark:border-slate-700 dark:bg-slate-900`}
|
? "max-w-[min(1120px,92vw)] max-h-[90vh] overflow-y-auto"
|
||||||
|
: "max-w-sm"
|
||||||
|
} rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
|
||||||
|
dark:border-slate-700 dark:bg-slate-900`}
|
||||||
>
|
>
|
||||||
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
|
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
|
||||||
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
|
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
@@ -201,10 +209,13 @@ export function Favicon({
|
|||||||
url,
|
url,
|
||||||
name,
|
name,
|
||||||
size = 16,
|
size = 16,
|
||||||
|
version = 0,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/** Bumped by "Refresh icons" to get past a wrongly cached one. */
|
||||||
|
version?: number;
|
||||||
}) {
|
}) {
|
||||||
let host = "";
|
let host = "";
|
||||||
try {
|
try {
|
||||||
@@ -228,7 +239,7 @@ export function Favicon({
|
|||||||
</span>
|
</span>
|
||||||
{host && (
|
{host && (
|
||||||
<img
|
<img
|
||||||
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}`}
|
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}&v=${version}`}
|
||||||
alt=""
|
alt=""
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface Group {
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
navCollapsed: boolean;
|
navCollapsed: boolean;
|
||||||
theme: "system" | "light" | "dark";
|
theme: "system" | "light" | "dark";
|
||||||
|
faviconVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|||||||
Reference in New Issue
Block a user