this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

GIF picker

+506 -2
+4
README.md
··· 179 179 - 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) 180 180 - List of fallback instances hard-coded in `/.env` 181 181 - [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances) 182 + - `PHANPY_GIPHY_API_KEY` (optional, no defaults): 183 + - API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/). 184 + - If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default. 185 + - This is not self-hosted. 182 186 183 187 ### Static site hosting 184 188
+3
src/assets/powered-by-giphy.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223"> 2 + <path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/> 3 + </svg>
+160
src/components/compose.css
··· 727 727 } 728 728 } 729 729 } 730 + 731 + @keyframes gif-shake { 732 + 0% { 733 + transform: rotate(0deg); 734 + } 735 + 25% { 736 + transform: rotate(5deg); 737 + } 738 + 50% { 739 + transform: rotate(0deg); 740 + } 741 + 75% { 742 + transform: rotate(-5deg); 743 + } 744 + 100% { 745 + transform: rotate(0deg); 746 + } 747 + } 748 + 749 + .gif-picker-button { 750 + span { 751 + font-weight: bold; 752 + font-size: 11.5px; 753 + display: block; 754 + } 755 + 756 + &:is(:hover, :focus) { 757 + span { 758 + animation: gif-shake 0.3s 3; 759 + } 760 + } 761 + } 762 + 763 + #gif-picker-sheet { 764 + form { 765 + display: flex; 766 + flex-direction: row; 767 + gap: 8px; 768 + align-items: center; 769 + 770 + input[type='search'] { 771 + flex-grow: 1; 772 + min-width: 0; 773 + } 774 + } 775 + 776 + main { 777 + overflow-x: auto; 778 + overflow-y: hidden; 779 + mask-image: linear-gradient( 780 + to right, 781 + transparent 2px, 782 + black 16px, 783 + black calc(100% - 16px), 784 + transparent calc(100% - 2px) 785 + ); 786 + 787 + @media (min-height: 480px) { 788 + overflow-y: auto; 789 + max-height: 50vh; 790 + } 791 + 792 + &.loading { 793 + opacity: 0.25; 794 + } 795 + 796 + .ui-state { 797 + min-height: 100px; 798 + } 799 + 800 + ul { 801 + min-height: 100px; 802 + display: flex; 803 + gap: 4px; 804 + list-style: none; 805 + padding: 8px 2px; 806 + margin: 0; 807 + 808 + @media (min-height: 480px) { 809 + display: grid; 810 + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 811 + grid-auto-rows: 1fr; 812 + } 813 + 814 + li { 815 + list-style: none; 816 + padding: 0; 817 + margin: 0; 818 + max-width: 100%; 819 + display: flex; 820 + 821 + button { 822 + padding: 4px; 823 + margin: 0; 824 + border: none; 825 + background-color: transparent; 826 + color: inherit; 827 + cursor: pointer; 828 + border-radius: 8px; 829 + background-color: var(--bg-faded-color); 830 + 831 + @media (min-height: 480px) { 832 + width: 100%; 833 + text-align: center; 834 + } 835 + 836 + &:is(:hover, :focus) { 837 + background-color: var(--link-bg-color); 838 + box-shadow: 0 0 0 2px var(--link-light-color); 839 + filter: none; 840 + } 841 + } 842 + 843 + figure { 844 + margin: 0; 845 + padding: 0; 846 + width: var(--figure-width); 847 + max-width: 100%; 848 + 849 + @media (min-height: 480px) { 850 + width: 100%; 851 + text-align: center; 852 + } 853 + 854 + figcaption { 855 + font-size: 0.8em; 856 + padding: 2px; 857 + overflow: hidden; 858 + white-space: nowrap; 859 + text-overflow: ellipsis; 860 + color: var(--text-insignificant-color); 861 + } 862 + } 863 + 864 + img { 865 + background-color: var(--img-bg-color); 866 + border-radius: 4px; 867 + vertical-align: top; 868 + object-fit: contain; 869 + } 870 + } 871 + } 872 + 873 + .pagination { 874 + display: flex; 875 + justify-content: space-between; 876 + gap: 8px; 877 + padding: 0; 878 + margin: 0; 879 + position: sticky; 880 + bottom: 0; 881 + left: 0; 882 + right: 0; 883 + 884 + @media (min-height: 480px) { 885 + position: static; 886 + } 887 + } 888 + } 889 + }
+298 -1
src/components/compose.jsx
··· 11 11 import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 12 12 import { useSnapshot } from 'valtio'; 13 13 14 + import poweredByGiphyURL from '../assets/powered-by-giphy.svg'; 15 + 14 16 import Menu2 from '../components/menu2'; 15 17 import supportedLanguages from '../data/status-supported-languages'; 16 18 import urlRegex from '../data/url-regex'; ··· 41 43 import Modal from './modal'; 42 44 import Status from './status'; 43 45 44 - const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; 46 + const { 47 + PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, 48 + PHANPY_GIPHY_API_KEY: GIPHY_API_KEY, 49 + } = import.meta.env; 45 50 46 51 const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { 47 52 const [code, common, native] = l; ··· 610 615 }, [mediaAttachments]); 611 616 612 617 const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); 618 + const [showGIFPicker, setShowGIFPicker] = useState(false); 613 619 614 620 const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { 615 621 const topLanguages = []; ··· 1235 1241 > 1236 1242 <Icon icon="emoji2" /> 1237 1243 </button> 1244 + {!!states.settings.composerGIFPicker && ( 1245 + <button 1246 + type="button" 1247 + class="toolbar-button gif-picker-button" 1248 + disabled={uiState === 'loading'} 1249 + onClick={() => { 1250 + setShowGIFPicker(true); 1251 + }} 1252 + > 1253 + <span>GIF</span> 1254 + </button> 1255 + )} 1238 1256 </span> 1239 1257 <div class="spacer" /> 1240 1258 {uiState === 'loading' ? ( ··· 1319 1337 /> 1320 1338 </Modal> 1321 1339 )} 1340 + {showGIFPicker && ( 1341 + <Modal 1342 + onClick={(e) => { 1343 + if (e.target === e.currentTarget) { 1344 + setShowGIFPicker(false); 1345 + } 1346 + }} 1347 + > 1348 + <GIFPickerModal 1349 + onClose={() => setShowGIFPicker(false)} 1350 + onSelect={({ url, type, alt_text }) => { 1351 + console.log('GIF URL', url); 1352 + if (mediaAttachments.length >= maxMediaAttachments) { 1353 + alert( 1354 + `You can only attach up to ${maxMediaAttachments} files.`, 1355 + ); 1356 + return; 1357 + } 1358 + // Download the GIF and insert it as media attachment 1359 + (async () => { 1360 + let theToast; 1361 + try { 1362 + theToast = showToast({ 1363 + text: 'Downloading GIF…', 1364 + duration: -1, 1365 + }); 1366 + const blob = await fetch(url, { 1367 + referrerPolicy: 'no-referrer', 1368 + }).then((res) => res.blob()); 1369 + const file = new File( 1370 + [blob], 1371 + type === 'video/mp4' ? 'video.mp4' : 'image.gif', 1372 + { 1373 + type, 1374 + }, 1375 + ); 1376 + const newMediaAttachments = [ 1377 + ...mediaAttachments, 1378 + { 1379 + file, 1380 + type, 1381 + size: file.size, 1382 + id: null, 1383 + description: alt_text || '', 1384 + }, 1385 + ]; 1386 + setMediaAttachments(newMediaAttachments); 1387 + theToast?.hideToast?.(); 1388 + } catch (err) { 1389 + console.error(err); 1390 + theToast?.hideToast?.(); 1391 + showToast('Failed to download GIF'); 1392 + } 1393 + })(); 1394 + }} 1395 + /> 1396 + </Modal> 1397 + )} 1322 1398 </div> 1323 1399 ); 1324 1400 } ··· 2241 2317 ), 2242 2318 )} 2243 2319 </div> 2320 + </main> 2321 + </div> 2322 + ); 2323 + } 2324 + 2325 + const GIFS_PER_PAGE = 20; 2326 + function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { 2327 + const [uiState, setUIState] = useState('default'); 2328 + const [results, setResults] = useState([]); 2329 + const formRef = useRef(null); 2330 + const qRef = useRef(null); 2331 + const currentOffset = useRef(0); 2332 + const scrollableRef = useRef(null); 2333 + 2334 + function fetchGIFs({ offset }) { 2335 + console.log('fetchGIFs', { offset }); 2336 + if (!qRef.current?.value) return; 2337 + setUIState('loading'); 2338 + scrollableRef.current?.scrollTo?.({ 2339 + top: 0, 2340 + left: 0, 2341 + behavior: 'smooth', 2342 + }); 2343 + (async () => { 2344 + try { 2345 + const query = { 2346 + api_key: GIPHY_API_KEY, 2347 + q: qRef.current.value, 2348 + rating: 'g', 2349 + limit: GIFS_PER_PAGE, 2350 + bundle: 'messaging_non_clips', 2351 + offset, 2352 + }; 2353 + const response = await fetch( 2354 + 'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query), 2355 + { 2356 + referrerPolicy: 'no-referrer', 2357 + }, 2358 + ).then((r) => r.json()); 2359 + currentOffset.current = response.pagination?.offset || 0; 2360 + setResults(response); 2361 + setUIState('results'); 2362 + } catch (e) { 2363 + setUIState('error'); 2364 + console.error(e); 2365 + } 2366 + })(); 2367 + } 2368 + 2369 + useEffect(() => { 2370 + qRef.current?.focus(); 2371 + }, []); 2372 + 2373 + return ( 2374 + <div id="gif-picker-sheet" class="sheet"> 2375 + {!!onClose && ( 2376 + <button type="button" class="sheet-close" onClick={onClose}> 2377 + <Icon icon="x" /> 2378 + </button> 2379 + )} 2380 + <header> 2381 + <form 2382 + ref={formRef} 2383 + onSubmit={(e) => { 2384 + e.preventDefault(); 2385 + fetchGIFs({ offset: 0 }); 2386 + }} 2387 + > 2388 + <input 2389 + ref={qRef} 2390 + type="search" 2391 + name="q" 2392 + placeholder="Search GIFs" 2393 + required 2394 + autocomplete="off" 2395 + autocorrect="off" 2396 + autocapitalize="off" 2397 + spellCheck="false" 2398 + dir="auto" 2399 + /> 2400 + <input 2401 + type="image" 2402 + class="powered-button" 2403 + src={poweredByGiphyURL} 2404 + width="86" 2405 + height="30" 2406 + /> 2407 + </form> 2408 + </header> 2409 + <main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}> 2410 + {uiState === 'default' && ( 2411 + <div class="ui-state"> 2412 + <p class="insignificant">Type to search GIFs</p> 2413 + </div> 2414 + )} 2415 + {uiState === 'loading' && !results?.data?.length && ( 2416 + <div class="ui-state"> 2417 + <Loader abrupt /> 2418 + </div> 2419 + )} 2420 + {results?.data?.length > 0 ? ( 2421 + <> 2422 + <ul> 2423 + {results.data.map((gif) => { 2424 + const { id, images, title, alt_text } = gif; 2425 + const { 2426 + fixed_height_small, 2427 + fixed_height_downsampled, 2428 + fixed_height, 2429 + original, 2430 + } = images; 2431 + const theImage = fixed_height_small?.url 2432 + ? fixed_height_small 2433 + : fixed_height_downsampled?.url 2434 + ? fixed_height_downsampled 2435 + : fixed_height; 2436 + let { url, webp, width, height } = theImage; 2437 + if (+height > 100) { 2438 + width = (width / height) * 100; 2439 + height = 100; 2440 + } 2441 + const urlObj = new URL(url); 2442 + const strippedURL = urlObj.origin + urlObj.pathname; 2443 + let strippedWebP; 2444 + if (webp) { 2445 + const webpObj = new URL(webp); 2446 + strippedWebP = webpObj.origin + webpObj.pathname; 2447 + } 2448 + return ( 2449 + <li key={id}> 2450 + <button 2451 + type="button" 2452 + onClick={() => { 2453 + const { mp4, url } = original; 2454 + const theURL = mp4 || url; 2455 + const urlObj = new URL(theURL); 2456 + const strippedURL = urlObj.origin + urlObj.pathname; 2457 + onClose(); 2458 + onSelect({ 2459 + url: strippedURL, 2460 + type: mp4 ? 'video/mp4' : 'image/gif', 2461 + alt_text: alt_text || title, 2462 + }); 2463 + }} 2464 + > 2465 + <figure 2466 + style={{ 2467 + '--figure-width': width + 'px', 2468 + // width: width + 'px' 2469 + }} 2470 + > 2471 + <picture> 2472 + {strippedWebP && ( 2473 + <source srcset={strippedWebP} type="image/webp" /> 2474 + )} 2475 + <img 2476 + src={strippedURL} 2477 + width={width} 2478 + height={height} 2479 + loading="lazy" 2480 + decoding="async" 2481 + alt={alt_text} 2482 + referrerpolicy="no-referrer" 2483 + onLoad={(e) => { 2484 + e.target.style.backgroundColor = 'transparent'; 2485 + }} 2486 + /> 2487 + </picture> 2488 + <figcaption>{alt_text || title}</figcaption> 2489 + </figure> 2490 + </button> 2491 + </li> 2492 + ); 2493 + })} 2494 + </ul> 2495 + <p class="pagination"> 2496 + {results.pagination?.offset > 0 && ( 2497 + <button 2498 + type="button" 2499 + class="light small" 2500 + disabled={uiState === 'loading'} 2501 + onClick={() => { 2502 + fetchGIFs({ 2503 + offset: results.pagination?.offset - GIFS_PER_PAGE, 2504 + }); 2505 + }} 2506 + > 2507 + <Icon icon="chevron-left" /> 2508 + <span>Previous</span> 2509 + </button> 2510 + )} 2511 + <span /> 2512 + {results.pagination?.offset + results.pagination?.count < 2513 + results.pagination?.total_count && ( 2514 + <button 2515 + type="button" 2516 + class="light small" 2517 + disabled={uiState === 'loading'} 2518 + onClick={() => { 2519 + fetchGIFs({ 2520 + offset: results.pagination?.offset + GIFS_PER_PAGE, 2521 + }); 2522 + }} 2523 + > 2524 + <span>Next</span> <Icon icon="chevron-right" /> 2525 + </button> 2526 + )} 2527 + </p> 2528 + </> 2529 + ) : ( 2530 + uiState === 'results' && ( 2531 + <div class="ui-state"> 2532 + <p>No results</p> 2533 + </div> 2534 + ) 2535 + )} 2536 + {uiState === 'error' && ( 2537 + <div class="ui-state"> 2538 + <p>Error loading GIFs</p> 2539 + </div> 2540 + )} 2244 2541 </main> 2245 2542 </div> 2246 2543 );
+3 -1
src/index.css
··· 347 347 } 348 348 349 349 input[type='text'], 350 + input[type='search'], 350 351 textarea, 351 352 select { 352 353 color: var(--text-color); ··· 356 357 border-radius: 4px; 357 358 } 358 359 input[type='text']:focus, 360 + input[type='search']:focus, 359 361 textarea:focus, 360 362 select:focus { 361 363 border-color: var(--outline-color); ··· 371 373 background-color: var(--bg-faded-color); 372 374 } 373 375 374 - :is(input[type='text'], textarea, select).block { 376 + :is(input[type='text'], input[type='search'], textarea, select).block { 375 377 display: block; 376 378 width: 100%; 377 379 }
+32
src/pages/settings.jsx
··· 28 28 PHANPY_WEBSITE: WEBSITE, 29 29 PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, 30 30 PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, 31 + PHANPY_GIPHY_API_KEY: GIPHY_API_KEY, 31 32 } = import.meta.env; 32 33 33 34 function Settings({ onClose }) { ··· 433 434 </div> 434 435 </div> 435 436 </li> 437 + {!!GIPHY_API_KEY && authenticated && ( 438 + <li> 439 + <label> 440 + <input 441 + type="checkbox" 442 + checked={snapStates.settings.composerGIFPicker} 443 + onChange={(e) => { 444 + states.settings.composerGIFPicker = e.target.checked; 445 + }} 446 + />{' '} 447 + GIF Picker for composer 448 + </label> 449 + <div class="sub-section insignificant"> 450 + <small> 451 + Note: This feature uses external GIF search service, powered 452 + by{' '} 453 + <a 454 + href="https://developers.giphy.com/" 455 + target="_blank" 456 + rel="noopener noreferrer" 457 + > 458 + GIPHY 459 + </a> 460 + . G-rated (suitable for viewing by all ages), tracking 461 + parameters are stripped, referrer information is omitted 462 + from requests, but search queries and IP address information 463 + will still reach their servers. 464 + </small> 465 + </div> 466 + </li> 467 + )} 436 468 {!!IMG_ALT_API_URL && authenticated && ( 437 469 <li> 438 470 <label>
+6
src/utils/states.js
··· 67 67 contentTranslationAutoInline: false, 68 68 shortcutSettingsCloudImportExport: false, 69 69 mediaAltGenerator: false, 70 + composerGIFPicker: false, 70 71 cloakMode: false, 71 72 }, 72 73 }); ··· 99 100 store.account.get('settings-shortcutSettingsCloudImportExport') ?? false; 100 101 states.settings.mediaAltGenerator = 101 102 store.account.get('settings-mediaAltGenerator') ?? false; 103 + states.settings.composerGIFPicker = 104 + store.account.get('settings-composerGIFPicker') ?? false; 102 105 states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; 103 106 } 104 107 ··· 139 142 } 140 143 if (path.join('.') === 'settings.mediaAltGenerator') { 141 144 store.account.set('settings-mediaAltGenerator', !!value); 145 + } 146 + if (path.join('.') === 'settings.composerGIFPicker') { 147 + store.account.set('settings-composerGIFPicker', !!value); 142 148 } 143 149 if (path?.[0] === 'shortcuts') { 144 150 store.account.set('shortcuts', states.shortcuts);