From c18efef7b6c271effa32b65b312240fa96b55308 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Tue, 2 Apr 2024 17:51:48 +0800 Subject: [PATCH] GIF picker --- README.md | 4 + src/assets/powered-by-giphy.svg | 3 + src/components/compose.css | 160 +++++++++++++++++ src/components/compose.jsx | 299 +++++++++++++++++++++++++++++++- src/index.css | 4 +- src/pages/settings.jsx | 32 ++++ src/utils/states.js | 6 + 7 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 src/assets/powered-by-giphy.svg diff --git a/README.md b/README.md index 156cfada..978abe15 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,10 @@ Available variables: - May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api) - List of fallback instances hard-coded in `/.env` - [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances) +- `PHANPY_GIPHY_API_KEY` (optional, no defaults): + - API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/). + - If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default. + - This is not self-hosted. ### Static site hosting diff --git a/src/assets/powered-by-giphy.svg b/src/assets/powered-by-giphy.svg new file mode 100644 index 00000000..b7b77c54 --- /dev/null +++ b/src/assets/powered-by-giphy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/compose.css b/src/components/compose.css index 5cb0e9ac..150afd99 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -727,3 +727,163 @@ } } } + +@keyframes gif-shake { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(5deg); + } + 50% { + transform: rotate(0deg); + } + 75% { + transform: rotate(-5deg); + } + 100% { + transform: rotate(0deg); + } +} + +.gif-picker-button { + span { + font-weight: bold; + font-size: 11.5px; + display: block; + } + + &:is(:hover, :focus) { + span { + animation: gif-shake 0.3s 3; + } + } +} + +#gif-picker-sheet { + form { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + input[type='search'] { + flex-grow: 1; + min-width: 0; + } + } + + main { + overflow-x: auto; + overflow-y: hidden; + mask-image: linear-gradient( + to right, + transparent 2px, + black 16px, + black calc(100% - 16px), + transparent calc(100% - 2px) + ); + + @media (min-height: 480px) { + overflow-y: auto; + max-height: 50vh; + } + + &.loading { + opacity: 0.25; + } + + .ui-state { + min-height: 100px; + } + + ul { + min-height: 100px; + display: flex; + gap: 4px; + list-style: none; + padding: 8px 2px; + margin: 0; + + @media (min-height: 480px) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-auto-rows: 1fr; + } + + li { + list-style: none; + padding: 0; + margin: 0; + max-width: 100%; + display: flex; + + button { + padding: 4px; + margin: 0; + border: none; + background-color: transparent; + color: inherit; + cursor: pointer; + border-radius: 8px; + background-color: var(--bg-faded-color); + + @media (min-height: 480px) { + width: 100%; + text-align: center; + } + + &:is(:hover, :focus) { + background-color: var(--link-bg-color); + box-shadow: 0 0 0 2px var(--link-light-color); + filter: none; + } + } + + figure { + margin: 0; + padding: 0; + width: var(--figure-width); + max-width: 100%; + + @media (min-height: 480px) { + width: 100%; + text-align: center; + } + + figcaption { + font-size: 0.8em; + padding: 2px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--text-insignificant-color); + } + } + + img { + background-color: var(--img-bg-color); + border-radius: 4px; + vertical-align: top; + object-fit: contain; + } + } + } + + .pagination { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 0; + margin: 0; + position: sticky; + bottom: 0; + left: 0; + right: 0; + + @media (min-height: 480px) { + position: static; + } + } + } +} diff --git a/src/components/compose.jsx b/src/components/compose.jsx index f8d9938a..de3177a0 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -11,6 +11,8 @@ import { uid } from 'uid/single'; import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; import { useSnapshot } from 'valtio'; +import poweredByGiphyURL from '../assets/powered-by-giphy.svg'; + import Menu2 from '../components/menu2'; import supportedLanguages from '../data/status-supported-languages'; import urlRegex from '../data/url-regex'; @@ -41,7 +43,10 @@ import Loader from './loader'; import Modal from './modal'; import Status from './status'; -const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; +const { + PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, + PHANPY_GIPHY_API_KEY: GIPHY_API_KEY, +} = import.meta.env; const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { const [code, common, native] = l; @@ -610,6 +615,7 @@ function Compose({ }, [mediaAttachments]); const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); + const [showGIFPicker, setShowGIFPicker] = useState(false); const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { const topLanguages = []; @@ -1235,6 +1241,18 @@ function Compose({ > + {!!states.settings.composerGIFPicker && ( + + )}
{uiState === 'loading' ? ( @@ -1319,6 +1337,64 @@ function Compose({ /> )} + {showGIFPicker && ( + { + if (e.target === e.currentTarget) { + setShowGIFPicker(false); + } + }} + > + setShowGIFPicker(false)} + onSelect={({ url, type, alt_text }) => { + console.log('GIF URL', url); + if (mediaAttachments.length >= maxMediaAttachments) { + alert( + `You can only attach up to ${maxMediaAttachments} files.`, + ); + return; + } + // Download the GIF and insert it as media attachment + (async () => { + let theToast; + try { + theToast = showToast({ + text: 'Downloading GIF…', + duration: -1, + }); + const blob = await fetch(url, { + referrerPolicy: 'no-referrer', + }).then((res) => res.blob()); + const file = new File( + [blob], + type === 'video/mp4' ? 'video.mp4' : 'image.gif', + { + type, + }, + ); + const newMediaAttachments = [ + ...mediaAttachments, + { + file, + type, + size: file.size, + id: null, + description: alt_text || '', + }, + ]; + setMediaAttachments(newMediaAttachments); + theToast?.hideToast?.(); + } catch (err) { + console.error(err); + theToast?.hideToast?.(); + showToast('Failed to download GIF'); + } + })(); + }} + /> + + )}
); } @@ -2246,4 +2322,225 @@ function CustomEmojisModal({ ); } +const GIFS_PER_PAGE = 20; +function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { + const [uiState, setUIState] = useState('default'); + const [results, setResults] = useState([]); + const formRef = useRef(null); + const qRef = useRef(null); + const currentOffset = useRef(0); + const scrollableRef = useRef(null); + + function fetchGIFs({ offset }) { + console.log('fetchGIFs', { offset }); + if (!qRef.current?.value) return; + setUIState('loading'); + scrollableRef.current?.scrollTo?.({ + top: 0, + left: 0, + behavior: 'smooth', + }); + (async () => { + try { + const query = { + api_key: GIPHY_API_KEY, + q: qRef.current.value, + rating: 'g', + limit: GIFS_PER_PAGE, + bundle: 'messaging_non_clips', + offset, + }; + const response = await fetch( + 'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query), + { + referrerPolicy: 'no-referrer', + }, + ).then((r) => r.json()); + currentOffset.current = response.pagination?.offset || 0; + setResults(response); + setUIState('results'); + } catch (e) { + setUIState('error'); + console.error(e); + } + })(); + } + + useEffect(() => { + qRef.current?.focus(); + }, []); + + return ( +
+ {!!onClose && ( + + )} +
+
{ + e.preventDefault(); + fetchGIFs({ offset: 0 }); + }} + > + + +
+
+
+ {uiState === 'default' && ( +
+

Type to search GIFs

+
+ )} + {uiState === 'loading' && !results?.data?.length && ( +
+ +
+ )} + {results?.data?.length > 0 ? ( + <> +
    + {results.data.map((gif) => { + const { id, images, title, alt_text } = gif; + const { + fixed_height_small, + fixed_height_downsampled, + fixed_height, + original, + } = images; + const theImage = fixed_height_small?.url + ? fixed_height_small + : fixed_height_downsampled?.url + ? fixed_height_downsampled + : fixed_height; + let { url, webp, width, height } = theImage; + if (+height > 100) { + width = (width / height) * 100; + height = 100; + } + const urlObj = new URL(url); + const strippedURL = urlObj.origin + urlObj.pathname; + let strippedWebP; + if (webp) { + const webpObj = new URL(webp); + strippedWebP = webpObj.origin + webpObj.pathname; + } + return ( +
  • + +
  • + ); + })} +
+

+ {results.pagination?.offset > 0 && ( + + )} + + {results.pagination?.offset + results.pagination?.count < + results.pagination?.total_count && ( + + )} +

+ + ) : ( + uiState === 'results' && ( +
+

No results

+
+ ) + )} + {uiState === 'error' && ( +
+

Error loading GIFs

+
+ )} +
+
+ ); +} + export default Compose; diff --git a/src/index.css b/src/index.css index b7128bc9..10c4380c 100644 --- a/src/index.css +++ b/src/index.css @@ -347,6 +347,7 @@ button[hidden] { } input[type='text'], +input[type='search'], textarea, select { color: var(--text-color); @@ -356,6 +357,7 @@ select { border-radius: 4px; } input[type='text']:focus, +input[type='search']:focus, textarea:focus, select:focus { border-color: var(--outline-color); @@ -371,7 +373,7 @@ textarea:disabled { background-color: var(--bg-faded-color); } -:is(input[type='text'], textarea, select).block { +:is(input[type='text'], input[type='search'], textarea, select).block { display: block; width: 100%; } diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx index f8e70349..bb6ff7f4 100644 --- a/src/pages/settings.jsx +++ b/src/pages/settings.jsx @@ -28,6 +28,7 @@ const { PHANPY_WEBSITE: WEBSITE, PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, + PHANPY_GIPHY_API_KEY: GIPHY_API_KEY, } = import.meta.env; function Settings({ onClose }) { @@ -433,6 +434,37 @@ function Settings({ onClose }) { + {!!GIPHY_API_KEY && authenticated && ( +
  • + +
    + + Note: This feature uses external GIF search service, powered + by{' '} + + GIPHY + + . G-rated (suitable for viewing by all ages), tracking + parameters are stripped, referrer information is omitted + from requests, but search queries and IP address information + will still reach their servers. + +
    +
  • + )} {!!IMG_ALT_API_URL && authenticated && (