diff options
Diffstat (limited to 'web/src')
| -rw-r--r-- | web/src/giphy.js | 107 | ||||
| -rw-r--r-- | web/src/index.js | 126 | ||||
| -rw-r--r-- | web/src/search-box.js | 8 | ||||
| -rw-r--r-- | web/src/widget-api.js | 3 |
4 files changed, 195 insertions, 49 deletions
diff --git a/web/src/giphy.js b/web/src/giphy.js new file mode 100644 index 0000000..16dcae3 --- /dev/null +++ b/web/src/giphy.js @@ -0,0 +1,107 @@ +import {Component, html} from "../lib/htm/preact.js"; +import * as widgetAPI from "./widget-api.js"; +import {SearchBox} from "./search-box.js"; + +const GIPHY_SEARCH_DEBOUNCE = 1000 +let GIPHY_API_KEY = "" +let GIPHY_MXC_PREFIX = "mxc://giphy.mau.dev/" + +export function giphyIsEnabled() { + return GIPHY_API_KEY !== "" +} + +export function setGiphyAPIKey(apiKey, mxcPrefix) { + GIPHY_API_KEY = apiKey + if (mxcPrefix) { + GIPHY_MXC_PREFIX = mxcPrefix + } +} + +export class GiphySearchTab extends Component { + constructor(props) { + super(props) + this.state = { + searchTerm: "", + gifs: [], + loading: false, + error: null, + } + this.handleGifClick = this.handleGifClick.bind(this) + this.searchKeyUp = this.searchKeyUp.bind(this) + this.updateGifSearchQuery = this.updateGifSearchQuery.bind(this) + this.searchTimeout = null + } + + async makeGifSearchRequest() { + try { + const resp = await fetch(`https://api.giphy.com/v1/gifs/search?q=${this.state.searchTerm}&api_key=${GIPHY_API_KEY}`) + // TODO handle error responses properly? + const data = await resp.json() + if (data.data.length === 0) { + this.setState({gifs: [], error: "No results"}) + } else { + this.setState({gifs: data.data, error: null}) + } + } catch (error) { + this.setState({error}) + } + } + + componentWillUnmount() { + clearTimeout(this.searchTimeout) + } + + searchKeyUp(event) { + if (event.key === "Enter") { + clearTimeout(this.searchTimeout) + this.makeGifSearchRequest() + } + } + + updateGifSearchQuery(event) { + this.setState({searchTerm: event.target.value}) + clearTimeout(this.searchTimeout) + this.searchTimeout = setTimeout(() => this.makeGifSearchRequest(), GIPHY_SEARCH_DEBOUNCE) + } + + handleGifClick(gif) { + widgetAPI.sendSticker({ + "body": gif.title, + "info": { + "h": gif.images.original.height, + "w": gif.images.original.width, + "size": gif.images.original.size, + "mimetype": "image/webp", + }, + "msgtype": "m.image", + "url": GIPHY_MXC_PREFIX + gif.id, + + "id": gif.id, + "filename": gif.id + ".webp", + }) + } + + render() { + // TODO display loading state? + return html` + <${SearchBox} onInput=${this.updateGifSearchQuery} onKeyUp=${this.searchKeyUp} value=${this.state.searchTerm} placeholder="Find GIFs"/> + <div class="pack-list"> + <section class="stickerpack" id="pack-giphy"> + <div class="error"> + ${this.state.error} + </div> + <div class="sticker-list"> + ${this.state.gifs.map((gif) => html` + <div class="sticker" onClick=${() => this.handleGifClick(gif)} data-gif-id=${gif.id}> + <img src=${gif.images.fixed_height.url} alt=${gif.title} class="visible" data=/> + </div> + `)} + </div> + <div class="footer powered-by-giphy"> + <img src="./res/powered-by-giphy.png" alt="Powered by GIPHY"/> + </div> + </section> + </div> + ` + } +} diff --git a/web/src/index.js b/web/src/index.js index a215c46..ff570da 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -13,9 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -import { html, render, Component } from "../lib/htm/preact.js" -import { Spinner } from "./spinner.js" -import { SearchBox } from "./search-box.js" +import {html, render, Component} from "../lib/htm/preact.js" +import {Spinner} from "./spinner.js" +import {SearchBox} from "./search-box.js" +import {giphyIsEnabled, GiphySearchTab, setGiphyAPIKey} from "./giphy.js" import * as widgetAPI from "./widget-api.js" import * as frequent from "./frequently-used.js" @@ -31,7 +32,7 @@ if (params.has('config')) { // This is updated from packs/index.json let HOMESERVER_URL = "https://matrix-client.matrix.org" -const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale` +const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/v3/thumbnail/${mxc.slice(6)}?height=128&width=128&method=scale` // We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs // This is also used to fix scrolling to sections on Element iOS @@ -52,6 +53,7 @@ class App extends Component { super(props) this.defaultTheme = params.get("theme") this.state = { + viewingGifs: false, packs: defaultState.packs, loading: true, error: null, @@ -118,7 +120,7 @@ class App extends Component { filtering: { ...this.state.filtering, searchTerm, - packs: packsWithFilteredStickers.filter(({ stickers }) => !!stickers.length), + packs: packsWithFilteredStickers.filter(({stickers}) => !!stickers.length), }, }) } @@ -135,10 +137,10 @@ class App extends Component { setTheme(theme) { if (theme === "default") { delete localStorage.mauStickerThemeOverride - this.setState({ theme: this.defaultTheme }) + this.setState({theme: this.defaultTheme}) } else { localStorage.mauStickerThemeOverride = theme - this.setState({ theme: theme }) + this.setState({theme: theme}) } } @@ -154,7 +156,7 @@ class App extends Component { _loadPacks(disableCache = false) { const cache = disableCache ? "no-cache" : undefined - fetch(INDEX, { cache }).then(async indexRes => { + fetch(INDEX, {cache}).then(async indexRes => { if (indexRes.status >= 400) { this.setState({ loading: false, @@ -164,13 +166,14 @@ class App extends Component { } const indexData = await indexRes.json() HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL + setGiphyAPIKey(indexData.giphy_api_key, indexData.giphy_mxc_prefix) // TODO only load pack metadata when scrolled into view? for (const packFile of indexData.packs) { let packRes if (packFile.startsWith("https://") || packFile.startsWith("http://")) { - packRes = await fetch(packFile, { cache }) + packRes = await fetch(packFile, {cache}) } else { - packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, { cache }) + packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, {cache}) } const packData = await packRes.json() for (const sticker of packData.stickers) { @@ -182,7 +185,7 @@ class App extends Component { }) } this.updateFrequentlyUsed() - }, error => this.setState({ loading: false, error })) + }, error => this.setState({loading: false, error})) } componentDidMount() { @@ -214,6 +217,9 @@ class App extends Component { let maxXElem = null for (const entry of intersections) { const packID = entry.target.getAttribute("data-pack-id") + if (!packID) { + continue + } const navElement = document.getElementById(`nav-${packID}`) if (entry.isIntersecting) { navElement.classList.add("visible") @@ -230,9 +236,9 @@ class App extends Component { } } if (minXElem !== null) { - minXElem.scrollIntoView({ inline: "start" }) + minXElem.scrollIntoView({inline: "start"}) } else if (maxXElem !== null) { - maxXElem.scrollIntoView({ inline: "end" }) + maxXElem.scrollIntoView({inline: "end"}) } } @@ -268,36 +274,66 @@ class App extends Component { render() { const theme = `theme-${this.state.theme}` const filterActive = !!this.state.filtering.searchTerm - const packs = filterActive ? this.state.filtering.packs : [this.state.frequentlyUsed, ...this.state.packs] + const packs = filterActive + ? this.state.filtering.packs + : [this.state.frequentlyUsed, ...this.state.packs] if (this.state.loading) { - return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>` + return html` + <main class="spinner ${theme}"> + <${Spinner} size=${80} green/> + </main> + ` } else if (this.state.error) { - return html`<main class="error ${theme}"> - <h1>Failed to load packs</h1> - <p>${this.state.error}</p> - </main>` + return html` + <main class="error ${theme}"> + <h1>Failed to load packs</h1> + <p>${this.state.error}</p> + </main> + ` } else if (this.state.packs.length === 0) { - return html`<main class="empty ${theme}"><h1>No packs found 😿</h1></main>` + return html` + <main class="empty ${theme}"><h1>No packs found 😿</h1></main> + ` } - return html`<main class="has-content ${theme}"> - <nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}> - <${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" /> - ${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)} - <${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" /> - </nav> - <${SearchBox} onKeyUp=${this.searchStickers} /> - <div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}> - ${filterActive && packs.length === 0 ? html`<div class="search-empty"><h1>No stickers match your search</h1></div>` : null} - ${packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)} - <${Settings} app=${this}/> - </div> - </main>` + const onClickOverride = this.state.viewingGifs + ? (evt, packID) => { + evt.preventDefault() + this.setState({viewingGifs: false}, () => { + scrollToSection(null, packID) + }) + } : null + const switchToGiphy = () => this.setState({viewingGifs: true, filtering: defaultState.filtering}) + + return html` + <main class="has-content ${theme}"> + <nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}> + ${giphyIsEnabled() && html` + <${NavBarItem} pack=${{id: "giphy", title: "GIPHY"}} iconOverride="giphy" onClickOverride=${switchToGiphy} extraClass=${this.state.viewingGifs ? "visible" : ""}/> + `} + <${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" onClickOverride=${onClickOverride}/> + ${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack} onClickOverride=${onClickOverride}/>`)} + <${NavBarItem} pack=${{id: "settings", title: "Settings"}} iconOverride="settings" onClickOverride=${onClickOverride}/> + </nav> + + ${this.state.viewingGifs ? html` + <${GiphySearchTab}/> + ` : html` + <${SearchBox} onInput=${this.searchStickers} value=${this.state.filtering.searchTerm ?? ""}/> + <div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${(elem) => (this.packListRef = elem)}> + ${filterActive && packs.length === 0 + ? html`<div class="search-empty"><h1>No stickers match your search</h1></div>` + : null} + ${packs.map((pack) => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)} + <${Settings} app=${this}/> + </div> + `} + </main>` } } -const Settings = ({ app }) => html` +const Settings = ({app}) => html` <section class="stickerpack settings" id="pack-settings" data-pack-id="settings"> <h1>Settings</h1> <div class="settings-list"> @@ -306,7 +342,7 @@ const Settings = ({ app }) => html` <label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label> <input type="range" min=2 max=10 id="stickers-per-row" id="stickers-per-row" value=${app.state.stickersPerRow} - onInput=${evt => app.setStickersPerRow(evt.target.value)} /> + onInput=${evt => app.setStickersPerRow(evt.target.value)}/> </div> <div> <label for="theme">Theme: </label> @@ -325,13 +361,15 @@ const Settings = ({ app }) => html` // open the link in the browser instead of just scrolling there, so we need to scroll manually: const scrollToSection = (evt, id) => { const pack = document.getElementById(`pack-${id}`) - pack.scrollIntoView({ block: "start", behavior: "instant" }) - evt.preventDefault() + if (pack) { + pack.scrollIntoView({block: "start", behavior: "instant"}) + } + evt?.preventDefault() } -const NavBarItem = ({ pack, iconOverride = 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}> +const NavBarItem = ({pack, iconOverride = null, onClickOverride = null, extraClass = null}) => html` + <a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title} class="${extraClass}" + onClick=${onClickOverride ? (evt => onClickOverride(evt, pack.id)) : (isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined)}> <div class="sticker"> ${iconOverride ? html` <span class="icon icon-${iconOverride}"/> @@ -343,7 +381,7 @@ const NavBarItem = ({ pack, iconOverride = null }) => html` </a> ` -const Pack = ({ pack, send }) => 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"> @@ -354,10 +392,10 @@ const Pack = ({ pack, send }) => html` </section> ` -const Sticker = ({ content, send }) => html` +const Sticker = ({content, send}) => html` <div class="sticker" onClick=${send} data-sticker-id=${content.id}> - <img data-src=${makeThumbnailURL(content.url)} alt=${content.body} title=${content.body} /> + <img data-src=${makeThumbnailURL(content.url)} alt=${content.body} title=${content.body}/> </div> ` -render(html`<${App} />`, document.body) +render(html`<${App}/>`, document.body) diff --git a/web/src/search-box.js b/web/src/search-box.js index ba2ed5d..b25769f 100644 --- a/web/src/search-box.js +++ b/web/src/search-box.js @@ -13,13 +13,13 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -import { html } from "../lib/htm/preact.js" +import {html} from "../lib/htm/preact.js" -export const SearchBox = ({ onKeyUp, placeholder = 'Find stickers' }) => { +export const SearchBox = ({onInput, onKeyUp, value, placeholder = 'Find stickers'}) => { const component = html` <div class="search-box"> - <input type="text" placeholder=${placeholder} onKeyUp=${onKeyUp} /> - <span class="icon icon-search" /> + <input type="text" placeholder=${placeholder} value=${value} onInput=${onInput} onKeyUp=${onKeyUp}/> + <span class="icon icon-search"/> </div> ` return component diff --git a/web/src/widget-api.js b/web/src/widget-api.js index fa72165..d9964a7 100644 --- a/web/src/widget-api.js +++ b/web/src/widget-api.js @@ -60,8 +60,9 @@ export function sendSticker(content) { const widgetData = { ...data, description: content.body, - file: `${content.id}.png`, + file: content.filename ?? `${content.id}.png`, } + delete widgetData.content.filename // Element iOS explodes if there are extra fields present delete widgetData.content["net.maunium.telegram.sticker"] |
