diff --git a/source/browser.ts b/source/browser.ts index 58ed52b21..b2fbacb7b 100644 --- a/source/browser.ts +++ b/source/browser.ts @@ -1,9 +1,12 @@ import {ipcRenderer as ipc, Event as ElectronEvent} from 'electron'; import elementReady from 'element-ready'; import {api, is} from 'electron-util'; + +import selectors from './browser/selectors'; import config from './config'; -const listSelector = 'div[role="navigation"] > div > ul'; +import './browser/conversation-list'; // eslint-disable-line import/no-unassigned-import + const conversationSelector = '._4u-c._1wfr > ._5f0v.uiScrollableArea'; const selectedConversationSelector = '._5l-3._1ht1._1ht2'; const preferencesSelector = '._10._4ebx.uiLayer._4-hy'; @@ -337,7 +340,7 @@ async function jumpToConversation(key: number): Promise { // Focus on the conversation with the given index async function selectConversation(index: number): Promise { - const conversationElement = (await elementReady(listSelector)).children[index]; + const conversationElement = (await elementReady(selectors.conversationList)).children[index]; if (conversationElement) { (conversationElement.firstChild!.firstChild as HTMLElement).click(); @@ -416,112 +419,6 @@ function closePreferences(): void { doneButton.click(); } -/// async function sendConversationList(): Promise { -// const conversations: Conversation[] = await Promise.all( -// ([...(await elementReady(listSelector)).children] as HTMLElement[]) -// .splice(0, 10) -// .map(async (el: HTMLElement) => { -// const profilePic = el.querySelector('._7t0d img')!; - -// // TODO: This logic is broken in the latest Messenger update. -// // const groupPic = el.querySelector('._7q1j div'); - -// // // This is only for group chats -// // if (groupPic) { -// // // Slice image source from background-image style property of div -// // const bgImage = groupPic.style.backgroundImage!; -// // groupPic.src = bgImage.slice(5, bgImage.length - 2); -// // } - -// const isConversationMuted = el.classList.contains('_569x'); - -// return { -// label: el.querySelector('._1ht6')!.textContent!, -// selected: el.classList.contains('_1ht2'), -// unread: el.classList.contains('_1ht3') && !isConversationMuted, -// icon: await getDataUrlFromImg( -// profilePic, -// el.classList.contains('_1ht3') -// ) -// }; -// }) -// ); - -// ipc.send('conversations', conversations); -// } - -// Return canvas with rounded image -// async function urlToCanvas(url: string, size: number): Promise { -// return new Promise(resolve => { -// const img = new Image(); -// img.crossOrigin = 'anonymous'; -// img.addEventListener('load', () => { -// const canvas = document.createElement('canvas'); -// const padding = { -// top: 3, -// right: 0, -// bottom: 3, -// left: 0 -// }; - -// canvas.width = size + padding.left + padding.right; -// canvas.height = size + padding.top + padding.bottom; - -// const ctx = canvas.getContext('2d')!; -// ctx.save(); -// ctx.beginPath(); -// ctx.arc(size / 2 + padding.left, size / 2 + padding.top, size / 2, 0, Math.PI * 2, true); -// ctx.closePath(); -// ctx.clip(); -// ctx.drawImage(img, padding.left, padding.top, size, size); -// ctx.restore(); - -// resolve(canvas); -// }); - -// img.src = url; -// }); -// } - -// Return data url for user avatar -// async function getDataUrlFromImg(img: HTMLImageElement, unread: boolean): Promise { -// // eslint-disable-next-line no-async-promise-executor -// return new Promise(async resolve => { -// if (unread) { -// const dataUnreadUrl = img.getAttribute('dataUnreadUrl'); - -// if (dataUnreadUrl) { -// return resolve(dataUnreadUrl); -// } -// } else { -// const dataUrl = img.getAttribute('dataUrl'); - -// if (dataUrl) { -// return resolve(dataUrl); -// } -// } - -// const canvas = await urlToCanvas(img.src, 30); -// const ctx = canvas.getContext('2d')!; -// const dataUrl = canvas.toDataURL(); -// img.setAttribute('dataUrl', dataUrl); - -// if (!unread) { -// return resolve(dataUrl); -// } - -// const markerSize = 8; -// ctx.fillStyle = '#f42020'; -// ctx.beginPath(); -// ctx.ellipse(canvas.width - markerSize, markerSize, markerSize, markerSize, 0, 0, 2 * Math.PI); -// ctx.fill(); -// const dataUnreadUrl = canvas.toDataURL(); -// img.setAttribute('dataUnreadUrl', dataUnreadUrl); - -// resolve(dataUnreadUrl); -// }); -// } - // Inject a global style node to maintain custom appearance after conversation change or startup document.addEventListener('DOMContentLoaded', () => { const style = document.createElement('style'); @@ -549,21 +446,6 @@ document.addEventListener('DOMContentLoaded', () => { }); window.addEventListener('load', () => { - // TODO: Broken in the latest Messenger update - // const sidebar = document.querySelector('[role=navigation]'); - - // if (sidebar) { - // sendConversationList(); - - // const conversationListObserver = new MutationObserver(sendConversationList); - // conversationListObserver.observe(sidebar, { - // subtree: true, - // childList: true, - // attributes: true, - // attributeFilter: ['class'] - // }); - // } - if (location.pathname.startsWith('/login')) { const keepMeSignedInCheckbox = document.querySelector('#u_0_0')!; keepMeSignedInCheckbox.checked = config.get('keepMeSignedIn'); @@ -578,7 +460,9 @@ window.addEventListener('blur', () => { document.documentElement.classList.add('is-window-inactive'); }); window.addEventListener('focus', () => { - document.documentElement.classList.remove('is-window-inactive'); + if (document.documentElement) { + document.documentElement.classList.remove('is-window-inactive'); + } }); // It's not possible to add multiple accelerators diff --git a/source/browser/conversation-list.ts b/source/browser/conversation-list.ts new file mode 100644 index 000000000..59fa472d1 --- /dev/null +++ b/source/browser/conversation-list.ts @@ -0,0 +1,161 @@ +import {ipcRenderer as ipc} from 'electron'; +import elementReady from 'element-ready'; + +import selectors from './selectors'; + +const icon = { + read: 'data-caprine-icon', + unread: 'data-caprine-icon-unread' +}; + +const padding = { + top: 3, + right: 0, + bottom: 3, + left: 0 +}; + +function drawIcon(size: number, img?: HTMLImageElement): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + + if (img) { + canvas.width = size + padding.left + padding.right; + canvas.height = size + padding.top + padding.bottom; + + const ctx = canvas.getContext('2d')!; + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2 + padding.left, size / 2 + padding.top, size / 2, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.clip(); + + ctx.drawImage(img, padding.left, padding.top, size, size); + ctx.restore(); + } else { + canvas.width = 0; + canvas.height = 0; + } + + return canvas; +} + +// Return canvas with rounded image +async function urlToCanvas(url: string, size: number): Promise { + return new Promise(resolve => { + const img = new Image(); + + img.crossOrigin = 'anonymous'; + + img.addEventListener('load', () => { + resolve(drawIcon(size, img)); + }); + + img.addEventListener('error', () => { + console.error('Image not found', url); + resolve(drawIcon(size)); + }); + + img.src = url; + }); +} + +async function createIcons(el: HTMLElement, url: string): Promise { + const canvas = await urlToCanvas(url, 50); + + el.setAttribute(icon.read, canvas.toDataURL()); + + const markerSize = 8; + const ctx = canvas.getContext('2d')!; + + ctx.fillStyle = '#f42020'; + ctx.beginPath(); + ctx.ellipse(canvas.width - markerSize, markerSize, markerSize, markerSize, 0, 0, 2 * Math.PI); + ctx.fill(); + + el.setAttribute(icon.unread, canvas.toDataURL()); +} + +async function discoverIcons(el: HTMLElement): Promise { + const profilePicElement = el.querySelector('img:first-of-type'); + + if (profilePicElement) { + return createIcons(el, profilePicElement.src); + } + + const groupPicElement = el.firstElementChild as HTMLElement; + + if (groupPicElement) { + const groupPicBackground = groupPicElement.style.backgroundImage; + + if (groupPicBackground) { + return createIcons(el, groupPicBackground.replace(/^url\(["']?(.*?)["']?\)$/, '$1')); + } + } + + console.warn('Could not discover profile picture. Falling back to default image.'); + + // Fall back to messenger favicon + const messengerIcon = document.querySelector('link[rel~="icon"]'); + + if (messengerIcon) { + return createIcons(el, messengerIcon.getAttribute('href')!); + } + + // Fall back to facebook favicon + return createIcons(el, 'https://facebook.com/favicon.ico'); +} + +async function getIcon(el: HTMLElement, unread: boolean): Promise { + if (!el.getAttribute(icon.read)) { + await discoverIcons(el); + } + + return el.getAttribute(unread ? icon.unread : icon.read)!; +} + +async function createConversation(el: HTMLElement): Promise { + const conversation: Partial = {}; + const muted = el.classList.contains('_569x'); + + conversation.selected = el.classList.contains('_1ht2'); + conversation.unread = !muted && el.getAttribute('aria-live') !== null; + + const profileElement = el.querySelector('div[data-tooltip-content]')!; + + conversation.label = profileElement.getAttribute('data-tooltip-content')!; + conversation.icon = await getIcon(profileElement, conversation.unread); + + return conversation as Conversation; +} + +async function createConversationList(): Promise { + const list: HTMLElement = await elementReady(selectors.conversationList); + const items: HTMLElement[] = [...list.children] as HTMLElement[]; + + const conversations: Conversation[] = await Promise.all( + items.map((el: HTMLElement): Promise => createConversation(el)) + ); + + return conversations; +} + +async function sendConversationList(): Promise { + ipc.send('conversations', await createConversationList()); +} + +window.addEventListener('load', () => { + const sidebar = document.querySelector('[role=navigation]'); + + if (sidebar) { + sendConversationList(); + + const conversationListObserver = new MutationObserver(sendConversationList); + + conversationListObserver.observe(sidebar, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class'] + }); + } +}); diff --git a/source/browser/selectors.ts b/source/browser/selectors.ts new file mode 100644 index 000000000..b4a335bc2 --- /dev/null +++ b/source/browser/selectors.ts @@ -0,0 +1,3 @@ +export default { + conversationList: 'div[role="navigation"] > div > ul' +};