Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix missing unread badge #908

Merged
merged 4 commits into from
May 30, 2019
Merged
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Rewritten conversation list handler
pkuczynski committed May 28, 2019
commit 08863c0ffca4947465970cc43842ee4ab8789887
117 changes: 4 additions & 113 deletions source/browser.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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 { sendConversationList } from './browser/conversation-list';
import config from './config';

const listSelector = 'div[role="navigation"] > div > ul';
const conversationSelector = '._4u-c._1wfr > ._5f0v.uiScrollableArea';
const selectedConversationSelector = '._5l-3._1ht1._1ht2';
const preferencesSelector = '._10._4ebx.uiLayer._4-hy';
@@ -337,7 +339,7 @@ async function jumpToConversation(key: number): Promise<void> {

// Focus on the conversation with the given index
async function selectConversation(index: number): Promise<void> {
const conversationElement = (await elementReady(listSelector)).children[index];
const conversationElement = (await elementReady(selectors.conversationList)).children[index];

if (conversationElement) {
(conversationElement.firstChild!.firstChild as HTMLElement).click();
@@ -416,117 +418,6 @@ function closePreferences(): void {
doneButton.click();
}

async function sendConversationList(): Promise<void> {
const conversations: Conversation[] = await Promise.all(
([...(await elementReady(listSelector)).children] as HTMLElement[])
.splice(0, 10)
.map(async (el: HTMLElement) => {
const profilePic = el.querySelector<HTMLImageElement>('._7q1k.img');
const groupPic = el.querySelector<HTMLImageElement>('._1qt3._6-5k._5l-3 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<HTMLElement>('._1ht6')!.textContent!,
selected: el.classList.contains('_1ht2'),
unread: el.classList.contains('_1ht3') && !isConversationMuted,
icon: await getDataUrlFromImg(
profilePic ? profilePic : groupPic!,
el.classList.contains('_1ht3')
)
};
})
);

ipc.send('conversations', conversations);
}

// Return canvas with rounded image
async function urlToCanvas(url: string, size: number): Promise<HTMLCanvasElement> {
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<string> {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async resolve => {
if (unread) {
const dataUnreadUrl = img.getAttribute('dataUnreadUrl');

if (dataUnreadUrl) {
resolve(dataUnreadUrl);
return;
}
} else {
const dataUrl = img.getAttribute('dataUrl');

if (dataUrl) {
resolve(dataUrl);
return;
}
}

const canvas = await urlToCanvas(img.src, 30);
const ctx = canvas.getContext('2d')!;
const dataUrl = canvas.toDataURL();

img.setAttribute('dataUrl', dataUrl);

if (!unread) {
resolve(dataUrl);
return;
}

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');
148 changes: 148 additions & 0 deletions source/browser/conversation-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import elementReady from 'element-ready';
import {ipcRenderer as ipc} from 'electron';

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) {
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<HTMLCanvasElement> {
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<void> {
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<void> {
const profilePicElement = el.querySelector<HTMLImageElement>('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.');

// fallback to messenger favicon
const messengerIcon = document.querySelector('link[rel~="icon"]');

if (messengerIcon) {
return createIcons(el, messengerIcon.getAttribute('href')!);
}

// fallback to facebook favicon
return createIcons(el, 'https://facebook.com/favicon.ico');
}

async function getIcon(el: HTMLElement, unread: boolean): Promise<string> {
if (!el.getAttribute(icon.read)) {
await discoverIcons(el)
}

return el.getAttribute(unread ? icon.unread : icon.read)!
}

async function createConversation(el: HTMLElement): Promise<Conversation> {
const conversation: Partial<Conversation> = {};
const muted = el.classList.contains('_569x');

conversation.selected = el.classList.contains('_1ht2');
conversation.unread = !muted && el.getAttribute('aria-live') !== null;

const profileElement = el.querySelector<HTMLElement>('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() {
const list: HTMLElement = await elementReady(selectors.conversationList);
const items: HTMLElement[] = Array.from(list.children) as HTMLElement[];

const conversations: Conversation[] = await Promise.all(
items.map((el: HTMLElement): Promise<Conversation> => createConversation(el))
);

return conversations;
}

async function sendConversationList(): Promise<void> {
ipc.send('conversations', await createConversationList());
}

export {
sendConversationList
}
3 changes: 3 additions & 0 deletions source/browser/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
conversationList: 'div[role="navigation"] > div > ul'
}