From d8932586b9d581da46189826c26afa03ae536ef3 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 27 Jan 2025 19:37:31 -0800 Subject: [PATCH] feat: gifabol (tenor search) (#10735) Signed-off-by: Matt Krick --- .env.example | 4 + codegen.json | 1 + .../client/hooks/useTipTapReflectionEditor.ts | 3 +- packages/client/styles/theme/global.css | 22 ++-- .../extensions/imageUpload/ImageSelector.tsx | 62 +++++---- .../imageUpload/ImageSelectorSearchTab.tsx | 106 +++++++++++++++ .../ImageSelectorSearchTabRoot.tsx | 39 ++++++ .../extensions/imageUpload/ImageUpload.ts | 25 ++-- .../imageUpload/ImageUploadView.tsx | 13 +- .../slashCommand/SlashCommandMenu.tsx | 11 +- .../extensions/slashCommand/slashCommands.ts | 13 +- packages/client/types/modules.d.ts | 1 + .../graphql/public/queries/searchGifs.ts | 69 ++++++++++ .../public/typeDefs/GifResponse.graphql | 29 +++++ .../typeDefs/GifResponseConnection.graphql | 14 ++ .../public/typeDefs/GifResponseEdge.graphql | 10 ++ .../graphql/public/typeDefs/ImageSize.graphql | 17 +++ .../graphql/public/typeDefs/Query.graphql | 22 ++++ .../graphql/public/types/GifResponse.ts | 21 +++ packages/server/utils/TenorManager.ts | 121 ++++++++++++++++++ .../toolboxSrc/applyEnvVarsToClientAssets.ts | 8 +- scripts/webpack/dev.client.config.js | 52 ++++---- 22 files changed, 576 insertions(+), 87 deletions(-) create mode 100644 packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx create mode 100644 packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTabRoot.tsx create mode 100644 packages/server/graphql/public/queries/searchGifs.ts create mode 100644 packages/server/graphql/public/typeDefs/GifResponse.graphql create mode 100644 packages/server/graphql/public/typeDefs/GifResponseConnection.graphql create mode 100644 packages/server/graphql/public/typeDefs/GifResponseEdge.graphql create mode 100644 packages/server/graphql/public/typeDefs/ImageSize.graphql create mode 100644 packages/server/graphql/public/types/GifResponse.ts create mode 100644 packages/server/utils/TenorManager.ts diff --git a/.env.example b/.env.example index 7da8022d6a1..4aa2b9119a7 100644 --- a/.env.example +++ b/.env.example @@ -162,3 +162,7 @@ PGADMIN_DEFAULT_PASSWORD='admin' # GLOBAL_BANNER_TEXT='UNCLASSIFIED CUI (IL4)' # GLOBAL_BANNER_BG_COLOR='#007A33' # GLOBAL_BANNER_COLOR='#FFFFFF' + +# gifabol | tenor | '' to hide gif selection tab +# GIF_PROVIDER=tenor +# TENOR_SECRET='' diff --git a/codegen.json b/codegen.json index 6ab2f114624..f66b35205d1 100644 --- a/codegen.json +++ b/codegen.json @@ -90,6 +90,7 @@ "GenerateGroupsSuccess": "./types/GenerateGroupsSuccess#GenerateGroupsSuccessSource", "GenerateInsightSuccess": "./types/GenerateInsightSuccess#GenerateInsightSuccessSource", "GenerateRetroSummariesSuccess": "./types/GenerateRetroSummariesSuccess#GenerateRetroSummariesSuccessSource", + "GifResponse": "./types/GifResponse#GifResponseSource", "GitHubIntegration": "../../postgres/queries/getGitHubAuthByUserIdTeamId#GitHubAuth", "GitLabIntegration": "./types/GitLabIntegration#GitLabIntegrationSource", "IntegrationProviderOAuth1": "../../postgres/queries/getIntegrationProvidersByIds#TIntegrationProvider", diff --git a/packages/client/hooks/useTipTapReflectionEditor.ts b/packages/client/hooks/useTipTapReflectionEditor.ts index 405bf777275..cb5ba7dbeac 100644 --- a/packages/client/hooks/useTipTapReflectionEditor.ts +++ b/packages/client/hooks/useTipTapReflectionEditor.ts @@ -17,6 +17,7 @@ import {mentionConfig, serverTipTapExtensions} from '../shared/tiptap/serverTipT import ImageBlock from '../tiptap/extensions/imageBlock/ImageBlock' import {ImageUpload} from '../tiptap/extensions/imageUpload/ImageUpload' import {SlashCommand} from '../tiptap/extensions/slashCommand/SlashCommand' +import {ElementWidth} from '../types/constEnums' import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig' import {tiptapMentionConfig} from '../utils/tiptapMentionConfig' @@ -69,7 +70,7 @@ export const useTipTapReflectionEditor = ( 'To-do list': false }), Focus, - ImageUpload, + ImageUpload.configure({editorWidth: ElementWidth.REFLECTION_CARD}), ImageBlock, LoomExtension, Placeholder.configure({ diff --git a/packages/client/styles/theme/global.css b/packages/client/styles/theme/global.css index c49b7099606..b327dbc5a9e 100644 --- a/packages/client/styles/theme/global.css +++ b/packages/client/styles/theme/global.css @@ -96,6 +96,10 @@ text-decoration: none; } + button { + @apply p-0; + } + button, input, select, @@ -170,6 +174,8 @@ .ProseMirror { width: 100%; + /* Gap cursor is 2px above the top of an element */ + padding-top: 2px; blockquote { border-left: 3px solid theme('colors.slate.500'); margin: 1.5rem 0; @@ -233,17 +239,13 @@ } } .node-imageBlock { - & img { - @apply overflow-hidden rounded-xl border-2 border-transparent; - } - - &:hover img { - @apply border-2 border-slate-100; + @apply relative; + &.has-focus > div::after { + content: ''; + @apply absolute inset-0 h-full w-full bg-[#2383e247]; } - - &:has(.is-active) img, - &.has-focus img { - @apply border-2 border-slate-800; + & img { + @apply overflow-hidden rounded-md border-2 border-transparent; } } } diff --git a/packages/client/tiptap/extensions/imageUpload/ImageSelector.tsx b/packages/client/tiptap/extensions/imageUpload/ImageSelector.tsx index f436c220452..39e574f5da7 100644 --- a/packages/client/tiptap/extensions/imageUpload/ImageSelector.tsx +++ b/packages/client/tiptap/extensions/imageUpload/ImageSelector.tsx @@ -3,6 +3,7 @@ import {useState} from 'react' import Tab from '../../../components/Tab/Tab' import Tabs from '../../../components/Tabs/Tabs' import {ImageSelectorEmbedTab} from './ImageSelectorEmbedTab' +import ImageSelectorSearchTabRoot from './ImageSelectorSearchTabRoot' import {ImageSelectorUploadTab} from './ImageSelectorUploadTab' interface Props { @@ -13,18 +14,21 @@ const tabs = [ { id: 'upload', label: 'Upload', - Component: ImageSelectorUploadTab + Component: ImageSelectorUploadTab, + isVisible: true }, { id: 'embedLink', label: 'Embed link', - Component: ImageSelectorEmbedTab + Component: ImageSelectorEmbedTab, + isVisible: true + }, + { + id: 'addGif', + label: 'Add Gif', + Component: ImageSelectorSearchTabRoot, + isVisible: !!window.__ACTION__.GIF_PROVIDER } - // { - // id: 'addGif', - // label: 'Add Gif', - // Component: ImageSelectorUploadTab - // } ] as const export const ImageSelector = (props: Props) => { @@ -32,28 +36,36 @@ export const ImageSelector = (props: Props) => { const [activeIdx, setActiveIdx] = useState(0) const {Component} = tabs[activeIdx]! const setImageURL = (url: string) => { - const {from} = editor.state.selection - editor.chain().setImageBlock({src: url}).deleteRange({from, to: from}).focus().run() + const {to} = editor.state.selection + const size = editor.state.doc.content.size + let command = editor.chain().focus().setImageBlock({src: url}) + if (size - to <= 1) { + // if we're at the end of the doc, add an extra paragraph to make it easier to click below + command = command.insertContent('

').setTextSelection(editor.state.selection.to + 1) + } + command.scrollIntoView().run() } return ( -
+
- {tabs.map((tab, idx) => ( - { - setActiveIdx(idx) - }} - className='whitespace-nowrap px-2 py-0' - label={ -
- {tab.label} -
- } - /> - ))} + {tabs + .filter((tab) => tab.isVisible) + .map((tab, idx) => ( + { + setActiveIdx(idx) + }} + className='whitespace-nowrap px-2 py-0' + label={ +
+ {tab.label} +
+ } + /> + ))}
- +
) } diff --git a/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx b/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx new file mode 100644 index 00000000000..626d338d919 --- /dev/null +++ b/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx @@ -0,0 +1,106 @@ +import type {Editor} from '@tiptap/core' +import graphql from 'babel-plugin-relay/macro' +import {useRef} from 'react' +import {usePaginationFragment, usePreloadedQuery, type PreloadedQuery} from 'react-relay' +import type {ImageSelectorSearchTabPaginationQuery} from '../../../__generated__/ImageSelectorSearchTabPaginationQuery.graphql' +import type {ImageSelectorSearchTabQuery} from '../../../__generated__/ImageSelectorSearchTabQuery.graphql' +import type {ImageSelectorSearchTabQuery_query$key} from '../../../__generated__/ImageSelectorSearchTabQuery_query.graphql' +import useLoadNextOnScrollBottom from '../../../hooks/useLoadNextOnScrollBottom' +import {cn} from '../../../ui/cn' + +interface Props { + editor: Editor + queryRef: PreloadedQuery + searchQuery: string + setSearchQuery: (query: string) => void + setImageURL: (url: string) => void +} + +export const ImageSelectorSearchTab = (props: Props) => { + const {queryRef, setImageURL, searchQuery, setSearchQuery} = props + const ref = useRef(null) + + const query = usePreloadedQuery( + graphql` + query ImageSelectorSearchTabQuery($query: String!, $fetchOriginal: Boolean!) { + ...ImageSelectorSearchTabQuery_query + } + `, + queryRef + ) + + const paginationRes = usePaginationFragment< + ImageSelectorSearchTabPaginationQuery, + ImageSelectorSearchTabQuery_query$key + >( + graphql` + fragment ImageSelectorSearchTabQuery_query on Query + @argumentDefinitions(after: {type: "String"}, first: {type: "Int", defaultValue: 20}) + @refetchable(queryName: "ImageSelectorSearchTabPaginationQuery") { + searchGifs(query: $query, first: $first, after: $after) + @connection(key: "ImageSelectorSearchTabQuery_searchGifs") { + edges { + node { + previewUrl: url(size: tiny) + originalUrl: url(size: original) @include(if: $fetchOriginal) + } + } + } + } + `, + query + ) + const {data} = paginationRes + const {searchGifs} = data + const {edges} = searchGifs! + const service = window.__ACTION__.GIF_PROVIDER + // Per attribution spec, the exact wording is required + // https://developers.google.com/tenor/guides/attribution + const placeholder = service === 'tenor' ? 'Search Tenor' : 'Search Gifs' + const onChange = (e: React.ChangeEvent) => { + const nextValue = e.target.value + setSearchQuery(nextValue) + } + const lastItem = useLoadNextOnScrollBottom(paginationRes, {}, 20) + return ( +
+
+ +
+
+ {edges.map((edge) => { + const {node} = edge + const {previewUrl, originalUrl} = node + return ( + + ) + })} + {lastItem} +
+
+ ) +} diff --git a/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTabRoot.tsx b/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTabRoot.tsx new file mode 100644 index 00000000000..881c66b8dab --- /dev/null +++ b/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTabRoot.tsx @@ -0,0 +1,39 @@ +import type {Editor} from '@tiptap/core' +import {Suspense, useState} from 'react' +import useQueryLoaderNow from '~/hooks/useQueryLoaderNow' +import type {ImageSelectorSearchTabQuery} from '../../../__generated__/ImageSelectorSearchTabQuery.graphql' +import imageSelectorSearchTabQuery from '../../../__generated__/ImageSelectorSearchTabQuery.graphql' +import {ImageSelectorSearchTab} from './ImageSelectorSearchTab' +interface Props { + editor: Editor + setImageURL: (url: string) => void +} + +export const ImageSelectorSearchTabRoot = (props: Props) => { + const {editor} = props + const [searchQuery, setSearchQuery] = useState('') + const queryToSendToServer = searchQuery.length > 2 ? searchQuery : '' + const queryRef = useQueryLoaderNow( + imageSelectorSearchTabQuery, + { + fetchOriginal: editor.storage.imageUpload.editorWidth > 500, + query: queryToSendToServer + }, + undefined, + true + ) + + return ( + + {queryRef && ( + + )} + + ) +} +export default ImageSelectorSearchTabRoot diff --git a/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts b/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts index 90697a194ce..6e77724823e 100644 --- a/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts +++ b/packages/client/tiptap/extensions/imageUpload/ImageUpload.ts @@ -3,10 +3,16 @@ import {EventEmitter} from 'eventemitter3' import {ImageUploadBase} from '../../../shared/tiptap/extensions/ImageUploadBase' import {ImageUploadView} from './ImageUploadView' -export const ImageUpload = ImageUploadBase.extend({ - addStorage() { +export const ImageUpload = ImageUploadBase.extend<{editorWidth: number}>({ + addOptions() { return { - emitter: new EventEmitter() + editorWidth: 300 + } + }, + addStorage(this) { + return { + emitter: new EventEmitter(), + editorWidth: this.options.editorWidth } }, @@ -28,15 +34,10 @@ export const ImageUpload = ImageUploadBase.extend({ return { setImageUpload: () => - ({commands, editor}) => { - const to = editor.state.selection.to - const size = editor.state.doc.content.size - if (size - to <= 1) { - // if we're at the end of the doc, add an extra paragraph to make it easier to click below - return commands.insertContent(`

`) - } else { - return commands.insertContent(`
`) - } + ({commands}) => { + // note: only call 1 command here. Calling multiple here & then having the caller also chaining commands + // will result in a fatal "Applying a mismatched transaction" + return commands.insertContent(`
`) } } }, diff --git a/packages/client/tiptap/extensions/imageUpload/ImageUploadView.tsx b/packages/client/tiptap/extensions/imageUpload/ImageUploadView.tsx index 4f5d5a49669..46a927f07ef 100644 --- a/packages/client/tiptap/extensions/imageUpload/ImageUploadView.tsx +++ b/packages/client/tiptap/extensions/imageUpload/ImageUploadView.tsx @@ -10,7 +10,7 @@ const useHideWhenTriggerHidden = (setOpen: (open: boolean) => void) => { useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { - if (!entry?.isIntersecting) { + if (entry && !entry?.isIntersecting && triggerRef.current) { setOpen(false) } }, @@ -71,16 +71,9 @@ export const ImageUploadView = (props: NodeViewProps) => {
- { - e.preventDefault() - }} - > + {/* z-30 is for expanded reflection stacks using Zindex.DIALOG */} -
+
diff --git a/packages/client/tiptap/extensions/slashCommand/SlashCommandMenu.tsx b/packages/client/tiptap/extensions/slashCommand/SlashCommandMenu.tsx index 05d4138fcfb..3355476348a 100644 --- a/packages/client/tiptap/extensions/slashCommand/SlashCommandMenu.tsx +++ b/packages/client/tiptap/extensions/slashCommand/SlashCommandMenu.tsx @@ -15,8 +15,8 @@ export const SlashCommandMenu = forwardRef( const activeRef = useRef(null) const flatItems = items.flatMap((item) => item.commands) const activeItem = flatItems[selectedIndex] - const selectItem = (idx: number) => { - const item = flatItems[idx] + const selectItem = (title: string) => { + const item = flatItems.find((item) => item.title === title) if (!item) return const {action} = item props.command({action}) @@ -31,7 +31,8 @@ export const SlashCommandMenu = forwardRef( } const enterHandler = () => { - selectItem(selectedIndex) + const title = flatItems[selectedIndex]!.title + selectItem(title) } useEffect(() => setSelectedIndex(0), [items]) @@ -61,7 +62,7 @@ export const SlashCommandMenu = forwardRef( if (!items.length) return null return (
- {items.map((item, idx) => ( + {items.map((item) => (
{item.group}
{item.commands.map((command) => ( @@ -72,7 +73,7 @@ export const SlashCommandMenu = forwardRef( className={ ' group flex w-full cursor-pointer items-center space-x-2 rounded-md px-3 py-2 text-sm leading-8 text-slate-700 outline-none hover:!bg-slate-200 hover:text-slate-900 focus:bg-slate-200 data-highlighted:bg-slate-100 data-highlighted:text-slate-900' } - onClick={() => selectItem(idx)} + onClick={() => selectItem(command.title)} >
diff --git a/packages/client/tiptap/extensions/slashCommand/slashCommands.ts b/packages/client/tiptap/extensions/slashCommand/slashCommands.ts index e3015d14f76..af0f0a71565 100644 --- a/packages/client/tiptap/extensions/slashCommand/slashCommands.ts +++ b/packages/client/tiptap/extensions/slashCommand/slashCommands.ts @@ -125,7 +125,18 @@ export const slashCommands = [ icon: ImageIcon, // shouldHide: () => true, action: (editor: Editor) => { - editor.chain().focus().setImageUpload().run() + const {to} = editor.state.selection + const size = editor.state.doc.content.size + let command = editor + .chain() + .focus() + .setImageUpload() + .setTextSelection(to + 1) + if (size - to <= 1) { + // if we're at the end of the doc, add an extra paragraph to make it easier to click below + command = command.insertContent('

').setTextSelection(to + 1) + } + return command.scrollIntoView().run() } } ] diff --git a/packages/client/types/modules.d.ts b/packages/client/types/modules.d.ts index ba48f69c78b..4b3d99c2a53 100644 --- a/packages/client/types/modules.d.ts +++ b/packages/client/types/modules.d.ts @@ -53,6 +53,7 @@ interface Window { GLOBAL_BANNER_TEXT: string GLOBAL_BANNER_BG_COLOR: string GLOBAL_BANNER_COLOR: string + GIF_PROVIDER: 'gifabol' | 'tenor' | '' } } declare type Json = null | boolean | number | string | Json[] | {[key: string]: Json} diff --git a/packages/server/graphql/public/queries/searchGifs.ts b/packages/server/graphql/public/queries/searchGifs.ts new file mode 100644 index 00000000000..9b39aebd62f --- /dev/null +++ b/packages/server/graphql/public/queries/searchGifs.ts @@ -0,0 +1,69 @@ +import {TenorManager} from '../../../utils/TenorManager' +import {QueryResolvers} from '../resolverTypes' + +export interface SSORelayState { + isInvited?: boolean + metadataURL?: string +} + +const searchGifs: QueryResolvers['searchGifs'] = async (_source, {query, first, after}) => { + const service = process.env.GIF_PROVIDER || 'tenor' + if (service === 'tenor') { + const manager = new TenorManager() + const request = + query === '' + ? manager.featured({limit: first, pos: after}) + : manager.search({query, limit: first, pos: after}) + const res = await request + if (res instanceof Error) { + throw res + } + const {next, results} = res + const nodes = results.map((result) => { + const {content_description: description, tags, id, media_formats} = result + const { + nanowebp_transparent: nano, + tinywebp_transparent: tiny, + webp_transparent: webp, + webp: original + } = media_formats + const urlOriginal = webp || original || tiny || nano + const urlTiny = tiny || webp || original || nano + const urlNano = nano || tiny || webp || original + return { + id, + description, + tags, + urlOriginal: urlOriginal?.url ?? '', + urlTiny: urlTiny?.url ?? '', + urlNano: urlNano?.url ?? '' + } + }) + const edges = nodes.map((node, idx) => ({ + node, + cursor: idx === nodes.length - 1 ? next : null + })) + + return { + pageInfo: { + hasNextPage: !!next, + hasPreviousPage: false, + startCursor: null, + endCursor: next + }, + edges + } + } + console.log(`${service} NOT IMPLEMENTED!`) + return { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null + }, + edges: [] + } +} + +export default searchGifs diff --git a/packages/server/graphql/public/typeDefs/GifResponse.graphql b/packages/server/graphql/public/typeDefs/GifResponse.graphql new file mode 100644 index 00000000000..0a66d4ac33c --- /dev/null +++ b/packages/server/graphql/public/typeDefs/GifResponse.graphql @@ -0,0 +1,29 @@ +""" +A response with info about a gif +""" +type GifResponse { + """ + The ID of the gif + """ + id: ID! + + """ + A description of the gif + """ + description: String! + + """ + A list of tags describing the gif + """ + tags: [String!]! + + """ + url + """ + url( + """ + The size of the gif + """ + size: ImageSize! + ): String! +} diff --git a/packages/server/graphql/public/typeDefs/GifResponseConnection.graphql b/packages/server/graphql/public/typeDefs/GifResponseConnection.graphql new file mode 100644 index 00000000000..4d6ccb06acf --- /dev/null +++ b/packages/server/graphql/public/typeDefs/GifResponseConnection.graphql @@ -0,0 +1,14 @@ +""" +A connection to list the returned gifs +""" +type GifResponseConnection { + """ + Page info with cursors as strings + """ + pageInfo: PageInfo + + """ + A list of edges. + """ + edges: [GifResponseEdge!]! +} diff --git a/packages/server/graphql/public/typeDefs/GifResponseEdge.graphql b/packages/server/graphql/public/typeDefs/GifResponseEdge.graphql new file mode 100644 index 00000000000..95b9e1ae3b2 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/GifResponseEdge.graphql @@ -0,0 +1,10 @@ +""" +An edge in a connection. +""" +type GifResponseEdge { + """ + The item at the end of the edge + """ + node: GifResponse! + cursor: String +} diff --git a/packages/server/graphql/public/typeDefs/ImageSize.graphql b/packages/server/graphql/public/typeDefs/ImageSize.graphql new file mode 100644 index 00000000000..f0201520343 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/ImageSize.graphql @@ -0,0 +1,17 @@ +""" +The size of an image +""" +enum ImageSize { + """ + Less than 90px tall + """ + nano + """ + Less than 220px tall + """ + tiny + """ + full size + """ + original +} diff --git a/packages/server/graphql/public/typeDefs/Query.graphql b/packages/server/graphql/public/typeDefs/Query.graphql index 53eff2278e8..5083f6a7899 100644 --- a/packages/server/graphql/public/typeDefs/Query.graphql +++ b/packages/server/graphql/public/typeDefs/Query.graphql @@ -6,6 +6,28 @@ type Query { """ token: ID! ): MassInvitationPayload! + searchGifs( + """ + The search query to send to the service + """ + query: String! + """ + The ISO 3166-1 country of the user, default is US + """ + country: String + """ + The ISO 639-1 locale of the user, default is en_US + """ + locale: String + """ + The first n records to return + """ + first: Int! + """ + The pagination cursor, if any + """ + after: String + ): GifResponseConnection! verifiedInvitation( """ The invitation token diff --git a/packages/server/graphql/public/types/GifResponse.ts b/packages/server/graphql/public/types/GifResponse.ts new file mode 100644 index 00000000000..85e12c90c35 --- /dev/null +++ b/packages/server/graphql/public/types/GifResponse.ts @@ -0,0 +1,21 @@ +import {GifResponseResolvers} from '../resolverTypes' + +export type GifResponseSource = { + id: string + description: string + tags: string[] + urlOriginal: string + urlTiny: string + urlNano: string +} + +const GifResponse: GifResponseResolvers = { + url: (source, {size}) => { + const {urlNano, urlOriginal, urlTiny} = source + if (size === 'nano') return urlNano + if (size === 'tiny') return urlTiny + return urlOriginal + } +} + +export default GifResponse diff --git a/packages/server/utils/TenorManager.ts b/packages/server/utils/TenorManager.ts new file mode 100644 index 00000000000..07311abdf53 --- /dev/null +++ b/packages/server/utils/TenorManager.ts @@ -0,0 +1,121 @@ +const MAX_REQUEST_TIME = 5000 + +interface TenorResponse { + results: ResponseObject[] + next: string +} + +interface ResponseObject { + created: number // Unix timestamp representing when this post was created + hasaudio: boolean // Indicates if the post contains audio (only video formats support audio) + id: string // Tenor result identifier + media_formats: Partial> // Dictionary with content format as the key and MediaObject as the value + tags: string[] // Array of tags for the post + title: string // Title of the post + content_description: string // Textual description of the content + itemurl: string // Full URL to view the post on tenor.com + hascaption: boolean // Indicates if the post contains captions + flags: string // Comma-separated list to describe content properties (e.g., sticker, static, audio) + bg_color: string // Most common background pixel color of the content + url: string // Short URL to view the post on tenor.com +} + +interface MediaObject { + url: string // URL to the media content + duration?: number // Duration of the media (if applicable) + preview?: string // URL to the preview image (if applicable) + dims?: [number, number] // Dimensions of the media (width, height) + size?: number // Size of the media file in bytes +} + +const mediaFilter = [ + 'webp', + 'webp_transparent', + 'tinywebp_transparent', + 'nanowebp_transparent' +] as const + +type MediaFilter = (typeof mediaFilter)[number] +export class TenorManager { + apiKey: string + clientKey: string + constructor() { + const {HOST, TENOR_SECRET} = process.env + if (!TENOR_SECRET) { + throw new Error('Missing ENV Var: TENOR_SECRET') + } + this.apiKey = TENOR_SECRET + this.clientKey = HOST! + } + private fetchWithTimeout = async (url: string, options: RequestInit) => { + const controller = new AbortController() + const {signal} = controller + const timeout = setTimeout(() => { + controller.abort() + }, MAX_REQUEST_TIME) + try { + const res = await fetch(url, {...options, signal}) + clearTimeout(timeout) + return res + } catch (e) { + clearTimeout(timeout) + return new Error('Tenor is not responding') + } + } + private async get(url: string): Promise { + const res = await this.fetchWithTimeout(url, {}) + if (res instanceof Error) return res + if (res.status !== 200) { + return new Error(`${res.status}: ${res.statusText}`) + } + const resJSON = await res.json() + return resJSON as T + } + + async featured(opts: {limit: number; pos?: string | null; country?: string; locale?: string}) { + const {limit, country, locale, pos} = opts + const url = new URL(`https://tenor.googleapis.com/v2/featured`) + const searchParams = { + key: this.apiKey, + client_key: this.clientKey, + media_filter: mediaFilter.join(','), + limit, + pos, + country, + locale + } + Object.entries(searchParams).forEach(([key, value]) => { + // filters out country, locale + if (!value) return + url.searchParams.append(key, value as string) + }) + return await this.get(url.toString()) + } + + async search(opts: { + query: string + limit: number + pos?: string | null + country?: string + locale?: string + }) { + const {query, limit, country, locale, pos} = opts + const url = new URL(`https://tenor.googleapis.com/v2/search`) + const searchParams = { + key: this.apiKey, + client_key: this.clientKey, + media_filter: mediaFilter.join(','), + limit, + pos, + country, + locale, + q: query + } + Object.entries(searchParams).forEach(([key, value]) => { + // filters out country, locale + if (!value) return + url.searchParams.append(key, value as string) + }) + return await this.get(url.toString()) + } +} diff --git a/scripts/toolboxSrc/applyEnvVarsToClientAssets.ts b/scripts/toolboxSrc/applyEnvVarsToClientAssets.ts index 701f5d61536..3569b26d77f 100644 --- a/scripts/toolboxSrc/applyEnvVarsToClientAssets.ts +++ b/scripts/toolboxSrc/applyEnvVarsToClientAssets.ts @@ -75,7 +75,13 @@ const rewriteIndexHTML = () => { GLOBAL_BANNER_ENABLED: process.env.GLOBAL_BANNER_ENABLED === 'true', GLOBAL_BANNER_TEXT: process.env.GLOBAL_BANNER_TEXT, GLOBAL_BANNER_BG_COLOR: process.env.GLOBAL_BANNER_BG_COLOR, - GLOBAL_BANNER_COLOR: process.env.GLOBAL_BANNER_COLOR + GLOBAL_BANNER_COLOR: process.env.GLOBAL_BANNER_COLOR, + GIF_PROVIDER: + process.env.GIF_PROVIDER !== 'tenor' + ? process.env.GIF_PROVIDER + : process.env.TENOR_SECRET + ? 'tenor' + : '' } const skeleton = fs.readFileSync(path.join(clientDir, 'skeleton.html'), 'utf8') diff --git a/scripts/webpack/dev.client.config.js b/scripts/webpack/dev.client.config.js index 0490d31867f..21096d327cf 100644 --- a/scripts/webpack/dev.client.config.js +++ b/scripts/webpack/dev.client.config.js @@ -49,27 +49,29 @@ module.exports = { hot: true, historyApiFallback: true, port: PORT, - proxy: [...[ - 'sse', - 'sse-ping', - 'jira-attachments', - 'stripe', - 'webhooks', - 'graphql', - 'intranet-graphql', - 'self-hosted', - 'mattermost', - // important terminating / so saml-redirect doesn't get targeted, too - 'saml/' - ].map((name) => ({ - context: [`/${name}`], - target: `http://localhost:${SOCKET_PORT}` - })), - { - context: '/components', - pathRewrite: { '^/components': '' }, - target: `http://localhost:3002` - }] + proxy: [ + ...[ + 'sse', + 'sse-ping', + 'jira-attachments', + 'stripe', + 'webhooks', + 'graphql', + 'intranet-graphql', + 'self-hosted', + 'mattermost', + // important terminating / so saml-redirect doesn't get targeted, too + 'saml/' + ].map((name) => ({ + context: [`/${name}`], + target: `http://localhost:${SOCKET_PORT}` + })), + { + context: '/components', + pathRewrite: {'^/components': ''}, + target: `http://localhost:3002` + } + ] }, infrastructureLogging: {level: 'warn'}, watchOptions: { @@ -147,7 +149,13 @@ module.exports = { GLOBAL_BANNER_ENABLED: process.env.GLOBAL_BANNER_ENABLED === 'true', GLOBAL_BANNER_TEXT: process.env.GLOBAL_BANNER_TEXT, GLOBAL_BANNER_BG_COLOR: process.env.GLOBAL_BANNER_BG_COLOR, - GLOBAL_BANNER_COLOR: process.env.GLOBAL_BANNER_COLOR + GLOBAL_BANNER_COLOR: process.env.GLOBAL_BANNER_COLOR, + GIF_PROVIDER: + process.env.GIF_PROVIDER !== 'tenor' + ? process.env.GIF_PROVIDER + : process.env.TENOR_SECRET + ? 'tenor' + : '' }) }), new ReactRefreshWebpackPlugin(),