Skip to content
Closed
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
61 changes: 58 additions & 3 deletions source/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,8 @@ ipc.answerMain('reload', () => {
});

async function setTheme(): Promise<void> {
type ThemeSource = typeof nativeTheme.themeSource;
const theme = await ipc.callMain<undefined, ThemeSource>('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();
}
Expand Down Expand Up @@ -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<typeof setTimeout> | 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.
Expand Down
61 changes: 48 additions & 13 deletions source/browser/conversation-list.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -104,23 +103,59 @@ async function getIcon(element: HTMLElement, unread: boolean): Promise<string> {
return element.getAttribute(unread ? icon.unread : icon.read)!;
}

async function getLabel(element: HTMLElement): Promise<string> {
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<HTMLElement>('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<HTMLElement>('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<Conversation> {
Expand All @@ -133,8 +168,8 @@ async function createConversationNewDesign(element: HTMLElement): Promise<Conver
conversation.selected = Boolean(element.querySelector('[role=row] [role=link] > div:only-child'));
conversation.unread = Boolean(element.querySelector('[aria-label="Mark as Read"]'));

const unparsedLabel = element.querySelector<HTMLElement>('.a8c37x1j.ni8dbmo4.stjgntxs.l9j0dhe7 > span > span')!;
conversation.label = await getLabel(unparsedLabel);
const label = getConversationLabel(element);
conversation.label = label;

const iconElement = element.querySelector<HTMLElement>('img')!;
conversation.icon = await getIcon(iconElement, conversation.unread);
Expand Down
20 changes: 12 additions & 8 deletions source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down