From 47f17fde452b5e9f0c9e96ce0e2c878dd0574b7f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 18 May 2024 16:08:35 +0300 Subject: Add support for sending gifs via Giphy Fixes #22 Closes #75 Co-authored-by: Nischay --- web/src/giphy.js | 107 ++++++++++++++++++++++++++++++++++++++++++ web/src/index.js | 126 ++++++++++++++++++++++++++++++++------------------ web/src/search-box.js | 8 ++-- web/src/widget-api.js | 3 +- 4 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 web/src/giphy.js (limited to 'web/src') 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"/> +
+
+
+ ${this.state.error} +
+
+ ${this.state.gifs.map((gif) => html` +
this.handleGifClick(gif)} data-gif-id=${gif.id}> + ${gif.title} +
+ `)} +
+ +
+
+ ` + } +} 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 . -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`
<${Spinner} size=${80} green />
` + return html` +
+ <${Spinner} size=${80} green/> +
+ ` } else if (this.state.error) { - return html`
-

Failed to load packs

-

${this.state.error}

-
` + return html` +
+

Failed to load packs

+

${this.state.error}

+
+ ` } else if (this.state.packs.length === 0) { - return html`

No packs found 😿

` + return html` +

No packs found 😿

+ ` } - return html`
- - <${SearchBox} onKeyUp=${this.searchStickers} /> -
this.packListRef = elem}> - ${filterActive && packs.length === 0 ? html`

No stickers match your search

` : null} - ${packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)} - <${Settings} app=${this}/> -
-
` + 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` +
+ + + ${this.state.viewingGifs ? html` + <${GiphySearchTab}/> + ` : html` + <${SearchBox} onInput=${this.searchStickers} value=${this.state.filtering.searchTerm ?? ""}/> +
(this.packListRef = elem)}> + ${filterActive && packs.length === 0 + ? html`

No stickers match your search

` + : null} + ${packs.map((pack) => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)} + <${Settings} app=${this}/> +
+ `} +
` } } -const Settings = ({ app }) => html` +const Settings = ({app}) => html`

Settings

@@ -306,7 +342,7 @@ const Settings = ({ app }) => html` app.setStickersPerRow(evt.target.value)} /> + onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
@@ -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` - scrollToSection(evt, pack.id)) : undefined}> +const NavBarItem = ({pack, iconOverride = null, onClickOverride = null, extraClass = null}) => html` + onClickOverride(evt, pack.id)) : (isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined)}>
${iconOverride ? html` @@ -343,7 +381,7 @@ const NavBarItem = ({ pack, iconOverride = null }) => html` ` -const Pack = ({ pack, send }) => html` +const Pack = ({pack, send}) => html`

${pack.title}

@@ -354,10 +392,10 @@ const Pack = ({ pack, send }) => html`
` -const Sticker = ({ content, send }) => html` +const Sticker = ({content, send}) => html`
- ${content.body} + ${content.body}
` -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 . -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` ` 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"] -- cgit v1.2.3