Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add pagination to tenor gif search #4646

Merged
merged 4 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/src/core/client/framework/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export { default as usePersistedSessionState } from "./usePersistedSessionState"
export { default as useInMemoryState } from "./useInMemoryState";
export { default as useMemoizer } from "./useMemoizer";
export { default as useModerationLink } from "./useModerationLink";
export { default as useDebounce } from "./useDebounce";
41 changes: 41 additions & 0 deletions client/src/core/client/framework/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";

const useDebounce = (callback: (...args: any[]) => void, delay: number) => {
const callbackRef = useRef(callback);

useLayoutEffect(() => {
callbackRef.current = callback;
});

const [timer, setTimer] = useState<number | null>(null);

const naiveDebounce = useMemo(() => {
const deb = (
func: (...args: any[]) => void,
delayMs: number,
...args: any[]
) => {
if (timer) {
clearTimeout(timer);
setTimer(null);
}

const t = setTimeout(() => {
func(args);
}, delayMs);

setTimer(t as unknown as number);
};

return deb;
}, [timer, setTimer]);

return useMemo(
() =>
(...args: any) =>
naiveDebounce(callbackRef.current, delay, args),
[delay, naiveDebounce]
);
};

export default useDebounce;
34 changes: 34 additions & 0 deletions client/src/core/client/stream/common/useFetchWithAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { graphql } from "react-relay";

import { useFetchWithAuth_local } from "coral-stream/__generated__/useFetchWithAuth_local.graphql";

import { useLocal } from "../../framework/lib/relay";

const useFetchWithAuth = () => {
const [{ accessToken }] = useLocal<useFetchWithAuth_local>(graphql`
fragment useFetchWithAuth_local on Local {
accessToken
}
`);

const fetchWithAuth = useCallback(
async (input: RequestInfo, init?: RequestInit) => {
const params = {
...init,
headers: new Headers({
Authorization: `Bearer ${accessToken}`,
}),
};

const response = await fetch(input, params);

return response;
},
[accessToken]
);

return fetchWithAuth;
};

export default useFetchWithAuth;
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
gap: 8px;
}

.gridControls {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}

.gridItem {
width: 85px;
background: var(--palette-background-body);
Expand All @@ -50,3 +57,14 @@
width: 100%;
height: auto;
}

.input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

.searchButton {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

183 changes: 126 additions & 57 deletions client/src/core/client/stream/tabs/Comments/TenorInput/TenorInput.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Localized } from "@fluent/react/compat";
import React, {
ChangeEventHandler,
FunctionComponent,
Expand All @@ -8,36 +9,18 @@ import React, {
useRef,
useState,
} from "react";
import useDebounce from "react-use/lib/useDebounce";
import { Environment } from "relay-runtime";
import useResizeObserver from "use-resize-observer";

import { createFetch } from "coral-framework/lib/relay";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import { HorizontalGutter } from "coral-ui/components/v2";
import { useDebounce } from "coral-framework/hooks";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import useFetchWithAuth from "coral-stream/common/useFetchWithAuth";
import { ButtonSvgIcon, SearchIcon } from "coral-ui/components/icons";
import { Button, HorizontalGutter, TextField } from "coral-ui/components/v2";

import { GifSearchInput } from "../GifSearchInput/GifSearchInput";
import TenorAttribution from "./TenorAttribution";

import styles from "./TenorInput.css";

function createGifFetch<T>(name: string, url: string) {
return createFetch(
name,
async (
environment: Environment,
variables: { query: string },
{ rest }
) => {
const params = new URLSearchParams(variables);

return rest.fetch<T>(`${url}?${params.toString()}`, {
method: "GET",
});
}
);
}

interface Props {
onSelect: (gif: GifResult) => void;
forwardRef?: Ref<HTMLInputElement>;
Expand All @@ -50,39 +33,81 @@ export interface GifResult {
title?: string;
}

const GifFetch = createGifFetch<GifResult[]>("tenorGifFetch", "/tenor/search");
export interface SearchPayload {
results: GifResult[];
next?: string;
}

const TenorInput: FunctionComponent<Props> = ({ onSelect }) => {
const [query, setQuery] = useState("");
const [lastUpdated, setLastUpdated] = useState<string>(
new Date().toISOString()
);
const { rootURL } = useCoralContext();
const fetchWithAuth = useFetchWithAuth();

const [gifs, loading] = useImmediateFetch(GifFetch, { query }, lastUpdated);
const [query, setQuery] = useState("");
const [next, setNext] = useState<string | null>(null);
const [gifs, setGifs] = useState<GifResult[]>([]);

const inputRef = useRef<HTMLInputElement>(null);

const { ref } = useResizeObserver<HTMLDivElement>();

const fetchGifs = useCallback(async () => {
setLastUpdated(new Date().toISOString());
}, [setLastUpdated]);

// Instead of updating the query with every keystroke, debounce the change to
// that state parameter.
const [debouncedQuery, setDebouncedQuery] = useState("");
const [, cancelDebounce] = useDebounce(
() => {
setQuery(debouncedQuery);
void fetchGifs();
const fetchGifs = useCallback(
async (q: string, n?: string) => {
if (!q || q.length === 0) {
return null;
}

const url = new URL("/api/tenor/search", rootURL);
url.searchParams.set("query", q);

if (n) {
url.searchParams.set("pos", n);
}

const response = await fetchWithAuth(url.toString());

if (!response.ok) {
return null;
}

const json = (await response.json()) as SearchPayload;
if (!json) {
return null;
}

return json;
},
500,
[debouncedQuery]
[fetchWithAuth, rootURL]
);

const onChange: ChangeEventHandler<HTMLInputElement> = useCallback((e) => {
setDebouncedQuery(e.target.value);
}, []);
const loadGifs = useCallback(async () => {
const response = await fetchGifs(query);
if (!response) {
return;
}

setGifs(response.results);
setNext(response.next ?? null);
}, [query, fetchGifs]);

const loadMoreGifs = useCallback(async () => {
const response = await fetchGifs(query);
if (!response) {
return;
}

setGifs([...gifs, ...response.results]);
setNext(response.next ?? null);
}, [fetchGifs, gifs, query]);

const debounceFetchGifs = useDebounce(loadGifs, 650);

const onChange: ChangeEventHandler<HTMLInputElement> = useCallback(
async (e) => {
setQuery(e.target.value);
setTimeout(debounceFetchGifs, 300);
},
[debounceFetchGifs, setQuery]
);

// Focus on the input as soon as the input is available.
useEffect(() => {
Expand All @@ -91,42 +116,77 @@ const TenorInput: FunctionComponent<Props> = ({ onSelect }) => {
}
}, []);

const onKeyPress = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") {
const onKeyPress = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") {
return;
}

debounceFetchGifs();

e.preventDefault();
},
[debounceFetchGifs]
);

const onClickSearch = useCallback(async () => {
setNext(null);
await loadGifs();
}, [loadGifs]);

const onLoadMore = useCallback(async () => {
if (!next) {
return;
}

e.preventDefault();
}, []);
await loadMoreGifs();
}, [loadMoreGifs, next]);

const onGifClick = useCallback(
(gif: GifResult) => {
// Cancel any active timers that might cause the query to be changed.
cancelDebounce();
setQuery("");
onSelect(gif);
},
[cancelDebounce, onSelect]
[onSelect]
);

return (
<div className={styles.root} ref={ref}>
<HorizontalGutter>
<GifSearchInput
debouncedQuery={debouncedQuery}
<TextField
value={query}
onChange={onChange}
onKeyPress={onKeyPress}
inputRef={inputRef}
fullWidth
variant="seamlessAdornment"
color="streamBlue"
id="coral-comments-postComment-gifSearch"
adornment={
<Localized
id="comments-postComment-gifSearch-search"
attrs={{ "aria-label": true }}
>
<Button
color="stream"
className={styles.searchButton}
aria-label="Search"
onClick={onClickSearch}
>
<ButtonSvgIcon Icon={SearchIcon} />
</Button>
</Localized>
}
ref={inputRef}
/>
<div className={styles.grid}>
{query &&
gifs &&
!loading &&
gifs.map((gif) => {
gifs.map((gif, index) => {
return (
<button
className={styles.gridItem}
key={gif.id}
key={`${gif.id}-${index}`}
onClick={() => onGifClick(gif)}
>
<img
Expand All @@ -137,6 +197,15 @@ const TenorInput: FunctionComponent<Props> = ({ onSelect }) => {
</button>
);
})}
{next && gifs && gifs.length > 0 && (
<div className={styles.gridControls}>
<Localized id="comments-postComment-gifSearch-search-loadMore">
<Button color="stream" onClick={onLoadMore}>
Load More
</Button>
</Localized>
</div>
)}
</div>
<TenorAttribution />
</HorizontalGutter>
Expand Down
1 change: 1 addition & 0 deletions locales/en-US/stream.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ comments-replyList-showMoreReplies = Show More Replies
comments-postComment-gifSearch = Search for a GIF
comments-postComment-gifSearch-search =
.aria-label = Search
comments-postComment-gifSearch-search-loadMore = Load more
comments-postComment-gifSearch-loading = Loading...
comments-postComment-gifSearch-no-results = No results found for {$query}
comments-postComment-gifSearch-powered-by-giphy =
Expand Down
Loading
Loading