From d55952a1b6c1e5c5213321f8c6254b50231eafb8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 3 Dec 2024 01:23:08 +0200 Subject: [PATCH] web/composer: add gif picker --- web/public/images/powered-by-giphy.png | Bin 0 -> 7741 bytes web/public/images/powered-by-tenor.svg | 34 +++++ web/src/api/types/preferences/preferences.ts | 16 +++ web/src/icons/gif.svg | 1 + web/src/icons/sticker.svg | 1 + web/src/ui/composer/MessageComposer.tsx | 33 +++-- web/src/ui/emojipicker/EmojiPicker.css | 89 ++++++++---- web/src/ui/emojipicker/EmojiPicker.tsx | 2 +- web/src/ui/emojipicker/GIFPicker.tsx | 135 +++++++++++++++++++ web/src/ui/emojipicker/gifsource.ts | 127 +++++++++++++++++ 10 files changed, 401 insertions(+), 37 deletions(-) create mode 100644 web/public/images/powered-by-giphy.png create mode 100644 web/public/images/powered-by-tenor.svg create mode 100644 web/src/icons/gif.svg create mode 100644 web/src/icons/sticker.svg create mode 100644 web/src/ui/emojipicker/GIFPicker.tsx create mode 100644 web/src/ui/emojipicker/gifsource.ts diff --git a/web/public/images/powered-by-giphy.png b/web/public/images/powered-by-giphy.png new file mode 100644 index 0000000000000000000000000000000000000000..41861e6bb0c56023504f0d133812153cd1f6f477 GIT binary patch literal 7741 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 literal 0 HcmV?d00001 diff --git a/web/public/images/powered-by-tenor.svg b/web/public/images/powered-by-tenor.svg new file mode 100644 index 0000000..18e7f02 --- /dev/null +++ b/web/public/images/powered-by-tenor.svg @@ -0,0 +1,34 @@ + + + + PB_tenor_logo_blue_horizontal + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 5200b12..6638057 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -27,9 +27,11 @@ export const codeBlockStyles = [ "tokyonight-storm", "trac", "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode", ] as const export const mapProviders = ["leaflet", "google", "none"] as const +export const gifProviders = ["giphy", "tenor"] as const export type CodeBlockStyle = typeof codeBlockStyles[number] export type MapProvider = typeof mapProviders[number] +export type GIFProvider = typeof gifProviders[number] /* eslint-disable max-len */ export const preferences = { @@ -119,6 +121,20 @@ export const preferences = { allowedContexts: anyContext, defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", }), + gif_provider: new Preference({ + displayName: "GIF provider", + description: "The service to use to search for GIFs", + allowedValues: gifProviders, + allowedContexts: anyContext, + defaultValue: "giphy", + }), + // TODO implement + // reupload_gifs: new Preference({ + // displayName: "Reupload GIFs", + // description: "Should GIFs be reuploaded to your server's media repo instead of using the proxy?", + // allowedContexts: anyContext, + // defaultValue: false, + // }), custom_notification_sound: new Preference({ displayName: "Custom notification sound", description: "The mxc:// URI to a custom notification sound.", diff --git a/web/src/icons/gif.svg b/web/src/icons/gif.svg new file mode 100644 index 0000000..10c42ca --- /dev/null +++ b/web/src/icons/gif.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/sticker.svg b/web/src/icons/sticker.svg new file mode 100644 index 0000000..b6d3423 --- /dev/null +++ b/web/src/icons/sticker.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 9d766d0..6e71f6a 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -31,6 +31,7 @@ import { escapeMarkdown } from "@/util/markdown.ts" import useEvent from "@/util/useEvent.ts" import ClientContext from "../ClientContext.ts" import EmojiPicker from "../emojipicker/EmojiPicker.tsx" +import GIFPicker from "../emojipicker/GIFPicker.tsx" import { keyToString } from "../keybindings.ts" import { LeafletPicker } from "../maps/async.tsx" import { ModalContext } from "../modal/Modal.tsx" @@ -42,6 +43,7 @@ import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./get import AttachIcon from "@/icons/attach.svg?react" import CloseIcon from "@/icons/close.svg?react" import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react" +import GIFIcon from "@/icons/gif.svg?react" import LocationIcon from "@/icons/location.svg?react" import SendIcon from "@/icons/send.svg?react" import "./MessageComposer.css" @@ -400,19 +402,26 @@ const MessageComposer = () => { evt.stopPropagation() roomCtx.setEditing(null) }, [roomCtx]) - const onSelectEmoji = useEvent((emoji: PartialEmoji) => { - setState({ - text: state.text.slice(0, textInput.current?.selectionStart ?? 0) - + emojiToMarkdown(emoji) - + state.text.slice(textInput.current?.selectionEnd ?? 0), - }) - }) const openEmojiPicker = useEvent(() => { openModal({ content: setState({ + text: state.text.slice(0, textInput.current?.selectionStart ?? 0) + + emojiToMarkdown(emoji) + + state.text.slice(textInput.current?.selectionEnd ?? 0), + })} + />, + onClose: () => textInput.current?.focus(), + }) + }) + const openGIFPicker = useEvent(() => { + openModal({ + content: setState({ media })} />, onClose: () => textInput.current?.focus(), }) @@ -476,20 +485,22 @@ const MessageComposer = () => { placeholder="Send a message" id="message-composer" /> - + + diff --git a/web/src/ui/emojipicker/EmojiPicker.css b/web/src/ui/emojipicker/EmojiPicker.css index 12dd75f..fc7c61f 100644 --- a/web/src/ui/emojipicker/EmojiPicker.css +++ b/web/src/ui/emojipicker/EmojiPicker.css @@ -1,4 +1,4 @@ -div.emoji-picker { +div.emoji-picker, div.gif-picker { position: fixed; background-color: var(--background-color); width: 22rem; @@ -9,30 +9,7 @@ div.emoji-picker { flex-direction: column; box-shadow: 0 0 1rem var(--modal-box-shadow-color); - div.emoji-category-bar { - /*height: 2.5rem;*/ - display: flex; - justify-content: center; - flex-wrap: wrap; - padding-top: .5rem; - border-bottom: 1px solid var(--border-color); - - > button { - padding-top: .25rem; - width: 2.125rem; - height: 2.5rem; - box-sizing: border-box; - - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom: 2px solid transparent; - &:hover { - border-bottom: 2px solid var(--primary-color-dark); - } - } - } - - div.emoji-search { + div.emoji-search, div.gif-search { display: flex; align-items: center; margin: .5rem; @@ -56,6 +33,68 @@ div.emoji-picker { border-top-left-radius: 0; } } +} + +div.gif-picker { + width: 32rem; + + > div.gif-list { + overflow-y: auto; + padding: 0 1rem; + flex: 1; + display: flex; + flex-wrap: wrap; + + > div.gif-entry { + cursor: var(--clickable-cursor); + max-width: 10rem; + display: flex; + justify-content: center; + + &:hover { + background-color: var(--button-hover-color); + } + + > img { + object-fit: contain; + width: 100%; + padding: .5rem; + } + } + } + + div.powered-by-footer { + margin-top: auto; + margin-bottom: .5rem; + > img { + max-width: 100%; + } + } +} + +div.emoji-picker { + div.emoji-category-bar { + /*height: 2.5rem;*/ + display: flex; + justify-content: center; + flex-wrap: wrap; + padding-top: .5rem; + border-bottom: 1px solid var(--border-color); + + > button { + padding-top: .25rem; + width: 2.125rem; + height: 2.5rem; + box-sizing: border-box; + + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 2px solid transparent; + &:hover { + border-bottom: 2px solid var(--primary-color-dark); + } + } + } div.emoji-list { overflow-y: auto; diff --git a/web/src/ui/emojipicker/EmojiPicker.tsx b/web/src/ui/emojipicker/EmojiPicker.tsx index 204a97d..e366283 100644 --- a/web/src/ui/emojipicker/EmojiPicker.tsx +++ b/web/src/ui/emojipicker/EmojiPicker.tsx @@ -59,7 +59,7 @@ interface EmojiPickerProps { selected?: string[] } -export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => { +const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => { const client = use(ClientContext)! const [query, setQuery] = useState("") const [previewEmoji, setPreviewEmoji] = useState() diff --git a/web/src/ui/emojipicker/GIFPicker.tsx b/web/src/ui/emojipicker/GIFPicker.tsx new file mode 100644 index 0000000..c450981 --- /dev/null +++ b/web/src/ui/emojipicker/GIFPicker.tsx @@ -0,0 +1,135 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import React, { CSSProperties, use, useCallback, useEffect, useState } from "react" +import { RoomStateStore, usePreference } from "@/api/statestore" +import { MediaMessageEventContent } from "@/api/types" +import ClientContext from "../ClientContext.ts" +import { ModalCloseContext } from "../modal/Modal.tsx" +import { GIF, getTrendingGIFs, searchGIF } from "./gifsource.ts" +import CloseIcon from "@/icons/close.svg?react" +import SearchIcon from "@/icons/search.svg?react" + +interface GIFPickerProps { + style: CSSProperties + onSelect: (media: MediaMessageEventContent) => void + room: RoomStateStore +} + +const trendingCache = new Map() + +const GIFPicker = ({ style, onSelect, room }: GIFPickerProps) => { + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [error, setError] = useState() + const close = use(ModalCloseContext) + const clearQuery = useCallback(() => setQuery(""), []) + const onChangeQuery = useCallback((evt: React.ChangeEvent) => setQuery(evt.target.value), []) + const client = use(ClientContext)! + const provider = usePreference(client.store, room, "gif_provider") + const providerName = provider.slice(0, 1).toUpperCase() + provider.slice(1) + // const reuploadGIFs = room.preferences.reupload_gifs + const onSelectGIF = useCallback((evt: React.MouseEvent) => { + const idx = evt.currentTarget.getAttribute("data-gif-index") + if (!idx) { + return + } + const gif = results[+idx] + // if (reuploadGIFs) { + // // TODO + // } + onSelect({ + msgtype: "m.image", + body: gif.filename, + info: { + mimetype: "image/webp", + size: gif.size, + w: gif.width, + h: gif.height, + }, + url: gif.proxied_mxc, + }) + close() + }, [onSelect, close, results]) + useEffect(() => { + if (!query) { + if (trendingCache.has(provider)) { + setResults(trendingCache.get(provider)!) + return + } else { + const abort = new AbortController() + getTrendingGIFs(provider).then( + res => { + trendingCache.set(provider, res) + if (!abort.signal.aborted) { + setResults(res) + } + }, + err => !abort.signal.aborted && setError(err), + ) + return () => abort.abort() + } + } + const abort = new AbortController() + const timeout = setTimeout(() => { + searchGIF(provider, query, abort.signal).then( + setResults, + err => !abort.signal.aborted && setError(err), + ) + }, 500) + return () => { + clearTimeout(timeout) + abort.abort() + } + }, [query, provider]) + let poweredBySrc: string | undefined + if (provider === "giphy") { + poweredBySrc = "images/powered-by-giphy.png" + } else if (provider === "tenor") { + poweredBySrc = "images/powered-by-tenor.svg" + } + return
+
+ + +
+ {error ?
+ {`${error}`} +
: null} +
+ {results.map((gif, idx) =>
+ {gif.alt_text}/ +
)} + {poweredBySrc &&
+ {`Powered +
} +
+
+} + +export default GIFPicker diff --git a/web/src/ui/emojipicker/gifsource.ts b/web/src/ui/emojipicker/gifsource.ts new file mode 100644 index 0000000..2c4afe3 --- /dev/null +++ b/web/src/ui/emojipicker/gifsource.ts @@ -0,0 +1,127 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { ContentURI } from "@/api/types" +import { GIFProvider } from "@/api/types/preferences" +import { GIPHY_API_KEY, TENOR_API_KEY } from "@/util/keys.ts" + +export interface GIF { + key: string + filename: string + title: string + alt_text: string + proxied_mxc: ContentURI + https_url: string + width: number + height: number + size: number +} + +function mapGiphyResults(results: unknown[]): GIF[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return results.map((entry: any): GIF => ({ + key: entry.id, + filename: `${entry.slug}.webp`, + title: entry.title, + alt_text: entry.alt_text, + proxied_mxc: `mxc://giphy.mau.dev/${entry.id}`, + https_url: entry.images.original.webp, + size: entry.images.original.webp_size, + width: entry.images.original.width, + height: entry.images.original.height, + })) +} + +const tenorMediaURLRegex = /https:\/\/media\.tenor\.com\/([A-Za-z0-9_-]+)\/.+/ + +function mapTenorResults(results: unknown[]): GIF[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return results.map((entry: any): GIF | undefined => { + const id = tenorMediaURLRegex.exec(entry.media_formats.webp.url)?.[1] + if (!id) { + return + } + return { + key: entry.id, + filename: `${entry.id}.webp`, + title: entry.title, + alt_text: entry.alt_text, + proxied_mxc: `mxc://tenor.mau.dev/${id}`, + https_url: entry.media_formats.webp.url, + size: entry.media_formats.webp.size, + width: entry.media_formats.webp.dims[0], + height: entry.media_formats.webp.dims[1], + } + }).filter((entry: GIF | undefined): entry is GIF => !!entry) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function doRequest(url: URL, signal?: AbortSignal): Promise { + const resp = await fetch(url, { signal }) + if (resp.status !== 200) { + throw new Error(`HTTP ${resp.status}: ${await resp.text()}`) + } + return await resp.json() +} + +async function searchGiphy(signal: AbortSignal, query: string): Promise { + const url = new URL("https://api.giphy.com/v1/gifs/search") + url.searchParams.set("api_key", GIPHY_API_KEY) + url.searchParams.set("q", query) + url.searchParams.set("limit", "50") + return mapGiphyResults((await doRequest(url, signal)).data) +} + +async function searchTenor(signal: AbortSignal, query: string): Promise { + const url = new URL("https://tenor.googleapis.com/v2/search") + url.searchParams.set("key", TENOR_API_KEY) + url.searchParams.set("media_filter", "webp") + url.searchParams.set("q", query) + url.searchParams.set("limit", "50") + return mapTenorResults((await doRequest(url, signal)).results) +} + +async function getGiphyTrending(): Promise { + const url = new URL("https://api.giphy.com/v1/gifs/trending") + url.searchParams.set("api_key", GIPHY_API_KEY) + url.searchParams.set("limit", "50") + return mapGiphyResults((await doRequest(url)).data) +} + +async function getTenorTrending(): Promise { + const url = new URL("https://tenor.googleapis.com/v2/featured") + url.searchParams.set("key", TENOR_API_KEY) + url.searchParams.set("media_filter", "webp") + url.searchParams.set("limit", "50") + return mapTenorResults((await doRequest(url)).results) +} + +const searchFuncs = { + giphy: searchGiphy, + tenor: searchTenor, +} + +const trendingFuncs = { + giphy: getGiphyTrending, + tenor: getTenorTrending, +} + +export async function searchGIF(provider: GIFProvider, query: string, signal: AbortSignal): Promise { + return searchFuncs[provider](signal, query) +} + +export async function getTrendingGIFs(provider: GIFProvider): Promise { + return trendingFuncs[provider]() +}