this repo has no description
0
fork

Configure Feed

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

Experimental opt-in description generator

+183 -4
+17
src/components/compose.css
··· 501 501 padding-inline: 24px; 502 502 } 503 503 } 504 + 505 + @keyframes breathe { 506 + 0% { 507 + opacity: 1; 508 + } 509 + 40% { 510 + opacity: 0.4; 511 + } 512 + 100% { 513 + opacity: 1; 514 + } 515 + } 516 + 504 517 #media-sheet { 505 518 .media-form { 506 519 flex: 1; ··· 514 527 resize: none; 515 528 width: 100%; 516 529 /* height: 10em; */ 530 + 531 + &.loading { 532 + animation: skeleton-breathe 1.5s linear infinite; 533 + } 517 534 } 518 535 519 536 footer {
+73 -3
src/components/compose.jsx
··· 1 1 import './compose.css'; 2 2 3 3 import '@github/text-expander-element'; 4 + import { MenuItem } from '@szhsin/react-menu'; 4 5 import equal from 'fast-deep-equal'; 5 6 import { forwardRef } from 'preact/compat'; 6 7 import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; ··· 11 12 import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 12 13 import { useSnapshot } from 'valtio'; 13 14 15 + import Menu2 from '../components/menu2'; 14 16 import supportedLanguages from '../data/status-supported-languages'; 15 17 import urlRegex from '../data/url-regex'; 16 18 import { api } from '../utils/api'; ··· 19 21 import localeMatch from '../utils/locale-match'; 20 22 import openCompose from '../utils/open-compose'; 21 23 import shortenNumber from '../utils/shorten-number'; 24 + import showToast from '../utils/show-toast'; 22 25 import states, { saveStatus } from '../utils/states'; 23 26 import store from '../utils/store'; 24 27 import { ··· 38 41 import Loader from './loader'; 39 42 import Modal from './modal'; 40 43 import Status from './status'; 44 + 45 + const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; 41 46 42 47 const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { 43 48 const [code, common, native] = l; ··· 1291 1296 const { masto } = api(); 1292 1297 const [text, setText] = useState(ref.current?.value || ''); 1293 1298 const { maxCharacters, performSearch = () => {}, ...textareaProps } = props; 1294 - const snapStates = useSnapshot(states); 1299 + // const snapStates = useSnapshot(states); 1295 1300 // const charCount = snapStates.composerCharacterCount; 1296 1301 1297 1302 const customEmojis = useRef(); ··· 1645 1650 onDescriptionChange = () => {}, 1646 1651 onRemove = () => {}, 1647 1652 }) { 1653 + const [uiState, setUIState] = useState('default'); 1648 1654 const supportsEdit = supports('@mastodon/edit-media-attributes'); 1649 1655 const { type, id, file } = attachment; 1650 1656 const url = useMemo( ··· 1653 1659 ); 1654 1660 console.log({ attachment }); 1655 1661 const [description, setDescription] = useState(attachment.description); 1656 - const suffixType = type.split('/')[0]; 1662 + const [suffixType, subtype] = type.split('/'); 1657 1663 const debouncedOnDescriptionChange = useDebouncedCallback( 1658 1664 onDescriptionChange, 1659 1665 250, ··· 1699 1705 autoCorrect="on" 1700 1706 spellCheck="true" 1701 1707 dir="auto" 1702 - disabled={disabled} 1708 + disabled={disabled || uiState === 'loading'} 1709 + class={uiState === 'loading' ? 'loading' : ''} 1703 1710 maxlength="1500" // Not unicode-aware :( 1704 1711 // TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39 1705 1712 onInput={(e) => { ··· 1712 1719 </> 1713 1720 ); 1714 1721 1722 + const toastRef = useRef(null); 1723 + useEffect(() => { 1724 + return () => { 1725 + toastRef.current?.hideToast?.(); 1726 + }; 1727 + }, []); 1728 + 1715 1729 return ( 1716 1730 <> 1717 1731 <div class="media-attachment"> ··· 1785 1799 <div class="media-form"> 1786 1800 {descTextarea} 1787 1801 <footer> 1802 + {suffixType === 'image' && 1803 + /^(png|jpe?g|gif|webp)$/i.test(subtype) && 1804 + !!states.settings.mediaAltGenerator && 1805 + !!IMG_ALT_API_URL && ( 1806 + <Menu2 1807 + portal={{ 1808 + target: document.body, 1809 + }} 1810 + containerProps={{ 1811 + style: { 1812 + zIndex: 1001, 1813 + }, 1814 + }} 1815 + align="center" 1816 + position="anchor" 1817 + overflow="auto" 1818 + menuButton={ 1819 + <button type="button" title="More" class="plain"> 1820 + <Icon icon="more" size="l" alt="More" /> 1821 + </button> 1822 + } 1823 + > 1824 + <MenuItem 1825 + disabled={uiState === 'loading'} 1826 + onClick={() => { 1827 + setUIState('loading'); 1828 + toastRef.current = showToast({ 1829 + text: 'Generating description. Please wait...', 1830 + duration: -1, 1831 + }); 1832 + // POST with multipart 1833 + (async function () { 1834 + try { 1835 + const body = new FormData(); 1836 + body.append('image', file); 1837 + const response = await fetch(IMG_ALT_API_URL, { 1838 + method: 'POST', 1839 + body, 1840 + }).then((r) => r.json()); 1841 + setDescription(response.description); 1842 + } catch (e) { 1843 + console.error(e); 1844 + showToast('Failed to generate description'); 1845 + } finally { 1846 + setUIState('default'); 1847 + toastRef.current?.hideToast?.(); 1848 + } 1849 + })(); 1850 + }} 1851 + > 1852 + <Icon icon="sparkles2" /> 1853 + <span>Generate description…</span> 1854 + </MenuItem> 1855 + </Menu2> 1856 + )} 1788 1857 <button 1789 1858 type="button" 1790 1859 class="light block" 1791 1860 onClick={() => { 1792 1861 setShowModal(false); 1793 1862 }} 1863 + disabled={uiState === 'loading'} 1794 1864 > 1795 1865 Done 1796 1866 </button>
+1
src/components/icon.jsx
··· 69 69 history: () => import('@iconify-icons/mingcute/history-line'), 70 70 share: () => import('@iconify-icons/mingcute/share-2-line'), 71 71 sparkles: () => import('@iconify-icons/mingcute/sparkles-line'), 72 + sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'), 72 73 exit: () => import('@iconify-icons/mingcute/exit-line'), 73 74 translate: () => import('@iconify-icons/mingcute/translate-line'), 74 75 play: () => import('@iconify-icons/mingcute/play-fill'),
+53 -1
src/components/media-modal.jsx
··· 1 - import { Menu } from '@szhsin/react-menu'; 1 + import { MenuDivider, MenuItem } from '@szhsin/react-menu'; 2 2 import { getBlurHashAverageColor } from 'fast-blurhash'; 3 3 import { 4 4 useEffect, ··· 10 10 import { useHotkeys } from 'react-hotkeys-hook'; 11 11 12 12 import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; 13 + import showToast from '../utils/show-toast'; 13 14 import states from '../utils/states'; 14 15 15 16 import Icon from './icon'; ··· 18 19 import Menu2 from './menu2'; 19 20 import MenuLink from './menu-link'; 20 21 22 + const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; 23 + 21 24 function MediaModal({ 22 25 mediaAttachments, 23 26 statusID, ··· 26 29 index = 0, 27 30 onClose = () => {}, 28 31 }) { 32 + const [uiState, setUIState] = useState('default'); 29 33 const carouselRef = useRef(null); 30 34 31 35 const [currentIndex, setCurrentIndex] = useState(index); ··· 144 148 ); 145 149 }, [mediaAccentColors]); 146 150 151 + let toastRef = useRef(null); 152 + useEffect(() => { 153 + return () => { 154 + toastRef.current?.hideToast?.(); 155 + }; 156 + }, []); 157 + 147 158 return ( 148 159 <div 149 160 class={`media-modal-container media-modal-count-${mediaAttachments?.length}`} ··· 284 295 <Icon icon="popout" /> 285 296 <span>Open original media</span> 286 297 </MenuLink> 298 + {import.meta.env.DEV && // Only dev for now 299 + !!states.settings.mediaAltGenerator && 300 + !!IMG_ALT_API_URL && 301 + !!mediaAttachments[currentIndex]?.url && 302 + !mediaAttachments[currentIndex]?.description && 303 + mediaAttachments[currentIndex]?.type === 'image' && ( 304 + <> 305 + <MenuDivider /> 306 + <MenuItem 307 + disabled={uiState === 'loading'} 308 + onClick={() => { 309 + setUIState('loading'); 310 + toastRef.current = showToast({ 311 + text: 'Attempting to describe image. Please wait...', 312 + duration: -1, 313 + }); 314 + (async function () { 315 + try { 316 + const response = await fetch( 317 + `${IMG_ALT_API_URL}?image=${encodeURIComponent( 318 + mediaAttachments[currentIndex]?.url, 319 + )}`, 320 + ).then((r) => r.json()); 321 + states.showMediaAlt = { 322 + alt: response.description, 323 + }; 324 + } catch (e) { 325 + console.error(e); 326 + showToast('Failed to describe image'); 327 + } finally { 328 + setUIState('default'); 329 + toastRef.current?.hideToast?.(); 330 + } 331 + })(); 332 + }} 333 + > 334 + <Icon icon="sparkles2" /> 335 + <span>Describe image…</span> 336 + </MenuItem> 337 + </> 338 + )} 287 339 </Menu2>{' '} 288 340 <Link 289 341 to={`${instance ? `/${instance}` : ''}/s/${statusID}${
+3
src/index.css
··· 338 338 font-size: 125%; 339 339 padding: 12px; 340 340 } 341 + textarea:disabled { 342 + background-color: var(--bg-faded-color); 343 + } 341 344 342 345 :is(input[type='text'], textarea, select).block { 343 346 display: block;
+29
src/pages/settings.jsx
··· 27 27 const { 28 28 PHANPY_WEBSITE: WEBSITE, 29 29 PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, 30 + PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, 30 31 } = import.meta.env; 31 32 32 33 function Settings({ onClose }) { ··· 432 433 </div> 433 434 </div> 434 435 </li> 436 + {!!IMG_ALT_API_URL && ( 437 + <li> 438 + <label> 439 + <input 440 + type="checkbox" 441 + checked={snapStates.settings.mediaAltGenerator} 442 + onChange={(e) => { 443 + states.settings.mediaAltGenerator = e.target.checked; 444 + }} 445 + />{' '} 446 + Image description generator{' '} 447 + <Icon icon="sparkles2" class="more-insignificant" /> 448 + </label> 449 + <div class="sub-section insignificant"> 450 + <small> 451 + Note: This feature uses external AI service, powered by{' '} 452 + <a 453 + href="https://github.com/cheeaun/img-alt-api" 454 + target="_blank" 455 + rel="noopener noreferrer" 456 + > 457 + img-alt-api 458 + </a> 459 + . May not work well. Only for images and in English. 460 + </small> 461 + </div> 462 + </li> 463 + )} 435 464 <li> 436 465 <label> 437 466 <input
+1
src/utils/show-toast.js
··· 23 23 } else { 24 24 toast.showToast(); 25 25 } 26 + return toast; 26 27 } 27 28 28 29 export default showToast;
+6
src/utils/states.js
··· 59 59 contentTranslationTargetLanguage: null, 60 60 contentTranslationHideLanguages: [], 61 61 contentTranslationAutoInline: false, 62 + mediaAltGenerator: false, 62 63 cloakMode: false, 63 64 }, 64 65 }); ··· 87 88 store.account.get('settings-contentTranslationHideLanguages') || []; 88 89 states.settings.contentTranslationAutoInline = 89 90 store.account.get('settings-contentTranslationAutoInline') ?? false; 91 + states.settings.mediaAltGenerator = 92 + store.account.get('settings-mediaAltGenerator') ?? false; 90 93 states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; 91 94 } 92 95 ··· 121 124 'settings-contentTranslationHideLanguages', 122 125 states.settings.contentTranslationHideLanguages, 123 126 ); 127 + } 128 + if (path.join('.') === 'settings.mediaAltGenerator') { 129 + store.account.set('settings-mediaAltGenerator', !!value); 124 130 } 125 131 if (path?.[0] === 'shortcuts') { 126 132 store.account.set('shortcuts', states.shortcuts);