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 && (
+ {
+ setShowGIFPicker(true);
+ }}
+ >
+ GIF
+
+ )}
{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 && (
+
+
+
+ )}
+
+
+ {uiState === 'default' && (
+
+ )}
+ {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 (
+
+ {
+ const { mp4, url } = original;
+ const theURL = mp4 || url;
+ const urlObj = new URL(theURL);
+ const strippedURL = urlObj.origin + urlObj.pathname;
+ onClose();
+ onSelect({
+ url: strippedURL,
+ type: mp4 ? 'video/mp4' : 'image/gif',
+ alt_text: alt_text || title,
+ });
+ }}
+ >
+
+
+ {strippedWebP && (
+
+ )}
+ {
+ e.target.style.backgroundColor = 'transparent';
+ }}
+ />
+
+ {alt_text || title}
+
+
+
+ );
+ })}
+
+
+ >
+ ) : (
+ uiState === 'results' && (
+
+ )
+ )}
+ {uiState === 'error' && (
+
+ )}
+
+
+ );
+}
+
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 && (
+
+
+ {
+ states.settings.composerGIFPicker = e.target.checked;
+ }}
+ />{' '}
+ GIF Picker for composer
+
+
+
+ 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 && (
diff --git a/src/utils/states.js b/src/utils/states.js
index 569f1a0d..d346bb77 100644
--- a/src/utils/states.js
+++ b/src/utils/states.js
@@ -67,6 +67,7 @@ const states = proxy({
contentTranslationAutoInline: false,
shortcutSettingsCloudImportExport: false,
mediaAltGenerator: false,
+ composerGIFPicker: false,
cloakMode: false,
},
});
@@ -99,6 +100,8 @@ export function initStates() {
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
states.settings.mediaAltGenerator =
store.account.get('settings-mediaAltGenerator') ?? false;
+ states.settings.composerGIFPicker =
+ store.account.get('settings-composerGIFPicker') ?? false;
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
}
@@ -140,6 +143,9 @@ subscribe(states, (changes) => {
if (path.join('.') === 'settings.mediaAltGenerator') {
store.account.set('settings-mediaAltGenerator', !!value);
}
+ if (path.join('.') === 'settings.composerGIFPicker') {
+ store.account.set('settings-composerGIFPicker', !!value);
+ }
if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts);
}