Skip to content

Commit 8649a6a

Browse files
committed
Improve giphy UI, use alt text if available
1 parent d2d1c97 commit 8649a6a

12 files changed

Lines changed: 204 additions & 126 deletions

File tree

backend/src/api-tests/giphy.test.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,40 @@ import request from 'superwstest';
22
import { WebSocketExpress } from 'websocket-express';
33
import { testConfig } from './testConfig';
44
import { addressToString, testServerRunner } from './testServerRunner';
5+
import type { GiphyResponse } from '../services/GiphyService';
56
import { appFactory } from '../app';
67

78
describe('API giphy', () => {
89
const MOCK_GIPHY = testServerRunner(async () => {
910
const giphyApp = new WebSocketExpress();
10-
giphyApp.use(WebSocketExpress.urlencoded({ extended: false }));
11-
giphyApp.get('/gifs/search', (_, res) => {
12-
res.json({
13-
status: 200,
14-
data: [
15-
{
16-
images: {
17-
original: { url: 'original.gif?extra' },
18-
fixed_height: { url: 'medium.gif?extra' },
19-
fixed_height_small: { url: 'small.gif?extra' },
11+
const response: GiphyResponse = {
12+
meta: { status: 200 },
13+
data: [
14+
{
15+
alt_text: 'An image',
16+
images: {
17+
original: {
18+
url: 'http://example.com/original.gif?extra',
19+
webp: 'http://example.com/original.webp?extra',
2020
},
21-
},
22-
{
23-
images: {
24-
original: { url: 'original2.gif' },
21+
fixed_height: {
22+
url: 'http://example.com/medium.gif?extra',
23+
webp: 'http://example.com/medium.webp?extra',
2524
},
25+
fixed_height_small: { url: 'http://example.com/small.gif?extra' },
2626
},
27-
],
28-
});
27+
},
28+
{
29+
images: {
30+
original: { url: 'http://example.com/original2.gif' },
31+
},
32+
},
33+
],
34+
pagination: {},
35+
};
36+
giphyApp.use(WebSocketExpress.urlencoded({ extended: false }));
37+
giphyApp.get('/gifs/search', (_, res) => {
38+
res.json(response);
2939
});
3040

3141
return { run: giphyApp.createServer() };
@@ -55,8 +65,15 @@ describe('API giphy', () => {
5565

5666
expect(response.body).toEqual({
5767
gifs: [
58-
{ small: 'small.gif', medium: 'medium.gif' },
59-
{ small: 'original2.gif', medium: 'original2.gif' },
68+
{
69+
small: 'http://example.com/small.gif',
70+
medium: 'http://example.com/medium.webp',
71+
alt: 'An image',
72+
},
73+
{
74+
small: 'http://example.com/original2.gif',
75+
medium: 'http://example.com/original2.gif',
76+
},
6077
],
6178
});
6279
});

backend/src/export/RetroJsonExport.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type MaybeAsyncIterable<T> = Iterable<T> | AsyncIterable<T>;
1212
export interface RetroItemAttachmentJsonExport {
1313
type: string;
1414
url: string;
15+
alt?: string | undefined;
1516
}
1617

1718
export interface RetroItemJsonExport {
@@ -57,6 +58,7 @@ function exportRetroItemAttachment(
5758
return {
5859
type: attachment.type,
5960
url: attachment.url,
61+
alt: attachment.alt,
6062
};
6163
}
6264

@@ -66,6 +68,7 @@ function importRetroItemAttachment(
6668
return {
6769
type: attachment.type,
6870
url: attachment.url,
71+
alt: attachment.alt,
6972
};
7073
}
7174

backend/src/helpers/exportedJsonParsers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const extractExportedRetroItem = json.object<RetroItemJsonExport>({
2828
json.object<RetroItemAttachmentJsonExport>({
2929
type: json.string,
3030
url: json.string,
31+
alt: json.optional(json.string),
3132
}),
3233
),
3334
});

backend/src/helpers/jsonParsers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const extractRetroItem = json.exactObject<RetroItem>({
1515
json.exactObject<RetroItemAttachment>({
1616
type: json.string,
1717
url: json.string,
18+
alt: json.optional(json.string),
1819
}),
1920
),
2021
votes: json.number,

backend/src/routers/ApiGiphyRouter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,20 @@ export class ApiGiphyRouter extends Router {
1010
this.get(
1111
'/search',
1212
safe(async (req, res) => {
13-
const { q, lang = 'en' } = req.query;
13+
const { q, lang } = req.query;
1414

1515
if (typeof q !== 'string' || !q) {
1616
res.status(400).json({ error: 'Bad request' });
1717
return;
1818
}
1919

20-
if (typeof lang !== 'string') {
20+
if (typeof lang !== 'string' && lang !== undefined) {
2121
res.status(400).json({ error: 'Bad request' });
2222
return;
2323
}
2424

2525
try {
26-
const gifs = await service.search(q, 10, lang);
26+
const gifs = await service.search(q, 0, 50, lang);
2727

2828
res.json({ gifs });
2929
} catch (err) {

backend/src/services/GiphyService.ts

Lines changed: 71 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,40 @@ interface Config {
88
interface GifInfo {
99
small: string;
1010
medium: string;
11+
alt: string | undefined;
1112
}
1213

1314
interface GiphyResponseResource {
1415
url?: string;
16+
webp?: string;
1517
}
1618

1719
interface GiphyResponseGif {
20+
alt_text?: string;
1821
images: {
1922
original?: GiphyResponseResource;
2023
fixed_height?: GiphyResponseResource;
2124
fixed_height_small?: GiphyResponseResource;
25+
fixed_height_downsampled?: GiphyResponseResource;
26+
fixed_width?: GiphyResponseResource;
27+
fixed_width_small?: GiphyResponseResource;
28+
fixed_width_downsampled?: GiphyResponseResource;
2229
};
2330
}
2431

25-
interface GiphyResponse {
26-
status: number;
27-
data: ReadonlyArray<GiphyResponseGif>;
32+
export interface GiphyResponse {
33+
meta: { status: number };
34+
data: GiphyResponseGif[];
35+
pagination: { total_count?: number };
2836
}
2937

3038
export class GiphyService {
3139
private readonly baseUrl: string;
3240

3341
private readonly apiKey: string;
3442

35-
// memory used = ~120 bytes per gif entry * max limit * max cache size
36-
private readonly searchCache = new LruCache<string, GifInfo[]>(1024);
43+
// memory used = ~200 bytes per gif entry * max limit * cache size
44+
private readonly searchCache = new LruCache<string, GifInfo[]>(256);
3745

3846
public constructor(config: Config) {
3947
this.baseUrl = config.baseUrl;
@@ -42,49 +50,68 @@ export class GiphyService {
4250

4351
public async search(
4452
query: string,
53+
offset: number,
4554
limit: number,
46-
lang = 'en',
55+
lang?: string | undefined,
4756
): Promise<GifInfo[]> {
48-
if (!this.apiKey || !query || limit <= 0) {
57+
if (!this.apiKey || !query || offset < 0 || limit <= 0) {
4958
return [];
5059
}
5160

52-
const cached = await this.searchCache.cachedAsync(
53-
`${lang}:${query}`,
54-
async (): Promise<GifInfo[]> => {
55-
const params = new URLSearchParams();
56-
params.append('api_key', this.apiKey);
57-
params.append('q', query);
58-
params.append('limit', String(limit));
59-
params.append('rating', 'g');
60-
params.append('lang', lang);
61-
const result = await fetch(
62-
`${this.baseUrl}/gifs/search?${params.toString()}`,
63-
);
64-
const resultJson = (await result.json()) as GiphyResponse;
65-
66-
if (resultJson.status === 400) {
67-
throw new Error('Giphy API returned Bad Request');
68-
} else if (resultJson.status === 403) {
69-
throw new Error('Giphy API key rejected');
70-
} else if (resultJson.status === 429) {
71-
throw new Error('Giphy API rate limit reached');
72-
} else if (resultJson.status >= 400) {
73-
throw new Error(`Unknown Giphy API response: ${resultJson.status}`);
74-
}
75-
76-
return resultJson.data.map((gif) => {
77-
const original = gif.images.original?.url ?? '';
78-
const fixed = gif.images.fixed_height?.url ?? original;
79-
const small = gif.images.fixed_height_small?.url ?? fixed;
80-
return {
81-
small: small.split('?')[0] ?? '',
82-
medium: fixed.split('?')[0] ?? '',
83-
};
84-
});
85-
},
86-
(c) => c.length >= limit,
87-
);
88-
return cached.slice(0, limit);
61+
const cacheKey = `${lang}:${query}:${offset}:${limit}`;
62+
63+
return this.searchCache.cachedAsync(cacheKey, async () => {
64+
const params = new URLSearchParams({
65+
api_key: this.apiKey,
66+
q: query,
67+
offset: String(offset),
68+
limit: String(limit),
69+
rating: 'g',
70+
bundle: 'messaging_non_clips',
71+
});
72+
if (lang) {
73+
params.set('lang', lang);
74+
}
75+
const result = await fetch(`${this.baseUrl}/gifs/search?${params}`);
76+
const resultJson = (await result.json()) as GiphyResponse;
77+
const status = resultJson.meta?.status || result.status;
78+
79+
if (status === 400) {
80+
throw new Error('Giphy API returned Bad Request');
81+
} else if (status === 403) {
82+
throw new Error('Giphy API key rejected');
83+
} else if (status === 429) {
84+
throw new Error('Giphy API rate limit reached');
85+
} else if (status >= 400) {
86+
throw new Error(`Unknown Giphy API response: ${status}`);
87+
}
88+
89+
return resultJson.data.map((gif) => {
90+
const original = getResourceURL(gif.images.original);
91+
const medium = getResourceURL(gif.images.fixed_height);
92+
const small = getResourceURL(gif.images.fixed_height_small);
93+
return {
94+
small: trimQuery(small ?? medium ?? original) ?? '',
95+
medium: trimQuery(medium ?? original ?? small) ?? '',
96+
alt: gif.alt_text || undefined,
97+
};
98+
});
99+
});
100+
}
101+
}
102+
103+
const getResourceURL = (image: GiphyResponseResource | undefined) =>
104+
image?.webp ?? image?.url;
105+
106+
function trimQuery(href: string | undefined) {
107+
if (!href) {
108+
return undefined;
109+
}
110+
try {
111+
const url = new URL(href);
112+
url.search = '';
113+
return url.toString();
114+
} catch {
115+
return href.split('?')[0] ?? '';
89116
}
90117
}

backend/src/shared/api-entities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ClientConfig {
1919
export interface RetroItemAttachment {
2020
type: string;
2121
url: string;
22+
alt?: string | undefined;
2223
}
2324

2425
export interface UserProvidedRetroItemDetails {

frontend/src/api/GiphyService.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@ import { jsonFetch } from './jsonFetch';
33
export interface GifInfo {
44
small: string;
55
medium: string;
6+
alt?: string;
67
}
78

89
export class GiphyService {
910
public constructor(private readonly apiBase: string) {}
1011

11-
public async search(query: string, signal: AbortSignal): Promise<GifInfo[]> {
12+
public async search(
13+
query: string,
14+
lang: string | undefined,
15+
signal: AbortSignal,
16+
): Promise<GifInfo[]> {
1217
const normedQuery = query.trim();
1318
if (!normedQuery) {
1419
return [];
1520
}
1621

1722
const params = new URLSearchParams({ q: normedQuery });
23+
if (lang) {
24+
params.set('lang', lang);
25+
}
1826
const body = await jsonFetch<{ gifs: GifInfo[] }>(
19-
`${this.apiBase}/giphy/search?${params.toString()}`,
27+
`${this.apiBase}/giphy/search?${params}`,
2028
{ signal },
2129
);
2230
return body.gifs;

frontend/src/components/attachments/giphy/GiphyAttachment.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ interface PropsT {
55
attachment: RetroItemAttachment;
66
}
77

8-
export const GiphyAttachment = memo(({ attachment: { url } }: PropsT) => (
8+
export const GiphyAttachment = memo(({ attachment: { url, alt } }: PropsT) => (
99
<figure>
1010
<img
1111
src={url}
12-
alt="Attachment"
12+
alt={alt}
13+
title={alt}
1314
crossOrigin="anonymous"
1415
referrerPolicy="no-referrer"
1516
/>

0 commit comments

Comments
 (0)