diff --git a/web/public/images/powered-by-giphy.png b/web/public/images/powered-by-giphy.png new file mode 100644 index 0000000..41861e6 Binary files /dev/null and b/web/public/images/powered-by-giphy.png differ 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]() +}