From ab23f571e63defe83e44cda6acac47bdcebcceac Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:18:26 +0000 Subject: [PATCH 1/7] Add gif functionality using klipy --- .../added_a_gif_search_functionality.md | 5 + config.json | 5 + src/app/components/emoji-board/EmojiBoard.tsx | 281 ++++++++++++++++-- .../emoji-board/components/Group.tsx | 18 +- .../emoji-board/components/Item.tsx | 100 ++++++- .../emoji-board/components/NoGifResults.tsx | 63 ++++ .../emoji-board/components/SearchInput.tsx | 7 +- .../emoji-board/components/Tabs.tsx | 12 + .../emoji-board/components/index.tsx | 1 + .../emoji-board/components/styles.css.ts | 56 ++++ src/app/components/emoji-board/types.ts | 11 + .../message/content/ImageContent.tsx | 99 ++++-- src/app/features/room/RoomInput.tsx | 73 ++++- src/app/hooks/useClientConfig.ts | 7 + src/app/hooks/useFavoriteGifs.ts | 13 + src/types/matrix-sdk-events.d.ts | 2 + src/unstable/prefixes/sable/accountdata.ts | 1 + 17 files changed, 695 insertions(+), 59 deletions(-) create mode 100644 .changeset/added_a_gif_search_functionality.md create mode 100644 src/app/components/emoji-board/components/NoGifResults.tsx create mode 100644 src/app/hooks/useFavoriteGifs.ts 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..71c93e0c0 100644 --- a/config.json +++ b/config.json @@ -44,5 +44,10 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "gifs": { + "proxyUrl": "gifs.sable.moe", + "klipyApiKey": "SET_YOUR_TOKEN_HERE" } } diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 2fe8bd50a..89fa11877 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,10 @@ import { EmojiGroup, EmojiBoardLayout, } from './components'; -import { EmojiBoardTab, EmojiType } from './types'; +import { EmojiBoardTab, EmojiType, GifData } from './types'; +import { GitDiffIcon } from '@phosphor-icons/react'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -70,11 +75,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 +148,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) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; isFullWidth?: boolean; }; +const getGifName = (v: GifData) => v.title; + export function EmojiBoard({ tab = EmojiBoardTab.Emoji, onTabChange, @@ -398,6 +462,7 @@ export function EmojiBoard({ onEmojiSelect, onCustomEmojiSelect, onStickerSelect, + onGifSelect, allowTextCustomEmoji, addToRecentEmoji = true, isFullWidth, @@ -406,18 +471,17 @@ export function EmojiBoard({ const [saveStickerEmojiBandwidth] = useSetting(settingsAtom, 'saveStickerEmojiBandwidth'); 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 +490,165 @@ 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, + }; + }, []); + + const searchGifs = useCallback( + async (query: string) => { + 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] + ); + + 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 +701,11 @@ 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; + onGifSelect?.(gifData); + } if (!evt.altKey && !evt.shiftKey) requestClose(); }; @@ -518,7 +730,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 +738,7 @@ export function EmojiBoard({ if (scrollElement) { scrollElement.scrollTo({ top: 0 }); } - }, [result?.query]); + }, [emojiResult?.query, gifResult?.query]); // reset scroll position on tab change useEffect(() => { @@ -560,7 +772,7 @@ export function EmojiBoard({ {onTabChange && } ) : ( - + !gifTab && ( + + ) ) } isFullWidth={isFullWidth} @@ -593,7 +807,7 @@ export function EmojiBoard({ previewAtom={previewAtom} onGroupItemClick={handleGroupItemClick} > - {searchedItems && ( + {tab !== EmojiBoardTab.Gif && searchedItems && ( - + {group.items.map(renderItem)} @@ -626,9 +840,16 @@ export function EmojiBoard({ })} {tab === EmojiBoardTab.Sticker && groups.length === 0 && } + {gifTab && ( + v.items.map((i) => '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..a4e7346a5 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -1,11 +1,16 @@ -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 { EmojiType } from '$components/emoji-board/types'; +import { EmojiItemInfo, EmojiType, GifData } from '$components/emoji-board/types'; +import { CSSProperties, ReactNode, useEffect, useState } from 'react'; import * as css from './styles.css'; +import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; +import { Star, menuIcon } from '$components/icons/phosphor'; +import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '$unstable/prefixes'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import classNames from 'classnames'; const ANIMATED_MIME_TYPES = new Set(['image/gif', 'image/apng']); @@ -140,3 +145,92 @@ 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 initialFavorited = useFavoriteGifs(); + const [favoritedContent, setFavoritedContent] = useState(initialFavorited); + const [favorited, setFavorited] = useState( + favoritedContent.gifs.find((v) => v.url == gif?.url) != undefined + ); + const mx = useMatrixClient(); + + useEffect(() => { + setFavoritedContent(initialFavorited); + }, [initialFavorited]); + + return ( + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > + {children} + {isHovered && ( + + + + { + e.preventDefault(); + if (!favorited) { + setFavorited(true); + await mx + .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { + gifs: [...favoritedContent.gifs, gif], + }) + .catch(() => setFavorited(false)); + } else { + setFavorited(false); + await mx + .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { + gifs: favoritedContent.gifs.filter((v) => v.url != gif.url), + }) + .catch(() => setFavorited(true)); + } + }} + > + {menuIcon(Star, { + weight: favorited ? 'fill' : 'regular', + color: favorited ? color.Warning.MainHover : 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..bf4250fc8 100644 --- a/src/app/components/emoji-board/components/SearchInput.tsx +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -3,6 +3,7 @@ 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; @@ -29,10 +30,12 @@ export function SearchInput({ ref={inputRef} variant="SurfaceVariant" size="400" - placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'} + placeholder={ + allowTextCustomEmoji && !EmojiBoardTab.Gif ? 'Search or Text Reaction ' : 'Search' + } maxLength={50} after={ - allowTextCustomEmoji && query ? ( + allowTextCustomEmoji && query && !EmojiBoardTab.Gif ? ( + 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 +394,66 @@ 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, + }, + ], + }) + .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..16a68e9ee 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -64,7 +64,7 @@ import { BlockType, } from '$components/editor'; import { plainToEditorInput } from '$components/editor/input'; -import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; +import { EmojiBoard, EmojiBoardTab, GifData } from '$components/emoji-board'; import { UseStateProvider } from '$components/UseStateProvider'; import type { TUploadContent } from '$utils/matrix'; import { encryptFile, getImageInfo, mxcUrlToHttp, toggleReaction } from '$utils/matrix'; @@ -179,6 +179,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,6 +279,7 @@ 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'); @@ -1304,6 +1307,57 @@ export const RoomInput = forwardRef( mx.sendEvent(roomId, EventType.Sticker, content); }; + const handleGifSelect = async (gif: GifData) => { + 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, ''); + } + + function toMatrixID(fname: string, prefix: string): string { + const base64 = toBase64Url(fname); + return prefix + base64; + } + + let url = gif.url.startsWith('mxc://') + ? gif.url + : `mxc://${clientConfig.gifs?.proxyUrl ?? ''}/${toMatrixID(gif.url.slice('https://static.klipy.com/ii/'.length), 'klipy_')}`; + + const content: RoomMessageEventContent & ReplyEventContent & IContent = { + body: gif.title, + url: url, + msgtype: MsgType.Image, + info: { + w: gif.width, + h: gif.height, + mimetype: 'image/gif', + }, + }; + + // Handle replies if there's a reply draft + if (replyDraft) { + content['m.relates_to'] = { + 'm.in_reply_to': { + event_id: replyDraft.eventId, + }, + }; + if (replyDraft.relation?.rel_type === RelationType.Thread) { + content['m.relates_to'].event_id = replyDraft.relation.event_id; + content['m.relates_to'].rel_type = RelationType.Thread; + content['m.relates_to'].is_falling_back = false; + } + } + + // Send the gif as sticker event. + await mx.sendEvent(roomId, EventType.RoomMessage, content); + setReplyDraft(undefined); + }; + return (
{selectedFiles.length > 0 && ( @@ -1702,6 +1756,7 @@ export const RoomInput = forwardRef( onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} + onGifSelect={handleGifSelect} requestClose={() => { setEmojiBoardTab((t) => { if (t) { @@ -1714,6 +1769,17 @@ export const RoomInput = forwardRef( /> } > + 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/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..6850a7e77 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -7,6 +7,11 @@ export type HashRouterConfig = { basename?: string; }; +export type GifsConfig = { + klipyApiKey?: string; + proxyUrl?: string; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -43,6 +48,8 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; + gifs?: GifsConfig; + matrixToBaseUrl?: string; themeCatalogBaseUrl?: string; diff --git a/src/app/hooks/useFavoriteGifs.ts b/src/app/hooks/useFavoriteGifs.ts new file mode 100644 index 000000000..8c3ef955e --- /dev/null +++ b/src/app/hooks/useFavoriteGifs.ts @@ -0,0 +1,13 @@ +import { AccountDataEvents } from 'matrix-js-sdk'; +import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '../../unstable/prefixes'; +import { useAccountData } from './useAccountData'; + +export const useFavoriteGifs = + (): AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] => { + const favoritedGifsData = useAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS); + const favoritedContent = favoritedGifsData?.getContent< + AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] + >() ?? { gifs: [] }; + + return favoritedContent; + }; diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index ef7e25880..3eb528f53 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 { 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'; From 9dd1d0df228fc90dac869d3d078d7842262f0ceb Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:25:21 +0000 Subject: [PATCH 2/7] Address lints --- src/app/components/emoji-board/EmojiBoard.tsx | 11 +++--- .../emoji-board/components/Item.tsx | 7 ++-- .../message/content/ImageContent.tsx | 2 -- src/app/features/room/RoomInput.tsx | 35 ++++++++++--------- src/app/hooks/useFavoriteGifs.ts | 2 +- src/types/matrix-sdk-events.d.ts | 2 +- 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 89fa11877..d464209eb 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -57,11 +57,14 @@ import { EmojiGroup, EmojiBoardLayout, } from './components'; -import { EmojiBoardTab, EmojiType, GifData } from './types'; -import { GitDiffIcon } from '@phosphor-icons/react'; +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'; @@ -596,7 +599,7 @@ export function EmojiBoard({ setLoading(false); } }, - [parseKlipyResult] + [parseKlipyResult, klipyApiKey] ); return { gifs, loading, error, searchGifs }; @@ -844,7 +847,7 @@ export function EmojiBoard({ v.items.map((i) => 'gif')).length === 0} + isEmpty={groups.flatMap((v) => v.items.map(() => 'gif')).length === 0} /> )} diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index a4e7346a5..1b3e180bb 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -3,14 +3,15 @@ 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 { EmojiItemInfo, EmojiType, GifData } from '$components/emoji-board/types'; -import { CSSProperties, ReactNode, useEffect, useState } from 'react'; +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, menuIcon } from '$components/icons/phosphor'; import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '$unstable/prefixes'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import classNames from 'classnames'; const ANIMATED_MIME_TYPES = new Set(['image/gif', 'image/apng']); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 02be8c8f0..65d822273 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -48,8 +48,6 @@ import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME, } from '../../../../unstable/prefixes'; -import { useAccountData } from '$hooks/useAccountData'; -import { AccountDataEvents, IContent } from 'matrix-js-sdk'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; function thumbnailDimsForMaxEdge( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 16a68e9ee..3b4a9d05e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -64,7 +64,8 @@ import { BlockType, } from '$components/editor'; import { plainToEditorInput } from '$components/editor/input'; -import { EmojiBoard, EmojiBoardTab, GifData } from '$components/emoji-board'; +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'; import { encryptFile, getImageInfo, mxcUrlToHttp, toggleReaction } from '$utils/matrix'; @@ -273,6 +274,22 @@ interface RoomInputProps { onEditLastMessage?: () => void; } +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, ''); +} + +function toMatrixID(fname: string, urlPrefix: string): string { + const base64 = toBase64Url(fname); + return urlPrefix + base64; +} + export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, threadRootId, onEditLastMessage }, ref) => { // When in thread mode, isolate drafts by thread root ID so thread replies @@ -1308,22 +1325,6 @@ export const RoomInput = forwardRef( }; const handleGifSelect = async (gif: GifData) => { - 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, ''); - } - - function toMatrixID(fname: string, prefix: string): string { - const base64 = toBase64Url(fname); - return prefix + base64; - } - let url = gif.url.startsWith('mxc://') ? gif.url : `mxc://${clientConfig.gifs?.proxyUrl ?? ''}/${toMatrixID(gif.url.slice('https://static.klipy.com/ii/'.length), 'klipy_')}`; diff --git a/src/app/hooks/useFavoriteGifs.ts b/src/app/hooks/useFavoriteGifs.ts index 8c3ef955e..7504f3ff8 100644 --- a/src/app/hooks/useFavoriteGifs.ts +++ b/src/app/hooks/useFavoriteGifs.ts @@ -1,4 +1,4 @@ -import { AccountDataEvents } from 'matrix-js-sdk'; +import type { AccountDataEvents } from 'matrix-js-sdk'; import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '../../unstable/prefixes'; import { useAccountData } from './useAccountData'; diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 3eb528f53..8aeb7bc9f 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -6,7 +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 { GifData } from '$components/emoji-board'; +import type { GifData } from '$components/emoji-board'; type PowerLevelTagsEventContent = Record; From 53ef9d69f54c3cf346dbf3868bce1817c39824e9 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 23 Jun 2026 16:30:00 -0500 Subject: [PATCH 3/7] consolidate gif handling, support spoilers, add missing metadata and sable api key --- config.json | 2 +- src/app/components/emoji-board/EmojiBoard.tsx | 15 ++- .../emoji-board/components/Item.tsx | 49 ++++++-- .../emoji-board/components/SearchInput.tsx | 10 +- src/app/components/emoji-board/types.ts | 1 + src/app/features/room/RoomInput.tsx | 105 ++++++------------ src/app/features/room/msgContent.ts | 44 +++++++- src/app/hooks/useFavoriteGifs.ts | 4 +- src/app/utils/blurHash.ts | 9 +- src/app/utils/dom.ts | 3 +- src/app/utils/klipy.ts | 25 +++++ 11 files changed, 177 insertions(+), 90 deletions(-) create mode 100644 src/app/utils/klipy.ts diff --git a/config.json b/config.json index 71c93e0c0..77be1ad3c 100644 --- a/config.json +++ b/config.json @@ -48,6 +48,6 @@ "gifs": { "proxyUrl": "gifs.sable.moe", - "klipyApiKey": "SET_YOUR_TOKEN_HERE" + "klipyApiKey": "IfeIBlDMvq0av2BcKPDuxwRqbnYRbS90yNqFHEkK2Ja207tkR5nssh3NIlJRCr76" } } diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index d464209eb..510acc956 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -165,7 +165,11 @@ const useGroups = ( return [emojiGroupItems, stickerGroupItems, gifGroupItems]; }; -const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) => { +const useItemRenderer = ( + tab: EmojiBoardTab, + saveStickerEmojiBandwidth: boolean, + onGifSelect?: (gif: GifData, spoiler?: boolean) => void +) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -448,7 +452,7 @@ type EmojiBoardProps = { onEmojiSelect?: (unicode: string, shortcode: string) => void; onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; - onGifSelect?: (gif: GifData) => void; + onGifSelect?: (gif: GifData, spoiler?: boolean) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; isFullWidth?: boolean; @@ -550,6 +554,7 @@ export function EmojiBoard({ preview_url: preview?.url || fullRes?.url || '', width, height, + size: fullRes?.size || preview?.size || 0, }; }, []); @@ -631,7 +636,7 @@ export function EmojiBoard({ : gifGroupItems, }; const groups = groupsByTab[tab]; - const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); + const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth, onGifSelect); const handleOnChange: ChangeEventHandler = useDebounce( useCallback( @@ -707,7 +712,8 @@ export function EmojiBoard({ if (emojiInfo.type === EmojiType.Gif) { const gifDataStr = targetEl.getAttribute('data-gif-data'); const gifData = gifDataStr ? JSON.parse(gifDataStr) : null; - onGifSelect?.(gifData); + const isSpoiler = targetEl.getAttribute('data-gif-spoiler') === 'true'; + onGifSelect?.(gifData, isSpoiler); } if (!evt.altKey && !evt.shiftKey) requestClose(); }; @@ -777,6 +783,7 @@ export function EmojiBoard({ key={tab} query={emojiResult?.query} onChange={handleOnChange} + tab={tab} allowTextCustomEmoji={allowTextCustomEmoji} onTextCustomEmojiSelect={handleTextCustomEmojiSelect} /> diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index 1b3e180bb..9dc104348 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -9,9 +9,11 @@ import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import * as css from './styles.css'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; -import { Star, menuIcon } from '$components/icons/phosphor'; +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']); @@ -165,16 +167,28 @@ export function GifItem({ children: ReactNode; }) { const [isHovered, setIsHovered] = useState(false); - const initialFavorited = useFavoriteGifs(); - const [favoritedContent, setFavoritedContent] = useState(initialFavorited); + const favoritedContent = useFavoriteGifs(); + const clientConfig = useClientConfig(); + + const mxcUrl = gif?.url ? getKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl) : ''; + const [favorited, setFavorited] = useState( - favoritedContent.gifs.find((v) => v.url == gif?.url) != undefined + 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(() => { - setFavoritedContent(initialFavorited); - }, [initialFavorited]); + 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)} > @@ -206,18 +221,19 @@ export function GifItem({ title={favorited ? 'Unfavorite gif' : 'Favorite gif'} onClick={async (e) => { e.preventDefault(); + e.stopPropagation(); if (!favorited) { setFavorited(true); await mx .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { - gifs: [...favoritedContent.gifs, gif], + gifs: [...favoritedContent.gifs, { ...gif, url: mxcUrl }], }) .catch(() => setFavorited(false)); } else { setFavorited(false); await mx .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { - gifs: favoritedContent.gifs.filter((v) => v.url != gif.url), + gifs: favoritedContent.gifs.filter((v) => getKlipyMxcUrl(v.url, clientConfig.gifs?.proxyUrl) !== mxcUrl), }) .catch(() => setFavorited(true)); } @@ -228,6 +244,23 @@ export function GifItem({ 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/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx index bf4250fc8..5d55b061b 100644 --- a/src/app/components/emoji-board/components/SearchInput.tsx +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -10,12 +10,14 @@ type SearchInputProps = { onChange: ChangeEventHandler; allowTextCustomEmoji?: boolean; onTextCustomEmojiSelect?: (text: string) => void; + tab?: EmojiBoardTab; }; export function SearchInput({ query, onChange, allowTextCustomEmoji, onTextCustomEmojiSelect, + tab, }: SearchInputProps) { const inputRef = useRef(null); @@ -31,11 +33,15 @@ export function SearchInput({ variant="SurfaceVariant" size="400" placeholder={ - allowTextCustomEmoji && !EmojiBoardTab.Gif ? 'Search or Text Reaction ' : 'Search' + tab === EmojiBoardTab.Gif + ? 'Search KLIPY' + : allowTextCustomEmoji + ? 'Search or Text Reaction ' + : 'Search' } maxLength={50} after={ - allowTextCustomEmoji && query && !EmojiBoardTab.Gif ? ( + allowTextCustomEmoji && query && tab !== EmojiBoardTab.Gif ? ( void; } -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, ''); -} - -function toMatrixID(fname: string, urlPrefix: string): string { - const base64 = toBase64Url(fname); - return urlPrefix + base64; -} export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, threadRootId, onEditLastMessage }, ref) => { @@ -590,27 +578,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, @@ -662,11 +632,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 { @@ -678,7 +648,7 @@ export const RoomInput = forwardRef( } catch { debugLog.error( 'message', - 'Failed to cancel scheduled event before immediate file send', + 'Failed to cancel scheduled event before immediate send', { roomId } ); } @@ -689,7 +659,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, @@ -697,11 +667,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; }) ) @@ -709,6 +679,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); @@ -1324,39 +1316,12 @@ export const RoomInput = forwardRef( mx.sendEvent(roomId, EventType.Sticker, content); }; - const handleGifSelect = async (gif: GifData) => { - let url = gif.url.startsWith('mxc://') - ? gif.url - : `mxc://${clientConfig.gifs?.proxyUrl ?? ''}/${toMatrixID(gif.url.slice('https://static.klipy.com/ii/'.length), 'klipy_')}`; - - const content: RoomMessageEventContent & ReplyEventContent & IContent = { - body: gif.title, - url: url, - msgtype: MsgType.Image, - info: { - w: gif.width, - h: gif.height, - mimetype: 'image/gif', - }, - }; + const handleGifSelect = async (gif: GifData, spoiler?: boolean) => { + const url = getKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl); - // Handle replies if there's a reply draft - if (replyDraft) { - content['m.relates_to'] = { - 'm.in_reply_to': { - event_id: replyDraft.eventId, - }, - }; - if (replyDraft.relation?.rel_type === RelationType.Thread) { - content['m.relates_to'].event_id = replyDraft.relation.event_id; - content['m.relates_to'].rel_type = RelationType.Thread; - content['m.relates_to'].is_falling_back = false; - } - } + const content = await getGifMsgContent(mx, gif, url, spoiler); - // Send the gif as sticker event. - await mx.sendEvent(roomId, EventType.RoomMessage, content); - setReplyDraft(undefined); + await handleSendContents([content]); }; return ( diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 8d51eb213..d0d77cadf 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -10,8 +10,9 @@ 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 +238,44 @@ 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/hooks/useFavoriteGifs.ts b/src/app/hooks/useFavoriteGifs.ts index 7504f3ff8..935d5efe9 100644 --- a/src/app/hooks/useFavoriteGifs.ts +++ b/src/app/hooks/useFavoriteGifs.ts @@ -2,12 +2,14 @@ import type { AccountDataEvents } from 'matrix-js-sdk'; import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '../../unstable/prefixes'; import { useAccountData } from './useAccountData'; +const DEFAULT_FAVORITE_GIFS: AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] = { gifs: [] }; + export const useFavoriteGifs = (): AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] => { const favoritedGifsData = useAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS); const favoritedContent = favoritedGifsData?.getContent< AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] - >() ?? { gifs: [] }; + >() ?? DEFAULT_FAVORITE_GIFS; return favoritedContent; }; 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; +} From 5117d56f28e7b4fd119b55f0fcd4ccd0bcd3936f Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 23 Jun 2026 16:37:39 -0500 Subject: [PATCH 4/7] formatting --- src/app/components/emoji-board/EmojiBoard.tsx | 8 ++------ .../components/emoji-board/components/Item.tsx | 6 ++++-- src/app/features/room/RoomInput.tsx | 10 +++------- src/app/features/room/msgContent.ts | 15 ++++++++++++--- src/app/hooks/useFavoriteGifs.ts | 11 +++++++---- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 510acc956..d6883ec16 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -165,11 +165,7 @@ const useGroups = ( return [emojiGroupItems, stickerGroupItems, gifGroupItems]; }; -const useItemRenderer = ( - tab: EmojiBoardTab, - saveStickerEmojiBandwidth: boolean, - onGifSelect?: (gif: GifData, spoiler?: boolean) => void -) => { +const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -636,7 +632,7 @@ export function EmojiBoard({ : gifGroupItems, }; const groups = groupsByTab[tab]; - const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth, onGifSelect); + const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); const handleOnChange: ChangeEventHandler = useDebounce( useCallback( diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index 9dc104348..029a09cc3 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -169,7 +169,7 @@ export function GifItem({ 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( @@ -233,7 +233,9 @@ export function GifItem({ setFavorited(false); await mx .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { - gifs: favoritedContent.gifs.filter((v) => getKlipyMxcUrl(v.url, clientConfig.gifs?.proxyUrl) !== mxcUrl), + gifs: favoritedContent.gifs.filter( + (v) => getKlipyMxcUrl(v.url, clientConfig.gifs?.proxyUrl) !== mxcUrl + ), }) .catch(() => setFavorited(true)); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c94b77d71..0e501ef28 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -276,8 +276,6 @@ interface RoomInputProps { onEditLastMessage?: () => void; } - - export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, threadRootId, onEditLastMessage }, ref) => { // When in thread mode, isolate drafts by thread root ID so thread replies @@ -646,11 +644,9 @@ export const RoomInput = forwardRef( invalidate(); setEditingScheduledDelayId(null); } catch { - debugLog.error( - 'message', - 'Failed to cancel scheduled event before immediate send', - { roomId } - ); + debugLog.error('message', 'Failed to cancel scheduled event before immediate send', { + roomId, + }); } } diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index d0d77cadf..2d3f10901 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -10,7 +10,13 @@ import { loadImageElement, loadVideoElement, } from '$utils/dom'; -import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo, mxcUrlToHttp } 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'; @@ -248,7 +254,10 @@ export const getGifMsgContent = async ( 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); + log.warn( + 'Failed to load image element anonymously for blurhash, falling back to basic metadata:', + imgError + ); } const content: IContent = { @@ -261,7 +270,7 @@ export const getGifMsgContent = async ( mimetype: 'image/gif', }, }; - + if (gif.size) { content.info.size = gif.size; } diff --git a/src/app/hooks/useFavoriteGifs.ts b/src/app/hooks/useFavoriteGifs.ts index 935d5efe9..3af563b17 100644 --- a/src/app/hooks/useFavoriteGifs.ts +++ b/src/app/hooks/useFavoriteGifs.ts @@ -2,14 +2,17 @@ import type { AccountDataEvents } from 'matrix-js-sdk'; import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '../../unstable/prefixes'; import { useAccountData } from './useAccountData'; -const DEFAULT_FAVORITE_GIFS: AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] = { gifs: [] }; +const DEFAULT_FAVORITE_GIFS: AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] = { + gifs: [], +}; export const useFavoriteGifs = (): AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] => { const favoritedGifsData = useAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS); - const favoritedContent = favoritedGifsData?.getContent< - AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] - >() ?? DEFAULT_FAVORITE_GIFS; + const favoritedContent = + favoritedGifsData?.getContent< + AccountDataEvents[typeof MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS] + >() ?? DEFAULT_FAVORITE_GIFS; return favoritedContent; }; From 2f9be8c82ff8fe0524b35b38d18b200ffc453c3d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 23 Jun 2026 16:45:34 -0500 Subject: [PATCH 5/7] remove some unnecessary data from account data --- src/app/components/emoji-board/components/Item.tsx | 11 ++++++++++- src/app/components/message/content/ImageContent.tsx | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index 029a09cc3..2fa0b6ad0 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -226,7 +226,16 @@ export function GifItem({ setFavorited(true); await mx .setAccountData(MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, { - gifs: [...favoritedContent.gifs, { ...gif, url: mxcUrl }], + gifs: [ + ...favoritedContent.gifs, + { + title: gif.title, + url: mxcUrl, + width: gif.width, + height: gif.height, + size: gif.size, + }, + ], }) .catch(() => setFavorited(false)); } else { diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 65d822273..42778abaf 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -430,6 +430,7 @@ export const ImageContent = as<'div', ImageContentProps>( url: url, width: imageW, height: imageH, + size: info?.size, }, ], }) From d086ffa56ac5c6f95b9bb8641de6a1e6c71c465a Mon Sep 17 00:00:00 2001 From: niki <295591807+nikiwastaken@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:34:00 +0000 Subject: [PATCH 6/7] Add a toggle for disabling the gif picker --- src/app/components/emoji-board/EmojiBoard.tsx | 5 ++++ .../emoji-board/components/Tabs.tsx | 29 +++++++++++-------- src/app/features/room/RoomInput.tsx | 25 +++++++++------- src/app/features/settings/general/General.tsx | 16 ++++++++++ src/app/state/settings.ts | 2 ++ 5 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index d6883ec16..a55ab44ec 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -472,6 +472,7 @@ export function EmojiBoard({ }: Readonly) { const mx = useMatrixClient(); const [saveStickerEmojiBandwidth] = useSetting(settingsAtom, 'saveStickerEmojiBandwidth'); + const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); const emojiTab = tab === EmojiBoardTab.Emoji; const gifTab = tab === EmojiBoardTab.Gif; @@ -556,6 +557,10 @@ export function EmojiBoard({ const searchGifs = useCallback( async (query: string) => { + if (!showGifPicker) { + return; + } + const trimmedQuery = query.trim(); setLoading(true); diff --git a/src/app/components/emoji-board/components/Tabs.tsx b/src/app/components/emoji-board/components/Tabs.tsx index 6b0458930..3e16fee77 100644 --- a/src/app/components/emoji-board/components/Tabs.tsx +++ b/src/app/components/emoji-board/components/Tabs.tsx @@ -1,6 +1,8 @@ import type { CSSProperties } from 'react'; import { Badge, Box, Text } from 'folds'; import { EmojiBoardTab } from '$components/emoji-board/types'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; const styles: CSSProperties = { cursor: 'pointer', @@ -13,20 +15,23 @@ export function EmojiBoardTabs({ tab: EmojiBoardTab; onTabChange: (tab: EmojiBoardTab) => void; }) { + const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); return ( - onTabChange(EmojiBoardTab.Gif)} - > - - GIF - - + {showGifPicker && ( + onTabChange(EmojiBoardTab.Gif)} + > + + GIF + + + )} ( 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'); @@ -1731,17 +1732,19 @@ export const RoomInput = forwardRef( /> } > - setEmojiBoardTab(EmojiBoardTab.Gif)} - variant="SurfaceVariant" - size="300" - radii="300" - > - {composerIcon(GifIcon, { - weight: emojiBoardTab === EmojiBoardTab.Gif ? 'fill' : 'regular', - })} - + {showGifPicker && ( + setEmojiBoardTab(EmojiBoardTab.Gif)} + variant="SurfaceVariant" + size="300" + radii="300" + > + {composerIcon(GifIcon, { + weight: emojiBoardTab === EmojiBoardTab.Gif ? 'fill' : 'regular', + })} + + )} {!hideStickerBtn && ( Embeds @@ -1310,6 +1311,21 @@ function Embeds() { )} + + + } + /> + Date: Mon, 29 Jun 2026 15:39:05 +0000 Subject: [PATCH 7/7] Add gif picker settings ID to settingsLink --- src/app/features/settings/settingsLink.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index e3621f413..0310e8f40 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -27,6 +27,7 @@ const settingsLinkFocusIdsBySection: Record