-
Notifications
You must be signed in to change notification settings - Fork 287
feat: gifs in chat #5159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: gifs in chat #5159
Changes from all commits
cb0ed7b
a87ba7f
6ae87c9
2eb757c
4cdc38e
86c5eb6
2718b82
8aba33a
7a6d88e
aead3e4
20c5cb6
5d5fe8a
1b0fcf2
65e30a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import type { ReactElement } from 'react'; | ||
| import React from 'react'; | ||
| import type { IconProps } from '../../Icon'; | ||
| import Icon from '../../Icon'; | ||
| import OutlinedIcon from './outlined.svg'; | ||
| import FilledIcon from './filled.svg'; | ||
|
|
||
| export const GifIcon = (props: IconProps): ReactElement => ( | ||
| <Icon {...props} IconPrimary={OutlinedIcon} IconSecondary={FilledIcon} /> | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; | ||
| import React, { useState } from 'react'; | ||
| import { useInView } from 'react-intersection-observer'; | ||
| import type { ButtonProps } from '../buttons/Button'; | ||
| import { Button } from '../buttons/Button'; | ||
| import { PopoverContent } from './Popover'; | ||
| import { TextField } from '../fields/TextField'; | ||
| import useDebounceFn from '../../hooks/useDebounceFn'; | ||
| import useGif from '../../hooks/useGif'; | ||
| import { StarIcon } from '../icons'; | ||
| import { | ||
| Typography, | ||
| TypographyColor, | ||
| TypographyType, | ||
| } from '../typography/Typography'; | ||
| import { GenericLoaderSpinner } from '../utilities/loaders'; | ||
| import { IconSize } from '../Icon'; | ||
|
|
||
| const searchSuggestions = [ | ||
| 'Nodding zoom', | ||
| 'Hamster looking into camera', | ||
| 'Jennifer Lawrence okay', | ||
| 'Elmo burning', | ||
| 'Mr Bean waiting', | ||
| 'Dancing dog', | ||
| 'Eye roll', | ||
| 'TWICE', | ||
| 'Hackerman', | ||
| 'Steve Carell cheers', | ||
| ]; | ||
|
|
||
| type GifPopoverProps = { | ||
| buttonProps: Pick<ButtonProps<'button'>, 'size' | 'variant' | 'icon'>; | ||
| onGifCommand?: (gifUrl: string, altText: string) => Promise<void>; | ||
| textareaRef?: React.MutableRefObject<HTMLTextAreaElement>; | ||
| }; | ||
|
|
||
| const GifPopover = ({ | ||
| buttonProps, | ||
| onGifCommand, | ||
| textareaRef, | ||
| }: GifPopoverProps) => { | ||
| const { ref: scrollRef, inView } = useInView({ | ||
| threshold: 0.5, | ||
| }); | ||
| const [open, setOpen] = useState(false); | ||
| const [query, setQuery] = useState(''); | ||
| const [savedSelection, setSavedSelection] = React.useState<[number, number]>([ | ||
| 0, 0, | ||
| ]); | ||
| const [debounceQuery] = useDebounceFn<string>( | ||
| (value) => setQuery(value), | ||
| 500, | ||
| ); | ||
| const { | ||
| data, | ||
| isLoading, | ||
| fetchNextPage, | ||
| isFetchingNextPage, | ||
| favorite, | ||
| favorites, | ||
| isFetchingFavorites, | ||
| } = useGif({ | ||
| query, | ||
| limit: '20', | ||
| favoritesEnabled: open, | ||
| }); | ||
| const [debounceNextPage] = useDebounceFn<void>(() => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needed to debounce this because Tenor has a 1RPS rate limit, and without this, whenever we re-fetched after a scroll, it resulted in two calls. In production this could result in hitting rate limits. |
||
| if (inView && data?.length > 0 && !isFetchingNextPage && query) { | ||
| fetchNextPage(); | ||
| } | ||
| }, 500); | ||
|
|
||
| const gifsToDisplay = !query ? favorites : data; | ||
| const isLoadingGifs = !query ? isFetchingFavorites : isLoading; | ||
|
|
||
| const handleOpenChange = (isOpen: boolean) => { | ||
| if (isOpen && textareaRef?.current) { | ||
| setSavedSelection([ | ||
| textareaRef.current.selectionStart, | ||
| textareaRef.current.selectionEnd, | ||
| ]); | ||
| } | ||
| setOpen(isOpen); | ||
| }; | ||
|
|
||
| const handleGifClick = async (gif: { url: string; title: string }) => { | ||
| if (textareaRef?.current) { | ||
| const [selectionStart, selectionEnd] = savedSelection; | ||
| const currentTextarea = textareaRef.current; | ||
|
|
||
| currentTextarea.focus(); | ||
| currentTextarea.selectionStart = selectionStart; | ||
| currentTextarea.selectionEnd = selectionEnd; | ||
| } | ||
|
|
||
| await onGifCommand?.(gif.url, gif.title); | ||
| setOpen(false); | ||
| setQuery(''); | ||
| }; | ||
|
|
||
| return ( | ||
| <Popover open={open} onOpenChange={handleOpenChange}> | ||
| <PopoverTrigger asChild> | ||
| <Button {...buttonProps} /> | ||
| </PopoverTrigger> | ||
| <PopoverContent | ||
| side="top" | ||
| align="start" | ||
| avoidCollisions | ||
| className="flex h-[25rem] w-screen flex-col rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4 data-[side=bottom]:mt-1 data-[side=top]:mb-1 tablet:w-[31.25rem]" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have you checked this one on mobile as well?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, works well on mobile :) Screen.Recording.2025-12-18.at.12.03.01.mov |
||
| > | ||
| <div className="mb-2 shrink-0"> | ||
| <TextField | ||
| value={query} | ||
| onChange={(e) => debounceQuery(e.target.value)} | ||
| inputId="gifs" | ||
| label="Search Tenor" | ||
| placeholder={ | ||
| searchSuggestions[ | ||
| Math.floor(Math.random() * searchSuggestions.length) | ||
| ] | ||
| } | ||
| /> | ||
| </div> | ||
| <div | ||
| className="min-h-0 min-w-0 max-w-full flex-1 overflow-x-hidden overflow-y-scroll" | ||
| onScroll={() => debounceNextPage()} | ||
| > | ||
| {!isLoadingGifs && gifsToDisplay?.length > 0 && ( | ||
| <> | ||
| <div className="grid min-w-0 max-w-full grid-cols-2 gap-2"> | ||
| {gifsToDisplay.map((gif, idx) => ( | ||
| <div | ||
| className="relative" | ||
| key={gif.id} | ||
| ref={idx === gifsToDisplay.length - 1 ? scrollRef : null} | ||
| > | ||
| <div className="z-10 absolute right-2 top-2 rounded-16 bg-overlay-primary-pepper"> | ||
| <Button | ||
| icon={ | ||
| <StarIcon | ||
| secondary={favorites?.some((f) => f.id === gif.id)} | ||
| className="text-accent-cheese-bolder" | ||
| /> | ||
| } | ||
| onClick={() => favorite(gif)} | ||
| /> | ||
| </div> | ||
| <button | ||
| className="mb-auto" | ||
| type="button" | ||
| onClick={() => | ||
| handleGifClick({ url: gif.url, title: gif.title }) | ||
| } | ||
| > | ||
| <img | ||
| src={gif.preview} | ||
| alt={gif.title} | ||
| className="h-auto min-h-32 w-full cursor-pointer rounded-8 object-cover" | ||
| /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| {query && ( | ||
| <div className="flex items-center justify-center py-4"> | ||
| <GenericLoaderSpinner size={IconSize.XLarge} /> | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| {!isLoadingGifs && (!gifsToDisplay || gifsToDisplay.length === 0) && ( | ||
| <div className="flex h-full w-full min-w-0 items-center justify-center px-4"> | ||
| <Typography | ||
| type={TypographyType.Body} | ||
| color={TypographyColor.Tertiary} | ||
| className="w-full min-w-0 whitespace-normal break-words text-center" | ||
| > | ||
| {!query | ||
| ? 'You have no favorites yet. Add some, and they will appear here!' | ||
| : 'no results matching your search 😞'} | ||
| </Typography> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </PopoverContent> | ||
| </Popover> | ||
| ); | ||
| }; | ||
|
|
||
| export default GifPopover; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does this mean the number, number?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Its the cursor / selection the user has made in the input area, where the selected gif will be inserted :)