feat: notification batching, per-folder mute, and global pause
Debounce embedding/tagging completion notifications per folder (6s window) so rapid file additions from downloaders fire one notification instead of one per file. Add per-folder mute via the sidebar context menu and a global pause toggle in Settings > General, both persisted across restarts.
This commit is contained in:
@@ -1852,3 +1852,68 @@ pub async fn cleanup_orphaned_thumbnails(
|
|||||||
freed_mb: freed_bytes as f64 / 1_048_576.0,
|
freed_mb: freed_bytes as f64 / 1_048_576.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MUTED_FOLDER_IDS_FILE: &str = "settings/muted_folder_ids.txt";
|
||||||
|
const NOTIFICATIONS_PAUSED_FILE: &str = "settings/notifications_paused.txt";
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_muted_folder_ids(app: AppHandle) -> Result<Vec<i64>, String> {
|
||||||
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
let path = app_dir.join(MUTED_FOLDER_IDS_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(content
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.filter_map(|s| s.parse::<i64>().ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct SetMutedFolderIdsParams {
|
||||||
|
pub folder_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_muted_folder_ids(
|
||||||
|
app: AppHandle,
|
||||||
|
params: SetMutedFolderIdsParams,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
let settings_dir = app_dir.join("settings");
|
||||||
|
std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?;
|
||||||
|
let content = params
|
||||||
|
.folder_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
std::fs::write(settings_dir.join("muted_folder_ids.txt"), content)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_notifications_paused(app: AppHandle) -> Result<bool, String> {
|
||||||
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
let path = app_dir.join(NOTIFICATIONS_PAUSED_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(content.trim() == "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_notifications_paused(app: AppHandle, paused: bool) -> Result<(), String> {
|
||||||
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
let settings_dir = app_dir.join("settings");
|
||||||
|
std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(
|
||||||
|
settings_dir.join("notifications_paused.txt"),
|
||||||
|
if paused { "true" } else { "false" },
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ pub fn run() {
|
|||||||
commands::vacuum_database,
|
commands::vacuum_database,
|
||||||
commands::get_orphaned_thumbnails_info,
|
commands::get_orphaned_thumbnails_info,
|
||||||
commands::cleanup_orphaned_thumbnails,
|
commands::cleanup_orphaned_thumbnails,
|
||||||
|
commands::get_muted_folder_ids,
|
||||||
|
commands::set_muted_folder_ids,
|
||||||
|
commands::get_notifications_paused,
|
||||||
|
commands::set_notifications_paused,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ export default function App() {
|
|||||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||||
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
||||||
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
|
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
|
||||||
|
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
|
||||||
|
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
|
||||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||||
const activeView = useGalleryStore((state) => state.activeView);
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void initializeNotifications();
|
void initializeNotifications();
|
||||||
|
void loadMutedFolderIds();
|
||||||
|
void loadNotificationsPaused();
|
||||||
loadFolders().then(() => {
|
loadFolders().then(() => {
|
||||||
void loadBackgroundJobProgress();
|
void loadBackgroundJobProgress();
|
||||||
void loadCaptionModelStatus();
|
void loadCaptionModelStatus();
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export function SettingsModal() {
|
|||||||
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
||||||
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
|
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
|
||||||
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
|
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
|
||||||
|
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
|
||||||
|
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
|
||||||
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
|
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
|
||||||
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
|
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
|
||||||
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
|
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
|
||||||
@@ -601,6 +603,26 @@ export function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Notifications"
|
||||||
|
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Pause all notifications</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">Suppress all indexing notifications until re-enabled.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notificationsPaused}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
|
||||||
|
onClick={() => setNotificationsPaused(!notificationsPaused)}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Compact database"
|
title="Compact database"
|
||||||
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time."
|
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time."
|
||||||
|
|||||||
@@ -11,18 +11,22 @@ interface ContextMenuState {
|
|||||||
function FolderContextMenu({
|
function FolderContextMenu({
|
||||||
menu,
|
menu,
|
||||||
folder,
|
folder,
|
||||||
|
isMuted,
|
||||||
onClose,
|
onClose,
|
||||||
onRename,
|
onRename,
|
||||||
onReindex,
|
onReindex,
|
||||||
onLocate,
|
onLocate,
|
||||||
|
onToggleMute,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
menu: ContextMenuState;
|
menu: ContextMenuState;
|
||||||
folder: Folder;
|
folder: Folder;
|
||||||
|
isMuted: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
onReindex: () => void;
|
onReindex: () => void;
|
||||||
onLocate: () => void;
|
onLocate: () => void;
|
||||||
|
onToggleMute: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -61,6 +65,7 @@ function FolderContextMenu({
|
|||||||
>
|
>
|
||||||
{item("Reindex", onReindex)}
|
{item("Reindex", onReindex)}
|
||||||
{item("Rename", onRename)}
|
{item("Rename", onRename)}
|
||||||
|
{item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)}
|
||||||
{folder.scan_error && item("Locate Folder", onLocate)}
|
{folder.scan_error && item("Locate Folder", onLocate)}
|
||||||
<div className="my-1 border-t border-white/[0.06]" />
|
<div className="my-1 border-t border-white/[0.06]" />
|
||||||
{item("Remove from app", onRemove, true)}
|
{item("Remove from app", onRemove, true)}
|
||||||
@@ -77,7 +82,9 @@ function FolderItem({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
progress: IndexProgress | undefined;
|
progress: IndexProgress | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath } = useGalleryStore();
|
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
|
||||||
|
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
|
||||||
|
const isMuted = mutedFolderIds.includes(folder.id);
|
||||||
const isIndexing = progress && !progress.done;
|
const isIndexing = progress && !progress.done;
|
||||||
const isMissing = !!folder.scan_error && !isIndexing;
|
const isMissing = !!folder.scan_error && !isIndexing;
|
||||||
|
|
||||||
@@ -250,10 +257,12 @@ function FolderItem({
|
|||||||
<FolderContextMenu
|
<FolderContextMenu
|
||||||
menu={contextMenu}
|
menu={contextMenu}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
|
isMuted={isMuted}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
onRename={() => setRenaming(true)}
|
onRename={() => setRenaming(true)}
|
||||||
onReindex={() => void reindexFolder(folder.id)}
|
onReindex={() => void reindexFolder(folder.id)}
|
||||||
onLocate={() => void handleLocateFolder()}
|
onLocate={() => void handleLocateFolder()}
|
||||||
|
onToggleMute={() => toggleMutedFolder(folder.id)}
|
||||||
onRemove={() => setConfirmingRemoval(true)}
|
onRemove={() => setConfirmingRemoval(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+95
-26
@@ -4,6 +4,11 @@ import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
|||||||
import { appDataDir, join } from "@tauri-apps/api/path";
|
import { appDataDir, join } from "@tauri-apps/api/path";
|
||||||
import { notifyTaskComplete } from "./notifications";
|
import { notifyTaskComplete } from "./notifications";
|
||||||
|
|
||||||
|
// Per-folder debounce timers for batching notifications.
|
||||||
|
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
|
||||||
|
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const NOTIFICATION_DEBOUNCE_MS = 6000;
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -288,6 +293,8 @@ interface GalleryState {
|
|||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
taggingQueueScope: TaggingQueueScope;
|
taggingQueueScope: TaggingQueueScope;
|
||||||
taggingQueueFolderIds: number[];
|
taggingQueueFolderIds: number[];
|
||||||
|
mutedFolderIds: number[];
|
||||||
|
notificationsPaused: boolean;
|
||||||
|
|
||||||
taggerModelStatus: TaggerModelStatus | null;
|
taggerModelStatus: TaggerModelStatus | null;
|
||||||
taggerModelPreparing: boolean;
|
taggerModelPreparing: boolean;
|
||||||
@@ -360,6 +367,10 @@ interface GalleryState {
|
|||||||
loadTaggingQueueFolderIds: () => Promise<void>;
|
loadTaggingQueueFolderIds: () => Promise<void>;
|
||||||
toggleTaggingQueueFolder: (folderId: number) => void;
|
toggleTaggingQueueFolder: (folderId: number) => void;
|
||||||
setTaggingQueueFolderIds: (folderIds: number[]) => void;
|
setTaggingQueueFolderIds: (folderIds: number[]) => void;
|
||||||
|
loadMutedFolderIds: () => Promise<void>;
|
||||||
|
toggleMutedFolder: (folderId: number) => void;
|
||||||
|
loadNotificationsPaused: () => Promise<void>;
|
||||||
|
setNotificationsPaused: (paused: boolean) => void;
|
||||||
openAppDataFolder: () => Promise<void>;
|
openAppDataFolder: () => Promise<void>;
|
||||||
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
||||||
vacuumDatabase: () => Promise<VacuumResult>;
|
vacuumDatabase: () => Promise<VacuumResult>;
|
||||||
@@ -635,6 +646,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
taggingQueueScope: "all",
|
taggingQueueScope: "all",
|
||||||
taggingQueueFolderIds: [],
|
taggingQueueFolderIds: [],
|
||||||
|
mutedFolderIds: [],
|
||||||
|
notificationsPaused: false,
|
||||||
|
|
||||||
taggerModelStatus: null,
|
taggerModelStatus: null,
|
||||||
taggerModelPreparing: false,
|
taggerModelPreparing: false,
|
||||||
@@ -1371,6 +1384,39 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {});
|
void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadMutedFolderIds: async () => {
|
||||||
|
try {
|
||||||
|
const folderIds = await invoke<number[]>("get_muted_folder_ids");
|
||||||
|
set({ mutedFolderIds: folderIds });
|
||||||
|
} catch {
|
||||||
|
// fall back to in-memory default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMutedFolder: (folderId) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.mutedFolderIds.includes(folderId)
|
||||||
|
? state.mutedFolderIds.filter((id) => id !== folderId)
|
||||||
|
: [...state.mutedFolderIds, folderId];
|
||||||
|
void invoke("set_muted_folder_ids", { folder_ids: next }).catch(() => {});
|
||||||
|
return { mutedFolderIds: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadNotificationsPaused: async () => {
|
||||||
|
try {
|
||||||
|
const paused = await invoke<boolean>("get_notifications_paused");
|
||||||
|
set({ notificationsPaused: paused });
|
||||||
|
} catch {
|
||||||
|
// fall back to in-memory default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setNotificationsPaused: (paused) => {
|
||||||
|
set({ notificationsPaused: paused });
|
||||||
|
void invoke("set_notifications_paused", { paused }).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
openAppDataFolder: async () => {
|
openAppDataFolder: async () => {
|
||||||
await invoke("open_app_data_folder");
|
await invoke("open_app_data_folder");
|
||||||
},
|
},
|
||||||
@@ -1659,11 +1705,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
progress.total > 0 &&
|
progress.total > 0 &&
|
||||||
progress.indexed >= progress.total
|
progress.indexed >= progress.total
|
||||||
) {
|
) {
|
||||||
const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name;
|
const { notificationsPaused, mutedFolderIds } = get();
|
||||||
void notifyTaskComplete(
|
if (!notificationsPaused && !mutedFolderIds.includes(progress.folder_id)) {
|
||||||
"Folder scan complete",
|
const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name;
|
||||||
folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.",
|
void notifyTaskComplete(
|
||||||
);
|
"Folder scan complete",
|
||||||
|
folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void get().loadFolders();
|
void get().loadFolders();
|
||||||
void get().loadBackgroundJobProgress();
|
void get().loadBackgroundJobProgress();
|
||||||
@@ -1688,32 +1737,52 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const previous = previousProgress[progress.folder_id];
|
const previous = previousProgress[progress.folder_id];
|
||||||
if (!previous) continue;
|
if (!previous) continue;
|
||||||
|
|
||||||
|
const { notificationsPaused, mutedFolderIds } = get();
|
||||||
|
const suppressed = notificationsPaused || mutedFolderIds.includes(progress.folder_id);
|
||||||
const folderName =
|
const folderName =
|
||||||
get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder";
|
get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder";
|
||||||
|
|
||||||
if (previous.embedding_pending > 0 && progress.embedding_pending === 0) {
|
// Embeddings — debounced so rapid file additions don't fire per-file.
|
||||||
const failureDetail =
|
const embeddingKey = `${progress.folder_id}:embedding`;
|
||||||
progress.embedding_failed > 0
|
if (!suppressed) {
|
||||||
? ` ${progress.embedding_failed.toLocaleString()} failed.`
|
if (previous.embedding_pending > 0 && progress.embedding_pending === 0) {
|
||||||
: "";
|
clearTimeout(notificationTimers.get(embeddingKey));
|
||||||
void notifyTaskComplete(
|
const failureDetail =
|
||||||
"Embeddings complete",
|
progress.embedding_failed > 0
|
||||||
`${folderName} finished generating embeddings.${failureDetail}`,
|
? ` ${progress.embedding_failed.toLocaleString()} failed.`
|
||||||
);
|
: "";
|
||||||
|
const body = `${folderName} finished generating embeddings.${failureDetail}`;
|
||||||
|
notificationTimers.set(embeddingKey, setTimeout(() => {
|
||||||
|
notificationTimers.delete(embeddingKey);
|
||||||
|
void notifyTaskComplete("Embeddings complete", body);
|
||||||
|
}, NOTIFICATION_DEBOUNCE_MS));
|
||||||
|
} else if (previous.embedding_pending === 0 && progress.embedding_pending > 0) {
|
||||||
|
// More jobs queued — cancel the pending notification.
|
||||||
|
clearTimeout(notificationTimers.get(embeddingKey));
|
||||||
|
notificationTimers.delete(embeddingKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previous.tagging_pending > 0 && progress.tagging_pending === 0) {
|
// Tagging — same debounce pattern.
|
||||||
const failureDetail =
|
const taggingKey = `${progress.folder_id}:tagging`;
|
||||||
progress.tagging_failed > 0
|
if (!suppressed) {
|
||||||
? ` ${progress.tagging_failed.toLocaleString()} failed.`
|
if (previous.tagging_pending > 0 && progress.tagging_pending === 0) {
|
||||||
: "";
|
clearTimeout(notificationTimers.get(taggingKey));
|
||||||
void notifyTaskComplete(
|
const failureDetail =
|
||||||
"AI tagging complete",
|
progress.tagging_failed > 0
|
||||||
`${folderName} finished generating tags.${failureDetail}`,
|
? ` ${progress.tagging_failed.toLocaleString()} failed.`
|
||||||
);
|
: "";
|
||||||
// New tags are now in the DB — invalidate the Explore tag cache so
|
const body = `${folderName} finished generating tags.${failureDetail}`;
|
||||||
// reopening Explore reflects the updated tag distribution.
|
notificationTimers.set(taggingKey, setTimeout(() => {
|
||||||
set({ exploreTagsFolderId: undefined });
|
notificationTimers.delete(taggingKey);
|
||||||
|
void notifyTaskComplete("AI tagging complete", body);
|
||||||
|
// New tags landed — invalidate Explore tag cache.
|
||||||
|
set({ exploreTagsFolderId: undefined });
|
||||||
|
}, NOTIFICATION_DEBOUNCE_MS));
|
||||||
|
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
|
||||||
|
clearTimeout(notificationTimers.get(taggingKey));
|
||||||
|
notificationTimers.delete(taggingKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user