diff --git a/source/browser.ts b/source/browser.ts index 27722bb8a..54caf414a 100644 --- a/source/browser.ts +++ b/source/browser.ts @@ -294,9 +294,8 @@ ipc.answerMain('reload', () => { }); async function setTheme(): Promise { - type ThemeSource = typeof nativeTheme.themeSource; - const theme = await ipc.callMain('get-config-theme'); - nativeTheme.themeSource = theme; + // Note: Don't set nativeTheme.themeSource here - it triggers nativeTheme.on('updated') + // in the main process which causes an infinite loop. The main process handles that. setThemeElement(document.documentElement); updateVibrancy(); } @@ -800,6 +799,62 @@ document.addEventListener('DOMContentLoaded', async () => { // Hook broken dark mode observer observeThemeBugs(); + + // Inject a transparent drag bar at the top of the window on macOS. + // This is needed because Facebook's JS event handlers on child elements + // prevent -webkit-app-region: drag from working when the window is focused. + // The drag bar sits above all web content and handles window dragging. + // On mousemove, we toggle pointer-events to allow clicking interactive + // elements (buttons, links) underneath while keeping empty space draggable. + if (is.macos) { + const dragBarHeight = 48; + const dragBar = document.createElement('div'); + dragBar.id = 'caprine-drag-bar'; + dragBar.style.position = 'fixed'; + dragBar.style.top = '0'; + dragBar.style.left = '0'; + dragBar.style.right = '0'; + dragBar.style.height = `${dragBarHeight}px`; + dragBar.style.zIndex = '99999'; + dragBar.style.setProperty('-webkit-app-region', 'drag'); + document.body.append(dragBar); + + const interactiveSelector = 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="search"], [contenteditable="true"]'; + + // Debounce mousemove to reduce CPU usage - only process every 100ms + let debounceTimer: ReturnType | undefined; + let lastMouseX = 0; + let lastMouseY = 0; + document.addEventListener('mousemove', (event: MouseEvent) => { + lastMouseX = event.clientX; + lastMouseY = event.clientY; + + if (debounceTimer) { + return; + } + + debounceTimer = setTimeout(() => { + debounceTimer = undefined; + + if (lastMouseY >= dragBarHeight) { + dragBar.style.pointerEvents = ''; + return; + } + + // Temporarily hide drag bar to find what's underneath + dragBar.style.pointerEvents = 'none'; + const target = document.elementFromPoint(lastMouseX, lastMouseY); + + if (target?.closest(interactiveSelector)) { + // Over an interactive element - keep drag bar transparent for clicks + return; + } + + // Over empty space - re-enable drag bar for window dragging + dragBar.style.pointerEvents = ''; + }, 100); + }, {passive: true}); + } }); // Handle title bar double-click. diff --git a/source/browser/conversation-list.ts b/source/browser/conversation-list.ts index 43282f9c2..a3905c341 100644 --- a/source/browser/conversation-list.ts +++ b/source/browser/conversation-list.ts @@ -1,6 +1,5 @@ import {ipcRenderer as ipc} from 'electron-better-ipc'; import elementReady from 'element-ready'; -import {isNull} from 'lodash'; import selectors from './selectors'; const icon = { @@ -104,23 +103,59 @@ async function getIcon(element: HTMLElement, unread: boolean): Promise { return element.getAttribute(unread ? icon.unread : icon.read)!; } -async function getLabel(element: HTMLElement): Promise { - if (isNull(element)) { - return ''; +function getConversationLabel(element: HTMLElement): string { + // Try multiple selectors to find the conversation name + // Facebook's class names change frequently, so we use multiple strategies + + // Strategy 1: Look for span with dir="auto" inside the conversation element + // This is the most common location for conversation names + const labelElement = element.querySelector('span[dir="auto"]'); + if (labelElement?.textContent?.trim()) { + return labelElement.textContent.trim(); } - const emojis: HTMLElement[] = []; - if (element !== null) { - for (const elementCurrent of element.children) { - emojis.push(elementCurrent as HTMLElement); + // Strategy 2: Look for the first span that appears to be a title (not message preview) + // The title is usually the first text node in the conversation row + const allSpans = element.querySelectorAll('span'); + const timePattern = /^\d+:\d+/; + const statusPattern = /^(sent|delivered|read|unread)$/i; + for (const span of allSpans) { + const text = span.textContent?.trim(); + // Skip empty strings, timestamps, and very short previews + if (text && text.length > 0 && !timePattern.test(text) && !statusPattern.test(text)) { + // Check that this span is not inside a message preview container + // by ensuring it doesn't have sibling spans with message-like content + const parent = span.parentElement; + if (parent) { + const siblings = parent.querySelectorAll('span'); + // If this is one of only 1-2 spans at this level, it's likely the title + if (siblings.length <= 3) { + return text; + } + } } } - for (const emoji of emojis) { - emoji.outerHTML = emoji.querySelector('img')?.getAttribute('alt') ?? ''; + // Strategy 3: Use aria-label if available + const ariaLabel = element.getAttribute('aria-label'); + if (ariaLabel) { + return ariaLabel; + } + + // Strategy 4: Look for data-tooltip-content attribute (Facebook sometimes uses this) + const tooltipContent = element.querySelector('[data-tooltip-content]')?.getAttribute('data-tooltip-content'); + if (tooltipContent) { + return tooltipContent; + } + + // Strategy 5: Look for img alt text (for conversations with no text names) + const imgAlt = element.querySelector('img[alt]')?.getAttribute('alt'); + if (imgAlt && imgAlt !== 'Profile photo') { + return imgAlt; } - return element.textContent ?? ''; + // Fallback: return empty string + return ''; } async function createConversationNewDesign(element: HTMLElement): Promise { @@ -133,8 +168,8 @@ async function createConversationNewDesign(element: HTMLElement): Promise div:only-child')); conversation.unread = Boolean(element.querySelector('[aria-label="Mark as Read"]')); - const unparsedLabel = element.querySelector('.a8c37x1j.ni8dbmo4.stjgntxs.l9j0dhe7 > span > span')!; - conversation.label = await getLabel(unparsedLabel); + const label = getConversationLabel(element); + conversation.label = label; const iconElement = element.querySelector('img')!; conversation.icon = await getIcon(iconElement, conversation.unread); diff --git a/source/index.ts b/source/index.ts index 078ee81c4..6eaba2a81 100644 --- a/source/index.ts +++ b/source/index.ts @@ -19,7 +19,7 @@ import electronDl from 'electron-dl'; import electronContextMenu from 'electron-context-menu'; import electronLocalshortcut from 'electron-localshortcut'; import electronDebug from 'electron-debug'; -import {is, darkMode} from 'electron-util'; +import {is} from 'electron-util'; import {bestFacebookLocaleFor} from 'facebook-locales'; import doNotDisturb from '@sindresorhus/do-not-disturb'; import updateAppMenu from './menu'; @@ -298,12 +298,13 @@ function createMainWindow(): BrowserWindow { setUserLocale(); initRequestsFiltering(); - let previousDarkMode = darkMode.isEnabled; - darkMode.onChange(() => { - if (darkMode.isEnabled !== previousDarkMode) { - previousDarkMode = darkMode.isEnabled; - win.webContents.send('set-theme'); - } + // Listen for native theme changes and notify renderer + // Use setImmediate to defer the call on Windows because the event fires + // before nativeTheme.shouldUseDarkColors is updated + nativeTheme.on('updated', () => { + setImmediate(() => { + ipc.callRenderer(win, 'set-theme'); + }); }); if (is.macos) { @@ -408,7 +409,10 @@ function createMainWindow(): BrowserWindow { return; } - const items = conversations.map(({label, icon}, index) => ({ + // Filter out conversations with empty labels to avoid blank menu items + const validConversations = conversations.filter(({label}) => label && label.trim().length > 0); + + const items = validConversations.map(({label, icon}, index) => ({ label: `${label}`, icon: nativeImage.createFromDataURL(icon), click() {