Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/shared/src/components/fields/MarkdownInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { usePopupSelector } from '../../../hooks/usePopupSelector';
import { focusInput } from '../../../lib/textarea';
import CloseButton from '../../CloseButton';
import { ACCEPTED_TYPES } from '../../../graphql/posts';
import GifPopover from '../../popover/GifPopover';
import { GifIcon } from '../../icons/Gif';

const RecommendedEmojiTooltip = dynamic(
() =>
Expand Down Expand Up @@ -132,6 +134,7 @@ function MarkdownInput(
uploadedCount,
onLinkCommand,
onUploadCommand,
onGifCommand,
onMentionCommand,
onApplyMention,
onCloseMention,
Expand Down Expand Up @@ -389,6 +392,15 @@ function MarkdownInput(
/>
)}
</ConditionalWrapper>
<GifPopover
buttonProps={{
size: actionButtonSizes,
variant: ButtonVariant.Tertiary,
icon: <GifIcon />,
}}
onGifCommand={onGifCommand}
textareaRef={textareaRef}
/>
{shouldShowSubmit && (
<Button
className="ml-auto"
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/components/icons/Gif/filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/shared/src/components/icons/Gif/index.tsx
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} />
);
10 changes: 10 additions & 0 deletions packages/shared/src/components/icons/Gif/outlined.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
192 changes: 192 additions & 0 deletions packages/shared/src/components/popover/GifPopover.tsx
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]>([
Copy link
Contributor

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?

Copy link
Contributor Author

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 :)

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>(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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]"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you checked this one on mobile as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
16 changes: 16 additions & 0 deletions packages/shared/src/hooks/input/useMarkdownInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export enum MarkdownCommand {
Link = 'link',
Mention = 'mention',
Emoji = 'emoji',
Gif = 'gif',
}

export interface UseMarkdownInputProps
Expand Down Expand Up @@ -81,6 +82,7 @@ export interface UseMarkdownInput {
onLinkCommand?: () => Promise<unknown>;
onMentionCommand?: () => Promise<void>;
onUploadCommand?: (files: FileList) => void;
onGifCommand?: (gifUrl: string, altText: string) => Promise<void>;
onApplyMention?: (user: UserShortProfile) => Promise<void>;
onApplyEmoji?: (emoji: string) => Promise<void>;
checkMention?: (position?: number[]) => void;
Expand All @@ -98,6 +100,7 @@ export const defaultMarkdownCommands = {
link: true,
mention: true,
emoji: true,
gif: true,
};

const specialCharsRegex = new RegExp(/[^A-Za-z0-9_.]/);
Expand All @@ -117,6 +120,7 @@ export const useMarkdownInput = ({
const isUploadEnabled = enabledCommand[MarkdownCommand.Upload];
const isMentionEnabled = enabledCommand[MarkdownCommand.Mention];
const isEmojiEnabled = enabledCommand[MarkdownCommand.Emoji];
const isGifEnabled = enabledCommand[MarkdownCommand.Gif];
const [command, setCommand] = useState<TextareaCommand>();
const [input, setInput] = useState(initialContent);
const [query, setQuery] = useState<string>(undefined);
Expand Down Expand Up @@ -433,6 +437,16 @@ export const useMarkdownInput = ({
startUploading();
};

const onGifCommand = async (gifUrl: string, altText: string) => {
const replace: GetReplacementFn = (type, { trailingChar }) => {
const replacement = `${
!trailingChar ? '' : '\n\n'
}![${altText}](${gifUrl})\n\n`;
return { replacement };
};
await command.replaceWord(replace, onUpdate);
};

const onPaste: ClipboardEventHandler<HTMLTextAreaElement> = async (e) => {
const pastedText = e.clipboardData.getData('text');
if (isValidHttpUrl(pastedText)) {
Expand Down Expand Up @@ -493,11 +507,13 @@ export const useMarkdownInput = ({
onCloseEmoji,
}
: {};
const gifProps = isGifEnabled ? { onGifCommand } : {};

return {
...uploadProps,
...mentionProps,
...emojiProps,
...gifProps,
input,
onLinkCommand: isLinkEnabled ? onLinkCommand : null,
callbacks: {
Expand Down
Loading