Add support for sending gifs via Giphy

Fixes #22
Closes #75

Co-authored-by: Nischay <hegdenischay@gmail.com>
This commit is contained in:
Tulir Asokan
2024-05-18 16:08:35 +03:00
parent f59406a47a
commit 47f17fde45
15 changed files with 631 additions and 188 deletions

107
web/src/giphy.js Normal file
View File

@ -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>
`
}
}

View File

@ -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)

View File

@ -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

View File

@ -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"]