diff --git a/package.json b/package.json index 11e7a31d9a..ae470ef71b 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ }, "dependencies": { "browser-request": "^0.3.3", - "favico.js": "^0.3.10", "gfm.css": "^1.1.2", "highlight.js": "^9.13.1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 9716cafe22..4cb74963d9 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -24,5 +24,11 @@ declare global { // electron-only ipcRenderer: any; + + // opera-only + opera: any; + + // https://developer.mozilla.org/en-US/docs/Web/API/InstallTrigger + InstallTrigger: any; } } diff --git a/src/favicon.ts b/src/favicon.ts new file mode 100644 index 0000000000..06d6268106 --- /dev/null +++ b/src/favicon.ts @@ -0,0 +1,256 @@ +/* +Copyright 2020 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license] + +interface IParams { + // colour parameters + bgColor: string; + textColor: string; + // font styling parameters + fontFamily: string; + fontWeight: "normal" | "italic" | "bold" | "bolder" | "lighter" | number; + + // positioning parameters + isUp: boolean; + isLeft: boolean; +} + +const defaults: IParams = { + bgColor: "#d00", + textColor: "#fff", + fontFamily: "sans-serif", // Arial,Verdana,Times New Roman,serif,sans-serif,... + fontWeight: "bold", // normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900 + + isUp: false, + isLeft: false, +}; + +// Allows dynamic rendering of a circular badge atop the loaded favicon +// supports colour, font and basic positioning parameters. +export default class Favicon { + private readonly browser = { + ff: typeof window.InstallTrigger !== "undefined", + opera: !!window.opera || navigator.userAgent.includes("Opera"), + }; + + private readonly params: IParams; + private readonly canvas: HTMLCanvasElement; + private readonly baseImage: HTMLImageElement; + private context: CanvasRenderingContext2D; + private icons: HTMLLinkElement[]; + + private isReady: boolean = false; + // callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown + private readyCb = () => {}; + + constructor(params: Partial = {}) { + this.params = {...defaults, ...params}; + + this.icons = Favicon.getIcons(); + // create work canvas + this.canvas = document.createElement("canvas"); + // create clone of favicon as a base + this.baseImage = document.createElement("img"); + + const lastIcon = this.icons[this.icons.length - 1]; + if (lastIcon.hasAttribute("href")) { + this.baseImage.setAttribute("crossOrigin", "anonymous"); + this.baseImage.onload = () => { + // get height and width of the favicon + this.canvas.height = (this.baseImage.height > 0) ? this.baseImage.height : 32; + this.canvas.width = (this.baseImage.width > 0) ? this.baseImage.width : 32; + this.context = this.canvas.getContext("2d"); + this.ready(); + }; + this.baseImage.setAttribute("src", lastIcon.getAttribute("href")); + } else { + this.canvas.height = this.baseImage.height = 32; + this.canvas.width = this.baseImage.width = 32; + this.context = this.canvas.getContext("2d"); + this.ready(); + } + } + + private reset() { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height); + } + + private options(n: number | string) { + const opt = { + n: ((typeof n) === "number") ? Math.abs(n as number | 0) : n, + len: ("" + n).length, + // badge positioning constants as percentages + x: 0.4, + y: 0.4, + w: 0.6, + h: 0.6, + }; + + // apply positional transformations + if (this.params.isUp) { + if (opt.y < 0.6) { + opt.y = opt.y - 0.4; + } else { + opt.y = opt.y - 2 * opt.y + (1 - opt.w); + } + } + if (this.params.isLeft) { + if (opt.x < 0.6) { + opt.x = opt.x - 0.4; + } else { + opt.x = opt.x - 2 * opt.x + (1 - opt.h); + } + } + + // scale the position to the canvas + opt.x = this.canvas.width * opt.x; + opt.y = this.canvas.height * opt.y; + opt.w = this.canvas.width * opt.w; + opt.h = this.canvas.height * opt.h; + return opt; + } + + private circle(n: number | string) { + const opt = this.options(n); + + let more = false; + if (opt.len === 2) { + opt.x = opt.x - opt.w * 0.4; + opt.w = opt.w * 1.4; + more = true; + } else if (opt.len >= 3) { + opt.x = opt.x - opt.w * 0.65; + opt.w = opt.w * 1.65; + more = true; + } + + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height); + this.context.beginPath(); + const fontSize = Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + "px"; + this.context.font = `${this.params.fontWeight} ${fontSize} ${this.params.fontFamily}`; + this.context.textAlign = "center"; + + if (more) { + this.context.moveTo(opt.x + opt.w / 2, opt.y); + this.context.lineTo(opt.x + opt.w - opt.h / 2, opt.y); + this.context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2); + this.context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2); + this.context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h); + this.context.lineTo(opt.x + opt.h / 2, opt.y + opt.h); + this.context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2); + this.context.lineTo(opt.x, opt.y + opt.h / 2); + this.context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y); + } else { + this.context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI); + } + + this.context.fillStyle = this.params.bgColor; + this.context.fill(); + this.context.closePath(); + this.context.beginPath(); + this.context.stroke(); + this.context.fillStyle = this.params.textColor; + + if ((typeof opt.n) === "number" && opt.n > 999) { + const count = ((opt.n > 9999) ? 9 : Math.floor(opt.n as number / 1000)) + "k+"; + this.context.fillText(count, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); + } else { + this.context.fillText("" + opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); + } + + this.context.closePath(); + } + + private ready() { + if (this.isReady) return; + this.isReady = true; + this.readyCb(); + } + + private setIcon(canvas) { + setImmediate(() => { + this.setIconSrc(canvas.toDataURL("image/png")); + }); + } + + private setIconSrc(url) { + // if is attached to fav icon + if (this.browser.ff || this.browser.opera) { + // for FF we need to "recreate" element, attach to dom and remove old + const old = this.icons[this.icons.length - 1]; + const newIcon = window.document.createElement("link"); + this.icons = [newIcon]; + newIcon.setAttribute("rel", "icon"); + newIcon.setAttribute("type", "image/png"); + window.document.getElementsByTagName("head")[0].appendChild(newIcon); + newIcon.setAttribute("href", url); + if (old.parentNode) { + old.parentNode.removeChild(old); + } + } else { + this.icons.forEach(icon => { + icon.setAttribute("href", url); + }); + } + } + + public badge(content: number | string) { + if (!this.isReady) { + this.readyCb = () => { + this.badge(content); + } + return; + } + + if (typeof content === "string" || content > 0) { + this.circle(content); + } else { + this.reset(); + } + + this.setIcon(this.canvas); + } + + private static getLinks() { + const icons: HTMLLinkElement[] = []; + const links = window.document.getElementsByTagName("head")[0].getElementsByTagName("link"); + for (let i = 0; i < links.length; i++) { + if ((/(^|\s)icon(\s|$)/i).test(links[i].getAttribute("rel"))) { + icons.push(links[i]); + } + } + return icons; + } + + private static getIcons() { + // get favicon link elements + let elms = Favicon.getLinks(); + // if link element + if (elms.length === 0) { + elms = [window.document.createElement("link")]; + elms[0].setAttribute("rel", "icon"); + window.document.getElementsByTagName("head")[0].appendChild(elms[0]); + } + + elms.forEach(item => { + item.setAttribute("type", "image/png"); + }); + return elms; + } +} diff --git a/src/vector/platform/VectorBasePlatform.js b/src/vector/platform/VectorBasePlatform.js index 3b8d3c2c2d..3050fef9ad 100644 --- a/src/vector/platform/VectorBasePlatform.js +++ b/src/vector/platform/VectorBasePlatform.js @@ -24,7 +24,7 @@ import { _t } from 'matrix-react-sdk/src/languageHandler'; import dis from 'matrix-react-sdk/src/dispatcher'; import {getVectorConfig} from "../getconfig"; -import Favico from 'favico.js'; +import Favico from '../../favicon'; export const updateCheckStatusEnum = { CHECKING: 'CHECKING', @@ -85,29 +85,9 @@ export default class VectorBasePlatform extends BasePlatform { bgColor = "#f00"; } - const doUpdate = () => { - this.favicon.badge(notif, { - bgColor: bgColor, - }); - }; - - doUpdate(); - - // HACK: Workaround for Chrome 78+ and dependency incompatibility. - // The library we use doesn't appear to work in Chrome 78, likely due to their - // changes surrounding tab behaviour. Tabs went through a bit of a redesign and - // restructuring in Chrome 78, so it's not terribly surprising that the library - // doesn't work correctly. The library we use hasn't been updated in years and - // does not look easy to fix/fork ourselves - we might as well write our own that - // doesn't include animation/webcam/etc support. However, that's a bit difficult - // so for now we'll just trigger the update twice. - // - // Note that trying to reproduce the problem in isolation doesn't seem to work: - // see https://gist.github.com/turt2live/5ab87919918adbfd7cfb8f1ad10f2409 for - // an example (you'll need your own web server to host that). - if (window.chrome) { - doUpdate(); - } + this.favicon.badge(notif, { + bgColor: bgColor, + }); } catch (e) { console.warn(`Failed to set badge count: ${e.message}`); } diff --git a/yarn.lock b/yarn.lock index b27bdebbd9..2fdb208e2e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4284,11 +4284,6 @@ fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= -favico.js@^0.3.10: - version "0.3.10" - resolved "https://registry.yarnpkg.com/favico.js/-/favico.js-0.3.10.tgz#80586e27a117f24a8d51c18a99bdc714d4339301" - integrity sha1-gFhuJ6EX8kqNUcGKmb3HFNQzkwE= - faye-websocket@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4"