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 b9a625b04..b120eaea4 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) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; }; +const getGifName = (v: GifData) => v.title; + export function EmojiBoard({ tab = EmojiBoardTab.Emoji, onTabChange, @@ -397,6 +464,7 @@ export function EmojiBoard({ onEmojiSelect, onCustomEmojiSelect, onStickerSelect, + onGifSelect, allowTextCustomEmoji, addToRecentEmoji = true, }: Readonly) { @@ -404,18 +472,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 = []; @@ -424,22 +491,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, 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 } ); @@ -492,6 +702,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(); }; @@ -516,7 +731,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(() => { @@ -524,7 +739,7 @@ export function EmojiBoard({ if (scrollElement) { scrollElement.scrollTo({ top: 0 }); } - }, [result?.query]); + }, [emojiResult?.query, gifResult?.query]); // reset scroll position on tab change useEffect(() => { @@ -554,7 +769,7 @@ export function EmojiBoard({ {onTabChange && } ) : ( - + !gifTab && ( + + ) ) } > @@ -586,7 +803,7 @@ export function EmojiBoard({ previewAtom={previewAtom} onGroupItemClick={handleGroupItemClick} > - {searchedItems && ( + {tab !== EmojiBoardTab.Gif && searchedItems && ( - + {group.items.map(renderItem)} @@ -619,9 +836,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..1b3e180bb 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -1,11 +1,17 @@ -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, menuIcon } from '$components/icons/phosphor'; +import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS } from '$unstable/prefixes'; +import { useMatrixClient } from '$hooks/useMatrixClient'; const ANIMATED_MIME_TYPES = new Set(['image/gif', 'image/apng']); @@ -140,3 +146,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 +392,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 82beb4064..0f8aada9e 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'; @@ -179,6 +180,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. @@ -271,12 +274,29 @@ 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 // 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'); @@ -1293,6 +1313,41 @@ 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', + }, + }; + + // 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 && ( @@ -1691,6 +1746,7 @@ export const RoomInput = forwardRef( onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} + onGifSelect={handleGifSelect} requestClose={() => { setEmojiBoardTab((t) => { if (t) { @@ -1703,6 +1759,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..7504f3ff8 --- /dev/null +++ b/src/app/hooks/useFavoriteGifs.ts @@ -0,0 +1,13 @@ +import type { 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..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';