From 134c0ba3500deb3a74f37e66fbde972330063c4c Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 22 Jan 2025 18:38:06 -0800 Subject: [PATCH] feat: searchGifs first pass Signed-off-by: Matt Krick --- codegen.json | 1 + .../imageUpload/ImageSelectorSearchTab.tsx | 40 ++++++ packages/client/types/modules.d.ts | 1 + .../graphql/public/queries/searchGifs.ts | 61 +++++++++ .../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 | 3 +- scripts/webpack/dev.client.config.js | 47 +++---- 13 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.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/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/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx b/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx new file mode 100644 index 00000000000..9c42d3505c8 --- /dev/null +++ b/packages/client/tiptap/extensions/imageUpload/ImageSelectorSearchTab.tsx @@ -0,0 +1,40 @@ +import {useRef} from 'react' +import {Button} from '../../../ui/Button/Button' + +interface Props { + setImageURL: (url: string) => void +} + +export const ImageSelectorSearchTab = (props: Props) => { + const {setImageURL} = props + const ref = useRef(null) + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const url = ref.current?.value + if (!url) return + setImageURL(url!) + } + 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' + + return ( +
+ + +
+ ) +} diff --git a/packages/client/types/modules.d.ts b/packages/client/types/modules.d.ts index ba48f69c78b..134f8f9396c 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..44df4e96abd --- /dev/null +++ b/packages/server/graphql/public/queries/searchGifs.ts @@ -0,0 +1,61 @@ +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, tinywebp_transparent, webp_transparent} = media_formats + return { + id, + description, + tags, + urlOriginal: webp_transparent.url, + urlTiny: tinywebp_transparent.url, + urlNano: nanowebp_transparent.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..45f893fb364 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..fd712c64223 --- /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: Record // 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, + 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..26133d4f26d 100644 --- a/scripts/toolboxSrc/applyEnvVarsToClientAssets.ts +++ b/scripts/toolboxSrc/applyEnvVarsToClientAssets.ts @@ -75,7 +75,8 @@ 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' } 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..6373bfcf72e 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,8 @@ 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' }) }), new ReactRefreshWebpackPlugin(),