diff --git a/.changeset/added_a_gif_search_functionality.md b/.changeset/added_a_gif_search_functionality.md new file mode 100644 index 000000000..2b7b937e1 --- /dev/null +++ b/.changeset/added_a_gif_search_functionality.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Added a GIF search functionality diff --git a/config.json b/config.json index 2809e4f68..77be1ad3c 100644 --- a/config.json +++ b/config.json @@ -44,5 +44,10 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "gifs": { + "proxyUrl": "gifs.sable.moe", + "klipyApiKey": "IfeIBlDMvq0av2BcKPDuxwRqbnYRbS90yNqFHEkK2Ja207tkR5nssh3NIlJRCr76" } } diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 2fe8bd50a..a55ab44ec 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -5,7 +5,7 @@ import type { ReactNode, RefObject, } from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, config, Scroll } from 'folds'; import { ClockCounterClockwise } from '$components/icons/phosphor'; import FocusTrap from 'focus-trap-react'; @@ -44,10 +44,12 @@ import { SidebarDivider, Sidebar, NoStickerPacks, + GifStatus, createPreviewDataAtom, Preview, EmojiItem, StickerItem, + GifItem, CustomEmojiItem, ImageGroupIcon, GroupIcon, @@ -55,7 +57,13 @@ import { EmojiGroup, EmojiBoardLayout, } from './components'; +import type { GifData } from './types'; import { EmojiBoardTab, EmojiType } from './types'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; + +/* oxlint-disable typescript/no-explicit-any */ +// TODO: type klipy api properly const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -70,11 +78,20 @@ type StickerGroupItem = { name: string; items: Array; }; +type GifGroupItem = { + id: string; + name: string; + items: GifData[]; +}; const useGroups = ( tab: EmojiBoardTab, - imagePacks: ImagePack[] -): [EmojiGroupItem[], StickerGroupItem[]] => { + imagePacks: ImagePack[], + data: { + gifs: GifData[]; + favorites: GifData[]; + } +): [EmojiGroupItem[], StickerGroupItem[], GifGroupItem[]] => { const mx = useMatrixClient(); const recentEmojis = useRecentEmoji(mx, 21); @@ -134,17 +151,64 @@ const useGroups = ( return g; }, [mx, imagePacks, tab]); - return [emojiGroupItems, stickerGroupItems]; + const gifGroupItems = useMemo(() => { + if (tab !== EmojiBoardTab.Gif) return []; + return [ + { + id: 'gif_group', + name: 'GIFs', + items: data.gifs, + }, + ]; + }, [tab, data]); + + return [emojiGroupItems, stickerGroupItems, gifGroupItems]; }; const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const renderItem = (emoji: IEmoji | PackImageReader, index: number) => { - if ('unicode' in emoji) { - return ; + const renderItem = (item: IEmoji | PackImageReader | GifData, index: number) => { + if (tab === EmojiBoardTab.Gif) { + const gif = item as GifData; + + let initialGifUrl = gif.preview_url ?? gif.url; + let gifUrl = initialGifUrl.startsWith('mxc://') + ? (mxcUrlToHttp(mx, initialGifUrl, useAuthentication) ?? '') + : initialGifUrl; + const aspectRatio = + gif.width && gif.height && gif.width > 0 && gif.height > 0 + ? `${gif.width} / ${gif.height}` + : '1 / 1'; + + return ( + + + + ); + } + + if ('unicode' in item) { + return ; } + + const emoji = item as PackImageReader; + if (tab === EmojiBoardTab.Sticker) { return ( void; onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; + onGifSelect?: (gif: GifData, spoiler?: boolean) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; isFullWidth?: boolean; }; +const getGifName = (v: GifData) => v.title; + export function EmojiBoard({ tab = EmojiBoardTab.Emoji, onTabChange, @@ -398,26 +465,27 @@ export function EmojiBoard({ onEmojiSelect, onCustomEmojiSelect, onStickerSelect, + onGifSelect, allowTextCustomEmoji, addToRecentEmoji = true, isFullWidth, }: Readonly) { const mx = useMatrixClient(); const [saveStickerEmojiBandwidth] = useSetting(settingsAtom, 'saveStickerEmojiBandwidth'); + const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); const emojiTab = tab === EmojiBoardTab.Emoji; + const gifTab = tab === EmojiBoardTab.Gif; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; const previewAtom = useMemo( - () => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined), - [emojiTab] + () => createPreviewDataAtom(tab === EmojiBoardTab.Emoji ? DefaultEmojiPreview : undefined), + [tab] ); const activeGroupIdAtom = useMemo(() => atom(undefined), []); const setActiveGroupId = useSetAtom(activeGroupIdAtom); const imagePacks = useRelevantImagePacks(usage, imagePackRooms); - const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); - const groups = emojiTab ? emojiGroupItems : stickerGroupItems; - const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); + const favoriteGifs = useFavoriteGifs().gifs as GifData[]; const searchList = useMemo(() => { let list: Array = []; @@ -426,22 +494,170 @@ export function EmojiBoard({ return list; }, [emojiTab, usage, imagePacks]); - const [result, search, resetSearch] = useAsyncSearch( + const [emojiResult, emojiSearch, resetEmojiSearch] = useAsyncSearch( searchList, getEmoticonSearchStr, SEARCH_OPTIONS ); - const searchedItems = result?.items.slice(0, 100); + const [gifResult, gifSearch, resetGifSearch] = useAsyncSearch( + favoriteGifs, + getGifName, + SEARCH_OPTIONS + ); + + const searchedItems = emojiResult?.items.slice(0, 100); + const searchedGifItems = gifResult?.items.slice(0, 100) ?? favoriteGifs; + + function useGifSearch() { + const [gifs, setGifs] = useState<{ + gifs: GifData[]; + favorites: GifData[]; + }>({ + gifs: [], + favorites: favoriteGifs, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const clientConfig = useClientConfig(); + const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? ''; + + const parseKlipyResult = useCallback((klipyResult: any): GifData => { + const SIZE_LIMIT = 3 * 1024 * 1024; // 3MB + + const formats = klipyResult.file || {}; + const preview = formats.xs.gif || formats.sm.gif || formats.md.gif; + + // Start with full resolution GIF + let fullRes = formats.hd.gif; + // If full res is too large and medium exists, use medium instead + if (fullRes && fullRes.size > SIZE_LIMIT && formats.md) { + fullRes = formats.md.gif; + } + + // Fallback if no suitable format found + if (!fullRes) { + fullRes = formats.md || preview; + } + + // Get dimensions from the selected full resolution format + const width = fullRes?.width || preview?.width || 0; + const height = fullRes?.height || preview?.height || 0; + + return { + id: klipyResult.id, + title: klipyResult.title || 'GIF', + url: fullRes?.url || '', + preview_url: preview?.url || fullRes?.url || '', + width, + height, + size: fullRes?.size || preview?.size || 0, + }; + }, []); + + const searchGifs = useCallback( + async (query: string) => { + if (!showGifPicker) { + return; + } + + const trimmedQuery = query.trim(); + + setLoading(true); + setError(null); + + gifSearch(trimmedQuery); + + try { + const url = new URL('https://api.klipy.com'); + url.pathname = `/api/v1/${klipyApiKey}/gifs/search`; + url.searchParams.set('q', trimmedQuery); + url.searchParams.set('per_page', '50'); // TODO: infinite scroll? + + const response = await fetch(url.toString()); + + if (response.status === 200) { + const data = await response.json(); + const results = data.data.data as any[] | undefined; + + if (results) { + const gifData: GifData[] = results.map(parseKlipyResult); + setGifs((old) => ({ + ...old, + gifs: gifData, + })); + } else { + setGifs((old) => ({ + ...old, + gifs: [], + })); + } + } else { + throw new Error(`HTTP ${response.status}`); + } + } catch { + setError('Failed to search GIFs'); + setGifs((old) => ({ + ...old, + gifs: [], + })); + } finally { + setLoading(false); + } + }, + [parseKlipyResult, klipyApiKey] + ); + + return { gifs, loading, error, searchGifs }; + } + + const { gifs, loading: gifsLoading, error: gifsError, searchGifs } = useGifSearch(); + const [emojiGroupItems, stickerGroupItems, gifGroupItems] = useGroups(tab, imagePacks, gifs); + const [showFavoritesOnly, setShowFavoritesOnly] = useState(true); + const groupsByTab = { + [EmojiBoardTab.Emoji]: emojiGroupItems, + [EmojiBoardTab.Sticker]: stickerGroupItems, + [EmojiBoardTab.Gif]: + showFavoritesOnly && gifs.favorites.length > 0 + ? [ + { + id: 'favorites_group', + name: 'Favorites', + items: searchedGifItems, + }, + ] + : searchedGifItems.length > 0 + ? [ + { + id: 'favorites_group', + name: 'Favorites', + items: searchedGifItems, + }, + ].concat(gifGroupItems) + : gifGroupItems, + }; + const groups = groupsByTab[tab]; + const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); const handleOnChange: ChangeEventHandler = useDebounce( useCallback( (evt) => { const term = evt.target.value; - if (term) search(term); - else resetSearch(); + if (tab === EmojiBoardTab.Gif) { + if (term) { + setShowFavoritesOnly(false); + searchGifs(term); + } else { + setShowFavoritesOnly(true); + resetGifSearch(); + } + } else if (term) { + emojiSearch(term); + } else { + resetEmojiSearch(); + } }, - [search, resetSearch] + [emojiSearch, resetEmojiSearch, searchGifs, resetGifSearch, tab] ), { wait: 200 } ); @@ -494,6 +710,12 @@ export function EmojiBoard({ if (emojiInfo.type === EmojiType.Sticker) { onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label); } + if (emojiInfo.type === EmojiType.Gif) { + const gifDataStr = targetEl.getAttribute('data-gif-data'); + const gifData = gifDataStr ? JSON.parse(gifDataStr) : null; + const isSpoiler = targetEl.getAttribute('data-gif-spoiler') === 'true'; + onGifSelect?.(gifData, isSpoiler); + } if (!evt.altKey && !evt.shiftKey) requestClose(); }; @@ -518,7 +740,7 @@ export function EmojiBoard({ const group = inViewVItem ? groups[inViewVItem?.index] : undefined; setActiveGroupId(group?.id); } - }, [vItems, groups, setActiveGroupId, result?.query]); + }, [vItems, groups, setActiveGroupId, emojiResult?.query, gifResult?.query]); // reset scroll position on search useEffect(() => { @@ -526,7 +748,7 @@ export function EmojiBoard({ if (scrollElement) { scrollElement.scrollTo({ top: 0 }); } - }, [result?.query]); + }, [emojiResult?.query, gifResult?.query]); // reset scroll position on tab change useEffect(() => { @@ -560,8 +782,9 @@ export function EmojiBoard({ {onTabChange && } @@ -576,12 +799,14 @@ export function EmojiBoard({ onScrollToGroup={handleScrollToGroup} /> ) : ( - + !gifTab && ( + + ) ) } isFullWidth={isFullWidth} @@ -593,7 +818,7 @@ export function EmojiBoard({ previewAtom={previewAtom} onGroupItemClick={handleGroupItemClick} > - {searchedItems && ( + {tab !== EmojiBoardTab.Gif && searchedItems && ( - + {group.items.map(renderItem)} @@ -626,9 +851,16 @@ export function EmojiBoard({ })} {tab === EmojiBoardTab.Sticker && groups.length === 0 && } + {gifTab && ( + v.items.map(() => 'gif')).length === 0} + /> + )} - + {!gifTab && } ); diff --git a/src/app/components/emoji-board/components/Group.tsx b/src/app/components/emoji-board/components/Group.tsx index f3cfa0799..c569713ed 100644 --- a/src/app/components/emoji-board/components/Group.tsx +++ b/src/app/components/emoji-board/components/Group.tsx @@ -10,9 +10,10 @@ export const EmojiGroup = as< { id: string; label: string; + isGifGroup?: boolean; children: ReactNode; } ->(({ className, id, label, children, ...props }, ref) => ( +>(({ className, id, label, isGifGroup, children, ...props }, ref) => ( {label} -
- - {children} - +
+ {isGifGroup ? ( + children + ) : ( + + {children} + + )}
)); diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index 6868a5e9b..2fa0b6ad0 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -1,11 +1,19 @@ -import { Box } from 'folds'; +import { Box, color, config, Menu, MenuItem } from 'folds'; import type { MatrixClient } from '$types/matrix-sdk'; import type { PackImageReader } from '$plugins/custom-emoji'; import type { IEmoji } from '$plugins/emoji'; import { mxcUrlToHttp } from '$utils/matrix'; -import type { EmojiItemInfo } from '$components/emoji-board/types'; +import type { EmojiItemInfo, GifData } from '$components/emoji-board/types'; import { EmojiType } from '$components/emoji-board/types'; +import type { CSSProperties, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import * as css from './styles.css'; +import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; +import { Star, Eye, EyeSlash, menuIcon } from '$components/icons/phosphor'; +import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '$unstable/prefixes'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { getKlipyMxcUrl } from '$utils/klipy'; const ANIMATED_MIME_TYPES = new Set(['image/gif', 'image/apng']); @@ -140,3 +148,134 @@ export function StickerItem({ ); } + +export function GifItem({ + label, + type, + data, + shortcode, + gif, + style, + children, +}: { + label: string; + type: EmojiType; + data: string; + shortcode: string; + gif: GifData; + style?: CSSProperties; + children: ReactNode; +}) { + const [isHovered, setIsHovered] = useState(false); + const favoritedContent = useFavoriteGifs(); + const clientConfig = useClientConfig(); + + const mxcUrl = gif?.url ? getKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl) : ''; + + const [favorited, setFavorited] = useState( + favoritedContent.gifs.some((v) => { + const vMxc = getKlipyMxcUrl(v.url, clientConfig.gifs?.proxyUrl); + return vMxc === mxcUrl && mxcUrl !== ''; + }) + ); + const [isSpoiler, setIsSpoiler] = useState(false); + const mx = useMatrixClient(); + + useEffect(() => { + setFavorited( + favoritedContent.gifs.some((v) => { + const vMxc = getKlipyMxcUrl(v.url, clientConfig.gifs?.proxyUrl); + return vMxc === mxcUrl && mxcUrl !== ''; + }) + ); + }, [favoritedContent, mxcUrl, clientConfig.gifs?.proxyUrl]); + + return ( + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > + {children} + {isHovered && ( + + + + { + e.preventDefault(); + e.stopPropagation(); + if (!favorited) { + setFavorited(true); + await mx + .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { + gifs: [ + ...favoritedContent.gifs, + { + title: gif.title, + url: mxcUrl, + width: gif.width, + height: gif.height, + size: gif.size, + }, + ], + }) + .catch(() => setFavorited(false)); + } else { + setFavorited(false); + await mx + .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { + gifs: favoritedContent.gifs.filter( + (v) => getKlipyMxcUrl(v.url, clientConfig.gifs?.proxyUrl) !== mxcUrl + ), + }) + .catch(() => setFavorited(true)); + } + }} + > + {menuIcon(Star, { + weight: favorited ? 'fill' : 'regular', + color: favorited ? color.Warning.MainHover : color.Surface.OnContainer, + })} + + { + e.preventDefault(); + e.stopPropagation(); + setIsSpoiler(!isSpoiler); + }} + > + {menuIcon(isSpoiler ? EyeSlash : Eye, { + weight: isSpoiler ? 'fill' : 'regular', + color: color.Surface.OnContainer, + })} + + + + + )} + + ); +} diff --git a/src/app/components/emoji-board/components/NoGifResults.tsx b/src/app/components/emoji-board/components/NoGifResults.tsx new file mode 100644 index 000000000..f1dd92d2b --- /dev/null +++ b/src/app/components/emoji-board/components/NoGifResults.tsx @@ -0,0 +1,63 @@ +import { SmileySadIcon } from '@phosphor-icons/react'; +import { Box, toRem, config, Text } from 'folds'; + +export function GifSearching() { + return ( + + Loading GIFs... + + ); +} + +export function GifSearchError({ error }: { error: string }) { + return ( + + Error: {error} + + ); +} + +export function NoGifResults() { + return ( + + + + No GIFs found! + + Try searching for something else or favoriting some gifs. + + + + ); +} + +type GifStatusProps = { + loading: boolean; + error: string | null; + isEmpty: boolean; +}; + +export function GifStatus({ loading, error, isEmpty }: Readonly) { + if (loading) return ; + if (error) return ; + if (isEmpty) return ; + return null; +} diff --git a/src/app/components/emoji-board/components/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx index 725e776b5..5d55b061b 100644 --- a/src/app/components/emoji-board/components/SearchInput.tsx +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -3,18 +3,21 @@ import { useRef } from 'react'; import { Input, Chip, Text } from 'folds'; import { mobileOrTablet } from '$utils/user-agent'; import { ArrowRight, sizedIcon, MagnifyingGlass } from '$components/icons/phosphor'; +import { EmojiBoardTab } from '../types'; type SearchInputProps = { query?: string; onChange: ChangeEventHandler; allowTextCustomEmoji?: boolean; onTextCustomEmojiSelect?: (text: string) => void; + tab?: EmojiBoardTab; }; export function SearchInput({ query, onChange, allowTextCustomEmoji, onTextCustomEmojiSelect, + tab, }: SearchInputProps) { const inputRef = useRef(null); @@ -29,10 +32,16 @@ export function SearchInput({ ref={inputRef} variant="SurfaceVariant" size="400" - placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'} + placeholder={ + tab === EmojiBoardTab.Gif + ? 'Search KLIPY' + : allowTextCustomEmoji + ? 'Search or Text Reaction ' + : 'Search' + } maxLength={50} after={ - allowTextCustomEmoji && query ? ( + allowTextCustomEmoji && query && tab !== EmojiBoardTab.Gif ? ( void; }) { + const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); return ( + {showGifPicker && ( + onTabChange(EmojiBoardTab.Gif)} + > + + GIF + + + )} ( const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [isHovered, setIsHovered] = useState(false); + const favoritedContent = useFavoriteGifs(); + const [favorited, setFavorited] = useState( + favoritedContent.gifs.find((v) => v.url == url) != undefined + ); + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { if (url.startsWith('http')) return url; @@ -374,21 +392,67 @@ export const ImageContent = as<'div', ImageContentProps>( {isHovered && ( - { - e.preventDefault(); - if (srcState.status === AsyncStatus.Idle) { - loadSrc(); - setBlurred(false); - } else setBlurred(!blurred); - }} - /> + + { + e.preventDefault(); + if (srcState.status === AsyncStatus.Idle) { + loadSrc(); + setBlurred(false); + } else setBlurred(!blurred); + }} + > + {menuIcon(blurred ? Eye : EyeSlash)} + + {info?.mimetype == 'image/gif' && ( + { + e.preventDefault(); + if (srcState.status === AsyncStatus.Success) { + if (!favorited) { + setFavorited(true); + await mx + .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { + gifs: [ + ...favoritedContent.gifs, + { + title: body, + url: url, + width: imageW, + height: imageH, + size: info?.size, + }, + ], + }) + .catch(() => setFavorited(false)); + } else { + setFavorited(false); + await mx + .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { + gifs: favoritedContent.gifs.filter((v) => v.url != url), + }) + .catch(() => setFavorited(true)); + } + } + }} + > + {menuIcon(Star, { + weight: favorited ? 'fill' : 'regular', + color: favorited ? color.Warning.MainHover : color.Surface.OnContainer, + })} + + )} + )} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 2f02a5822..aff2088f9 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -64,6 +64,7 @@ import { BlockType, } from '$components/editor'; import { plainToEditorInput } from '$components/editor/input'; +import type { GifData } from '$components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; import { UseStateProvider } from '$components/UseStateProvider'; import type { TUploadContent } from '$utils/matrix'; @@ -168,8 +169,10 @@ import { getFileMsgContent, getImageMsgContent, getVideoMsgContent, + getGifMsgContent, } from './msgContent'; import { outgoingMessageTransforms } from './outgoingMessageTransforms'; +import { getKlipyMxcUrl } from '$utils/klipy'; import { CommandAutocomplete } from './CommandAutocomplete'; import type { AudioMessageRecorderHandle, @@ -179,6 +182,8 @@ import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { LocationDialog } from './location-modal'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { GifIcon } from '@phosphor-icons/react'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. @@ -277,9 +282,11 @@ export const RoomInput = forwardRef( // don't clobber the main room draft (and vice versa). const draftKey = threadRootId ?? roomId; const mx = useMatrixClient(); + const clientConfig = useClientConfig(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); + const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); @@ -570,27 +577,9 @@ export const RoomInput = forwardRef( handleRemoveUpload(uploads.map((upload) => upload.file)); }; - const handleSendUpload = async (uploads: UploadSuccess[]) => { + const handleSendContents = async (contents: IContent[]) => { const plainText = toPlainText(editor.children).trim(); - const contentsPromises = uploads.map(async (upload) => { - const fileItem = selectedFiles.find((f) => f.file === upload.file); - if (!fileItem) throw new Error('Broken upload'); - - if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); - } - if (fileItem.file.type.startsWith('video')) { - return getVideoMsgContent(mx, fileItem, upload.mxc); - } - if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc); - } - return getFileMsgContent(fileItem, upload.mxc); - }); - handleCancelUpload(uploads); - const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - /** * the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending. * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, @@ -642,11 +631,11 @@ export const RoomInput = forwardRef( setEditingScheduledDelayId(null); setScheduledTime(null); } catch (error) { - debugLog.error('message', 'Failed to schedule uploaded file message', { + debugLog.error('message', 'Failed to schedule message', { roomId, error: error instanceof Error ? error.message : String(error), }); - log.error('failed to schedule uploaded message', { roomId }, error); + log.error('failed to schedule message', { roomId }, error); throw error; } } else { @@ -656,11 +645,9 @@ export const RoomInput = forwardRef( invalidate(); setEditingScheduledDelayId(null); } catch { - debugLog.error( - 'message', - 'Failed to cancel scheduled event before immediate file send', - { roomId } - ); + debugLog.error('message', 'Failed to cancel scheduled event before immediate send', { + roomId, + }); } } @@ -669,7 +656,7 @@ export const RoomInput = forwardRef( mx .sendMessage(roomId, threadRootId ?? null, content as RoomMessageEventContent) .then((res: { event_id: string }) => { - debugLog.info('message', 'Uploaded file message sent', { + debugLog.info('message', 'Message sent', { roomId, eventId: res.event_id, msgtype: content.msgtype, @@ -677,11 +664,11 @@ export const RoomInput = forwardRef( return res; }) .catch((error: unknown) => { - debugLog.error('message', 'Failed to send uploaded file message', { + debugLog.error('message', 'Failed to send message', { roomId, error: error instanceof Error ? error.message : String(error), }); - log.error('failed to send uploaded message', { roomId }, error); + log.error('failed to send message', { roomId }, error); throw error; }) ) @@ -689,6 +676,28 @@ export const RoomInput = forwardRef( } }; + const handleSendUpload = async (uploads: UploadSuccess[]) => { + const contentsPromises = uploads.map(async (upload) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + if (!fileItem) throw new Error('Broken upload'); + + if (fileItem.file.type.startsWith('image')) { + return getImageMsgContent(mx, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('video')) { + return getVideoMsgContent(mx, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('audio')) { + return getAudioMsgContent(fileItem, upload.mxc); + } + return getFileMsgContent(fileItem, upload.mxc); + }); + handleCancelUpload(uploads); + const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); + + await handleSendContents(contents); + }; + const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery(undefined); ReactEditor.focus(editor); @@ -1304,6 +1313,14 @@ export const RoomInput = forwardRef( mx.sendEvent(roomId, EventType.Sticker, content); }; + const handleGifSelect = async (gif: GifData, spoiler?: boolean) => { + const url = getKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl); + + const content = await getGifMsgContent(mx, gif, url, spoiler); + + await handleSendContents([content]); + }; + return (
{selectedFiles.length > 0 && ( @@ -1702,6 +1719,7 @@ export const RoomInput = forwardRef( onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} + onGifSelect={handleGifSelect} requestClose={() => { setEmojiBoardTab((t) => { if (t) { @@ -1714,6 +1732,19 @@ export const RoomInput = forwardRef( /> } > + {showGifPicker && ( + setEmojiBoardTab(EmojiBoardTab.Gif)} + variant="SurfaceVariant" + size="300" + radii="300" + > + {composerIcon(GifIcon, { + weight: emojiBoardTab === EmojiBoardTab.Gif ? 'fill' : 'regular', + })} + + )} {!hideStickerBtn && ( ( setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 8d51eb213..2d3f10901 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -10,8 +10,15 @@ import { loadImageElement, loadVideoElement, } from '$utils/dom'; -import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '$utils/matrix'; +import { + encryptFile, + getImageInfo, + getThumbnailContent, + getVideoInfo, + mxcUrlToHttp, +} from '$utils/matrix'; import type { TUploadItem } from '$state/room/roomInputDrafts'; +import type { GifData } from '$components/emoji-board/types'; import { encodeBlurHash } from '$utils/blurHash'; import { scaleYDimension } from '$utils/common'; import { createLogger } from '$utils/debug'; @@ -237,3 +244,47 @@ export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { } return content; }; + +export const getGifMsgContent = async ( + mx: MatrixClient, + gif: GifData, + mxcUrl: string, + spoiler?: boolean +): Promise => { + const proxyUrl = mxcUrlToHttp(mx, mxcUrl, true); + const [imgError, imgEl] = await to(loadImageElement(proxyUrl ?? gif.url, 'anonymous')); + if (imgError) { + log.warn( + 'Failed to load image element anonymously for blurhash, falling back to basic metadata:', + imgError + ); + } + + const content: IContent = { + msgtype: MsgType.Image, + body: gif.title, + url: mxcUrl, + info: { + w: gif.width, + h: gif.height, + mimetype: 'image/gif', + }, + }; + + if (gif.size) { + content.info.size = gif.size; + } + + if (spoiler) { + content[MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME] = true; + } + + if (imgEl) { + const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height)); + if (blurHash) { + content.info[MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME] = blurHash; + } + } + + return content; +}; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 27eba6d0b..5d5363fdb 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1218,6 +1218,7 @@ function Embeds() { settingsAtom, 'clientPreviewYoutube' ); + const [enableGifPicker, setEnableGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); return ( Embeds @@ -1310,6 +1311,21 @@ function Embeds() { )} + + + } + /> + { + const favoritedGifsData = useAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS); + const favoritedContent = + favoritedGifsData?.getContent< + AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] + >() ?? DEFAULT_FAVORITE_GIFS; + + return favoritedContent; + }; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 0ec9d3bb2..22445a35b 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -123,6 +123,7 @@ export interface Settings { clientUrlPreview: boolean; encClientUrlPreview: boolean; clientPreviewYoutube: boolean; + enableGifPicker: boolean; showInteractiveMap: boolean; showEncInteractiveMap: boolean; @@ -265,6 +266,7 @@ export const defaultSettings: Settings = { clientUrlPreview: false, encClientUrlPreview: false, clientPreviewYoutube: false, + enableGifPicker: true, showInteractiveMap: true, showEncInteractiveMap: false, showHiddenEvents: false, diff --git a/src/app/utils/blurHash.ts b/src/app/utils/blurHash.ts index 566f6d189..3fce2662e 100644 --- a/src/app/utils/blurHash.ts +++ b/src/app/utils/blurHash.ts @@ -14,8 +14,13 @@ export const encodeBlurHash = ( if (!context) return undefined; context.drawImage(img, 0, 0, canvas.width, canvas.height); - const data = context.getImageData(0, 0, canvas.width, canvas.height); - return encode(data.data, data.width, data.height, 4, 4); + try { + const data = context.getImageData(0, 0, canvas.width, canvas.height); + return encode(data.data, data.width, data.height, 4, 4); + } catch (err) { + console.warn('Failed to encode blurhash, possibly due to cross-origin tainted canvas:', err); + return undefined; + } }; export const validBlurHash = (hash?: string): string | undefined => { diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 45099c9ad..50cba5424 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -99,9 +99,10 @@ export const getImageFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL( export const getVideoFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob); -export const loadImageElement = (url: string): Promise => +export const loadImageElement = (url: string, crossOrigin?: string): Promise => new Promise((resolve, reject) => { const img = document.createElement('img'); + if (crossOrigin) img.crossOrigin = crossOrigin; img.addEventListener('load', () => resolve(img)); img.addEventListener('error', (err) => reject(err)); img.src = url; diff --git a/src/app/utils/klipy.ts b/src/app/utils/klipy.ts new file mode 100644 index 000000000..aa04c07f6 --- /dev/null +++ b/src/app/utils/klipy.ts @@ -0,0 +1,25 @@ +export function toBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ''; + + for (const byte of bytes) { + binary += String.fromCodePoint(byte); + } + + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll(/=+$/g, ''); +} + +export function toMatrixID(fname: string, urlPrefix: string): string { + const base64 = toBase64Url(fname); + return urlPrefix + base64; +} + +export function getKlipyMxcUrl(url: string, proxyUrl?: string): string { + if (url.startsWith('mxc://')) return url; + if (!proxyUrl) return url; + if (url.startsWith('https://static.klipy.com/ii/')) { + const id = url.slice('https://static.klipy.com/ii/'.length); + return `mxc://${proxyUrl}/${toMatrixID(id, 'klipy_')}`; + } + return url; +} diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index ef7e25880..8aeb7bc9f 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -6,6 +6,7 @@ import type { MemberPowerTag } from '$types/matrix/room'; import type { RoomAbbreviationsContent } from '$utils/abbreviations'; import type { PronounSet } from '$utils/pronouns'; import type * as prefix from '$unstable/prefixes'; +import type { GifData } from '$components/emoji-board'; type PowerLevelTagsEventContent = Record; @@ -57,5 +58,6 @@ declare module 'matrix-js-sdk/lib/@types/event' { [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME]: Record; [prefix.MATRIX_SABLE_UNSTABLE_DISMISSED_INVITES]: { roomIds: string[] }; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_ADDED_SERVERS_PROPERTY_NAME]: AddedServersContent; + [prefix.MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS]: { gifs: Omit[] }; } } diff --git a/src/unstable/prefixes/sable/accountdata.ts b/src/unstable/prefixes/sable/accountdata.ts index 93441cece..57bde6303 100644 --- a/src/unstable/prefixes/sable/accountdata.ts +++ b/src/unstable/prefixes/sable/accountdata.ts @@ -14,3 +14,4 @@ export const MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME = export const MATRIX_SABLE_UNSTABLE_DISMISSED_INVITES = 'moe.sable.dismissed_invites'; export const MATRIX_SABLE_UNSTABLE_ACCOUNT_ADDED_SERVERS_PROPERTY_NAME = 'moe.sable.added_servers'; +export const MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS = 'moe.sable.favorite_gifs';