1
0
Fork 0
forked from Mirrors/gomuks

web/composer: add gif picker

This commit is contained in:
Tulir Asokan 2024-12-03 01:23:08 +02:00
parent dee7e5c72d
commit d55952a1b6
10 changed files with 401 additions and 37 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -27,9 +27,11 @@ export const codeBlockStyles = [
"tokyonight-storm", "trac", "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode", "tokyonight-storm", "trac", "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode",
] as const ] as const
export const mapProviders = ["leaflet", "google", "none"] 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 CodeBlockStyle = typeof codeBlockStyles[number]
export type MapProvider = typeof mapProviders[number] export type MapProvider = typeof mapProviders[number]
export type GIFProvider = typeof gifProviders[number]
/* eslint-disable max-len */ /* eslint-disable max-len */
export const preferences = { export const preferences = {
@ -119,6 +121,20 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", defaultValue: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
}), }),
gif_provider: new Preference<GIFProvider>({
displayName: "GIF provider",
description: "The service to use to search for GIFs",
allowedValues: gifProviders,
allowedContexts: anyContext,
defaultValue: "giphy",
}),
// TODO implement
// reupload_gifs: new Preference<boolean>({
// 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<ContentURI>({ custom_notification_sound: new Preference<ContentURI>({
displayName: "Custom notification sound", displayName: "Custom notification sound",
description: "The mxc:// URI to a custom notification sound.", description: "The mxc:// URI to a custom notification sound.",

1
web/src/icons/gif.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm240-160h60v-240h-60v240Zm-160 0h80q17 0 28.5-11.5T400-400v-80h-60v60h-40v-120h100v-20q0-17-11.5-28.5T360-600h-80q-17 0-28.5 11.5T240-560v160q0 17 11.5 28.5T280-360Zm280 0h60v-80h80v-60h-80v-40h120v-60H560v240ZM200-200v-560 560Z"/></svg>

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#5f6368"><path d="M5.5,2C3.56,2 2,3.56 2,5.5V18.5C2,20.44 3.56,22 5.5,22H16L22,16V5.5C22,3.56 20.44,2 18.5,2H5.5M5.75,4H18.25A1.75,1.75 0 0,1 20,5.75V15H18.5C16.56,15 15,16.56 15,18.5V20H5.75A1.75,1.75 0 0,1 4,18.25V5.75A1.75,1.75 0 0,1 5.75,4M14.44,6.77C14.28,6.77 14.12,6.79 13.97,6.83C13.03,7.09 12.5,8.05 12.74,9C12.79,9.15 12.86,9.3 12.95,9.44L16.18,8.56C16.18,8.39 16.16,8.22 16.12,8.05C15.91,7.3 15.22,6.77 14.44,6.77M8.17,8.5C8,8.5 7.85,8.5 7.7,8.55C6.77,8.81 6.22,9.77 6.47,10.7C6.5,10.86 6.59,11 6.68,11.16L9.91,10.28C9.91,10.11 9.89,9.94 9.85,9.78C9.64,9 8.95,8.5 8.17,8.5M16.72,11.26L7.59,13.77C8.91,15.3 11,15.94 12.95,15.41C14.9,14.87 16.36,13.25 16.72,11.26Z" /></svg>

After

Width:  |  Height:  |  Size: 750 B

View file

@ -31,6 +31,7 @@ import { escapeMarkdown } from "@/util/markdown.ts"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import GIFPicker from "../emojipicker/GIFPicker.tsx"
import { keyToString } from "../keybindings.ts" import { keyToString } from "../keybindings.ts"
import { LeafletPicker } from "../maps/async.tsx" import { LeafletPicker } from "../maps/async.tsx"
import { ModalContext } from "../modal/Modal.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 AttachIcon from "@/icons/attach.svg?react"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.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 LocationIcon from "@/icons/location.svg?react"
import SendIcon from "@/icons/send.svg?react" import SendIcon from "@/icons/send.svg?react"
import "./MessageComposer.css" import "./MessageComposer.css"
@ -400,19 +402,26 @@ const MessageComposer = () => {
evt.stopPropagation() evt.stopPropagation()
roomCtx.setEditing(null) roomCtx.setEditing(null)
}, [roomCtx]) }, [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(() => { const openEmojiPicker = useEvent(() => {
openModal({ openModal({
content: <EmojiPicker content: <EmojiPicker
style={{ bottom: (composerRef.current?.clientHeight ?? 32) + 2, right: "1rem" }} style={{ bottom: (composerRef.current?.clientHeight ?? 32) + 2, right: "1rem" }}
room={roomCtx.store} room={roomCtx.store}
onSelect={onSelectEmoji} onSelect={(emoji: PartialEmoji) => 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: <GIFPicker
style={{ bottom: (composerRef.current?.clientHeight ?? 32) + 2, right: "1rem" }}
room={roomCtx.store}
onSelect={media => setState({ media })}
/>, />,
onClose: () => textInput.current?.focus(), onClose: () => textInput.current?.focus(),
}) })
@ -476,20 +485,22 @@ const MessageComposer = () => {
placeholder="Send a message" placeholder="Send a message"
id="message-composer" id="message-composer"
/> />
<button onClick={openEmojiPicker}><EmojiIcon/></button> <button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/></button>
<button onClick={openGIFPicker} title="Add gif attachment"><GIFIcon/></button>
<button <button
onClick={openLocationPicker} onClick={openLocationPicker}
disabled={!!locationDisabledTitle} disabled={!!locationDisabledTitle}
title={locationDisabledTitle} title={locationDisabledTitle ?? "Add location"}
><LocationIcon/></button> ><LocationIcon/></button>
<button <button
onClick={openFilePicker} onClick={openFilePicker}
disabled={!!mediaDisabledTitle} disabled={!!mediaDisabledTitle}
title={mediaDisabledTitle} title={mediaDisabledTitle ?? "Add file attachment"}
><AttachIcon/></button> ><AttachIcon/></button>
<button <button
onClick={sendMessage} onClick={sendMessage}
disabled={(!state.text && !state.media && !state.location) || loadingMedia} disabled={(!state.text && !state.media && !state.location) || loadingMedia}
title="Send message"
><SendIcon/></button> ><SendIcon/></button>
<input ref={fileInput} onChange={onAttachFile} type="file" value=""/> <input ref={fileInput} onChange={onAttachFile} type="file" value=""/>
</div> </div>

View file

@ -1,4 +1,4 @@
div.emoji-picker { div.emoji-picker, div.gif-picker {
position: fixed; position: fixed;
background-color: var(--background-color); background-color: var(--background-color);
width: 22rem; width: 22rem;
@ -9,30 +9,7 @@ div.emoji-picker {
flex-direction: column; flex-direction: column;
box-shadow: 0 0 1rem var(--modal-box-shadow-color); box-shadow: 0 0 1rem var(--modal-box-shadow-color);
div.emoji-category-bar { div.emoji-search, div.gif-search {
/*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 {
display: flex; display: flex;
align-items: center; align-items: center;
margin: .5rem; margin: .5rem;
@ -56,6 +33,68 @@ div.emoji-picker {
border-top-left-radius: 0; 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 { div.emoji-list {
overflow-y: auto; overflow-y: auto;

View file

@ -59,7 +59,7 @@ interface EmojiPickerProps {
selected?: string[] 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 client = use(ClientContext)!
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [previewEmoji, setPreviewEmoji] = useState<Emoji>() const [previewEmoji, setPreviewEmoji] = useState<Emoji>()

View file

@ -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 <https://www.gnu.org/licenses/>.
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<string, GIF[]>()
const GIFPicker = ({ style, onSelect, room }: GIFPickerProps) => {
const [query, setQuery] = useState("")
const [results, setResults] = useState<GIF[]>([])
const [error, setError] = useState<unknown>()
const close = use(ModalCloseContext)
const clearQuery = useCallback(() => setQuery(""), [])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => 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<HTMLDivElement>) => {
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 <div className="gif-picker" style={style}>
<div className="gif-search">
<input
autoFocus
onChange={onChangeQuery}
value={query}
type="search"
placeholder={`Search ${providerName}`}
/>
<button onClick={clearQuery} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>
{error ? <div className="gif-error">
{`${error}`}
</div> : null}
<div className="gif-list">
{results.map((gif, idx) => <div
className="gif-entry"
key={gif.key}
data-gif-index={idx}
onClick={onSelectGIF}
>
<img loading="lazy" src={gif.https_url} alt={gif.alt_text}/>
</div>)}
{poweredBySrc && <div className="powered-by-footer">
<img src={poweredBySrc} alt={`Powered by ${providerName}`}/>
</div>}
</div>
</div>
}
export default GIFPicker

View file

@ -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 <https://www.gnu.org/licenses/>.
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<any> {
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<GIF[]> {
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<GIF[]> {
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<GIF[]> {
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<GIF[]> {
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<GIF[]> {
return searchFuncs[provider](signal, query)
}
export async function getTrendingGIFs(provider: GIFProvider): Promise<GIF[]> {
return trendingFuncs[provider]()
}