diff --git a/web/frequently-used.js b/web/frequently-used.js new file mode 100644 index 0000000000000000000000000000000000000000..1601e03d8a895aa852a823dbbb44af74538ebb5d --- /dev/null +++ b/web/frequently-used.js @@ -0,0 +1,24 @@ +// Copyright (c) 2020 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +const FREQUENTLY_USED = JSON.parse(window.localStorage.mauFrequentlyUsedStickerIDs || "{}") +let FREQUENTLY_USED_SORTED = null + +export const add = id => { + const [count] = FREQUENTLY_USED[id] || [0] + FREQUENTLY_USED[id] = [count + 1, Date.now()] + window.localStorage.mauFrequentlyUsedStickerIDs = JSON.stringify(FREQUENTLY_USED) + FREQUENTLY_USED_SORTED = null +} + +export const get = (limit = 16) => { + if (FREQUENTLY_USED_SORTED === null) { + FREQUENTLY_USED_SORTED = Object.entries(FREQUENTLY_USED) + .sort(([, [count1, date1]], [, [count2, date2]]) => + count2 === count1 ? date2 - date1 : count2 - count1) + .map(([emoji]) => emoji) + } + return FREQUENTLY_USED_SORTED.slice(0, limit) +} diff --git a/web/index.css b/web/index.css index e3c85830686513cbf57dfbf344c0b0040724042e..d0ea06ccd4991afeb35b37337799b18b32836590 100644 --- a/web/index.css +++ b/web/index.css @@ -37,6 +37,10 @@ nav { nav > a > div.sticker { width: 12vw; height: 12vw; } + nav > a > div.sticker.icon > img { + width: 70%; + height: 70%; + padding: 15%; } div.pack-list, nav { scrollbar-width: none; } diff --git a/web/index.js b/web/index.js index ab39761147260e382b0af0880a022022d306a9bc..236e327235cc97686679c5799ddee435b7156d2a 100644 --- a/web/index.js +++ b/web/index.js @@ -5,7 +5,8 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. import { html, render, Component } from "https://unpkg.com/htm/preact/index.mjs?module" import { Spinner } from "./spinner.js" -import { sendSticker } from "./widget-api.js" +import * as widgetAPI from "./widget-api.js" +import * as frequent from "./frequently-used.js" // The base URL for fetching packs. The app will first fetch ${PACK_BASE_URL}/index.json, // then ${PACK_BASE_URL}/${packFile} for each packFile in the packs object of the index.json file. @@ -26,9 +27,35 @@ class App extends Component { packs: [], loading: true, error: null, + frequentlyUsed: { + id: "frequently-used", + title: "Frequently used", + stickerIDs: frequent.get(), + stickers: [], + }, } + this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]")) + this.state.frequentlyUsed.stickers = this._getStickersByID(this.state.frequentlyUsed.stickerIDs) this.imageObserver = null this.packListRef = null + this.sendSticker = this.sendSticker.bind(this) + } + + _getStickersByID(ids) { + return ids.map(id => this.stickersByID.get(id)).filter(sticker => !!sticker) + } + + updateFrequentlyUsed() { + const stickerIDs = frequent.get() + const stickers = this._getStickersByID(stickerIDs) + this.setState({ + frequentlyUsed: { + ...this.state.frequentlyUsed, + stickerIDs, + stickers + } + }) + localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker])) } componentDidMount() { @@ -46,11 +73,15 @@ class App extends Component { for (const packFile of indexData.packs) { const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`) const packData = await packRes.json() + for (const sticker of packData.stickers) { + this.stickersByID.set(sticker.id, sticker) + } this.setState({ packs: [...this.state.packs, packData], loading: false, }) } + this.updateFrequentlyUsed() }, error => this.setState({ loading: false, error })) this.imageObserver = new IntersectionObserver(this.observeImageIntersections, { @@ -98,6 +129,14 @@ class App extends Component { this.sectionObserver.disconnect() } + sendSticker(evt) { + const id = evt.currentTarget.getAttribute("data-sticker-id") + const sticker = this.stickersByID.get(id) + frequent.add(id) + this.updateFrequentlyUsed() + widgetAPI.sendSticker(sticker) + } + render() { if (this.state.loading) { return html`<main class="spinner"><${Spinner} size=${80} green /></main>` @@ -111,10 +150,12 @@ class App extends Component { } return html`<main class="has-content"> <nav> + <${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="res/recent.svg" altOverride="🕓️" /> ${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)} </nav> <div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}> - ${this.state.packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack}/>`)} + <${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker} /> + ${this.state.packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)} </div> </main>` } @@ -128,29 +169,33 @@ const scrollToSection = (evt, id) => { evt.preventDefault() } -const NavBarItem = ({ pack }) => html` +const NavBarItem = ({ pack, iconOverride = null, altOverride = null }) => html` <a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title} onClick=${isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined}> - <div class="sticker"> - <img src=${makeThumbnailURL(pack.stickers[0].url)} - alt=${pack.stickers[0].body} class="visible" /> + <div class="sticker ${iconOverride ? "icon" : ""}"> + ${iconOverride ? html` + <img src=${iconOverride} alt=${altOverride} class="visible"/> + ` : html` + <img src=${makeThumbnailURL(pack.stickers[0].url)} + alt=${pack.stickers[0].body} class="visible" /> + `} </div> </a> ` -const Pack = ({ pack }) => html` +const Pack = ({ pack, send }) => html` <section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}> <h1>${pack.title}</h1> <div class="sticker-list"> ${pack.stickers.map(sticker => html` - <${Sticker} key=${sticker.id} content=${sticker}/> + <${Sticker} key=${sticker.id} content=${sticker} send=${send}/> `)} </div> </section> ` -const Sticker = ({ content }) => html` - <div class="sticker" onClick=${() => sendSticker(content)}> +const Sticker = ({ content, send }) => html` + <div class="sticker" onClick=${send} data-sticker-id=${content.id}> <img data-src=${makeThumbnailURL(content.url)} alt=${content.body} /> </div> ` diff --git a/web/index.sass b/web/index.sass index 6f5ad4af8b1ea0b4eb8c1e1290a3b55bb419b374..a2e84fa3b2b6e9835381038d7bd555dd055ff938 100644 --- a/web/index.sass +++ b/web/index.sass @@ -54,6 +54,10 @@ nav > div.sticker width: $nav-sticker-size height: $nav-sticker-size + > div.sticker.icon > img + width: 70% + height: 70% + padding: 15% div.pack-list, nav scrollbar-width: none diff --git a/web/res/favorite.svg b/web/res/favorite.svg new file mode 100644 index 0000000000000000000000000000000000000000..4af2b498d8f368adde7860275e389704eb459638 --- /dev/null +++ b/web/res/favorite.svg @@ -0,0 +1 @@ +<svg height='100px' width='100px' fill="#000000" xmlns:x="http://ns.adobe.com/Extensibility/1.0/" xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/" xmlns:graph="http://ns.adobe.com/Graphs/1.0/" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve"><g><g i:extraneous="self"><path d="M77,95.3c-0.7,0-1.4-0.2-2-0.6l-25.1-16l-25.1,16c-1.3,0.8-2.9,0.8-4.1-0.1c-1.2-0.9-1.8-2.4-1.4-3.9l7.5-28.9l-23-18.9 c-1.2-1-1.6-2.5-1.2-4c0.5-1.4,1.8-2.4,3.3-2.5l29.8-1.8L46.6,6.9c1.1-2.8,5.7-2.8,6.8,0l10.9,27.8L94,36.4 c1.5,0.1,2.8,1.1,3.3,2.5c0.5,1.4,0,3-1.2,4l-23,19l7.5,28.8c0.4,1.5-0.2,3-1.4,3.9C78.6,95,77.8,95.3,77,95.3z M49.9,70.6 c0.7,0,1.4,0.2,2,0.6l19.2,12.3l-5.7-22c-0.4-1.4,0.1-2.9,1.2-3.8l17.6-14.5l-22.7-1.4c-1.4-0.1-2.7-1-3.2-2.3L50,18.3l-8.3,21.2 c-0.5,1.3-1.8,2.2-3.2,2.3l-22.8,1.4l17.5,14.4c1.1,0.9,1.6,2.4,1.2,3.8l-5.7,22.1l19.2-12.2C48.5,70.8,49.2,70.6,49.9,70.6z"></path></g></g></svg> \ No newline at end of file diff --git a/web/res/recent.svg b/web/res/recent.svg new file mode 100644 index 0000000000000000000000000000000000000000..59be87d4d1ac8cc3f17cd1a283dfa244650ed422 --- /dev/null +++ b/web/res/recent.svg @@ -0,0 +1 @@ +<svg height='100px' width='100px' fill="#000000" xmlns:x="http://ns.adobe.com/Extensibility/1.0/" xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/" xmlns:graph="http://ns.adobe.com/Graphs/1.0/" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve"><g><g i:extraneous="self"><g><path d="M84,17.3c-17.8-17.8-46.7-18-64.8-0.5l-6.4-6.4c-0.8-0.8-2-1.1-3.2-0.8C8.7,9.9,7.7,10.9,7.5,12L2.6,35.6 c-0.2,1.1,0.1,2.1,0.8,2.8c0.8,0.8,1.8,1.1,2.9,0.8l23.6-4.9c1.2-0.2,2.1-1,2.4-2.2c0.3-1.1,0-2.4-0.8-3.2L25,22.6 C39.9,8.3,63.6,8.5,78.2,23.1C93,37.9,93,62,78.2,76.9C71,84.1,61.5,88,51.3,88s-19.7-4-26.9-11.1c-5.6-5.6-9.3-12.7-10.6-20.5 c-0.4-2.2-2.5-3.8-4.7-3.4c-2.2,0.4-3.7,2.5-3.4,4.7c1.6,9.5,6.1,18.1,12.9,24.9c8.7,8.7,20.3,13.5,32.7,13.5s24-4.8,32.7-13.5 C102,64.6,102,35.3,84,17.3z"></path><path d="M51.6,21c-2.3,0-4.1,1.8-4.1,4.1V50c0,1.5,0.8,2.9,2.1,3.6l13.2,7.3c0.6,0.4,1.3,0.5,2,0.5c1.4,0,2.8-0.8,3.6-2.1 c1.1-2,0.4-4.5-1.6-5.6l-11.1-6.1V25.1C55.7,22.8,53.9,21,51.6,21z"></path></g></g></g></svg> \ No newline at end of file