diff --git a/packages/shared/src/components/fields/MarkdownInput/index.tsx b/packages/shared/src/components/fields/MarkdownInput/index.tsx index a93ee9a7d0..8b4ceebaec 100644 --- a/packages/shared/src/components/fields/MarkdownInput/index.tsx +++ b/packages/shared/src/components/fields/MarkdownInput/index.tsx @@ -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( () => @@ -132,6 +134,7 @@ function MarkdownInput( uploadedCount, onLinkCommand, onUploadCommand, + onGifCommand, onMentionCommand, onApplyMention, onCloseMention, @@ -389,6 +392,15 @@ function MarkdownInput( /> )} + , + }} + onGifCommand={onGifCommand} + textareaRef={textareaRef} + /> {shouldShowSubmit && ( + + ))} + + {query && ( +
+ +
+ )} + + )} + {!isLoadingGifs && (!gifsToDisplay || gifsToDisplay.length === 0) && ( +
+ + {!query + ? 'You have no favorites yet. Add some, and they will appear here!' + : 'no results matching your search 😞'} + +
+ )} + + + + ); +}; + +export default GifPopover; diff --git a/packages/shared/src/hooks/input/useMarkdownInput.ts b/packages/shared/src/hooks/input/useMarkdownInput.ts index 53532dadbf..c4faaa78ea 100644 --- a/packages/shared/src/hooks/input/useMarkdownInput.ts +++ b/packages/shared/src/hooks/input/useMarkdownInput.ts @@ -53,6 +53,7 @@ export enum MarkdownCommand { Link = 'link', Mention = 'mention', Emoji = 'emoji', + Gif = 'gif', } export interface UseMarkdownInputProps @@ -81,6 +82,7 @@ export interface UseMarkdownInput { onLinkCommand?: () => Promise; onMentionCommand?: () => Promise; onUploadCommand?: (files: FileList) => void; + onGifCommand?: (gifUrl: string, altText: string) => Promise; onApplyMention?: (user: UserShortProfile) => Promise; onApplyEmoji?: (emoji: string) => Promise; checkMention?: (position?: number[]) => void; @@ -98,6 +100,7 @@ export const defaultMarkdownCommands = { link: true, mention: true, emoji: true, + gif: true, }; const specialCharsRegex = new RegExp(/[^A-Za-z0-9_.]/); @@ -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(); const [input, setInput] = useState(initialContent); const [query, setQuery] = useState(undefined); @@ -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 = async (e) => { const pastedText = e.clipboardData.getData('text'); if (isValidHttpUrl(pastedText)) { @@ -493,11 +507,13 @@ export const useMarkdownInput = ({ onCloseEmoji, } : {}; + const gifProps = isGifEnabled ? { onGifCommand } : {}; return { ...uploadProps, ...mentionProps, ...emojiProps, + ...gifProps, input, onLinkCommand: isLinkEnabled ? onLinkCommand : null, callbacks: { diff --git a/packages/shared/src/hooks/useGif.ts b/packages/shared/src/hooks/useGif.ts new file mode 100644 index 0000000000..57543ad1c4 --- /dev/null +++ b/packages/shared/src/hooks/useGif.ts @@ -0,0 +1,138 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { apiUrl } from '../lib/config'; +import { generateQueryKey, RequestKey } from '../lib/query'; +import { useAuthContext } from '../contexts/AuthContext'; + +export type GifData = { + id: string; + title: string; + preview: string; + url: string; +}; + +interface GifResponse { + gifs: GifData[]; + next?: string; +} + +const useGif = ({ + query, + limit = '10', + favoritesEnabled = true, +}: { + query: string; + limit?: string; + favoritesEnabled?: boolean; +}) => { + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + const favoritesQueryKey = generateQueryKey(RequestKey.Gif, user, 'favorites'); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: generateQueryKey(RequestKey.Gif, user, { + query, + limit, + }), + queryFn: async ({ pageParam }) => { + const params = new URLSearchParams({ + q: query, + limit: limit.toString(), + ...(pageParam && { pos: pageParam as string }), + }); + + const response = await fetch(`${apiUrl}/gifs?${params}`, { + credentials: 'include', + }); + return response.json(); + }, + getNextPageParam: (lastPage) => { + return lastPage.next || undefined; + }, + initialPageParam: undefined, + enabled: !!query, + }); + const gifs = data?.pages.flatMap((page) => page.gifs) ?? []; + const { data: favData, isFetching: isFetchingFavorites } = + useQuery({ + queryKey: favoritesQueryKey, + queryFn: async () => { + const response = await fetch(`${apiUrl}/gifs/favorites`, { + credentials: 'include', + }); + if (!response.ok) { + throw new Error('Failed to fetch favorite gifs'); + } + return await response.json(); + }, + enabled: favoritesEnabled, + staleTime: Infinity, + }); + const favorites = favData?.gifs ?? []; + + const { mutate } = useMutation< + unknown, + Error, + GifData, + { previousFavorites?: GifResponse } + >({ + mutationKey: generateQueryKey(RequestKey.Gif, user, 'favorites'), + mutationFn: async (gif: GifData) => { + const response = await fetch(`${apiUrl}/gifs/favorite`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(gif), + }); + if (!response.ok) { + throw new Error('Failed to favorite gif'); + } + return response.json(); + }, + onMutate: async (gif) => { + await queryClient.cancelQueries({ queryKey: favoritesQueryKey }); + + const previousFavorites = + queryClient.getQueryData(favoritesQueryKey); + const prevGifs = previousFavorites?.gifs ?? []; + const isFavorite = prevGifs.some(({ id }) => id === gif.id); + const updatedGifs = isFavorite + ? prevGifs.filter(({ id }) => id !== gif.id) + : [gif, ...prevGifs]; + + queryClient.setQueryData( + favoritesQueryKey, + (existingFavorites) => ({ + gifs: updatedGifs, + next: existingFavorites?.next, + }), + ); + + return { previousFavorites }; + }, + onError: (_error, _gif, context) => { + if (context?.previousFavorites) { + queryClient.setQueryData(favoritesQueryKey, context.previousFavorites); + } + }, + }); + + return { + data: gifs, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + favorite: mutate, + favorites, + isFetchingFavorites, + }; +}; + +export default useGif; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 70cb18eb3f..3a5655af84 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -223,6 +223,7 @@ export enum RequestKey { OpportunityStats = 'opportunity_stats', UserCandidatePreferences = 'user_candidate_preferences', KeywordAutocomplete = 'keyword_autocomplete', + Gif = 'gif', Location = 'location', Autocomplete = 'autocomplete', UserExperience = 'user_experience',