aboutsummaryrefslogtreecommitdiffstats
path: root/web/src
diff options
context:
space:
mode:
authorTulir Asokan <tulir@maunium.net>2024-05-18 16:08:35 +0300
committerTulir Asokan <tulir@maunium.net>2024-05-18 16:18:55 +0300
commit47f17fde452b5e9f0c9e96ce0e2c878dd0574b7f (patch)
treed24482ccf9720fe9defce1b361575ae9bbaf1a25 /web/src
parentf59406a47a6778cd402e656ffb64f667335f665a (diff)
Add support for sending gifs via Giphy
Fixes #22 Closes #75 Co-authored-by: Nischay <hegdenischay@gmail.com>
Diffstat (limited to 'web/src')
-rw-r--r--web/src/giphy.js107
-rw-r--r--web/src/index.js126
-rw-r--r--web/src/search-box.js8
-rw-r--r--web/src/widget-api.js3
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"]
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage