Skip to content

Commit

Permalink
feat: searchGifs first pass
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <matt.krick@gmail.com>
  • Loading branch information
mattkrick committed Jan 23, 2025
1 parent 5e9705b commit 134c0ba
Show file tree
Hide file tree
Showing 13 changed files with 364 additions and 23 deletions.
1 change: 1 addition & 0 deletions codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null)
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<form
className='flex w-full min-w-44 flex-col items-center justify-center space-y-3 rounded-md bg-slate-100 p-2'
onSubmit={onSubmit}
>
<input
autoComplete='off'
autoFocus
placeholder={placeholder}
type='url'
className='w-full outline-none focus:ring-2'
ref={ref}
/>
<Button variant='outline' shape='pill' className='w-full' type='submit'>
Search image
</Button>
</form>
)
}
1 change: 1 addition & 0 deletions packages/client/types/modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
61 changes: 61 additions & 0 deletions packages/server/graphql/public/queries/searchGifs.ts
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions packages/server/graphql/public/typeDefs/GifResponse.graphql
Original file line number Diff line number Diff line change
@@ -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!
}
Original file line number Diff line number Diff line change
@@ -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!]!
}
10 changes: 10 additions & 0 deletions packages/server/graphql/public/typeDefs/GifResponseEdge.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
An edge in a connection.
"""
type GifResponseEdge {
"""
The item at the end of the edge
"""
node: GifResponse!
cursor: String
}
17 changes: 17 additions & 0 deletions packages/server/graphql/public/typeDefs/ImageSize.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
The size of an image
"""
enum ImageSize {
"""
Less than 90px tall
"""
nano
"""
Less than 220px tall
"""
tiny
"""
full size
"""
original
}
22 changes: 22 additions & 0 deletions packages/server/graphql/public/typeDefs/Query.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/server/graphql/public/types/GifResponse.ts
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions packages/server/utils/TenorManager.ts
Original file line number Diff line number Diff line change
@@ -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<MediaFilter, MediaObject> // 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<T>(url: string): Promise<T | Error> {
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<TenorResponse>(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<TenorResponse>(url.toString())
}
}
3 changes: 2 additions & 1 deletion scripts/toolboxSrc/applyEnvVarsToClientAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading

0 comments on commit 134c0ba

Please sign in to comment.