Skip to content

Commit 5b5c1f3

Browse files
authored
Merge pull request #20 from devlargs/staging
2 parents 23bd229 + 9d2f682 commit 5b5c1f3

10 files changed

Lines changed: 675 additions & 51 deletions

File tree

electron/main.ts

Lines changed: 135 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ interface Service {
1717
icon: string;
1818
color: string;
1919
notificationCount: number;
20+
muted?: boolean;
21+
enabled?: boolean;
22+
notificationsEnabled?: boolean;
2023
}
2124

2225
interface StoreSchema {
@@ -41,6 +44,8 @@ let mainWindow: BrowserWindow | null = null;
4144
const serviceViews = new Map<string, WebContentsView>();
4245
const notificationCounts = new Map<string, number>();
4346
let activeServiceId: string | null = null;
47+
const pendingDecrease = new Map<string, { count: number; streak: number }>();
48+
const DECREASE_THRESHOLD = 3; // require 3 consecutive lower readings before decreasing
4449
const SIDEBAR_WIDTH = 68;
4550
const TITLEBAR_HEIGHT = 46;
4651

@@ -104,7 +109,7 @@ function createWindow() {
104109
mainWindow.webContents.on("did-finish-load", () => {
105110
const services = store.get("services");
106111
for (const service of services) {
107-
if (!serviceViews.has(service.id) && mainWindow) {
112+
if (!serviceViews.has(service.id) && mainWindow && service.enabled !== false) {
108113
const view = createServiceView(service);
109114
serviceViews.set(service.id, view);
110115
mainWindow.contentView.addChildView(view);
@@ -188,18 +193,47 @@ function createServiceView(service: Service): WebContentsView {
188193

189194
view.webContents.loadURL(service.url);
190195

196+
// Apply mute state
197+
if (service.muted) {
198+
view.webContents.setAudioMuted(true);
199+
}
200+
191201
// Track page title changes for notification detection
202+
// Debounce decreases to avoid blinking badges during page transitions
192203
const updateNotificationCount = (count: number) => {
204+
// Check if notifications are disabled for this service
205+
const currentService = store.get("services").find((s) => s.id === service.id);
206+
if (currentService?.notificationsEnabled === false) {
207+
count = 0;
208+
}
209+
193210
const prev = notificationCounts.get(service.id) || 0;
194-
if (count !== prev) {
195-
notificationCounts.set(service.id, count);
196-
updateTaskbarBadge();
197-
if (mainWindow) {
198-
mainWindow.webContents.send("notification-update", {
199-
serviceId: service.id,
200-
count,
201-
});
211+
if (count === prev) {
212+
pendingDecrease.delete(service.id);
213+
return;
214+
}
215+
216+
if (count < prev) {
217+
const pending = pendingDecrease.get(service.id);
218+
if (pending && pending.count === count) {
219+
pending.streak++;
220+
if (pending.streak < DECREASE_THRESHOLD) return;
221+
} else {
222+
pendingDecrease.set(service.id, { count, streak: 1 });
223+
return;
202224
}
225+
pendingDecrease.delete(service.id);
226+
} else {
227+
pendingDecrease.delete(service.id);
228+
}
229+
230+
notificationCounts.set(service.id, count);
231+
updateTaskbarBadge();
232+
if (mainWindow) {
233+
mainWindow.webContents.send("notification-update", {
234+
serviceId: service.id,
235+
count,
236+
});
203237
}
204238
};
205239

@@ -211,7 +245,7 @@ function createServiceView(service: Service): WebContentsView {
211245

212246
// Poll for unread count (title-based + DOM-based for apps like WhatsApp)
213247
const pollInterval = setInterval(() => {
214-
if (view.webContents.isDestroyed()) {
248+
if (!view.webContents || view.webContents.isDestroyed()) {
215249
clearInterval(pollInterval);
216250
return;
217251
}
@@ -223,20 +257,41 @@ function createServiceView(service: Service): WebContentsView {
223257
const titleMatch = document.title.match(/\\((\\d+)\\)/);
224258
if (titleMatch) return parseInt(titleMatch[1], 10);
225259
226-
// WhatsApp: check the "Unread N" filter button or unread badge spans
260+
// Check for "Unread N" text in the page (e.g. WhatsApp filter button)
227261
const allText = document.body.innerText || "";
228-
const unreadTabMatch = allText.match(/Unread\\s+(\\d+)/);
262+
const unreadTabMatch = allText.match(/Unread\\s+(\\d+)/i);
229263
if (unreadTabMatch) return parseInt(unreadTabMatch[1], 10);
230264
231-
// WhatsApp: count green unread indicator dots in chat list
232-
const unreadSpans = document.querySelectorAll('span[aria-label*="unread"]');
233-
if (unreadSpans.length > 0) {
234-
let total = 0;
235-
unreadSpans.forEach(el => {
265+
// Count elements with aria-label containing "unread" (case-insensitive)
266+
const allElements = document.querySelectorAll('[aria-label]');
267+
let unreadTotal = 0;
268+
allElements.forEach(el => {
269+
if (el.getAttribute('aria-label').toLowerCase().includes('unread')) {
236270
const num = parseInt(el.textContent || "0", 10);
237-
total += num > 0 ? num : 1;
271+
unreadTotal += num > 0 ? num : 1;
272+
}
273+
});
274+
if (unreadTotal > 0) return unreadTotal;
275+
276+
// Messenger: count chat rows with unread delivery status indicators
277+
const messengerUnread = document.querySelectorAll('[data-testid="unread-indicator"], [aria-label*="Delivered"], [aria-label*="Sent"]');
278+
if (messengerUnread.length === 0) {
279+
// Fallback: count bold/unread chat previews in Messenger
280+
// Messenger marks unread chat names with heavier font weight
281+
const chatRows = document.querySelectorAll('[role="row"], [role="listitem"]');
282+
let boldCount = 0;
283+
chatRows.forEach(row => {
284+
const spans = row.querySelectorAll('span');
285+
spans.forEach(span => {
286+
const weight = window.getComputedStyle(span).fontWeight;
287+
if ((weight === 'bold' || parseInt(weight) >= 700) && span.textContent && span.textContent.trim().length > 0 && span.closest('[role="row"], [role="listitem"]') === row) {
288+
// Check if this row also has a small colored dot (unread indicator)
289+
const dots = row.querySelectorAll('span[data-visualcompletion="ignore"]');
290+
if (dots.length > 0) boldCount++;
291+
}
292+
});
238293
});
239-
if (total > 0) return total;
294+
if (boldCount > 0) return boldCount;
240295
}
241296
242297
return 0;
@@ -305,7 +360,7 @@ function showService(serviceId: string) {
305360
if (!view) {
306361
const services = store.get("services");
307362
const service = services.find((s) => s.id === serviceId);
308-
if (!service) return;
363+
if (!service || service.enabled === false) return;
309364
view = createServiceView(service);
310365
serviceViews.set(serviceId, view);
311366
mainWindow.contentView.addChildView(view);
@@ -376,6 +431,66 @@ ipcMain.handle("reorder-services", (_event, serviceIds: string[]) => {
376431
return reordered;
377432
});
378433

434+
ipcMain.handle("toggle-mute-service", (_event, serviceId: string) => {
435+
const services = store.get("services");
436+
const updated = services.map((s) => {
437+
if (s.id === serviceId) {
438+
const muted = !s.muted;
439+
// Apply mute to the live view
440+
const view = serviceViews.get(serviceId);
441+
if (view) {
442+
view.webContents.setAudioMuted(muted);
443+
}
444+
return { ...s, muted };
445+
}
446+
return s;
447+
});
448+
store.set("services", updated);
449+
return updated;
450+
});
451+
452+
ipcMain.handle("toggle-service-enabled", (_event, serviceId: string) => {
453+
const services = store.get("services");
454+
const updated = services.map((s) => {
455+
if (s.id === serviceId) {
456+
const enabled = s.enabled === false; // toggle: undefined/true -> false, false -> true
457+
if (!enabled) {
458+
// Destroy the view when disabling
459+
const view = serviceViews.get(serviceId);
460+
if (view) {
461+
if (activeServiceId === serviceId) {
462+
activeServiceId = null;
463+
}
464+
if (mainWindow) {
465+
mainWindow.contentView.removeChildView(view);
466+
}
467+
view.webContents.close();
468+
serviceViews.delete(serviceId);
469+
}
470+
notificationCounts.delete(serviceId);
471+
pendingDecrease.delete(serviceId);
472+
updateTaskbarBadge();
473+
}
474+
return { ...s, enabled };
475+
}
476+
return s;
477+
});
478+
store.set("services", updated);
479+
return updated;
480+
});
481+
482+
ipcMain.handle("toggle-service-notifications", (_event, serviceId: string) => {
483+
const services = store.get("services");
484+
const updated = services.map((s) => {
485+
if (s.id === serviceId) {
486+
return { ...s, notificationsEnabled: s.notificationsEnabled === false };
487+
}
488+
return s;
489+
});
490+
store.set("services", updated);
491+
return updated;
492+
});
493+
379494
ipcMain.on("show-service", (_event, serviceId: string) => {
380495
showService(serviceId);
381496
});

electron/preload.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export interface Service {
77
icon: string;
88
color: string;
99
notificationCount: number;
10+
muted?: boolean;
11+
enabled?: boolean;
12+
notificationsEnabled?: boolean;
1013
}
1114

1215
const api = {
@@ -20,6 +23,12 @@ const api = {
2023
ipcRenderer.invoke("update-service", service),
2124
reorderServices: (serviceIds: string[]): Promise<Service[]> =>
2225
ipcRenderer.invoke("reorder-services", serviceIds),
26+
toggleMuteService: (serviceId: string): Promise<Service[]> =>
27+
ipcRenderer.invoke("toggle-mute-service", serviceId),
28+
toggleServiceEnabled: (serviceId: string): Promise<Service[]> =>
29+
ipcRenderer.invoke("toggle-service-enabled", serviceId),
30+
toggleServiceNotifications: (serviceId: string): Promise<Service[]> =>
31+
ipcRenderer.invoke("toggle-service-notifications", serviceId),
2332

2433
// View management
2534
showService: (serviceId: string): void =>

package-lock.json

Lines changed: 33 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"dependencies": {
2222
"electron-store": "^8.2.0",
2323
"react-icons": "^5.6.0",
24-
"uuid": "^11.1.0"
24+
"uuid": "^11.1.0",
25+
"zustand": "^5.0.12"
2526
},
2627
"devDependencies": {
2728
"@tailwindcss/vite": "^4.1.0",

0 commit comments

Comments
 (0)