this repo has no description
0
fork

Configure Feed

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

Refactor components from compose

+2425 -2365
+52
src/components/camera-capture-input.jsx
··· 1 + const isMobileSafari = 2 + /iPad|iPhone|iPod/.test(navigator.userAgent) && 3 + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 4 + 5 + function CameraCaptureInput({ 6 + hidden, 7 + disabled = false, 8 + supportedMimeTypes, 9 + setMediaAttachments, 10 + }) { 11 + // If not Mobile Safari, only apply image/* 12 + // Chrome Android doesn't show the camera if image and video combined 13 + // It also can't switch between photo and video mode like iOS/Safari 14 + const filteredSupportedMimeTypes = isMobileSafari 15 + ? supportedMimeTypes 16 + : supportedMimeTypes?.filter((mimeType) => !/^image\//i.test(mimeType)); 17 + 18 + return ( 19 + <input 20 + type="file" 21 + hidden={hidden} 22 + accept={filteredSupportedMimeTypes?.join(',')} 23 + capture="environment" 24 + disabled={disabled} 25 + onChange={(e) => { 26 + const files = e.target.files; 27 + if (!files) return; 28 + const mediaFile = Array.from(files)[0]; 29 + if (!mediaFile) return; 30 + setMediaAttachments((attachments) => [ 31 + ...attachments, 32 + { 33 + file: mediaFile, 34 + type: mediaFile.type, 35 + size: mediaFile.size, 36 + url: URL.createObjectURL(mediaFile), 37 + id: null, // indicate uploaded state 38 + description: null, 39 + }, 40 + ]); 41 + e.target.value = null; 42 + }} 43 + /> 44 + ); 45 + } 46 + 47 + export const supportsCameraCapture = (() => { 48 + const input = document.createElement('input'); 49 + return 'capture' in input; 50 + })(); 51 + 52 + export default CameraCaptureInput;
+38
src/components/char-count-meter.jsx
··· 1 + import { useSnapshot } from 'valtio'; 2 + 3 + import states from '../utils/states'; 4 + 5 + function CharCountMeter({ maxCharacters = 500, hidden }) { 6 + const snapStates = useSnapshot(states); 7 + const charCount = snapStates.composerCharacterCount; 8 + const leftChars = maxCharacters - charCount; 9 + if (hidden) { 10 + return <span class="char-counter" hidden />; 11 + } 12 + return ( 13 + <span 14 + class="char-counter" 15 + title={`${leftChars}/${maxCharacters}`} 16 + style={{ 17 + '--percentage': (charCount / maxCharacters) * 100, 18 + }} 19 + > 20 + <meter 21 + class={`${ 22 + leftChars <= -10 23 + ? 'explode' 24 + : leftChars <= 0 25 + ? 'danger' 26 + : leftChars <= 20 27 + ? 'warning' 28 + : '' 29 + }`} 30 + value={charCount} 31 + max={maxCharacters} 32 + /> 33 + <span class="counter">{leftChars}</span> 34 + </span> 35 + ); 36 + } 37 + 38 + export default CharCountMeter;
+129
src/components/compose-poll.jsx
··· 1 + import { Trans, useLingui } from '@lingui/react/macro'; 2 + 3 + import i18nDuration from '../utils/i18n-duration'; 4 + 5 + import Icon from './icon'; 6 + 7 + export const expiryOptions = { 8 + 300: i18nDuration(5, 'minute'), 9 + 1_800: i18nDuration(30, 'minute'), 10 + 3_600: i18nDuration(1, 'hour'), 11 + 21_600: i18nDuration(6, 'hour'), 12 + 86_400: i18nDuration(1, 'day'), 13 + 259_200: i18nDuration(3, 'day'), 14 + 604_800: i18nDuration(1, 'week'), 15 + }; 16 + 17 + function ComposePoll({ 18 + lang, 19 + poll, 20 + disabled, 21 + onInput = () => {}, 22 + maxOptions, 23 + maxExpiration, 24 + minExpiration, 25 + maxCharactersPerOption, 26 + }) { 27 + const { t } = useLingui(); 28 + const { options, expiresIn, multiple } = poll; 29 + 30 + return ( 31 + <div class={`poll ${multiple ? 'multiple' : ''}`}> 32 + <div class="poll-choices"> 33 + {options.map((option, i) => ( 34 + <div class="poll-choice" key={i}> 35 + <input 36 + required 37 + type="text" 38 + value={option} 39 + disabled={disabled} 40 + maxlength={maxCharactersPerOption} 41 + placeholder={t`Choice ${i + 1}`} 42 + lang={lang} 43 + spellCheck="true" 44 + dir="auto" 45 + onInput={(e) => { 46 + const { value } = e.target; 47 + options[i] = value; 48 + onInput(poll); 49 + }} 50 + /> 51 + <button 52 + type="button" 53 + class="plain2 poll-button" 54 + disabled={disabled || options.length <= 1} 55 + onClick={() => { 56 + options.splice(i, 1); 57 + onInput(poll); 58 + }} 59 + > 60 + <Icon icon="x" size="s" alt={t`Remove`} /> 61 + </button> 62 + </div> 63 + ))} 64 + </div> 65 + <div class="poll-toolbar"> 66 + <button 67 + type="button" 68 + class="plain2 poll-button" 69 + disabled={disabled || options.length >= maxOptions} 70 + onClick={() => { 71 + options.push(''); 72 + onInput(poll); 73 + }} 74 + > 75 + + 76 + </button>{' '} 77 + <label class="multiple-choices"> 78 + <input 79 + type="checkbox" 80 + checked={multiple} 81 + disabled={disabled} 82 + onChange={(e) => { 83 + const { checked } = e.target; 84 + poll.multiple = checked; 85 + onInput(poll); 86 + }} 87 + />{' '} 88 + <Trans>Multiple choices</Trans> 89 + </label> 90 + <label class="expires-in"> 91 + <Trans>Duration</Trans>{' '} 92 + <select 93 + value={expiresIn} 94 + disabled={disabled} 95 + onChange={(e) => { 96 + const { value } = e.target; 97 + poll.expiresIn = value; 98 + onInput(poll); 99 + }} 100 + > 101 + {Object.entries(expiryOptions) 102 + .filter(([value]) => { 103 + return value >= minExpiration && value <= maxExpiration; 104 + }) 105 + .map(([value, label]) => ( 106 + <option value={value} key={value}> 107 + {label()} 108 + </option> 109 + ))} 110 + </select> 111 + </label> 112 + </div> 113 + <div class="poll-toolbar"> 114 + <button 115 + type="button" 116 + class="plain remove-poll-button" 117 + disabled={disabled} 118 + onClick={() => { 119 + onInput(null); 120 + }} 121 + > 122 + <Trans>Remove poll</Trans> 123 + </button> 124 + </div> 125 + </div> 126 + ); 127 + } 128 + 129 + export default ComposePoll;
+562
src/components/compose-textarea.jsx
··· 1 + import '@github/text-expander-element'; 2 + 3 + import { useLingui } from '@lingui/react/macro'; 4 + import { forwardRef } from 'preact/compat'; 5 + import { useEffect, useRef, useState } from 'preact/hooks'; 6 + import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 7 + 8 + import { api } from '../utils/api'; 9 + import { langDetector } from '../utils/browser-translator'; 10 + import getCustomEmojis from '../utils/custom-emojis'; 11 + import emojifyText from '../utils/emojify-text'; 12 + import escapeHTML from '../utils/escape-html'; 13 + import getDomain from '../utils/get-domain'; 14 + import isRTL from '../utils/is-rtl'; 15 + import shortenNumber from '../utils/shorten-number'; 16 + import states from '../utils/states'; 17 + import urlRegexObj from '../utils/url-regex'; 18 + 19 + const menu = document.createElement('ul'); 20 + menu.role = 'listbox'; 21 + menu.className = 'text-expander-menu'; 22 + 23 + // Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it 24 + const windowMargin = 16; 25 + const observer = new IntersectionObserver((entries) => { 26 + entries.forEach((entry) => { 27 + if (entry.isIntersecting) { 28 + const { left, width } = entry.boundingClientRect; 29 + const { innerWidth } = window; 30 + if (left + width > innerWidth) { 31 + const insetInlineStart = isRTL() ? 'right' : 'left'; 32 + menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px'; 33 + } 34 + } 35 + }); 36 + }); 37 + observer.observe(menu); 38 + 39 + // https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69 40 + const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i; 41 + const MENTION_RE = new RegExp( 42 + `(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`, 43 + 'uig', 44 + ); 45 + 46 + // AI-generated, all other regexes are too complicated 47 + const HASHTAG_RE = new RegExp( 48 + `(^|[^=\\/\\w])(#[\\p{L}\\p{N}_]+([\\p{L}\\p{N}_.]+[\\p{L}\\p{N}_]+)?)(?![\\/\\w])`, 49 + 'iug', 50 + ); 51 + 52 + // https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31 53 + const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'; 54 + const SCAN_RE = new RegExp( 55 + `(^|[^=\\/\\w])(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`, 56 + 'g', 57 + ); 58 + 59 + const segmenter = new Intl.Segmenter(); 60 + 61 + function highlightText(text, { maxCharacters = Infinity }) { 62 + // Exceeded characters limit 63 + const { composerCharacterCount } = states; 64 + if (composerCharacterCount > maxCharacters) { 65 + // Highlight exceeded characters 66 + let withinLimitHTML = '', 67 + exceedLimitHTML = ''; 68 + const htmlSegments = segmenter.segment(text); 69 + for (const { segment, index } of htmlSegments) { 70 + if (index < maxCharacters) { 71 + withinLimitHTML += segment; 72 + } else { 73 + exceedLimitHTML += segment; 74 + } 75 + } 76 + if (exceedLimitHTML) { 77 + exceedLimitHTML = 78 + '<mark class="compose-highlight-exceeded">' + 79 + escapeHTML(exceedLimitHTML) + 80 + '</mark>'; 81 + } 82 + return escapeHTML(withinLimitHTML) + exceedLimitHTML; 83 + } 84 + 85 + return escapeHTML(text) 86 + .replace(urlRegexObj, '$2<mark class="compose-highlight-url">$3</mark>') // URLs 87 + .replace(MENTION_RE, '$1<mark class="compose-highlight-mention">$2</mark>') // Mentions 88 + .replace(HASHTAG_RE, '$1<mark class="compose-highlight-hashtag">$2</mark>') // Hashtags 89 + .replace( 90 + SCAN_RE, 91 + '$1<mark class="compose-highlight-emoji-shortcode">$2</mark>', 92 + ); // Emoji shortcodes 93 + } 94 + 95 + function autoResizeTextarea(textarea) { 96 + if (!textarea) return; 97 + const { value, offsetHeight, scrollHeight, clientHeight } = textarea; 98 + if (offsetHeight < window.innerHeight) { 99 + // NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render 100 + // No idea why it does that, will re-investigate in far future 101 + const offset = offsetHeight - clientHeight; 102 + const height = value ? scrollHeight + offset + 'px' : null; 103 + textarea.style.height = height; 104 + } 105 + } 106 + 107 + const detectLangs = async (text) => { 108 + if (langDetector) { 109 + const langs = await langDetector.detect(text); 110 + if (langs?.length) { 111 + return langs.slice(0, 2).map((lang) => lang.detectedLanguage); 112 + } 113 + } 114 + const { detectAll } = await import('tinyld/light'); 115 + const langs = detectAll(text); 116 + if (langs?.length) { 117 + // return max 2 118 + return langs.slice(0, 2).map((lang) => lang.lang); 119 + } 120 + return null; 121 + }; 122 + 123 + function encodeHTML(str) { 124 + return str.replace(/[&<>"']/g, function (char) { 125 + return '&#' + char.charCodeAt(0) + ';'; 126 + }); 127 + } 128 + 129 + const Textarea = forwardRef((props, ref) => { 130 + const { t } = useLingui(); 131 + const { masto, instance } = api(); 132 + const [text, setText] = useState(ref.current?.value || ''); 133 + const { 134 + maxCharacters, 135 + performSearch = () => {}, 136 + onTrigger = () => {}, 137 + ...textareaProps 138 + } = props; 139 + // const snapStates = useSnapshot(states); 140 + // const charCount = snapStates.composerCharacterCount; 141 + 142 + // const customEmojis = useRef(); 143 + const searcherRef = useRef(); 144 + useEffect(() => { 145 + getCustomEmojis(instance, masto) 146 + .then((r) => { 147 + const [emojis, searcher] = r; 148 + searcherRef.current = searcher; 149 + }) 150 + .catch((e) => { 151 + console.error(e); 152 + }); 153 + }, []); 154 + 155 + const textExpanderRef = useRef(); 156 + const textExpanderTextRef = useRef(''); 157 + const hasTextExpanderRef = useRef(false); 158 + useEffect(() => { 159 + let handleChange, 160 + handleValue, 161 + handleCommited, 162 + handleActivate, 163 + handleDeactivate; 164 + if (textExpanderRef.current) { 165 + handleChange = (e) => { 166 + // console.log('text-expander-change', e); 167 + const { key, provide, text } = e.detail; 168 + textExpanderTextRef.current = text; 169 + 170 + if (text === '') { 171 + provide( 172 + Promise.resolve({ 173 + matched: false, 174 + }), 175 + ); 176 + return; 177 + } 178 + 179 + if (key === ':') { 180 + // const emojis = customEmojis.current.filter((emoji) => 181 + // emoji.shortcode.startsWith(text), 182 + // ); 183 + const results = searcherRef.current?.search(text, { 184 + limit: 5, 185 + }); 186 + let html = ''; 187 + results.forEach(({ item: emoji }) => { 188 + const { shortcode, url } = emoji; 189 + html += ` 190 + <li role="option" data-value="${encodeHTML(shortcode)}"> 191 + <img src="${encodeHTML( 192 + url, 193 + )}" width="16" height="16" alt="" loading="lazy" /> 194 + ${encodeHTML(shortcode)} 195 + </li>`; 196 + }); 197 + html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`; 198 + // console.log({ emojis, html }); 199 + menu.innerHTML = html; 200 + provide( 201 + Promise.resolve({ 202 + matched: results.length > 0, 203 + fragment: menu, 204 + }), 205 + ); 206 + return; 207 + } 208 + 209 + const type = { 210 + '@': 'accounts', 211 + '#': 'hashtags', 212 + }[key]; 213 + provide( 214 + new Promise((resolve) => { 215 + const searchResults = performSearch({ 216 + type, 217 + q: text, 218 + limit: 5, 219 + }); 220 + searchResults.then((value) => { 221 + if (text !== textExpanderTextRef.current) { 222 + return; 223 + } 224 + console.log({ value, type, v: value[type] }); 225 + const results = value[type] || value; 226 + console.log('RESULTS', value, results); 227 + let html = ''; 228 + results.forEach((result) => { 229 + const { 230 + name, 231 + avatarStatic, 232 + displayName, 233 + username, 234 + acct, 235 + emojis, 236 + history, 237 + roles, 238 + url, 239 + } = result; 240 + const displayNameWithEmoji = emojifyText(displayName, emojis); 241 + const accountInstance = getDomain(url); 242 + // const item = menuItem.cloneNode(); 243 + if (acct) { 244 + html += ` 245 + <li role="option" data-value="${encodeHTML(acct)}"> 246 + <span class="avatar"> 247 + <img src="${encodeHTML( 248 + avatarStatic, 249 + )}" width="16" height="16" alt="" loading="lazy" /> 250 + </span> 251 + <span> 252 + <b>${displayNameWithEmoji || username}</b> 253 + <br><span class="bidi-isolate">@${encodeHTML( 254 + acct, 255 + )}</span> 256 + ${ 257 + roles?.map( 258 + (role) => ` <span class="tag collapsed"> 259 + ${role.name} 260 + ${ 261 + !!accountInstance && 262 + `<span class="more-insignificant"> 263 + ${accountInstance} 264 + </span>` 265 + } 266 + </span>`, 267 + ) || '' 268 + } 269 + </span> 270 + </li> 271 + `; 272 + } else { 273 + const total = history?.reduce?.( 274 + (acc, cur) => acc + +cur.uses, 275 + 0, 276 + ); 277 + html += ` 278 + <li role="option" data-value="${encodeHTML(name)}"> 279 + <span class="grow">#<b>${encodeHTML(name)}</b></span> 280 + ${ 281 + total 282 + ? `<span class="count">${shortenNumber(total)}</span>` 283 + : '' 284 + } 285 + </li> 286 + `; 287 + } 288 + }); 289 + if (type === 'accounts') { 290 + html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`; 291 + } 292 + menu.innerHTML = html; 293 + console.log('MENU', results, menu); 294 + resolve({ 295 + matched: results.length > 0, 296 + fragment: menu, 297 + }); 298 + }); 299 + }), 300 + ); 301 + }; 302 + 303 + textExpanderRef.current.addEventListener( 304 + 'text-expander-change', 305 + handleChange, 306 + ); 307 + 308 + handleValue = (e) => { 309 + const { key, item } = e.detail; 310 + const { value, more } = item.dataset; 311 + if (key === ':') { 312 + e.detail.value = value ? `:${value}:` : '​'; // zero-width space 313 + if (more) { 314 + // Prevent adding space after the above value 315 + e.detail.continue = true; 316 + 317 + setTimeout(() => { 318 + onTrigger?.({ 319 + name: 'custom-emojis', 320 + defaultSearchTerm: more, 321 + }); 322 + }, 300); 323 + } 324 + } else if (key === '@') { 325 + e.detail.value = value ? `@${value}` : '​'; // zero-width space 326 + if (more) { 327 + e.detail.continue = true; 328 + setTimeout(() => { 329 + onTrigger?.({ 330 + name: 'mention', 331 + defaultSearchTerm: more, 332 + }); 333 + }, 300); 334 + } 335 + } else { 336 + e.detail.value = `${key}${value}`; 337 + } 338 + }; 339 + 340 + textExpanderRef.current.addEventListener( 341 + 'text-expander-value', 342 + handleValue, 343 + ); 344 + 345 + handleCommited = (e) => { 346 + const { input } = e.detail; 347 + setText(input.value); 348 + // fire input event 349 + if (ref.current) { 350 + const event = new Event('input', { bubbles: true }); 351 + ref.current.dispatchEvent(event); 352 + } 353 + }; 354 + 355 + textExpanderRef.current.addEventListener( 356 + 'text-expander-committed', 357 + handleCommited, 358 + ); 359 + 360 + handleActivate = () => { 361 + hasTextExpanderRef.current = true; 362 + }; 363 + 364 + textExpanderRef.current.addEventListener( 365 + 'text-expander-activate', 366 + handleActivate, 367 + ); 368 + 369 + handleDeactivate = () => { 370 + hasTextExpanderRef.current = false; 371 + }; 372 + 373 + textExpanderRef.current.addEventListener( 374 + 'text-expander-deactivate', 375 + handleDeactivate, 376 + ); 377 + } 378 + 379 + return () => { 380 + if (textExpanderRef.current) { 381 + textExpanderRef.current.removeEventListener( 382 + 'text-expander-change', 383 + handleChange, 384 + ); 385 + textExpanderRef.current.removeEventListener( 386 + 'text-expander-value', 387 + handleValue, 388 + ); 389 + textExpanderRef.current.removeEventListener( 390 + 'text-expander-committed', 391 + handleCommited, 392 + ); 393 + textExpanderRef.current.removeEventListener( 394 + 'text-expander-activate', 395 + handleActivate, 396 + ); 397 + textExpanderRef.current.removeEventListener( 398 + 'text-expander-deactivate', 399 + handleDeactivate, 400 + ); 401 + } 402 + }; 403 + }, []); 404 + 405 + useEffect(() => { 406 + // Resize observer for textarea 407 + const textarea = ref.current; 408 + if (!textarea) return; 409 + const resizeObserver = new ResizeObserver(() => { 410 + // Get height of textarea, set height to textExpander 411 + if (textExpanderRef.current) { 412 + const { height } = textarea.getBoundingClientRect(); 413 + textExpanderRef.current.style.height = height + 'px'; 414 + } 415 + }); 416 + resizeObserver.observe(textarea); 417 + }, []); 418 + 419 + const slowHighlightPerf = useRef(0); // increment if slow 420 + const composeHighlightRef = useRef(); 421 + const throttleHighlightText = useThrottledCallback((text) => { 422 + if (!composeHighlightRef.current) return; 423 + if (slowHighlightPerf.current > 3) { 424 + // After 3 times of lag, disable highlighting 425 + composeHighlightRef.current.innerHTML = ''; 426 + composeHighlightRef.current = null; // Destroy the whole thing 427 + throttleHighlightText?.cancel?.(); 428 + return; 429 + } 430 + let start; 431 + let end; 432 + if (slowHighlightPerf.current <= 3) start = Date.now(); 433 + composeHighlightRef.current.innerHTML = 434 + highlightText(text, { 435 + maxCharacters, 436 + }) + '\n'; 437 + if (slowHighlightPerf.current <= 3) end = Date.now(); 438 + console.debug('HIGHLIGHT PERF', { start, end, diff: end - start }); 439 + if (start && end && end - start > 50) { 440 + // if slow, increment 441 + slowHighlightPerf.current++; 442 + } 443 + // Newline to prevent multiple line breaks at the end from being collapsed, no idea why 444 + }, 500); 445 + 446 + const debouncedAutoDetectLanguage = useDebouncedCallback(() => { 447 + // Make use of the highlightRef to get the DOM 448 + // Clone the dom 449 + const dom = composeHighlightRef.current?.cloneNode(true); 450 + if (!dom) return; 451 + // Remove mark 452 + dom.querySelectorAll('mark').forEach((mark) => { 453 + mark.remove(); 454 + }); 455 + const text = dom.innerText?.trim(); 456 + if (!text) return; 457 + (async () => { 458 + const langs = await detectLangs(text); 459 + if (langs?.length) { 460 + onTrigger?.({ 461 + name: 'auto-detect-language', 462 + languages: langs, 463 + }); 464 + } 465 + })(); 466 + }, 2000); 467 + 468 + return ( 469 + <text-expander 470 + ref={textExpanderRef} 471 + keys="@ # :" 472 + class="compose-field-container" 473 + > 474 + <textarea 475 + class="compose-field" 476 + autoCapitalize="sentences" 477 + autoComplete="on" 478 + autoCorrect="on" 479 + spellCheck="true" 480 + dir="auto" 481 + rows="6" 482 + cols="50" 483 + {...textareaProps} 484 + ref={ref} 485 + name="status" 486 + value={text} 487 + onKeyDown={(e) => { 488 + // Get line before cursor position after pressing 'Enter' 489 + const { key, target } = e; 490 + const hasTextExpander = hasTextExpanderRef.current; 491 + if ( 492 + key === 'Enter' && 493 + !(e.ctrlKey || e.metaKey || hasTextExpander) && 494 + !e.isComposing 495 + ) { 496 + try { 497 + const { value, selectionStart } = target; 498 + const textBeforeCursor = value.slice(0, selectionStart); 499 + const lastLine = textBeforeCursor.split('\n').slice(-1)[0]; 500 + if (lastLine) { 501 + // If line starts with "- " or "12. " 502 + if (/^\s*(-|\d+\.)\s/.test(lastLine)) { 503 + // insert "- " at cursor position 504 + const [_, preSpaces, bullet, postSpaces, anything] = 505 + lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || []; 506 + if (anything) { 507 + e.preventDefault(); 508 + const [number] = bullet.match(/\d+/) || []; 509 + const newBullet = number ? `${+number + 1}.` : '-'; 510 + const text = `\n${preSpaces}${newBullet}${postSpaces}`; 511 + target.setRangeText(text, selectionStart, selectionStart); 512 + const pos = selectionStart + text.length; 513 + target.setSelectionRange(pos, pos); 514 + } else { 515 + // trim the line before the cursor, then insert new line 516 + const pos = selectionStart - lastLine.length; 517 + target.setRangeText('', pos, selectionStart); 518 + } 519 + autoResizeTextarea(target); 520 + target.dispatchEvent(new Event('input')); 521 + } 522 + } 523 + } catch (e) { 524 + // silent fail 525 + console.error(e); 526 + } 527 + } 528 + if (composeHighlightRef.current) { 529 + composeHighlightRef.current.scrollTop = target.scrollTop; 530 + } 531 + }} 532 + onInput={(e) => { 533 + const { target } = e; 534 + const text = target.value; 535 + setText(text); 536 + autoResizeTextarea(target); 537 + props.onInput?.(e); 538 + throttleHighlightText(text); 539 + debouncedAutoDetectLanguage(); 540 + }} 541 + style={{ 542 + width: '100%', 543 + height: '4em', 544 + // '--text-weight': (1 + charCount / 140).toFixed(1) || 1, 545 + }} 546 + onScroll={(e) => { 547 + if (composeHighlightRef.current) { 548 + const { scrollTop } = e.target; 549 + composeHighlightRef.current.scrollTop = scrollTop; 550 + } 551 + }} 552 + /> 553 + <div 554 + ref={composeHighlightRef} 555 + class="compose-highlight" 556 + aria-hidden="true" 557 + /> 558 + </text-expander> 559 + ); 560 + }); 561 + 562 + export default Textarea;
+15 -2140
src/components/compose.jsx
··· 1 1 import './compose.css'; 2 - import '@github/text-expander-element'; 3 2 4 3 import { msg, plural } from '@lingui/core/macro'; 5 4 import { Trans, useLingui } from '@lingui/react/macro'; 6 5 import { MenuItem } from '@szhsin/react-menu'; 7 6 import { deepEqual } from 'fast-equals'; 8 - import Fuse from 'fuse.js'; 9 - import { forwardRef, memo } from 'preact/compat'; 10 - import { 11 - useCallback, 12 - useEffect, 13 - useMemo, 14 - useRef, 15 - useState, 16 - } from 'preact/hooks'; 7 + import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 17 8 import { useHotkeys } from 'react-hotkeys-hook'; 18 9 import stringLength from 'string-length'; 19 - // import { detectAll } from 'tinyld/light'; 20 10 import { uid } from 'uid/single'; 21 - import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 22 11 import useResizeObserver from 'use-resize-observer'; 23 - import { useSnapshot } from 'valtio'; 24 12 25 - import poweredByGiphyURL from '../assets/powered-by-giphy.svg'; 26 - 27 - import Menu2 from '../components/menu2'; 28 13 import supportedLanguages from '../data/status-supported-languages'; 29 - import urlRegex from '../data/url-regex'; 30 14 import { api, getPreferences } from '../utils/api'; 31 - import { langDetector } from '../utils/browser-translator'; 32 15 import db from '../utils/db'; 33 - import emojifyText from '../utils/emojify-text'; 34 - import escapeHTML from '../utils/escape-html'; 35 - import getDomain from '../utils/get-domain'; 36 - import i18nDuration from '../utils/i18n-duration'; 37 - import isRTL from '../utils/is-rtl'; 38 16 import localeMatch from '../utils/locale-match'; 39 17 import localeCode2Text from '../utils/localeCode2Text'; 40 18 import mem from '../utils/mem'; 41 19 import openCompose from '../utils/open-compose'; 42 - import pmem from '../utils/pmem'; 43 - import prettyBytes from '../utils/pretty-bytes'; 44 - import { fetchRelationships } from '../utils/relationships'; 45 20 import RTF from '../utils/relative-time-format'; 46 - import shortenNumber from '../utils/shorten-number'; 47 21 import showToast from '../utils/show-toast'; 48 22 import states, { saveStatus } from '../utils/states'; 49 23 import store from '../utils/store'; 50 24 import { 51 25 getCurrentAccount, 52 26 getCurrentAccountNS, 53 - getCurrentInstance, 54 27 getCurrentInstanceConfiguration, 55 28 } from '../utils/store-utils'; 56 29 import supports from '../utils/supports'; 30 + import urlRegexObj from '../utils/url-regex'; 57 31 import useCloseWatcher from '../utils/useCloseWatcher'; 58 32 import useInterval from '../utils/useInterval'; 59 33 import visibilityIconsMap from '../utils/visibility-icons-map'; 60 34 61 35 import AccountBlock from './account-block'; 62 36 // import Avatar from './avatar'; 37 + import CameraCaptureInput, { 38 + supportsCameraCapture, 39 + } from './camera-capture-input'; 40 + import CharCountMeter from './char-count-meter'; 41 + import ComposePoll, { expiryOptions } from './compose-poll'; 42 + import Textarea from './compose-textarea'; 43 + import CustomEmojisModal from './custom-emojis-modal'; 44 + import FilePickerInput from './file-picker-input'; 45 + import GIFPickerModal from './gif-picker-modal'; 63 46 import Icon from './icon'; 64 47 import Loader from './loader'; 48 + import MediaAttachment from './media-attachment'; 49 + import MentionModal from './mention-modal'; 50 + import Menu2 from './menu2'; 65 51 import Modal from './modal'; 66 52 import ScheduledAtField, { 67 53 getLocalTimezoneName, ··· 69 55 } from './ScheduledAtField'; 70 56 import Status from './status'; 71 57 72 - const { 73 - PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, 74 - PHANPY_GIPHY_API_KEY: GIPHY_API_KEY, 75 - } = import.meta.env; 76 - 77 58 const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { 78 59 const [code, common, native] = l; 79 60 acc[code] = { ··· 87 68 - Max character limit includes BOTH status text and Content Warning text 88 69 */ 89 70 90 - const expiryOptions = { 91 - 300: i18nDuration(5, 'minute'), 92 - 1_800: i18nDuration(30, 'minute'), 93 - 3_600: i18nDuration(1, 'hour'), 94 - 21_600: i18nDuration(6, 'hour'), 95 - 86_400: i18nDuration(1, 'day'), 96 - 259_200: i18nDuration(3, 'day'), 97 - 604_800: i18nDuration(1, 'week'), 98 - }; 99 71 const expirySeconds = Object.keys(expiryOptions); 100 72 const oneDay = 24 * 60 * 60; 101 73 ··· 105 77 return expirySeconds.find((s) => s >= delta) || oneDay; 106 78 }; 107 79 108 - const menu = document.createElement('ul'); 109 - menu.role = 'listbox'; 110 - menu.className = 'text-expander-menu'; 111 - 112 - // Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it 113 - const windowMargin = 16; 114 - const observer = new IntersectionObserver((entries) => { 115 - entries.forEach((entry) => { 116 - if (entry.isIntersecting) { 117 - const { left, width } = entry.boundingClientRect; 118 - const { innerWidth } = window; 119 - if (left + width > innerWidth) { 120 - const insetInlineStart = isRTL() ? 'right' : 'left'; 121 - menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px'; 122 - } 123 - } 124 - }); 125 - }); 126 - observer.observe(menu); 127 - 128 80 const DEFAULT_LANG = localeMatch( 129 81 [new Intl.DateTimeFormat().resolvedOptions().locale, ...navigator.languages], 130 82 supportedLanguages.map((l) => l[0]), ··· 132 84 ); 133 85 134 86 // https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js 135 - const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags); 136 87 const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi; 137 88 const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx'; 138 89 function countableText(inputText) { ··· 141 92 .replace(usernameRegex, '$1@$3'); 142 93 } 143 94 144 - // https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69 145 - const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i; 146 - const MENTION_RE = new RegExp( 147 - `(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`, 148 - 'uig', 149 - ); 150 - 151 - // AI-generated, all other regexes are too complicated 152 - const HASHTAG_RE = new RegExp( 153 - `(^|[^=\\/\\w])(#[\\p{L}\\p{N}_]+([\\p{L}\\p{N}_.]+[\\p{L}\\p{N}_]+)?)(?![\\/\\w])`, 154 - 'iug', 155 - ); 156 - 157 - // https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31 158 - const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'; 159 - const SCAN_RE = new RegExp( 160 - `(^|[^=\\/\\w])(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`, 161 - 'g', 162 - ); 163 - 164 - const segmenter = new Intl.Segmenter(); 165 - function highlightText(text, { maxCharacters = Infinity }) { 166 - // Exceeded characters limit 167 - const { composerCharacterCount } = states; 168 - if (composerCharacterCount > maxCharacters) { 169 - // Highlight exceeded characters 170 - let withinLimitHTML = '', 171 - exceedLimitHTML = ''; 172 - const htmlSegments = segmenter.segment(text); 173 - for (const { segment, index } of htmlSegments) { 174 - if (index < maxCharacters) { 175 - withinLimitHTML += segment; 176 - } else { 177 - exceedLimitHTML += segment; 178 - } 179 - } 180 - if (exceedLimitHTML) { 181 - exceedLimitHTML = 182 - '<mark class="compose-highlight-exceeded">' + 183 - escapeHTML(exceedLimitHTML) + 184 - '</mark>'; 185 - } 186 - return escapeHTML(withinLimitHTML) + exceedLimitHTML; 187 - } 188 - 189 - return escapeHTML(text) 190 - .replace(urlRegexObj, '$2<mark class="compose-highlight-url">$3</mark>') // URLs 191 - .replace(MENTION_RE, '$1<mark class="compose-highlight-mention">$2</mark>') // Mentions 192 - .replace(HASHTAG_RE, '$1<mark class="compose-highlight-hashtag">$2</mark>') // Hashtags 193 - .replace( 194 - SCAN_RE, 195 - '$1<mark class="compose-highlight-emoji-shortcode">$2</mark>', 196 - ); // Emoji shortcodes 197 - } 198 - 199 95 // const rtf = new Intl.RelativeTimeFormat(); 200 96 const LF = mem((locale) => new Intl.ListFormat(locale || undefined)); 201 - 202 - const CUSTOM_EMOJIS_COUNT = 100; 203 97 204 98 const ADD_LABELS = { 205 99 camera: msg`Take photo or video`, ··· 1377 1271 </div> 1378 1272 )} 1379 1273 {!!poll && ( 1380 - <Poll 1274 + <ComposePoll 1381 1275 lang={language} 1382 1276 maxOptions={maxOptions} 1383 1277 maxExpiration={maxExpiration} ··· 1836 1730 ); 1837 1731 } 1838 1732 1839 - const supportsCameraCapture = (() => { 1840 - const input = document.createElement('input'); 1841 - return 'capture' in input; 1842 - })(); 1843 - const isMobileSafari = 1844 - /iPad|iPhone|iPod/.test(navigator.userAgent) && 1845 - /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 1846 - function CameraCaptureInput({ 1847 - hidden, 1848 - disabled = false, 1849 - supportedMimeTypes, 1850 - setMediaAttachments, 1851 - }) { 1852 - // If not Mobile Safari, only apply image/* 1853 - // Chrome Android doesn't show the camera if image and video combined 1854 - // It also can't switch between photo and video mode like iOS/Safari 1855 - const filteredSupportedMimeTypes = isMobileSafari 1856 - ? supportedMimeTypes 1857 - : supportedMimeTypes?.filter((mimeType) => !/^image\//i.test(mimeType)); 1858 - 1859 - return ( 1860 - <input 1861 - type="file" 1862 - hidden={hidden} 1863 - accept={filteredSupportedMimeTypes?.join(',')} 1864 - capture="environment" 1865 - disabled={disabled} 1866 - onChange={(e) => { 1867 - const files = e.target.files; 1868 - if (!files) return; 1869 - const mediaFile = Array.from(files)[0]; 1870 - if (!mediaFile) return; 1871 - setMediaAttachments((attachments) => [ 1872 - ...attachments, 1873 - { 1874 - file: mediaFile, 1875 - type: mediaFile.type, 1876 - size: mediaFile.size, 1877 - url: URL.createObjectURL(mediaFile), 1878 - id: null, // indicate uploaded state 1879 - description: null, 1880 - }, 1881 - ]); 1882 - e.target.value = null; 1883 - }} 1884 - /> 1885 - ); 1886 - } 1887 - 1888 - function FilePickerInput({ 1889 - hidden, 1890 - supportedMimeTypes, 1891 - maxMediaAttachments, 1892 - mediaAttachments, 1893 - disabled = false, 1894 - setMediaAttachments, 1895 - }) { 1896 - return ( 1897 - <input 1898 - type="file" 1899 - hidden={hidden} 1900 - accept={supportedMimeTypes?.join(',')} 1901 - multiple={ 1902 - maxMediaAttachments === undefined || 1903 - maxMediaAttachments - mediaAttachments >= 2 1904 - } 1905 - disabled={disabled} 1906 - onChange={(e) => { 1907 - const files = e.target.files; 1908 - if (!files) return; 1909 - 1910 - const mediaFiles = Array.from(files).map((file) => ({ 1911 - file, 1912 - type: file.type, 1913 - size: file.size, 1914 - url: URL.createObjectURL(file), 1915 - id: null, // indicate uploaded state 1916 - description: null, 1917 - })); 1918 - console.log('MEDIA ATTACHMENTS', files, mediaFiles); 1919 - 1920 - // Validate max media attachments 1921 - if (mediaAttachments.length + mediaFiles.length > maxMediaAttachments) { 1922 - alert( 1923 - plural(maxMediaAttachments, { 1924 - one: 'You can only attach up to 1 file.', 1925 - other: 'You can only attach up to # files.', 1926 - }), 1927 - ); 1928 - } else { 1929 - setMediaAttachments((attachments) => { 1930 - return attachments.concat(mediaFiles); 1931 - }); 1932 - } 1933 - // Reset 1934 - e.target.value = ''; 1935 - }} 1936 - /> 1937 - ); 1938 - } 1939 - 1940 - function autoResizeTextarea(textarea) { 1941 - if (!textarea) return; 1942 - const { value, offsetHeight, scrollHeight, clientHeight } = textarea; 1943 - if (offsetHeight < window.innerHeight) { 1944 - // NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render 1945 - // No idea why it does that, will re-investigate in far future 1946 - const offset = offsetHeight - clientHeight; 1947 - const height = value ? scrollHeight + offset + 'px' : null; 1948 - textarea.style.height = height; 1949 - } 1950 - } 1951 - 1952 - async function _getCustomEmojis(instance, masto) { 1953 - const emojis = await masto.v1.customEmojis.list(); 1954 - const visibleEmojis = emojis.filter((e) => e.visibleInPicker); 1955 - const searcher = new Fuse(visibleEmojis, { 1956 - keys: ['shortcode'], 1957 - findAllMatches: true, 1958 - }); 1959 - return [visibleEmojis, searcher]; 1960 - } 1961 - const getCustomEmojis = pmem(_getCustomEmojis, { 1962 - // Limit by time to reduce memory usage 1963 - // Cached by instance 1964 - matchesArg: (cacheKeyArg, keyArg) => cacheKeyArg.instance === keyArg.instance, 1965 - maxAge: 30 * 60 * 1000, // 30 minutes 1966 - }); 1967 - 1968 - const detectLangs = async (text) => { 1969 - if (langDetector) { 1970 - const langs = await langDetector.detect(text); 1971 - if (langs?.length) { 1972 - return langs.slice(0, 2).map((lang) => lang.detectedLanguage); 1973 - } 1974 - } 1975 - const { detectAll } = await import('tinyld/light'); 1976 - const langs = detectAll(text); 1977 - if (langs?.length) { 1978 - // return max 2 1979 - return langs.slice(0, 2).map((lang) => lang.lang); 1980 - } 1981 - return null; 1982 - }; 1983 - 1984 - const Textarea = forwardRef((props, ref) => { 1985 - const { t } = useLingui(); 1986 - const { masto, instance } = api(); 1987 - const [text, setText] = useState(ref.current?.value || ''); 1988 - const { 1989 - maxCharacters, 1990 - performSearch = () => {}, 1991 - onTrigger = () => {}, 1992 - ...textareaProps 1993 - } = props; 1994 - // const snapStates = useSnapshot(states); 1995 - // const charCount = snapStates.composerCharacterCount; 1996 - 1997 - // const customEmojis = useRef(); 1998 - const searcherRef = useRef(); 1999 - useEffect(() => { 2000 - getCustomEmojis(instance, masto) 2001 - .then((r) => { 2002 - const [emojis, searcher] = r; 2003 - searcherRef.current = searcher; 2004 - }) 2005 - .catch((e) => { 2006 - console.error(e); 2007 - }); 2008 - }, []); 2009 - 2010 - const textExpanderRef = useRef(); 2011 - const textExpanderTextRef = useRef(''); 2012 - const hasTextExpanderRef = useRef(false); 2013 - useEffect(() => { 2014 - let handleChange, 2015 - handleValue, 2016 - handleCommited, 2017 - handleActivate, 2018 - handleDeactivate; 2019 - if (textExpanderRef.current) { 2020 - handleChange = (e) => { 2021 - // console.log('text-expander-change', e); 2022 - const { key, provide, text } = e.detail; 2023 - textExpanderTextRef.current = text; 2024 - 2025 - if (text === '') { 2026 - provide( 2027 - Promise.resolve({ 2028 - matched: false, 2029 - }), 2030 - ); 2031 - return; 2032 - } 2033 - 2034 - if (key === ':') { 2035 - // const emojis = customEmojis.current.filter((emoji) => 2036 - // emoji.shortcode.startsWith(text), 2037 - // ); 2038 - // const emojis = filterShortcodes(customEmojis.current, text); 2039 - const results = searcherRef.current?.search(text, { 2040 - limit: 5, 2041 - }); 2042 - let html = ''; 2043 - results.forEach(({ item: emoji }) => { 2044 - const { shortcode, url } = emoji; 2045 - html += ` 2046 - <li role="option" data-value="${encodeHTML(shortcode)}"> 2047 - <img src="${encodeHTML( 2048 - url, 2049 - )}" width="16" height="16" alt="" loading="lazy" /> 2050 - ${encodeHTML(shortcode)} 2051 - </li>`; 2052 - }); 2053 - html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`; 2054 - // console.log({ emojis, html }); 2055 - menu.innerHTML = html; 2056 - provide( 2057 - Promise.resolve({ 2058 - matched: results.length > 0, 2059 - fragment: menu, 2060 - }), 2061 - ); 2062 - return; 2063 - } 2064 - 2065 - const type = { 2066 - '@': 'accounts', 2067 - '#': 'hashtags', 2068 - }[key]; 2069 - provide( 2070 - new Promise((resolve) => { 2071 - const searchResults = performSearch({ 2072 - type, 2073 - q: text, 2074 - limit: 5, 2075 - }); 2076 - searchResults.then((value) => { 2077 - if (text !== textExpanderTextRef.current) { 2078 - return; 2079 - } 2080 - console.log({ value, type, v: value[type] }); 2081 - const results = value[type] || value; 2082 - console.log('RESULTS', value, results); 2083 - let html = ''; 2084 - results.forEach((result) => { 2085 - const { 2086 - name, 2087 - avatarStatic, 2088 - displayName, 2089 - username, 2090 - acct, 2091 - emojis, 2092 - history, 2093 - roles, 2094 - url, 2095 - } = result; 2096 - const displayNameWithEmoji = emojifyText(displayName, emojis); 2097 - const accountInstance = getDomain(url); 2098 - // const item = menuItem.cloneNode(); 2099 - if (acct) { 2100 - html += ` 2101 - <li role="option" data-value="${encodeHTML(acct)}"> 2102 - <span class="avatar"> 2103 - <img src="${encodeHTML( 2104 - avatarStatic, 2105 - )}" width="16" height="16" alt="" loading="lazy" /> 2106 - </span> 2107 - <span> 2108 - <b>${displayNameWithEmoji || username}</b> 2109 - <br><span class="bidi-isolate">@${encodeHTML( 2110 - acct, 2111 - )}</span> 2112 - ${ 2113 - roles?.map( 2114 - (role) => ` <span class="tag collapsed"> 2115 - ${role.name} 2116 - ${ 2117 - !!accountInstance && 2118 - `<span class="more-insignificant"> 2119 - ${accountInstance} 2120 - </span>` 2121 - } 2122 - </span>`, 2123 - ) || '' 2124 - } 2125 - </span> 2126 - </li> 2127 - `; 2128 - } else { 2129 - const total = history?.reduce?.( 2130 - (acc, cur) => acc + +cur.uses, 2131 - 0, 2132 - ); 2133 - html += ` 2134 - <li role="option" data-value="${encodeHTML(name)}"> 2135 - <span class="grow">#<b>${encodeHTML(name)}</b></span> 2136 - ${ 2137 - total 2138 - ? `<span class="count">${shortenNumber(total)}</span>` 2139 - : '' 2140 - } 2141 - </li> 2142 - `; 2143 - } 2144 - }); 2145 - if (type === 'accounts') { 2146 - html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`; 2147 - } 2148 - menu.innerHTML = html; 2149 - console.log('MENU', results, menu); 2150 - resolve({ 2151 - matched: results.length > 0, 2152 - fragment: menu, 2153 - }); 2154 - }); 2155 - }), 2156 - ); 2157 - }; 2158 - 2159 - textExpanderRef.current.addEventListener( 2160 - 'text-expander-change', 2161 - handleChange, 2162 - ); 2163 - 2164 - handleValue = (e) => { 2165 - const { key, item } = e.detail; 2166 - const { value, more } = item.dataset; 2167 - if (key === ':') { 2168 - e.detail.value = value ? `:${value}:` : '​'; // zero-width space 2169 - if (more) { 2170 - // Prevent adding space after the above value 2171 - e.detail.continue = true; 2172 - 2173 - setTimeout(() => { 2174 - onTrigger?.({ 2175 - name: 'custom-emojis', 2176 - defaultSearchTerm: more, 2177 - }); 2178 - }, 300); 2179 - } 2180 - } else if (key === '@') { 2181 - e.detail.value = value ? `@${value}` : '​'; // zero-width space 2182 - if (more) { 2183 - e.detail.continue = true; 2184 - setTimeout(() => { 2185 - onTrigger?.({ 2186 - name: 'mention', 2187 - defaultSearchTerm: more, 2188 - }); 2189 - }, 300); 2190 - } 2191 - } else { 2192 - e.detail.value = `${key}${value}`; 2193 - } 2194 - }; 2195 - 2196 - textExpanderRef.current.addEventListener( 2197 - 'text-expander-value', 2198 - handleValue, 2199 - ); 2200 - 2201 - handleCommited = (e) => { 2202 - const { input } = e.detail; 2203 - setText(input.value); 2204 - // fire input event 2205 - if (ref.current) { 2206 - const event = new Event('input', { bubbles: true }); 2207 - ref.current.dispatchEvent(event); 2208 - } 2209 - }; 2210 - 2211 - textExpanderRef.current.addEventListener( 2212 - 'text-expander-committed', 2213 - handleCommited, 2214 - ); 2215 - 2216 - handleActivate = () => { 2217 - hasTextExpanderRef.current = true; 2218 - }; 2219 - 2220 - textExpanderRef.current.addEventListener( 2221 - 'text-expander-activate', 2222 - handleActivate, 2223 - ); 2224 - 2225 - handleDeactivate = () => { 2226 - hasTextExpanderRef.current = false; 2227 - }; 2228 - 2229 - textExpanderRef.current.addEventListener( 2230 - 'text-expander-deactivate', 2231 - handleDeactivate, 2232 - ); 2233 - } 2234 - 2235 - return () => { 2236 - if (textExpanderRef.current) { 2237 - textExpanderRef.current.removeEventListener( 2238 - 'text-expander-change', 2239 - handleChange, 2240 - ); 2241 - textExpanderRef.current.removeEventListener( 2242 - 'text-expander-value', 2243 - handleValue, 2244 - ); 2245 - textExpanderRef.current.removeEventListener( 2246 - 'text-expander-committed', 2247 - handleCommited, 2248 - ); 2249 - textExpanderRef.current.removeEventListener( 2250 - 'text-expander-activate', 2251 - handleActivate, 2252 - ); 2253 - textExpanderRef.current.removeEventListener( 2254 - 'text-expander-deactivate', 2255 - handleDeactivate, 2256 - ); 2257 - } 2258 - }; 2259 - }, []); 2260 - 2261 - useEffect(() => { 2262 - // Resize observer for textarea 2263 - const textarea = ref.current; 2264 - if (!textarea) return; 2265 - const resizeObserver = new ResizeObserver(() => { 2266 - // Get height of textarea, set height to textExpander 2267 - if (textExpanderRef.current) { 2268 - const { height } = textarea.getBoundingClientRect(); 2269 - textExpanderRef.current.style.height = height + 'px'; 2270 - } 2271 - }); 2272 - resizeObserver.observe(textarea); 2273 - }, []); 2274 - 2275 - const slowHighlightPerf = useRef(0); // increment if slow 2276 - const composeHighlightRef = useRef(); 2277 - const throttleHighlightText = useThrottledCallback((text) => { 2278 - if (!composeHighlightRef.current) return; 2279 - if (slowHighlightPerf.current > 3) { 2280 - // After 3 times of lag, disable highlighting 2281 - composeHighlightRef.current.innerHTML = ''; 2282 - composeHighlightRef.current = null; // Destroy the whole thing 2283 - throttleHighlightText?.cancel?.(); 2284 - return; 2285 - } 2286 - let start; 2287 - let end; 2288 - if (slowHighlightPerf.current <= 3) start = Date.now(); 2289 - composeHighlightRef.current.innerHTML = 2290 - highlightText(text, { 2291 - maxCharacters, 2292 - }) + '\n'; 2293 - if (slowHighlightPerf.current <= 3) end = Date.now(); 2294 - console.debug('HIGHLIGHT PERF', { start, end, diff: end - start }); 2295 - if (start && end && end - start > 50) { 2296 - // if slow, increment 2297 - slowHighlightPerf.current++; 2298 - } 2299 - // Newline to prevent multiple line breaks at the end from being collapsed, no idea why 2300 - }, 500); 2301 - 2302 - const debouncedAutoDetectLanguage = useDebouncedCallback(() => { 2303 - // Make use of the highlightRef to get the DOM 2304 - // Clone the dom 2305 - const dom = composeHighlightRef.current?.cloneNode(true); 2306 - if (!dom) return; 2307 - // Remove mark 2308 - dom.querySelectorAll('mark').forEach((mark) => { 2309 - mark.remove(); 2310 - }); 2311 - const text = dom.innerText?.trim(); 2312 - if (!text) return; 2313 - (async () => { 2314 - const langs = await detectLangs(text); 2315 - if (langs?.length) { 2316 - onTrigger?.({ 2317 - name: 'auto-detect-language', 2318 - languages: langs, 2319 - }); 2320 - } 2321 - })(); 2322 - }, 2000); 2323 - 2324 - return ( 2325 - <text-expander 2326 - ref={textExpanderRef} 2327 - keys="@ # :" 2328 - class="compose-field-container" 2329 - > 2330 - <textarea 2331 - class="compose-field" 2332 - autoCapitalize="sentences" 2333 - autoComplete="on" 2334 - autoCorrect="on" 2335 - spellCheck="true" 2336 - dir="auto" 2337 - rows="6" 2338 - cols="50" 2339 - {...textareaProps} 2340 - ref={ref} 2341 - name="status" 2342 - value={text} 2343 - onKeyDown={(e) => { 2344 - // Get line before cursor position after pressing 'Enter' 2345 - const { key, target } = e; 2346 - const hasTextExpander = hasTextExpanderRef.current; 2347 - if ( 2348 - key === 'Enter' && 2349 - !(e.ctrlKey || e.metaKey || hasTextExpander) && 2350 - !e.isComposing 2351 - ) { 2352 - try { 2353 - const { value, selectionStart } = target; 2354 - const textBeforeCursor = value.slice(0, selectionStart); 2355 - const lastLine = textBeforeCursor.split('\n').slice(-1)[0]; 2356 - if (lastLine) { 2357 - // If line starts with "- " or "12. " 2358 - if (/^\s*(-|\d+\.)\s/.test(lastLine)) { 2359 - // insert "- " at cursor position 2360 - const [_, preSpaces, bullet, postSpaces, anything] = 2361 - lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || []; 2362 - if (anything) { 2363 - e.preventDefault(); 2364 - const [number] = bullet.match(/\d+/) || []; 2365 - const newBullet = number ? `${+number + 1}.` : '-'; 2366 - const text = `\n${preSpaces}${newBullet}${postSpaces}`; 2367 - target.setRangeText(text, selectionStart, selectionStart); 2368 - const pos = selectionStart + text.length; 2369 - target.setSelectionRange(pos, pos); 2370 - } else { 2371 - // trim the line before the cursor, then insert new line 2372 - const pos = selectionStart - lastLine.length; 2373 - target.setRangeText('', pos, selectionStart); 2374 - } 2375 - autoResizeTextarea(target); 2376 - target.dispatchEvent(new Event('input')); 2377 - } 2378 - } 2379 - } catch (e) { 2380 - // silent fail 2381 - console.error(e); 2382 - } 2383 - } 2384 - if (composeHighlightRef.current) { 2385 - composeHighlightRef.current.scrollTop = target.scrollTop; 2386 - } 2387 - }} 2388 - onInput={(e) => { 2389 - const { target } = e; 2390 - const text = target.value; 2391 - setText(text); 2392 - autoResizeTextarea(target); 2393 - props.onInput?.(e); 2394 - throttleHighlightText(text); 2395 - debouncedAutoDetectLanguage(); 2396 - }} 2397 - style={{ 2398 - width: '100%', 2399 - height: '4em', 2400 - // '--text-weight': (1 + charCount / 140).toFixed(1) || 1, 2401 - }} 2402 - onScroll={(e) => { 2403 - if (composeHighlightRef.current) { 2404 - const { scrollTop } = e.target; 2405 - composeHighlightRef.current.scrollTop = scrollTop; 2406 - } 2407 - }} 2408 - /> 2409 - <div 2410 - ref={composeHighlightRef} 2411 - class="compose-highlight" 2412 - aria-hidden="true" 2413 - /> 2414 - </text-expander> 2415 - ); 2416 - }); 2417 - 2418 - function CharCountMeter({ maxCharacters = 500, hidden }) { 2419 - const snapStates = useSnapshot(states); 2420 - const charCount = snapStates.composerCharacterCount; 2421 - const leftChars = maxCharacters - charCount; 2422 - if (hidden) { 2423 - return <span class="char-counter" hidden />; 2424 - } 2425 - return ( 2426 - <span 2427 - class="char-counter" 2428 - title={`${leftChars}/${maxCharacters}`} 2429 - style={{ 2430 - '--percentage': (charCount / maxCharacters) * 100, 2431 - }} 2432 - > 2433 - <meter 2434 - class={`${ 2435 - leftChars <= -10 2436 - ? 'explode' 2437 - : leftChars <= 0 2438 - ? 'danger' 2439 - : leftChars <= 20 2440 - ? 'warning' 2441 - : '' 2442 - }`} 2443 - value={charCount} 2444 - max={maxCharacters} 2445 - /> 2446 - <span class="counter">{leftChars}</span> 2447 - </span> 2448 - ); 2449 - } 2450 - 2451 - function scaleDimension(matrix, matrixLimit, width, height) { 2452 - // matrix = number of pixels 2453 - // matrixLimit = max number of pixels 2454 - // Calculate new width and height, downsize to within the limit, preserve aspect ratio, no decimals 2455 - const scalingFactor = Math.sqrt(matrixLimit / matrix); 2456 - const newWidth = Math.floor(width * scalingFactor); 2457 - const newHeight = Math.floor(height * scalingFactor); 2458 - return { newWidth, newHeight }; 2459 - } 2460 - 2461 - function MediaAttachment({ 2462 - attachment, 2463 - disabled, 2464 - lang, 2465 - descriptionLimit = 1500, 2466 - onDescriptionChange = () => {}, 2467 - onRemove = () => {}, 2468 - }) { 2469 - const { i18n, t } = useLingui(); 2470 - const [uiState, setUIState] = useState('default'); 2471 - const supportsEdit = supports('@mastodon/edit-media-attributes'); 2472 - const { type, id, file } = attachment; 2473 - const url = useMemo( 2474 - () => (file ? URL.createObjectURL(file) : attachment.url), 2475 - [file, attachment.url], 2476 - ); 2477 - console.log({ attachment }); 2478 - 2479 - const checkMaxError = !!file?.size; 2480 - const configuration = checkMaxError ? getCurrentInstanceConfiguration() : {}; 2481 - const { 2482 - mediaAttachments: { 2483 - imageSizeLimit, 2484 - imageMatrixLimit, 2485 - videoSizeLimit, 2486 - videoMatrixLimit, 2487 - videoFrameRateLimit, 2488 - } = {}, 2489 - } = configuration || {}; 2490 - 2491 - const [maxError, setMaxError] = useState(() => { 2492 - if (!checkMaxError) return null; 2493 - if ( 2494 - type.startsWith('image') && 2495 - imageSizeLimit && 2496 - file.size > imageSizeLimit 2497 - ) { 2498 - return { 2499 - type: 'imageSizeLimit', 2500 - details: { 2501 - imageSize: file.size, 2502 - imageSizeLimit, 2503 - }, 2504 - }; 2505 - } else if ( 2506 - type.startsWith('video') && 2507 - videoSizeLimit && 2508 - file.size > videoSizeLimit 2509 - ) { 2510 - return { 2511 - type: 'videoSizeLimit', 2512 - details: { 2513 - videoSize: file.size, 2514 - videoSizeLimit, 2515 - }, 2516 - }; 2517 - } 2518 - return null; 2519 - }); 2520 - 2521 - const [imageMatrix, setImageMatrix] = useState({}); 2522 - useEffect(() => { 2523 - if (!checkMaxError || !imageMatrixLimit) return; 2524 - if (imageMatrix?.matrix > imageMatrixLimit) { 2525 - setMaxError({ 2526 - type: 'imageMatrixLimit', 2527 - details: { 2528 - imageMatrix: imageMatrix?.matrix, 2529 - imageMatrixLimit, 2530 - width: imageMatrix?.width, 2531 - height: imageMatrix?.height, 2532 - }, 2533 - }); 2534 - } 2535 - }, [imageMatrix, imageMatrixLimit, checkMaxError]); 2536 - 2537 - const [videoMatrix, setVideoMatrix] = useState({}); 2538 - useEffect(() => { 2539 - if (!checkMaxError || !videoMatrixLimit) return; 2540 - if (videoMatrix?.matrix > videoMatrixLimit) { 2541 - setMaxError({ 2542 - type: 'videoMatrixLimit', 2543 - details: { 2544 - videoMatrix: videoMatrix?.matrix, 2545 - videoMatrixLimit, 2546 - width: videoMatrix?.width, 2547 - height: videoMatrix?.height, 2548 - }, 2549 - }); 2550 - } 2551 - }, [videoMatrix, videoMatrixLimit, checkMaxError]); 2552 - 2553 - const [description, setDescription] = useState(attachment.description); 2554 - const [suffixType, subtype] = type.split('/'); 2555 - const debouncedOnDescriptionChange = useDebouncedCallback( 2556 - onDescriptionChange, 2557 - 250, 2558 - ); 2559 - useEffect(() => { 2560 - debouncedOnDescriptionChange(description); 2561 - }, [description, debouncedOnDescriptionChange]); 2562 - 2563 - const [showModal, setShowModal] = useState(false); 2564 - const textareaRef = useRef(null); 2565 - useEffect(() => { 2566 - let timer; 2567 - if (showModal && textareaRef.current) { 2568 - timer = setTimeout(() => { 2569 - textareaRef.current.focus(); 2570 - }, 100); 2571 - } 2572 - return () => { 2573 - clearTimeout(timer); 2574 - }; 2575 - }, [showModal]); 2576 - 2577 - const descTextarea = ( 2578 - <> 2579 - {!!id && !supportsEdit ? ( 2580 - <div class="media-desc"> 2581 - <span class="tag"> 2582 - <Trans>Uploaded</Trans> 2583 - </span> 2584 - <p title={description}> 2585 - {attachment.description || <i>No description</i>} 2586 - </p> 2587 - </div> 2588 - ) : ( 2589 - <textarea 2590 - ref={textareaRef} 2591 - value={description || ''} 2592 - lang={lang} 2593 - placeholder={ 2594 - { 2595 - image: t`Image description`, 2596 - video: t`Video description`, 2597 - audio: t`Audio description`, 2598 - }[suffixType] 2599 - } 2600 - autoCapitalize="sentences" 2601 - autoComplete="on" 2602 - autoCorrect="on" 2603 - spellCheck="true" 2604 - dir="auto" 2605 - disabled={disabled || uiState === 'loading'} 2606 - class={uiState === 'loading' ? 'loading' : ''} 2607 - maxlength={descriptionLimit} // Not unicode-aware :( 2608 - onInput={(e) => { 2609 - const { value } = e.target; 2610 - setDescription(value); 2611 - // debouncedOnDescriptionChange(value); 2612 - }} 2613 - ></textarea> 2614 - )} 2615 - </> 2616 - ); 2617 - 2618 - const toastRef = useRef(null); 2619 - useEffect(() => { 2620 - return () => { 2621 - toastRef.current?.hideToast?.(); 2622 - }; 2623 - }, []); 2624 - 2625 - const maxErrorToast = useRef(null); 2626 - 2627 - const maxErrorText = (err) => { 2628 - const { type, details } = err; 2629 - switch (type) { 2630 - case 'imageSizeLimit': { 2631 - const { imageSize, imageSizeLimit } = details; 2632 - return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes( 2633 - imageSize, 2634 - )} to ${prettyBytes(imageSizeLimit)} or lower.`; 2635 - } 2636 - case 'imageMatrixLimit': { 2637 - const { imageMatrix, imageMatrixLimit, width, height } = details; 2638 - const { newWidth, newHeight } = scaleDimension( 2639 - imageMatrix, 2640 - imageMatrixLimit, 2641 - width, 2642 - height, 2643 - ); 2644 - return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number( 2645 - width, 2646 - )}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number( 2647 - newHeight, 2648 - )}px.`; 2649 - } 2650 - case 'videoSizeLimit': { 2651 - const { videoSize, videoSizeLimit } = details; 2652 - return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes( 2653 - videoSize, 2654 - )} to ${prettyBytes(videoSizeLimit)} or lower.`; 2655 - } 2656 - case 'videoMatrixLimit': { 2657 - const { videoMatrix, videoMatrixLimit, width, height } = details; 2658 - const { newWidth, newHeight } = scaleDimension( 2659 - videoMatrix, 2660 - videoMatrixLimit, 2661 - width, 2662 - height, 2663 - ); 2664 - return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number( 2665 - width, 2666 - )}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number( 2667 - newHeight, 2668 - )}px.`; 2669 - } 2670 - case 'videoFrameRateLimit': { 2671 - // Not possible to detect this on client-side for now 2672 - return t`Frame rate too high. Uploading might encounter issues.`; 2673 - } 2674 - } 2675 - }; 2676 - 2677 - return ( 2678 - <> 2679 - <div class="media-attachment"> 2680 - <div 2681 - class="media-preview" 2682 - tabIndex="0" 2683 - onClick={() => { 2684 - setShowModal(true); 2685 - }} 2686 - > 2687 - {suffixType === 'image' ? ( 2688 - <img 2689 - src={url} 2690 - alt="" 2691 - onLoad={(e) => { 2692 - if (!checkMaxError) return; 2693 - const { naturalWidth, naturalHeight } = e.target; 2694 - setImageMatrix({ 2695 - matrix: naturalWidth * naturalHeight, 2696 - width: naturalWidth, 2697 - height: naturalHeight, 2698 - }); 2699 - }} 2700 - /> 2701 - ) : suffixType === 'video' || suffixType === 'gifv' ? ( 2702 - <video 2703 - src={url + '#t=0.1'} // Make Safari show 1st-frame preview 2704 - playsinline 2705 - muted 2706 - disablePictureInPicture 2707 - preload="metadata" 2708 - onLoadedMetadata={(e) => { 2709 - if (!checkMaxError) return; 2710 - const { videoWidth, videoHeight } = e.target; 2711 - if (videoWidth && videoHeight) { 2712 - setVideoMatrix({ 2713 - matrix: videoWidth * videoHeight, 2714 - width: videoWidth, 2715 - height: videoHeight, 2716 - }); 2717 - } 2718 - }} 2719 - /> 2720 - ) : suffixType === 'audio' ? ( 2721 - <audio src={url} controls /> 2722 - ) : null} 2723 - </div> 2724 - {descTextarea} 2725 - <div class="media-aside"> 2726 - <button 2727 - type="button" 2728 - class="plain close-button" 2729 - disabled={disabled} 2730 - onClick={onRemove} 2731 - > 2732 - <Icon icon="x" alt={t`Remove`} /> 2733 - </button> 2734 - {!!maxError && ( 2735 - <button 2736 - type="button" 2737 - class="media-error" 2738 - title={maxErrorText(maxError)} 2739 - onClick={() => { 2740 - if (maxErrorToast.current) { 2741 - maxErrorToast.current.hideToast(); 2742 - } 2743 - maxErrorToast.current = showToast({ 2744 - text: maxErrorText(maxError), 2745 - duration: 10_000, 2746 - }); 2747 - }} 2748 - > 2749 - <Icon icon="alert" alt={t`Error`} /> 2750 - </button> 2751 - )} 2752 - </div> 2753 - </div> 2754 - {showModal && ( 2755 - <Modal 2756 - onClose={() => { 2757 - setShowModal(false); 2758 - }} 2759 - > 2760 - <div id="media-sheet" class="sheet sheet-max"> 2761 - <button 2762 - type="button" 2763 - class="sheet-close" 2764 - onClick={() => { 2765 - setShowModal(false); 2766 - }} 2767 - > 2768 - <Icon icon="x" alt={t`Close`} /> 2769 - </button> 2770 - <header> 2771 - <h2> 2772 - { 2773 - { 2774 - image: t`Edit image description`, 2775 - video: t`Edit video description`, 2776 - audio: t`Edit audio description`, 2777 - }[suffixType] 2778 - } 2779 - </h2> 2780 - </header> 2781 - <main tabIndex="-1"> 2782 - <div class="media-preview"> 2783 - {suffixType === 'image' ? ( 2784 - <img src={url} alt="" /> 2785 - ) : suffixType === 'video' || suffixType === 'gifv' ? ( 2786 - <video src={url} playsinline controls /> 2787 - ) : suffixType === 'audio' ? ( 2788 - <audio src={url} controls /> 2789 - ) : null} 2790 - </div> 2791 - <div class="media-form"> 2792 - {descTextarea} 2793 - <footer> 2794 - {suffixType === 'image' && 2795 - /^(png|jpe?g|gif|webp)$/i.test(subtype) && 2796 - !!states.settings.mediaAltGenerator && 2797 - !!IMG_ALT_API_URL && ( 2798 - <Menu2 2799 - portal={{ 2800 - target: document.body, 2801 - }} 2802 - containerProps={{ 2803 - style: { 2804 - zIndex: 1001, 2805 - }, 2806 - }} 2807 - align="center" 2808 - position="anchor" 2809 - overflow="auto" 2810 - menuButton={ 2811 - <button type="button" class="plain"> 2812 - <Icon icon="more" size="l" alt={t`More`} /> 2813 - </button> 2814 - } 2815 - > 2816 - <MenuItem 2817 - disabled={uiState === 'loading'} 2818 - onClick={() => { 2819 - setUIState('loading'); 2820 - toastRef.current = showToast({ 2821 - text: t`Generating description. Please wait…`, 2822 - duration: -1, 2823 - }); 2824 - // POST with multipart 2825 - (async function () { 2826 - try { 2827 - const body = new FormData(); 2828 - body.append('image', file); 2829 - const response = await fetch(IMG_ALT_API_URL, { 2830 - method: 'POST', 2831 - body, 2832 - }).then((r) => r.json()); 2833 - if (response.error) { 2834 - throw new Error(response.error); 2835 - } 2836 - setDescription(response.description); 2837 - } catch (e) { 2838 - console.error(e); 2839 - showToast( 2840 - e.message 2841 - ? t`Failed to generate description: ${e.message}` 2842 - : t`Failed to generate description`, 2843 - ); 2844 - } finally { 2845 - setUIState('default'); 2846 - toastRef.current?.hideToast?.(); 2847 - } 2848 - })(); 2849 - }} 2850 - > 2851 - <Icon icon="sparkles2" /> 2852 - {lang && lang !== 'en' ? ( 2853 - <small> 2854 - <Trans>Generate description…</Trans> 2855 - <br /> 2856 - (English) 2857 - </small> 2858 - ) : ( 2859 - <span> 2860 - <Trans>Generate description…</Trans> 2861 - </span> 2862 - )} 2863 - </MenuItem> 2864 - {!!lang && lang !== 'en' && ( 2865 - <MenuItem 2866 - disabled={uiState === 'loading'} 2867 - onClick={() => { 2868 - setUIState('loading'); 2869 - toastRef.current = showToast({ 2870 - text: t`Generating description. Please wait…`, 2871 - duration: -1, 2872 - }); 2873 - // POST with multipart 2874 - (async function () { 2875 - try { 2876 - const body = new FormData(); 2877 - body.append('image', file); 2878 - const params = `?lang=${lang}`; 2879 - const response = await fetch( 2880 - IMG_ALT_API_URL + params, 2881 - { 2882 - method: 'POST', 2883 - body, 2884 - }, 2885 - ).then((r) => r.json()); 2886 - if (response.error) { 2887 - throw new Error(response.error); 2888 - } 2889 - setDescription(response.description); 2890 - } catch (e) { 2891 - console.error(e); 2892 - showToast( 2893 - t`Failed to generate description${ 2894 - e?.message ? `: ${e.message}` : '' 2895 - }`, 2896 - ); 2897 - } finally { 2898 - setUIState('default'); 2899 - toastRef.current?.hideToast?.(); 2900 - } 2901 - })(); 2902 - }} 2903 - > 2904 - <Icon icon="sparkles2" /> 2905 - <small> 2906 - <Trans>Generate description…</Trans> 2907 - <br /> 2908 - <Trans> 2909 - ({localeCode2Text(lang)}){' '} 2910 - <span class="more-insignificant"> 2911 - — experimental 2912 - </span> 2913 - </Trans> 2914 - </small> 2915 - </MenuItem> 2916 - )} 2917 - </Menu2> 2918 - )} 2919 - <button 2920 - type="button" 2921 - class="light block" 2922 - onClick={() => { 2923 - setShowModal(false); 2924 - }} 2925 - disabled={uiState === 'loading'} 2926 - > 2927 - <Trans>Done</Trans> 2928 - </button> 2929 - </footer> 2930 - </div> 2931 - </main> 2932 - </div> 2933 - </Modal> 2934 - )} 2935 - </> 2936 - ); 2937 - } 2938 - 2939 - function Poll({ 2940 - lang, 2941 - poll, 2942 - disabled, 2943 - onInput = () => {}, 2944 - maxOptions, 2945 - maxExpiration, 2946 - minExpiration, 2947 - maxCharactersPerOption, 2948 - }) { 2949 - const { t } = useLingui(); 2950 - const { options, expiresIn, multiple } = poll; 2951 - 2952 - return ( 2953 - <div class={`poll ${multiple ? 'multiple' : ''}`}> 2954 - <div class="poll-choices"> 2955 - {options.map((option, i) => ( 2956 - <div class="poll-choice" key={i}> 2957 - <input 2958 - required 2959 - type="text" 2960 - value={option} 2961 - disabled={disabled} 2962 - maxlength={maxCharactersPerOption} 2963 - placeholder={t`Choice ${i + 1}`} 2964 - lang={lang} 2965 - spellCheck="true" 2966 - dir="auto" 2967 - onInput={(e) => { 2968 - const { value } = e.target; 2969 - options[i] = value; 2970 - onInput(poll); 2971 - }} 2972 - /> 2973 - <button 2974 - type="button" 2975 - class="plain2 poll-button" 2976 - disabled={disabled || options.length <= 1} 2977 - onClick={() => { 2978 - options.splice(i, 1); 2979 - onInput(poll); 2980 - }} 2981 - > 2982 - <Icon icon="x" size="s" alt={t`Remove`} /> 2983 - </button> 2984 - </div> 2985 - ))} 2986 - </div> 2987 - <div class="poll-toolbar"> 2988 - <button 2989 - type="button" 2990 - class="plain2 poll-button" 2991 - disabled={disabled || options.length >= maxOptions} 2992 - onClick={() => { 2993 - options.push(''); 2994 - onInput(poll); 2995 - }} 2996 - > 2997 - + 2998 - </button>{' '} 2999 - <label class="multiple-choices"> 3000 - <input 3001 - type="checkbox" 3002 - checked={multiple} 3003 - disabled={disabled} 3004 - onChange={(e) => { 3005 - const { checked } = e.target; 3006 - poll.multiple = checked; 3007 - onInput(poll); 3008 - }} 3009 - />{' '} 3010 - <Trans>Multiple choices</Trans> 3011 - </label> 3012 - <label class="expires-in"> 3013 - <Trans>Duration</Trans>{' '} 3014 - <select 3015 - value={expiresIn} 3016 - disabled={disabled} 3017 - onChange={(e) => { 3018 - const { value } = e.target; 3019 - poll.expiresIn = value; 3020 - onInput(poll); 3021 - }} 3022 - > 3023 - {Object.entries(expiryOptions) 3024 - .filter(([value]) => { 3025 - return value >= minExpiration && value <= maxExpiration; 3026 - }) 3027 - .map(([value, label]) => ( 3028 - <option value={value} key={value}> 3029 - {label()} 3030 - </option> 3031 - ))} 3032 - </select> 3033 - </label> 3034 - </div> 3035 - <div class="poll-toolbar"> 3036 - <button 3037 - type="button" 3038 - class="plain remove-poll-button" 3039 - disabled={disabled} 3040 - onClick={() => { 3041 - onInput(null); 3042 - }} 3043 - > 3044 - <Trans>Remove poll</Trans> 3045 - </button> 3046 - </div> 3047 - </div> 3048 - ); 3049 - } 3050 - 3051 - function filterShortcodes(emojis, searchTerm) { 3052 - searchTerm = searchTerm.toLowerCase(); 3053 - 3054 - // Return an array of shortcodes that start with or contain the search term, sorted by relevance and limited to the first 5 3055 - return emojis 3056 - .sort((a, b) => { 3057 - let aLower = a.shortcode.toLowerCase(); 3058 - let bLower = b.shortcode.toLowerCase(); 3059 - 3060 - let aStartsWith = aLower.startsWith(searchTerm); 3061 - let bStartsWith = bLower.startsWith(searchTerm); 3062 - let aContains = aLower.includes(searchTerm); 3063 - let bContains = bLower.includes(searchTerm); 3064 - let bothStartWith = aStartsWith && bStartsWith; 3065 - let bothContain = aContains && bContains; 3066 - 3067 - return bothStartWith 3068 - ? a.length - b.length 3069 - : aStartsWith 3070 - ? -1 3071 - : bStartsWith 3072 - ? 1 3073 - : bothContain 3074 - ? a.length - b.length 3075 - : aContains 3076 - ? -1 3077 - : bContains 3078 - ? 1 3079 - : 0; 3080 - }) 3081 - .slice(0, 5); 3082 - } 3083 - 3084 - function encodeHTML(str) { 3085 - return str.replace(/[&<>"']/g, function (char) { 3086 - return '&#' + char.charCodeAt(0) + ';'; 3087 - }); 3088 - } 3089 - 3090 1733 function removeNullUndefined(obj) { 3091 1734 for (let key in obj) { 3092 1735 if (obj[key] === null || obj[key] === undefined) { ··· 3094 1737 } 3095 1738 } 3096 1739 return obj; 3097 - } 3098 - 3099 - function MentionModal({ 3100 - onClose = () => {}, 3101 - onSelect = () => {}, 3102 - defaultSearchTerm, 3103 - }) { 3104 - const { t } = useLingui(); 3105 - const { masto } = api(); 3106 - const [uiState, setUIState] = useState('default'); 3107 - const [accounts, setAccounts] = useState([]); 3108 - const [relationshipsMap, setRelationshipsMap] = useState({}); 3109 - 3110 - const [selectedIndex, setSelectedIndex] = useState(0); 3111 - 3112 - const loadRelationships = async (accounts) => { 3113 - if (!accounts?.length) return; 3114 - const relationships = await fetchRelationships(accounts, relationshipsMap); 3115 - if (relationships) { 3116 - setRelationshipsMap({ 3117 - ...relationshipsMap, 3118 - ...relationships, 3119 - }); 3120 - } 3121 - }; 3122 - 3123 - const loadAccounts = (term) => { 3124 - if (!term) return; 3125 - setUIState('loading'); 3126 - (async () => { 3127 - try { 3128 - const accounts = await masto.v1.accounts.search.list({ 3129 - q: term, 3130 - limit: 40, 3131 - resolve: false, 3132 - }); 3133 - setAccounts(accounts); 3134 - loadRelationships(accounts); 3135 - setUIState('default'); 3136 - } catch (e) { 3137 - setUIState('error'); 3138 - console.error(e); 3139 - } 3140 - })(); 3141 - }; 3142 - 3143 - const debouncedLoadAccounts = useDebouncedCallback(loadAccounts, 1000); 3144 - 3145 - useEffect(() => { 3146 - loadAccounts(); 3147 - }, [loadAccounts]); 3148 - 3149 - const inputRef = useRef(); 3150 - useEffect(() => { 3151 - if (inputRef.current) { 3152 - inputRef.current.focus(); 3153 - // Put cursor at the end 3154 - if (inputRef.current.value) { 3155 - inputRef.current.selectionStart = inputRef.current.value.length; 3156 - inputRef.current.selectionEnd = inputRef.current.value.length; 3157 - } 3158 - } 3159 - }, []); 3160 - 3161 - useEffect(() => { 3162 - if (defaultSearchTerm) { 3163 - loadAccounts(defaultSearchTerm); 3164 - } 3165 - }, [defaultSearchTerm]); 3166 - 3167 - const selectAccount = (account) => { 3168 - const socialAddress = account.acct; 3169 - onSelect(socialAddress); 3170 - onClose(); 3171 - }; 3172 - 3173 - useHotkeys( 3174 - 'enter', 3175 - () => { 3176 - const selectedAccount = accounts[selectedIndex]; 3177 - if (selectedAccount) { 3178 - selectAccount(selectedAccount); 3179 - } 3180 - }, 3181 - { 3182 - preventDefault: true, 3183 - enableOnFormTags: ['input'], 3184 - useKey: true, 3185 - ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 3186 - }, 3187 - ); 3188 - 3189 - const listRef = useRef(); 3190 - useHotkeys( 3191 - 'down', 3192 - () => { 3193 - if (selectedIndex < accounts.length - 1) { 3194 - setSelectedIndex(selectedIndex + 1); 3195 - } else { 3196 - setSelectedIndex(0); 3197 - } 3198 - setTimeout(() => { 3199 - const selectedItem = listRef.current.querySelector('.selected'); 3200 - if (selectedItem) { 3201 - selectedItem.scrollIntoView({ 3202 - behavior: 'smooth', 3203 - block: 'center', 3204 - inline: 'center', 3205 - }); 3206 - } 3207 - }, 1); 3208 - }, 3209 - { 3210 - preventDefault: true, 3211 - enableOnFormTags: ['input'], 3212 - useKey: true, 3213 - ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 3214 - }, 3215 - ); 3216 - 3217 - useHotkeys( 3218 - 'up', 3219 - () => { 3220 - if (selectedIndex > 0) { 3221 - setSelectedIndex(selectedIndex - 1); 3222 - } else { 3223 - setSelectedIndex(accounts.length - 1); 3224 - } 3225 - setTimeout(() => { 3226 - const selectedItem = listRef.current.querySelector('.selected'); 3227 - if (selectedItem) { 3228 - selectedItem.scrollIntoView({ 3229 - behavior: 'smooth', 3230 - block: 'center', 3231 - inline: 'center', 3232 - }); 3233 - } 3234 - }, 1); 3235 - }, 3236 - { 3237 - preventDefault: true, 3238 - enableOnFormTags: ['input'], 3239 - useKey: true, 3240 - ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 3241 - }, 3242 - ); 3243 - 3244 - return ( 3245 - <div id="mention-sheet" class="sheet"> 3246 - {!!onClose && ( 3247 - <button type="button" class="sheet-close" onClick={onClose}> 3248 - <Icon icon="x" alt={t`Close`} /> 3249 - </button> 3250 - )} 3251 - <header> 3252 - <form 3253 - onSubmit={(e) => { 3254 - e.preventDefault(); 3255 - debouncedLoadAccounts.flush?.(); 3256 - // const searchTerm = inputRef.current.value; 3257 - // debouncedLoadAccounts(searchTerm); 3258 - }} 3259 - > 3260 - <input 3261 - ref={inputRef} 3262 - required 3263 - type="search" 3264 - class="block" 3265 - placeholder={t`Search accounts`} 3266 - onInput={(e) => { 3267 - const { value } = e.target; 3268 - debouncedLoadAccounts(value); 3269 - }} 3270 - autocomplete="off" 3271 - autocorrect="off" 3272 - autocapitalize="off" 3273 - spellCheck="false" 3274 - dir="auto" 3275 - defaultValue={defaultSearchTerm || ''} 3276 - /> 3277 - </form> 3278 - </header> 3279 - <main> 3280 - {accounts?.length > 0 ? ( 3281 - <ul 3282 - ref={listRef} 3283 - class={`accounts-list ${uiState === 'loading' ? 'loading' : ''}`} 3284 - > 3285 - {accounts.map((account, i) => { 3286 - const relationship = relationshipsMap[account.id]; 3287 - return ( 3288 - <li 3289 - key={account.id} 3290 - class={i === selectedIndex ? 'selected' : ''} 3291 - > 3292 - <AccountBlock 3293 - avatarSize="xxl" 3294 - account={account} 3295 - relationship={relationship} 3296 - showStats 3297 - showActivity 3298 - /> 3299 - <button 3300 - type="button" 3301 - class="plain2" 3302 - onClick={() => { 3303 - selectAccount(account); 3304 - }} 3305 - > 3306 - <Icon icon="plus" size="xl" alt={t`Add`} /> 3307 - </button> 3308 - </li> 3309 - ); 3310 - })} 3311 - </ul> 3312 - ) : uiState === 'loading' ? ( 3313 - <div class="ui-state"> 3314 - <Loader abrupt /> 3315 - </div> 3316 - ) : uiState === 'error' ? ( 3317 - <div class="ui-state"> 3318 - <p> 3319 - <Trans>Error loading accounts</Trans> 3320 - </p> 3321 - </div> 3322 - ) : null} 3323 - </main> 3324 - </div> 3325 - ); 3326 - } 3327 - 3328 - function CustomEmojisModal({ 3329 - masto, 3330 - instance, 3331 - onClose = () => {}, 3332 - onSelect = () => {}, 3333 - defaultSearchTerm, 3334 - }) { 3335 - const { t } = useLingui(); 3336 - const [uiState, setUIState] = useState('default'); 3337 - const customEmojisList = useRef([]); 3338 - const [customEmojis, setCustomEmojis] = useState([]); 3339 - const recentlyUsedCustomEmojis = useMemo( 3340 - () => store.account.get('recentlyUsedCustomEmojis') || [], 3341 - ); 3342 - const searcherRef = useRef(); 3343 - useEffect(() => { 3344 - setUIState('loading'); 3345 - (async () => { 3346 - try { 3347 - const [emojis, searcher] = await getCustomEmojis(instance, masto); 3348 - console.log('emojis', emojis); 3349 - searcherRef.current = searcher; 3350 - setCustomEmojis(emojis); 3351 - setUIState('default'); 3352 - } catch (e) { 3353 - setUIState('error'); 3354 - console.error(e); 3355 - } 3356 - })(); 3357 - }, []); 3358 - 3359 - const customEmojisCatList = useMemo(() => { 3360 - // Group emojis by category 3361 - const emojisCat = { 3362 - '--recent--': recentlyUsedCustomEmojis.filter((emoji) => 3363 - customEmojis.find((e) => e.shortcode === emoji.shortcode), 3364 - ), 3365 - }; 3366 - const othersCat = []; 3367 - customEmojis.forEach((emoji) => { 3368 - customEmojisList.current?.push?.(emoji); 3369 - if (!emoji.category) { 3370 - othersCat.push(emoji); 3371 - return; 3372 - } 3373 - if (!emojisCat[emoji.category]) { 3374 - emojisCat[emoji.category] = []; 3375 - } 3376 - emojisCat[emoji.category].push(emoji); 3377 - }); 3378 - if (othersCat.length) { 3379 - emojisCat['--others--'] = othersCat; 3380 - } 3381 - return emojisCat; 3382 - }, [customEmojis]); 3383 - 3384 - const scrollableRef = useRef(); 3385 - const [matches, setMatches] = useState(null); 3386 - const onFind = useCallback( 3387 - (e) => { 3388 - const { value } = e.target; 3389 - if (value) { 3390 - const results = searcherRef.current?.search(value, { 3391 - limit: CUSTOM_EMOJIS_COUNT, 3392 - }); 3393 - setMatches(results.map((r) => r.item)); 3394 - scrollableRef.current?.scrollTo?.(0, 0); 3395 - } else { 3396 - setMatches(null); 3397 - } 3398 - }, 3399 - [customEmojis], 3400 - ); 3401 - useEffect(() => { 3402 - if (defaultSearchTerm && customEmojis?.length) { 3403 - onFind({ target: { value: defaultSearchTerm } }); 3404 - } 3405 - }, [defaultSearchTerm, onFind, customEmojis]); 3406 - 3407 - const onSelectEmoji = useCallback( 3408 - (emoji) => { 3409 - onSelect?.(emoji); 3410 - onClose?.(); 3411 - 3412 - queueMicrotask(() => { 3413 - let recentlyUsedCustomEmojis = 3414 - store.account.get('recentlyUsedCustomEmojis') || []; 3415 - const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis.findIndex( 3416 - (e) => e.shortcode === emoji.shortcode, 3417 - ); 3418 - if (recentlyUsedEmojiIndex !== -1) { 3419 - // Move emoji to index 0 3420 - recentlyUsedCustomEmojis.splice(recentlyUsedEmojiIndex, 1); 3421 - recentlyUsedCustomEmojis.unshift(emoji); 3422 - } else { 3423 - recentlyUsedCustomEmojis.unshift(emoji); 3424 - // Remove unavailable ones 3425 - recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.filter((e) => 3426 - customEmojisList.current?.find?.( 3427 - (emoji) => emoji.shortcode === e.shortcode, 3428 - ), 3429 - ); 3430 - // Limit to 10 3431 - recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.slice(0, 10); 3432 - } 3433 - 3434 - // Store back 3435 - store.account.set('recentlyUsedCustomEmojis', recentlyUsedCustomEmojis); 3436 - }); 3437 - }, 3438 - [onSelect], 3439 - ); 3440 - 3441 - const inputRef = useRef(); 3442 - useEffect(() => { 3443 - if (inputRef.current) { 3444 - inputRef.current.focus(); 3445 - // Put cursor at the end 3446 - if (inputRef.current.value) { 3447 - inputRef.current.selectionStart = inputRef.current.value.length; 3448 - inputRef.current.selectionEnd = inputRef.current.value.length; 3449 - } 3450 - } 3451 - }, []); 3452 - 3453 - return ( 3454 - <div id="custom-emojis-sheet" class="sheet"> 3455 - {!!onClose && ( 3456 - <button type="button" class="sheet-close" onClick={onClose}> 3457 - <Icon icon="x" alt={t`Close`} /> 3458 - </button> 3459 - )} 3460 - <header> 3461 - <div> 3462 - <b> 3463 - <Trans>Custom emojis</Trans> 3464 - </b>{' '} 3465 - {uiState === 'loading' ? ( 3466 - <Loader /> 3467 - ) : ( 3468 - <small class="insignificant"> • {instance}</small> 3469 - )} 3470 - </div> 3471 - <form 3472 - onSubmit={(e) => { 3473 - e.preventDefault(); 3474 - const emoji = matches[0]; 3475 - if (emoji) { 3476 - onSelectEmoji(`:${emoji.shortcode}:`); 3477 - } 3478 - }} 3479 - > 3480 - <input 3481 - ref={inputRef} 3482 - type="search" 3483 - placeholder={t`Search emoji`} 3484 - onInput={onFind} 3485 - autocomplete="off" 3486 - autocorrect="off" 3487 - autocapitalize="off" 3488 - spellCheck="false" 3489 - dir="auto" 3490 - defaultValue={defaultSearchTerm || ''} 3491 - /> 3492 - </form> 3493 - </header> 3494 - <main ref={scrollableRef}> 3495 - {matches !== null ? ( 3496 - <ul class="custom-emojis-matches custom-emojis-list"> 3497 - {matches.map((emoji) => ( 3498 - <li key={emoji.shortcode} class="custom-emojis-match"> 3499 - <CustomEmojiButton 3500 - emoji={emoji} 3501 - onClick={() => { 3502 - onSelectEmoji(`:${emoji.shortcode}:`); 3503 - }} 3504 - showCode 3505 - /> 3506 - </li> 3507 - ))} 3508 - </ul> 3509 - ) : ( 3510 - <div class="custom-emojis-list"> 3511 - {uiState === 'error' && ( 3512 - <div class="ui-state"> 3513 - <p> 3514 - <Trans>Error loading custom emojis</Trans> 3515 - </p> 3516 - </div> 3517 - )} 3518 - {uiState === 'default' && 3519 - Object.entries(customEmojisCatList).map( 3520 - ([category, emojis]) => 3521 - !!emojis?.length && ( 3522 - <div class="section-container"> 3523 - <div class="section-header"> 3524 - {{ 3525 - '--recent--': t`Recently used`, 3526 - '--others--': t`Others`, 3527 - }[category] || category} 3528 - </div> 3529 - <CustomEmojisList 3530 - emojis={emojis} 3531 - onSelect={onSelectEmoji} 3532 - /> 3533 - </div> 3534 - ), 3535 - )} 3536 - </div> 3537 - )} 3538 - </main> 3539 - </div> 3540 - ); 3541 - } 3542 - 3543 - const CustomEmojisList = memo(({ emojis, onSelect }) => { 3544 - const { i18n } = useLingui(); 3545 - const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT); 3546 - const showMore = emojis.length > max; 3547 - return ( 3548 - <section> 3549 - {emojis.slice(0, max).map((emoji) => ( 3550 - <CustomEmojiButton 3551 - key={emoji.shortcode} 3552 - emoji={emoji} 3553 - onClick={() => { 3554 - onSelect(`:${emoji.shortcode}:`); 3555 - }} 3556 - /> 3557 - ))} 3558 - {showMore && ( 3559 - <button 3560 - type="button" 3561 - class="plain small" 3562 - onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)} 3563 - > 3564 - <Trans>{i18n.number(emojis.length - max)} more…</Trans> 3565 - </button> 3566 - )} 3567 - </section> 3568 - ); 3569 - }); 3570 - 3571 - const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => { 3572 - const addEdges = (e) => { 3573 - // Add edge-left or edge-right class based on self position relative to scrollable parent 3574 - // If near left edge, add edge-left, if near right edge, add edge-right 3575 - const buffer = 88; 3576 - const parent = e.currentTarget.closest('main'); 3577 - if (parent) { 3578 - const rect = parent.getBoundingClientRect(); 3579 - const selfRect = e.currentTarget.getBoundingClientRect(); 3580 - const targetClassList = e.currentTarget.classList; 3581 - if (selfRect.left < rect.left + buffer) { 3582 - targetClassList.add('edge-left'); 3583 - targetClassList.remove('edge-right'); 3584 - } else if (selfRect.right > rect.right - buffer) { 3585 - targetClassList.add('edge-right'); 3586 - targetClassList.remove('edge-left'); 3587 - } else { 3588 - targetClassList.remove('edge-left', 'edge-right'); 3589 - } 3590 - } 3591 - }; 3592 - 3593 - return ( 3594 - <button 3595 - type="button" 3596 - className="plain4" 3597 - onClick={onClick} 3598 - data-title={showCode ? undefined : emoji.shortcode} 3599 - onPointerEnter={addEdges} 3600 - onFocus={addEdges} 3601 - > 3602 - <picture> 3603 - {!!emoji.staticUrl && ( 3604 - <source 3605 - srcSet={emoji.staticUrl} 3606 - media="(prefers-reduced-motion: reduce)" 3607 - /> 3608 - )} 3609 - <img 3610 - className="shortcode-emoji" 3611 - src={emoji.url || emoji.staticUrl} 3612 - alt={emoji.shortcode} 3613 - width="24" 3614 - height="24" 3615 - loading="lazy" 3616 - decoding="async" 3617 - /> 3618 - </picture> 3619 - {showCode && ( 3620 - <> 3621 - {' '} 3622 - <code>{emoji.shortcode}</code> 3623 - </> 3624 - )} 3625 - </button> 3626 - ); 3627 - }); 3628 - 3629 - const GIFS_PER_PAGE = 20; 3630 - function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { 3631 - const { i18n, t } = useLingui(); 3632 - const [uiState, setUIState] = useState('default'); 3633 - const [results, setResults] = useState([]); 3634 - const formRef = useRef(null); 3635 - const qRef = useRef(null); 3636 - const currentOffset = useRef(0); 3637 - const scrollableRef = useRef(null); 3638 - 3639 - function fetchGIFs({ offset }) { 3640 - console.log('fetchGIFs', { offset }); 3641 - if (!qRef.current?.value) return; 3642 - setUIState('loading'); 3643 - scrollableRef.current?.scrollTo?.({ 3644 - top: 0, 3645 - left: 0, 3646 - behavior: 'smooth', 3647 - }); 3648 - (async () => { 3649 - try { 3650 - const query = { 3651 - api_key: GIPHY_API_KEY, 3652 - q: qRef.current.value, 3653 - rating: 'g', 3654 - limit: GIFS_PER_PAGE, 3655 - bundle: 'messaging_non_clips', 3656 - offset, 3657 - lang: i18n.locale || 'en', 3658 - }; 3659 - const response = await fetch( 3660 - 'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query), 3661 - { 3662 - referrerPolicy: 'no-referrer', 3663 - }, 3664 - ).then((r) => r.json()); 3665 - currentOffset.current = response.pagination?.offset || 0; 3666 - setResults(response); 3667 - setUIState('results'); 3668 - } catch (e) { 3669 - setUIState('error'); 3670 - console.error(e); 3671 - } 3672 - })(); 3673 - } 3674 - 3675 - useEffect(() => { 3676 - qRef.current?.focus(); 3677 - }, []); 3678 - 3679 - const debouncedOnInput = useDebouncedCallback(() => { 3680 - fetchGIFs({ offset: 0 }); 3681 - }, 1000); 3682 - 3683 - return ( 3684 - <div id="gif-picker-sheet" class="sheet"> 3685 - {!!onClose && ( 3686 - <button type="button" class="sheet-close" onClick={onClose}> 3687 - <Icon icon="x" alt={t`Close`} /> 3688 - </button> 3689 - )} 3690 - <header> 3691 - <form 3692 - ref={formRef} 3693 - onSubmit={(e) => { 3694 - e.preventDefault(); 3695 - fetchGIFs({ offset: 0 }); 3696 - }} 3697 - > 3698 - <input 3699 - ref={qRef} 3700 - type="search" 3701 - name="q" 3702 - placeholder={t`Search GIFs`} 3703 - required 3704 - autocomplete="off" 3705 - autocorrect="off" 3706 - autocapitalize="off" 3707 - spellCheck="false" 3708 - dir="auto" 3709 - onInput={debouncedOnInput} 3710 - /> 3711 - <input 3712 - type="image" 3713 - class="powered-button" 3714 - src={poweredByGiphyURL} 3715 - width="86" 3716 - height="30" 3717 - alt={t`Powered by GIPHY`} 3718 - /> 3719 - </form> 3720 - </header> 3721 - <main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}> 3722 - {uiState === 'default' && ( 3723 - <div class="ui-state"> 3724 - <p class="insignificant"> 3725 - <Trans>Type to search GIFs</Trans> 3726 - </p> 3727 - </div> 3728 - )} 3729 - {uiState === 'loading' && !results?.data?.length && ( 3730 - <div class="ui-state"> 3731 - <Loader abrupt /> 3732 - </div> 3733 - )} 3734 - {results?.data?.length > 0 ? ( 3735 - <> 3736 - <ul> 3737 - {results.data.map((gif) => { 3738 - const { id, images, title, alt_text } = gif; 3739 - const { 3740 - fixed_height_small, 3741 - fixed_height_downsampled, 3742 - fixed_height, 3743 - original, 3744 - } = images; 3745 - const theImage = fixed_height_small?.url 3746 - ? fixed_height_small 3747 - : fixed_height_downsampled?.url 3748 - ? fixed_height_downsampled 3749 - : fixed_height; 3750 - let { url, webp, width, height } = theImage; 3751 - if (+height > 100) { 3752 - width = (width / height) * 100; 3753 - height = 100; 3754 - } 3755 - const urlObj = URL.parse(url); 3756 - const strippedURL = urlObj.origin + urlObj.pathname; 3757 - let strippedWebP; 3758 - if (webp) { 3759 - const webpObj = URL.parse(webp); 3760 - strippedWebP = webpObj.origin + webpObj.pathname; 3761 - } 3762 - return ( 3763 - <li key={id}> 3764 - <button 3765 - type="button" 3766 - onClick={() => { 3767 - const { mp4, url } = original; 3768 - const theURL = mp4 || url; 3769 - const urlObj = URL.parse(theURL); 3770 - const strippedURL = urlObj.origin + urlObj.pathname; 3771 - onClose(); 3772 - onSelect({ 3773 - url: strippedURL, 3774 - type: mp4 ? 'video/mp4' : 'image/gif', 3775 - alt_text: alt_text || title, 3776 - }); 3777 - }} 3778 - > 3779 - <figure 3780 - style={{ 3781 - '--figure-width': width + 'px', 3782 - // width: width + 'px' 3783 - }} 3784 - > 3785 - <picture> 3786 - {strippedWebP && ( 3787 - <source srcset={strippedWebP} type="image/webp" /> 3788 - )} 3789 - <img 3790 - src={strippedURL} 3791 - width={width} 3792 - height={height} 3793 - loading="lazy" 3794 - decoding="async" 3795 - alt={alt_text} 3796 - referrerpolicy="no-referrer" 3797 - onLoad={(e) => { 3798 - e.target.style.backgroundColor = 'transparent'; 3799 - }} 3800 - /> 3801 - </picture> 3802 - <figcaption>{alt_text || title}</figcaption> 3803 - </figure> 3804 - </button> 3805 - </li> 3806 - ); 3807 - })} 3808 - </ul> 3809 - <p class="pagination"> 3810 - {results.pagination?.offset > 0 && ( 3811 - <button 3812 - type="button" 3813 - class="light small" 3814 - disabled={uiState === 'loading'} 3815 - onClick={() => { 3816 - fetchGIFs({ 3817 - offset: results.pagination?.offset - GIFS_PER_PAGE, 3818 - }); 3819 - }} 3820 - > 3821 - <Icon icon="chevron-left" /> 3822 - <span> 3823 - <Trans>Previous</Trans> 3824 - </span> 3825 - </button> 3826 - )} 3827 - <span /> 3828 - {results.pagination?.offset + results.pagination?.count < 3829 - results.pagination?.total_count && ( 3830 - <button 3831 - type="button" 3832 - class="light small" 3833 - disabled={uiState === 'loading'} 3834 - onClick={() => { 3835 - fetchGIFs({ 3836 - offset: results.pagination?.offset + GIFS_PER_PAGE, 3837 - }); 3838 - }} 3839 - > 3840 - <span> 3841 - <Trans>Next</Trans> 3842 - </span>{' '} 3843 - <Icon icon="chevron-right" /> 3844 - </button> 3845 - )} 3846 - </p> 3847 - </> 3848 - ) : ( 3849 - uiState === 'results' && ( 3850 - <div class="ui-state"> 3851 - <p>No results</p> 3852 - </div> 3853 - ) 3854 - )} 3855 - {uiState === 'error' && ( 3856 - <div class="ui-state"> 3857 - <p> 3858 - <Trans>Error loading GIFs</Trans> 3859 - </p> 3860 - </div> 3861 - )} 3862 - </main> 3863 - </div> 3864 - ); 3865 1740 } 3866 1741 3867 1742 export default Compose;
+320
src/components/custom-emojis-modal.jsx
··· 1 + import { Trans, useLingui } from '@lingui/react/macro'; 2 + import { memo } from 'preact/compat'; 3 + import { 4 + useCallback, 5 + useEffect, 6 + useMemo, 7 + useRef, 8 + useState, 9 + } from 'preact/hooks'; 10 + 11 + import getCustomEmojis from '../utils/custom-emojis'; 12 + import store from '../utils/store'; 13 + 14 + import Icon from './icon'; 15 + import Loader from './loader'; 16 + 17 + const CUSTOM_EMOJIS_COUNT = 100; 18 + 19 + const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => { 20 + const addEdges = (e) => { 21 + // Add edge-left or edge-right class based on self position relative to scrollable parent 22 + // If near left edge, add edge-left, if near right edge, add edge-right 23 + const buffer = 88; 24 + const parent = e.currentTarget.closest('main'); 25 + if (parent) { 26 + const rect = parent.getBoundingClientRect(); 27 + const selfRect = e.currentTarget.getBoundingClientRect(); 28 + const targetClassList = e.currentTarget.classList; 29 + if (selfRect.left < rect.left + buffer) { 30 + targetClassList.add('edge-left'); 31 + targetClassList.remove('edge-right'); 32 + } else if (selfRect.right > rect.right - buffer) { 33 + targetClassList.add('edge-right'); 34 + targetClassList.remove('edge-left'); 35 + } else { 36 + targetClassList.remove('edge-left', 'edge-right'); 37 + } 38 + } 39 + }; 40 + 41 + return ( 42 + <button 43 + type="button" 44 + className="plain4" 45 + onClick={onClick} 46 + data-title={showCode ? undefined : emoji.shortcode} 47 + onPointerEnter={addEdges} 48 + onFocus={addEdges} 49 + > 50 + <picture> 51 + {!!emoji.staticUrl && ( 52 + <source 53 + srcSet={emoji.staticUrl} 54 + media="(prefers-reduced-motion: reduce)" 55 + /> 56 + )} 57 + <img 58 + className="shortcode-emoji" 59 + src={emoji.url || emoji.staticUrl} 60 + alt={emoji.shortcode} 61 + width="24" 62 + height="24" 63 + loading="lazy" 64 + decoding="async" 65 + /> 66 + </picture> 67 + {showCode && ( 68 + <> 69 + {' '} 70 + <code>{emoji.shortcode}</code> 71 + </> 72 + )} 73 + </button> 74 + ); 75 + }); 76 + 77 + const CustomEmojisList = memo(({ emojis, onSelect }) => { 78 + const { i18n } = useLingui(); 79 + const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT); 80 + const showMore = emojis.length > max; 81 + return ( 82 + <section> 83 + {emojis.slice(0, max).map((emoji) => ( 84 + <CustomEmojiButton 85 + key={emoji.shortcode} 86 + emoji={emoji} 87 + onClick={() => { 88 + onSelect(`:${emoji.shortcode}:`); 89 + }} 90 + /> 91 + ))} 92 + {showMore && ( 93 + <button 94 + type="button" 95 + class="plain small" 96 + onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)} 97 + > 98 + <Trans>{i18n.number(emojis.length - max)} more…</Trans> 99 + </button> 100 + )} 101 + </section> 102 + ); 103 + }); 104 + 105 + function CustomEmojisModal({ 106 + masto, 107 + instance, 108 + onClose = () => {}, 109 + onSelect = () => {}, 110 + defaultSearchTerm, 111 + }) { 112 + const { t } = useLingui(); 113 + const [uiState, setUIState] = useState('default'); 114 + const customEmojisList = useRef([]); 115 + const [customEmojis, setCustomEmojis] = useState([]); 116 + const recentlyUsedCustomEmojis = useMemo( 117 + () => store.account.get('recentlyUsedCustomEmojis') || [], 118 + ); 119 + const searcherRef = useRef(); 120 + useEffect(() => { 121 + setUIState('loading'); 122 + (async () => { 123 + try { 124 + const [emojis, searcher] = await getCustomEmojis(instance, masto); 125 + console.log('emojis', emojis); 126 + searcherRef.current = searcher; 127 + setCustomEmojis(emojis); 128 + setUIState('default'); 129 + } catch (e) { 130 + setUIState('error'); 131 + console.error(e); 132 + } 133 + })(); 134 + }, []); 135 + 136 + const customEmojisCatList = useMemo(() => { 137 + // Group emojis by category 138 + const emojisCat = { 139 + '--recent--': recentlyUsedCustomEmojis.filter((emoji) => 140 + customEmojis.find((e) => e.shortcode === emoji.shortcode), 141 + ), 142 + }; 143 + const othersCat = []; 144 + customEmojis.forEach((emoji) => { 145 + customEmojisList.current?.push?.(emoji); 146 + if (!emoji.category) { 147 + othersCat.push(emoji); 148 + return; 149 + } 150 + if (!emojisCat[emoji.category]) { 151 + emojisCat[emoji.category] = []; 152 + } 153 + emojisCat[emoji.category].push(emoji); 154 + }); 155 + if (othersCat.length) { 156 + emojisCat['--others--'] = othersCat; 157 + } 158 + return emojisCat; 159 + }, [customEmojis]); 160 + 161 + const scrollableRef = useRef(); 162 + const [matches, setMatches] = useState(null); 163 + const onFind = useCallback( 164 + (e) => { 165 + const { value } = e.target; 166 + if (value) { 167 + const results = searcherRef.current?.search(value, { 168 + limit: CUSTOM_EMOJIS_COUNT, 169 + }); 170 + setMatches(results.map((r) => r.item)); 171 + scrollableRef.current?.scrollTo?.(0, 0); 172 + } else { 173 + setMatches(null); 174 + } 175 + }, 176 + [customEmojis], 177 + ); 178 + useEffect(() => { 179 + if (defaultSearchTerm && customEmojis?.length) { 180 + onFind({ target: { value: defaultSearchTerm } }); 181 + } 182 + }, [defaultSearchTerm, onFind, customEmojis]); 183 + 184 + const onSelectEmoji = useCallback( 185 + (emoji) => { 186 + onSelect?.(emoji); 187 + onClose?.(); 188 + 189 + queueMicrotask(() => { 190 + let recentlyUsedCustomEmojis = 191 + store.account.get('recentlyUsedCustomEmojis') || []; 192 + const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis.findIndex( 193 + (e) => e.shortcode === emoji.shortcode, 194 + ); 195 + if (recentlyUsedEmojiIndex !== -1) { 196 + // Move emoji to index 0 197 + recentlyUsedCustomEmojis.splice(recentlyUsedEmojiIndex, 1); 198 + recentlyUsedCustomEmojis.unshift(emoji); 199 + } else { 200 + recentlyUsedCustomEmojis.unshift(emoji); 201 + // Remove unavailable ones 202 + recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.filter((e) => 203 + customEmojisList.current?.find?.( 204 + (emoji) => emoji.shortcode === e.shortcode, 205 + ), 206 + ); 207 + // Limit to 10 208 + recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.slice(0, 10); 209 + } 210 + 211 + // Store back 212 + store.account.set('recentlyUsedCustomEmojis', recentlyUsedCustomEmojis); 213 + }); 214 + }, 215 + [onSelect], 216 + ); 217 + 218 + const inputRef = useRef(); 219 + useEffect(() => { 220 + if (inputRef.current) { 221 + inputRef.current.focus(); 222 + // Put cursor at the end 223 + if (inputRef.current.value) { 224 + inputRef.current.selectionStart = inputRef.current.value.length; 225 + inputRef.current.selectionEnd = inputRef.current.value.length; 226 + } 227 + } 228 + }, []); 229 + 230 + return ( 231 + <div id="custom-emojis-sheet" class="sheet"> 232 + {!!onClose && ( 233 + <button type="button" class="sheet-close" onClick={onClose}> 234 + <Icon icon="x" alt={t`Close`} /> 235 + </button> 236 + )} 237 + <header> 238 + <div> 239 + <b> 240 + <Trans>Custom emojis</Trans> 241 + </b>{' '} 242 + {uiState === 'loading' ? ( 243 + <Loader /> 244 + ) : ( 245 + <small class="insignificant"> • {instance}</small> 246 + )} 247 + </div> 248 + <form 249 + onSubmit={(e) => { 250 + e.preventDefault(); 251 + const emoji = matches[0]; 252 + if (emoji) { 253 + onSelectEmoji(`:${emoji.shortcode}:`); 254 + } 255 + }} 256 + > 257 + <input 258 + ref={inputRef} 259 + type="search" 260 + placeholder={t`Search emoji`} 261 + onInput={onFind} 262 + autocomplete="off" 263 + autocorrect="off" 264 + autocapitalize="off" 265 + spellCheck="false" 266 + dir="auto" 267 + defaultValue={defaultSearchTerm || ''} 268 + /> 269 + </form> 270 + </header> 271 + <main ref={scrollableRef}> 272 + {matches !== null ? ( 273 + <ul class="custom-emojis-matches custom-emojis-list"> 274 + {matches.map((emoji) => ( 275 + <li key={emoji.shortcode} class="custom-emojis-match"> 276 + <CustomEmojiButton 277 + emoji={emoji} 278 + onClick={() => { 279 + onSelectEmoji(`:${emoji.shortcode}:`); 280 + }} 281 + showCode 282 + /> 283 + </li> 284 + ))} 285 + </ul> 286 + ) : ( 287 + <div class="custom-emojis-list"> 288 + {uiState === 'error' && ( 289 + <div class="ui-state"> 290 + <p> 291 + <Trans>Error loading custom emojis</Trans> 292 + </p> 293 + </div> 294 + )} 295 + {uiState === 'default' && 296 + Object.entries(customEmojisCatList).map( 297 + ([category, emojis]) => 298 + !!emojis?.length && ( 299 + <div class="section-container"> 300 + <div class="section-header"> 301 + {{ 302 + '--recent--': t`Recently used`, 303 + '--others--': t`Others`, 304 + }[category] || category} 305 + </div> 306 + <CustomEmojisList 307 + emojis={emojis} 308 + onSelect={onSelectEmoji} 309 + /> 310 + </div> 311 + ), 312 + )} 313 + </div> 314 + )} 315 + </main> 316 + </div> 317 + ); 318 + } 319 + 320 + export default CustomEmojisModal;
+55
src/components/file-picker-input.jsx
··· 1 + import { plural } from '@lingui/core/macro'; 2 + 3 + function FilePickerInput({ 4 + hidden, 5 + supportedMimeTypes, 6 + maxMediaAttachments, 7 + mediaAttachments, 8 + disabled = false, 9 + setMediaAttachments, 10 + }) { 11 + return ( 12 + <input 13 + type="file" 14 + hidden={hidden} 15 + accept={supportedMimeTypes?.join(',')} 16 + multiple={ 17 + maxMediaAttachments === undefined || 18 + maxMediaAttachments - mediaAttachments >= 2 19 + } 20 + disabled={disabled} 21 + onChange={(e) => { 22 + const files = e.target.files; 23 + if (!files) return; 24 + 25 + const mediaFiles = Array.from(files).map((file) => ({ 26 + file, 27 + type: file.type, 28 + size: file.size, 29 + url: URL.createObjectURL(file), 30 + id: null, // indicate uploaded state 31 + description: null, 32 + })); 33 + console.log('MEDIA ATTACHMENTS', files, mediaFiles); 34 + 35 + // Validate max media attachments 36 + if (mediaAttachments.length + mediaFiles.length > maxMediaAttachments) { 37 + alert( 38 + plural(maxMediaAttachments, { 39 + one: 'You can only attach up to 1 file.', 40 + other: 'You can only attach up to # files.', 41 + }), 42 + ); 43 + } else { 44 + setMediaAttachments((attachments) => { 45 + return attachments.concat(mediaFiles); 46 + }); 47 + } 48 + // Reset 49 + e.target.value = ''; 50 + }} 51 + /> 52 + ); 53 + } 54 + 55 + export default FilePickerInput;
+251
src/components/gif-picker-modal.jsx
··· 1 + import { Trans, useLingui } from '@lingui/react/macro'; 2 + import { useEffect, useRef, useState } from 'preact/hooks'; 3 + import { useDebouncedCallback } from 'use-debounce'; 4 + 5 + import poweredByGiphyURL from '../assets/powered-by-giphy.svg'; 6 + 7 + import Icon from './icon'; 8 + import Loader from './loader'; 9 + 10 + const { PHANPY_GIPHY_API_KEY: GIPHY_API_KEY } = import.meta.env; 11 + 12 + const GIFS_PER_PAGE = 20; 13 + 14 + function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { 15 + const { i18n, t } = useLingui(); 16 + const [uiState, setUIState] = useState('default'); 17 + const [results, setResults] = useState([]); 18 + const formRef = useRef(null); 19 + const qRef = useRef(null); 20 + const currentOffset = useRef(0); 21 + const scrollableRef = useRef(null); 22 + 23 + function fetchGIFs({ offset }) { 24 + console.log('fetchGIFs', { offset }); 25 + if (!qRef.current?.value) return; 26 + setUIState('loading'); 27 + scrollableRef.current?.scrollTo?.({ 28 + top: 0, 29 + left: 0, 30 + behavior: 'smooth', 31 + }); 32 + (async () => { 33 + try { 34 + const query = { 35 + api_key: GIPHY_API_KEY, 36 + q: qRef.current.value, 37 + rating: 'g', 38 + limit: GIFS_PER_PAGE, 39 + bundle: 'messaging_non_clips', 40 + offset, 41 + lang: i18n.locale || 'en', 42 + }; 43 + const response = await fetch( 44 + 'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query), 45 + { 46 + referrerPolicy: 'no-referrer', 47 + }, 48 + ).then((r) => r.json()); 49 + currentOffset.current = response.pagination?.offset || 0; 50 + setResults(response); 51 + setUIState('results'); 52 + } catch (e) { 53 + setUIState('error'); 54 + console.error(e); 55 + } 56 + })(); 57 + } 58 + 59 + useEffect(() => { 60 + qRef.current?.focus(); 61 + }, []); 62 + 63 + const debouncedOnInput = useDebouncedCallback(() => { 64 + fetchGIFs({ offset: 0 }); 65 + }, 1000); 66 + 67 + return ( 68 + <div id="gif-picker-sheet" class="sheet"> 69 + {!!onClose && ( 70 + <button type="button" class="sheet-close" onClick={onClose}> 71 + <Icon icon="x" alt={t`Close`} /> 72 + </button> 73 + )} 74 + <header> 75 + <form 76 + ref={formRef} 77 + onSubmit={(e) => { 78 + e.preventDefault(); 79 + fetchGIFs({ offset: 0 }); 80 + }} 81 + > 82 + <input 83 + ref={qRef} 84 + type="search" 85 + name="q" 86 + placeholder={t`Search GIFs`} 87 + required 88 + autocomplete="off" 89 + autocorrect="off" 90 + autocapitalize="off" 91 + spellCheck="false" 92 + dir="auto" 93 + onInput={debouncedOnInput} 94 + /> 95 + <input 96 + type="image" 97 + class="powered-button" 98 + src={poweredByGiphyURL} 99 + width="86" 100 + height="30" 101 + alt={t`Powered by GIPHY`} 102 + /> 103 + </form> 104 + </header> 105 + <main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}> 106 + {uiState === 'default' && ( 107 + <div class="ui-state"> 108 + <p class="insignificant"> 109 + <Trans>Type to search GIFs</Trans> 110 + </p> 111 + </div> 112 + )} 113 + {uiState === 'loading' && !results?.data?.length && ( 114 + <div class="ui-state"> 115 + <Loader abrupt /> 116 + </div> 117 + )} 118 + {results?.data?.length > 0 ? ( 119 + <> 120 + <ul> 121 + {results.data.map((gif) => { 122 + const { id, images, title, alt_text } = gif; 123 + const { 124 + fixed_height_small, 125 + fixed_height_downsampled, 126 + fixed_height, 127 + original, 128 + } = images; 129 + const theImage = fixed_height_small?.url 130 + ? fixed_height_small 131 + : fixed_height_downsampled?.url 132 + ? fixed_height_downsampled 133 + : fixed_height; 134 + let { url, webp, width, height } = theImage; 135 + if (+height > 100) { 136 + width = (width / height) * 100; 137 + height = 100; 138 + } 139 + const urlObj = URL.parse(url); 140 + const strippedURL = urlObj.origin + urlObj.pathname; 141 + let strippedWebP; 142 + if (webp) { 143 + const webpObj = URL.parse(webp); 144 + strippedWebP = webpObj.origin + webpObj.pathname; 145 + } 146 + return ( 147 + <li key={id}> 148 + <button 149 + type="button" 150 + onClick={() => { 151 + const { mp4, url } = original; 152 + const theURL = mp4 || url; 153 + const urlObj = URL.parse(theURL); 154 + const strippedURL = urlObj.origin + urlObj.pathname; 155 + onClose(); 156 + onSelect({ 157 + url: strippedURL, 158 + type: mp4 ? 'video/mp4' : 'image/gif', 159 + alt_text: alt_text || title, 160 + }); 161 + }} 162 + > 163 + <figure 164 + style={{ 165 + '--figure-width': width + 'px', 166 + // width: width + 'px' 167 + }} 168 + > 169 + <picture> 170 + {strippedWebP && ( 171 + <source srcset={strippedWebP} type="image/webp" /> 172 + )} 173 + <img 174 + src={strippedURL} 175 + width={width} 176 + height={height} 177 + loading="lazy" 178 + decoding="async" 179 + alt={alt_text} 180 + referrerpolicy="no-referrer" 181 + onLoad={(e) => { 182 + e.target.style.backgroundColor = 'transparent'; 183 + }} 184 + /> 185 + </picture> 186 + <figcaption>{alt_text || title}</figcaption> 187 + </figure> 188 + </button> 189 + </li> 190 + ); 191 + })} 192 + </ul> 193 + <p class="pagination"> 194 + {results.pagination?.offset > 0 && ( 195 + <button 196 + type="button" 197 + class="light small" 198 + disabled={uiState === 'loading'} 199 + onClick={() => { 200 + fetchGIFs({ 201 + offset: results.pagination?.offset - GIFS_PER_PAGE, 202 + }); 203 + }} 204 + > 205 + <Icon icon="chevron-left" /> 206 + <span> 207 + <Trans>Previous</Trans> 208 + </span> 209 + </button> 210 + )} 211 + <span /> 212 + {results.pagination?.offset + results.pagination?.count < 213 + results.pagination?.total_count && ( 214 + <button 215 + type="button" 216 + class="light small" 217 + disabled={uiState === 'loading'} 218 + onClick={() => { 219 + fetchGIFs({ 220 + offset: results.pagination?.offset + GIFS_PER_PAGE, 221 + }); 222 + }} 223 + > 224 + <span> 225 + <Trans>Next</Trans> 226 + </span>{' '} 227 + <Icon icon="chevron-right" /> 228 + </button> 229 + )} 230 + </p> 231 + </> 232 + ) : ( 233 + uiState === 'results' && ( 234 + <div class="ui-state"> 235 + <p>No results</p> 236 + </div> 237 + ) 238 + )} 239 + {uiState === 'error' && ( 240 + <div class="ui-state"> 241 + <p> 242 + <Trans>Error loading GIFs</Trans> 243 + </p> 244 + </div> 245 + )} 246 + </main> 247 + </div> 248 + ); 249 + } 250 + 251 + export default GIFPickerModal;
+507
src/components/media-attachment.jsx
··· 1 + import { Trans, useLingui } from '@lingui/react/macro'; 2 + import { MenuItem } from '@szhsin/react-menu'; 3 + import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 4 + import { useDebouncedCallback } from 'use-debounce'; 5 + 6 + import localeCode2Text from '../utils/localeCode2Text'; 7 + import prettyBytes from '../utils/pretty-bytes'; 8 + import showToast from '../utils/show-toast'; 9 + import states from '../utils/states'; 10 + import { getCurrentInstanceConfiguration } from '../utils/store-utils'; 11 + import supports from '../utils/supports'; 12 + 13 + import Icon from './icon'; 14 + import Menu2 from './menu2'; 15 + import Modal from './modal'; 16 + 17 + const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; 18 + 19 + function scaleDimension(matrix, matrixLimit, width, height) { 20 + // matrix = number of pixels 21 + // matrixLimit = max number of pixels 22 + // Calculate new width and height, downsize to within the limit, preserve aspect ratio, no decimals 23 + const scalingFactor = Math.sqrt(matrixLimit / matrix); 24 + const newWidth = Math.floor(width * scalingFactor); 25 + const newHeight = Math.floor(height * scalingFactor); 26 + return { newWidth, newHeight }; 27 + } 28 + 29 + function MediaAttachment({ 30 + attachment, 31 + disabled, 32 + lang, 33 + descriptionLimit = 1500, 34 + onDescriptionChange = () => {}, 35 + onRemove = () => {}, 36 + }) { 37 + const { i18n, t } = useLingui(); 38 + const [uiState, setUIState] = useState('default'); 39 + const supportsEdit = supports('@mastodon/edit-media-attributes'); 40 + const { type, id, file } = attachment; 41 + const url = useMemo( 42 + () => (file ? URL.createObjectURL(file) : attachment.url), 43 + [file, attachment.url], 44 + ); 45 + console.log({ attachment }); 46 + 47 + const checkMaxError = !!file?.size; 48 + const configuration = checkMaxError ? getCurrentInstanceConfiguration() : {}; 49 + const { 50 + mediaAttachments: { 51 + imageSizeLimit, 52 + imageMatrixLimit, 53 + videoSizeLimit, 54 + videoMatrixLimit, 55 + videoFrameRateLimit, 56 + } = {}, 57 + } = configuration || {}; 58 + 59 + const [maxError, setMaxError] = useState(() => { 60 + if (!checkMaxError) return null; 61 + if ( 62 + type.startsWith('image') && 63 + imageSizeLimit && 64 + file.size > imageSizeLimit 65 + ) { 66 + return { 67 + type: 'imageSizeLimit', 68 + details: { 69 + imageSize: file.size, 70 + imageSizeLimit, 71 + }, 72 + }; 73 + } else if ( 74 + type.startsWith('video') && 75 + videoSizeLimit && 76 + file.size > videoSizeLimit 77 + ) { 78 + return { 79 + type: 'videoSizeLimit', 80 + details: { 81 + videoSize: file.size, 82 + videoSizeLimit, 83 + }, 84 + }; 85 + } 86 + return null; 87 + }); 88 + 89 + const [imageMatrix, setImageMatrix] = useState({}); 90 + useEffect(() => { 91 + if (!checkMaxError || !imageMatrixLimit) return; 92 + if (imageMatrix?.matrix > imageMatrixLimit) { 93 + setMaxError({ 94 + type: 'imageMatrixLimit', 95 + details: { 96 + imageMatrix: imageMatrix?.matrix, 97 + imageMatrixLimit, 98 + width: imageMatrix?.width, 99 + height: imageMatrix?.height, 100 + }, 101 + }); 102 + } 103 + }, [imageMatrix, imageMatrixLimit, checkMaxError]); 104 + 105 + const [videoMatrix, setVideoMatrix] = useState({}); 106 + useEffect(() => { 107 + if (!checkMaxError || !videoMatrixLimit) return; 108 + if (videoMatrix?.matrix > videoMatrixLimit) { 109 + setMaxError({ 110 + type: 'videoMatrixLimit', 111 + details: { 112 + videoMatrix: videoMatrix?.matrix, 113 + videoMatrixLimit, 114 + width: videoMatrix?.width, 115 + height: videoMatrix?.height, 116 + }, 117 + }); 118 + } 119 + }, [videoMatrix, videoMatrixLimit, checkMaxError]); 120 + 121 + const [description, setDescription] = useState(attachment.description); 122 + const [suffixType, subtype] = type.split('/'); 123 + const debouncedOnDescriptionChange = useDebouncedCallback( 124 + onDescriptionChange, 125 + 250, 126 + ); 127 + useEffect(() => { 128 + debouncedOnDescriptionChange(description); 129 + }, [description, debouncedOnDescriptionChange]); 130 + 131 + const [showModal, setShowModal] = useState(false); 132 + const textareaRef = useRef(null); 133 + useEffect(() => { 134 + let timer; 135 + if (showModal && textareaRef.current) { 136 + timer = setTimeout(() => { 137 + textareaRef.current.focus(); 138 + }, 100); 139 + } 140 + return () => { 141 + clearTimeout(timer); 142 + }; 143 + }, [showModal]); 144 + 145 + const descTextarea = ( 146 + <> 147 + {!!id && !supportsEdit ? ( 148 + <div class="media-desc"> 149 + <span class="tag"> 150 + <Trans>Uploaded</Trans> 151 + </span> 152 + <p title={description}> 153 + {attachment.description || <i>No description</i>} 154 + </p> 155 + </div> 156 + ) : ( 157 + <textarea 158 + ref={textareaRef} 159 + value={description || ''} 160 + lang={lang} 161 + placeholder={ 162 + { 163 + image: t`Image description`, 164 + video: t`Video description`, 165 + audio: t`Audio description`, 166 + }[suffixType] 167 + } 168 + autoCapitalize="sentences" 169 + autoComplete="on" 170 + autoCorrect="on" 171 + spellCheck="true" 172 + dir="auto" 173 + disabled={disabled || uiState === 'loading'} 174 + class={uiState === 'loading' ? 'loading' : ''} 175 + maxlength={descriptionLimit} // Not unicode-aware :( 176 + onInput={(e) => { 177 + const { value } = e.target; 178 + setDescription(value); 179 + // debouncedOnDescriptionChange(value); 180 + }} 181 + ></textarea> 182 + )} 183 + </> 184 + ); 185 + 186 + const toastRef = useRef(null); 187 + useEffect(() => { 188 + return () => { 189 + toastRef.current?.hideToast?.(); 190 + }; 191 + }, []); 192 + 193 + const maxErrorToast = useRef(null); 194 + 195 + const maxErrorText = (err) => { 196 + const { type, details } = err; 197 + switch (type) { 198 + case 'imageSizeLimit': { 199 + const { imageSize, imageSizeLimit } = details; 200 + return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes( 201 + imageSize, 202 + )} to ${prettyBytes(imageSizeLimit)} or lower.`; 203 + } 204 + case 'imageMatrixLimit': { 205 + const { imageMatrix, imageMatrixLimit, width, height } = details; 206 + const { newWidth, newHeight } = scaleDimension( 207 + imageMatrix, 208 + imageMatrixLimit, 209 + width, 210 + height, 211 + ); 212 + return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number( 213 + width, 214 + )}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number( 215 + newHeight, 216 + )}px.`; 217 + } 218 + case 'videoSizeLimit': { 219 + const { videoSize, videoSizeLimit } = details; 220 + return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes( 221 + videoSize, 222 + )} to ${prettyBytes(videoSizeLimit)} or lower.`; 223 + } 224 + case 'videoMatrixLimit': { 225 + const { videoMatrix, videoMatrixLimit, width, height } = details; 226 + const { newWidth, newHeight } = scaleDimension( 227 + videoMatrix, 228 + videoMatrixLimit, 229 + width, 230 + height, 231 + ); 232 + return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number( 233 + width, 234 + )}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number( 235 + newHeight, 236 + )}px.`; 237 + } 238 + case 'videoFrameRateLimit': { 239 + // Not possible to detect this on client-side for now 240 + return t`Frame rate too high. Uploading might encounter issues.`; 241 + } 242 + } 243 + }; 244 + 245 + return ( 246 + <> 247 + <div class="media-attachment"> 248 + <div 249 + class="media-preview" 250 + tabIndex="0" 251 + onClick={() => { 252 + setShowModal(true); 253 + }} 254 + > 255 + {suffixType === 'image' ? ( 256 + <img 257 + src={url} 258 + alt="" 259 + onLoad={(e) => { 260 + if (!checkMaxError) return; 261 + const { naturalWidth, naturalHeight } = e.target; 262 + setImageMatrix({ 263 + matrix: naturalWidth * naturalHeight, 264 + width: naturalWidth, 265 + height: naturalHeight, 266 + }); 267 + }} 268 + /> 269 + ) : suffixType === 'video' || suffixType === 'gifv' ? ( 270 + <video 271 + src={url + '#t=0.1'} // Make Safari show 1st-frame preview 272 + playsinline 273 + muted 274 + disablePictureInPicture 275 + preload="metadata" 276 + onLoadedMetadata={(e) => { 277 + if (!checkMaxError) return; 278 + const { videoWidth, videoHeight } = e.target; 279 + if (videoWidth && videoHeight) { 280 + setVideoMatrix({ 281 + matrix: videoWidth * videoHeight, 282 + width: videoWidth, 283 + height: videoHeight, 284 + }); 285 + } 286 + }} 287 + /> 288 + ) : suffixType === 'audio' ? ( 289 + <audio src={url} controls /> 290 + ) : null} 291 + </div> 292 + {descTextarea} 293 + <div class="media-aside"> 294 + <button 295 + type="button" 296 + class="plain close-button" 297 + disabled={disabled} 298 + onClick={onRemove} 299 + > 300 + <Icon icon="x" alt={t`Remove`} /> 301 + </button> 302 + {!!maxError && ( 303 + <button 304 + type="button" 305 + class="media-error" 306 + title={maxErrorText(maxError)} 307 + onClick={() => { 308 + if (maxErrorToast.current) { 309 + maxErrorToast.current.hideToast(); 310 + } 311 + maxErrorToast.current = showToast({ 312 + text: maxErrorText(maxError), 313 + duration: 10_000, 314 + }); 315 + }} 316 + > 317 + <Icon icon="alert" alt={t`Error`} /> 318 + </button> 319 + )} 320 + </div> 321 + </div> 322 + {showModal && ( 323 + <Modal 324 + onClose={() => { 325 + setShowModal(false); 326 + }} 327 + > 328 + <div id="media-sheet" class="sheet sheet-max"> 329 + <button 330 + type="button" 331 + class="sheet-close" 332 + onClick={() => { 333 + setShowModal(false); 334 + }} 335 + > 336 + <Icon icon="x" alt={t`Close`} /> 337 + </button> 338 + <header> 339 + <h2> 340 + { 341 + { 342 + image: t`Edit image description`, 343 + video: t`Edit video description`, 344 + audio: t`Edit audio description`, 345 + }[suffixType] 346 + } 347 + </h2> 348 + </header> 349 + <main tabIndex="-1"> 350 + <div class="media-preview"> 351 + {suffixType === 'image' ? ( 352 + <img src={url} alt="" /> 353 + ) : suffixType === 'video' || suffixType === 'gifv' ? ( 354 + <video src={url} playsinline controls /> 355 + ) : suffixType === 'audio' ? ( 356 + <audio src={url} controls /> 357 + ) : null} 358 + </div> 359 + <div class="media-form"> 360 + {descTextarea} 361 + <footer> 362 + {suffixType === 'image' && 363 + /^(png|jpe?g|gif|webp)$/i.test(subtype) && 364 + !!states.settings.mediaAltGenerator && 365 + !!IMG_ALT_API_URL && ( 366 + <Menu2 367 + portal={{ 368 + target: document.body, 369 + }} 370 + containerProps={{ 371 + style: { 372 + zIndex: 1001, 373 + }, 374 + }} 375 + align="center" 376 + position="anchor" 377 + overflow="auto" 378 + menuButton={ 379 + <button type="button" class="plain"> 380 + <Icon icon="more" size="l" alt={t`More`} /> 381 + </button> 382 + } 383 + > 384 + <MenuItem 385 + disabled={uiState === 'loading'} 386 + onClick={() => { 387 + setUIState('loading'); 388 + toastRef.current = showToast({ 389 + text: t`Generating description. Please wait…`, 390 + duration: -1, 391 + }); 392 + // POST with multipart 393 + (async function () { 394 + try { 395 + const body = new FormData(); 396 + body.append('image', file); 397 + const response = await fetch(IMG_ALT_API_URL, { 398 + method: 'POST', 399 + body, 400 + }).then((r) => r.json()); 401 + if (response.error) { 402 + throw new Error(response.error); 403 + } 404 + setDescription(response.description); 405 + } catch (e) { 406 + console.error(e); 407 + showToast( 408 + e.message 409 + ? t`Failed to generate description: ${e.message}` 410 + : t`Failed to generate description`, 411 + ); 412 + } finally { 413 + setUIState('default'); 414 + toastRef.current?.hideToast?.(); 415 + } 416 + })(); 417 + }} 418 + > 419 + <Icon icon="sparkles2" /> 420 + {lang && lang !== 'en' ? ( 421 + <small> 422 + <Trans>Generate description…</Trans> 423 + <br /> 424 + (English) 425 + </small> 426 + ) : ( 427 + <span> 428 + <Trans>Generate description…</Trans> 429 + </span> 430 + )} 431 + </MenuItem> 432 + {!!lang && lang !== 'en' && ( 433 + <MenuItem 434 + disabled={uiState === 'loading'} 435 + onClick={() => { 436 + setUIState('loading'); 437 + toastRef.current = showToast({ 438 + text: t`Generating description. Please wait…`, 439 + duration: -1, 440 + }); 441 + // POST with multipart 442 + (async function () { 443 + try { 444 + const body = new FormData(); 445 + body.append('image', file); 446 + const params = `?lang=${lang}`; 447 + const response = await fetch( 448 + IMG_ALT_API_URL + params, 449 + { 450 + method: 'POST', 451 + body, 452 + }, 453 + ).then((r) => r.json()); 454 + if (response.error) { 455 + throw new Error(response.error); 456 + } 457 + setDescription(response.description); 458 + } catch (e) { 459 + console.error(e); 460 + showToast( 461 + t`Failed to generate description${ 462 + e?.message ? `: ${e.message}` : '' 463 + }`, 464 + ); 465 + } finally { 466 + setUIState('default'); 467 + toastRef.current?.hideToast?.(); 468 + } 469 + })(); 470 + }} 471 + > 472 + <Icon icon="sparkles2" /> 473 + <small> 474 + <Trans>Generate description…</Trans> 475 + <br /> 476 + <Trans> 477 + ({localeCode2Text(lang)}){' '} 478 + <span class="more-insignificant"> 479 + — experimental 480 + </span> 481 + </Trans> 482 + </small> 483 + </MenuItem> 484 + )} 485 + </Menu2> 486 + )} 487 + <button 488 + type="button" 489 + class="light block" 490 + onClick={() => { 491 + setShowModal(false); 492 + }} 493 + disabled={uiState === 'loading'} 494 + > 495 + <Trans>Done</Trans> 496 + </button> 497 + </footer> 498 + </div> 499 + </main> 500 + </div> 501 + </Modal> 502 + )} 503 + </> 504 + ); 505 + } 506 + 507 + export default MediaAttachment;
+242
src/components/mention-modal.jsx
··· 1 + import { Trans, useLingui } from '@lingui/react/macro'; 2 + import { useEffect, useRef, useState } from 'preact/hooks'; 3 + import { useHotkeys } from 'react-hotkeys-hook'; 4 + import { useDebouncedCallback } from 'use-debounce'; 5 + 6 + import { api } from '../utils/api'; 7 + import { fetchRelationships } from '../utils/relationships'; 8 + 9 + import AccountBlock from './account-block'; 10 + import Icon from './icon'; 11 + import Loader from './loader'; 12 + 13 + function MentionModal({ 14 + onClose = () => {}, 15 + onSelect = () => {}, 16 + defaultSearchTerm, 17 + }) { 18 + const { t } = useLingui(); 19 + const { masto } = api(); 20 + const [uiState, setUIState] = useState('default'); 21 + const [accounts, setAccounts] = useState([]); 22 + const [relationshipsMap, setRelationshipsMap] = useState({}); 23 + 24 + const [selectedIndex, setSelectedIndex] = useState(0); 25 + 26 + const loadRelationships = async (accounts) => { 27 + if (!accounts?.length) return; 28 + const relationships = await fetchRelationships(accounts, relationshipsMap); 29 + if (relationships) { 30 + setRelationshipsMap({ 31 + ...relationshipsMap, 32 + ...relationships, 33 + }); 34 + } 35 + }; 36 + 37 + const loadAccounts = (term) => { 38 + if (!term) return; 39 + setUIState('loading'); 40 + (async () => { 41 + try { 42 + const accounts = await masto.v1.accounts.search.list({ 43 + q: term, 44 + limit: 40, 45 + resolve: false, 46 + }); 47 + setAccounts(accounts); 48 + loadRelationships(accounts); 49 + setUIState('default'); 50 + } catch (e) { 51 + setUIState('error'); 52 + console.error(e); 53 + } 54 + })(); 55 + }; 56 + 57 + const debouncedLoadAccounts = useDebouncedCallback(loadAccounts, 1000); 58 + 59 + useEffect(() => { 60 + loadAccounts(); 61 + }, [loadAccounts]); 62 + 63 + const inputRef = useRef(); 64 + useEffect(() => { 65 + if (inputRef.current) { 66 + inputRef.current.focus(); 67 + // Put cursor at the end 68 + if (inputRef.current.value) { 69 + inputRef.current.selectionStart = inputRef.current.value.length; 70 + inputRef.current.selectionEnd = inputRef.current.value.length; 71 + } 72 + } 73 + }, []); 74 + 75 + useEffect(() => { 76 + if (defaultSearchTerm) { 77 + loadAccounts(defaultSearchTerm); 78 + } 79 + }, [defaultSearchTerm]); 80 + 81 + const selectAccount = (account) => { 82 + const socialAddress = account.acct; 83 + onSelect(socialAddress); 84 + onClose(); 85 + }; 86 + 87 + useHotkeys( 88 + 'enter', 89 + () => { 90 + const selectedAccount = accounts[selectedIndex]; 91 + if (selectedAccount) { 92 + selectAccount(selectedAccount); 93 + } 94 + }, 95 + { 96 + preventDefault: true, 97 + enableOnFormTags: ['input'], 98 + useKey: true, 99 + ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 100 + }, 101 + ); 102 + 103 + const listRef = useRef(); 104 + useHotkeys( 105 + 'down', 106 + () => { 107 + if (selectedIndex < accounts.length - 1) { 108 + setSelectedIndex(selectedIndex + 1); 109 + } else { 110 + setSelectedIndex(0); 111 + } 112 + setTimeout(() => { 113 + const selectedItem = listRef.current.querySelector('.selected'); 114 + if (selectedItem) { 115 + selectedItem.scrollIntoView({ 116 + behavior: 'smooth', 117 + block: 'center', 118 + inline: 'center', 119 + }); 120 + } 121 + }, 1); 122 + }, 123 + { 124 + preventDefault: true, 125 + enableOnFormTags: ['input'], 126 + useKey: true, 127 + ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 128 + }, 129 + ); 130 + 131 + useHotkeys( 132 + 'up', 133 + () => { 134 + if (selectedIndex > 0) { 135 + setSelectedIndex(selectedIndex - 1); 136 + } else { 137 + setSelectedIndex(accounts.length - 1); 138 + } 139 + setTimeout(() => { 140 + const selectedItem = listRef.current.querySelector('.selected'); 141 + if (selectedItem) { 142 + selectedItem.scrollIntoView({ 143 + behavior: 'smooth', 144 + block: 'center', 145 + inline: 'center', 146 + }); 147 + } 148 + }, 1); 149 + }, 150 + { 151 + preventDefault: true, 152 + enableOnFormTags: ['input'], 153 + useKey: true, 154 + ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 155 + }, 156 + ); 157 + 158 + return ( 159 + <div id="mention-sheet" class="sheet"> 160 + {!!onClose && ( 161 + <button type="button" class="sheet-close" onClick={onClose}> 162 + <Icon icon="x" alt={t`Close`} /> 163 + </button> 164 + )} 165 + <header> 166 + <form 167 + onSubmit={(e) => { 168 + e.preventDefault(); 169 + debouncedLoadAccounts.flush?.(); 170 + // const searchTerm = inputRef.current.value; 171 + // debouncedLoadAccounts(searchTerm); 172 + }} 173 + > 174 + <input 175 + ref={inputRef} 176 + required 177 + type="search" 178 + class="block" 179 + placeholder={t`Search accounts`} 180 + onInput={(e) => { 181 + const { value } = e.target; 182 + debouncedLoadAccounts(value); 183 + }} 184 + autocomplete="off" 185 + autocorrect="off" 186 + autocapitalize="off" 187 + spellCheck="false" 188 + dir="auto" 189 + defaultValue={defaultSearchTerm || ''} 190 + /> 191 + </form> 192 + </header> 193 + <main> 194 + {accounts?.length > 0 ? ( 195 + <ul 196 + ref={listRef} 197 + class={`accounts-list ${uiState === 'loading' ? 'loading' : ''}`} 198 + > 199 + {accounts.map((account, i) => { 200 + const relationship = relationshipsMap[account.id]; 201 + return ( 202 + <li 203 + key={account.id} 204 + class={i === selectedIndex ? 'selected' : ''} 205 + > 206 + <AccountBlock 207 + avatarSize="xxl" 208 + account={account} 209 + relationship={relationship} 210 + showStats 211 + showActivity 212 + /> 213 + <button 214 + type="button" 215 + class="plain2" 216 + onClick={() => { 217 + selectAccount(account); 218 + }} 219 + > 220 + <Icon icon="plus" size="xl" alt={t`Add`} /> 221 + </button> 222 + </li> 223 + ); 224 + })} 225 + </ul> 226 + ) : uiState === 'loading' ? ( 227 + <div class="ui-state"> 228 + <Loader abrupt /> 229 + </div> 230 + ) : uiState === 'error' ? ( 231 + <div class="ui-state"> 232 + <p> 233 + <Trans>Error loading accounts</Trans> 234 + </p> 235 + </div> 236 + ) : null} 237 + </main> 238 + </div> 239 + ); 240 + } 241 + 242 + export default MentionModal;
+226 -225
src/locales/en.po
··· 105 105 106 106 #: src/components/account-info.jsx:457 107 107 #: src/components/account-info.jsx:1268 108 - #: src/components/compose.jsx:2812 109 108 #: src/components/media-alt-modal.jsx:55 109 + #: src/components/media-attachment.jsx:380 110 110 #: src/components/media-modal.jsx:363 111 111 #: src/components/status.jsx:1771 112 112 #: src/components/status.jsx:1788 ··· 463 463 #: src/components/account-info.jsx:2212 464 464 #: src/components/account-info.jsx:2332 465 465 #: src/components/account-sheet.jsx:38 466 - #: src/components/compose.jsx:885 467 - #: src/components/compose.jsx:2768 468 - #: src/components/compose.jsx:3248 469 - #: src/components/compose.jsx:3457 470 - #: src/components/compose.jsx:3687 466 + #: src/components/compose.jsx:779 467 + #: src/components/custom-emojis-modal.jsx:234 471 468 #: src/components/drafts.jsx:57 472 469 #: src/components/embed-modal.jsx:13 473 470 #: src/components/generic-accounts.jsx:151 471 + #: src/components/gif-picker-modal.jsx:71 474 472 #: src/components/keyboard-shortcuts-help.jsx:43 475 473 #: src/components/list-add-edit.jsx:37 476 474 #: src/components/media-alt-modal.jsx:43 475 + #: src/components/media-attachment.jsx:336 477 476 #: src/components/media-modal.jsx:327 477 + #: src/components/mention-modal.jsx:162 478 478 #: src/components/notification-service.jsx:157 479 479 #: src/components/post-embed-modal.jsx:196 480 480 #: src/components/report-modal.jsx:118 ··· 639 639 msgid "Add to thread" 640 640 msgstr "Add to thread" 641 641 642 - #: src/components/compose.jsx:205 642 + #. placeholder {0}: i + 1 643 + #: src/components/compose-poll.jsx:41 644 + msgid "Choice {0}" 645 + msgstr "Choice {0}" 646 + 647 + #: src/components/compose-poll.jsx:60 648 + #: src/components/media-attachment.jsx:300 649 + #: src/components/shortcuts-settings.jsx:726 650 + #: src/pages/catchup.jsx:1081 651 + #: src/pages/filters.jsx:413 652 + msgid "Remove" 653 + msgstr "" 654 + 655 + #: src/components/compose-poll.jsx:88 656 + msgid "Multiple choices" 657 + msgstr "" 658 + 659 + #: src/components/compose-poll.jsx:91 660 + msgid "Duration" 661 + msgstr "" 662 + 663 + #: src/components/compose-poll.jsx:122 664 + msgid "Remove poll" 665 + msgstr "" 666 + 667 + #: src/components/compose-textarea.jsx:197 668 + #: src/components/compose-textarea.jsx:290 669 + #: src/components/nav-menu.jsx:244 670 + msgid "More…" 671 + msgstr "" 672 + 673 + #: src/components/compose.jsx:99 643 674 msgid "Take photo or video" 644 675 msgstr "Take photo or video" 645 676 646 - #: src/components/compose.jsx:206 677 + #: src/components/compose.jsx:100 647 678 msgid "Add media" 648 679 msgstr "Add media" 649 680 650 - #: src/components/compose.jsx:207 681 + #: src/components/compose.jsx:101 651 682 msgid "Add custom emoji" 652 683 msgstr "" 653 684 654 - #: src/components/compose.jsx:208 685 + #: src/components/compose.jsx:102 655 686 msgid "Add GIF" 656 687 msgstr "Add GIF" 657 688 658 - #: src/components/compose.jsx:209 689 + #: src/components/compose.jsx:103 659 690 msgid "Add poll" 660 691 msgstr "" 661 692 662 - #: src/components/compose.jsx:210 693 + #: src/components/compose.jsx:104 663 694 msgid "Schedule post" 664 695 msgstr "Schedule post" 665 696 666 - #: src/components/compose.jsx:410 697 + #: src/components/compose.jsx:304 667 698 msgid "You have unsaved changes. Discard this post?" 668 699 msgstr "You have unsaved changes. Discard this post?" 669 700 670 701 #. placeholder {0}: unsupportedFiles.length 671 702 #. placeholder {1}: unsupportedFiles[0].name 672 703 #. placeholder {2}: lf.format( unsupportedFiles.map((f) => f.name), ) 673 - #: src/components/compose.jsx:648 704 + #: src/components/compose.jsx:542 674 705 msgid "{0, plural, one {File {1} is not supported.} other {Files {2} are not supported.}}" 675 706 msgstr "{0, plural, one {File {1} is not supported.} other {Files {2} are not supported.}}" 676 707 677 - #: src/components/compose.jsx:658 678 - #: src/components/compose.jsx:676 679 - #: src/components/compose.jsx:1788 680 - #: src/components/compose.jsx:1923 708 + #: src/components/compose.jsx:552 709 + #: src/components/compose.jsx:570 710 + #: src/components/compose.jsx:1682 711 + #: src/components/file-picker-input.jsx:38 681 712 msgid "{maxMediaAttachments, plural, one {You can only attach up to 1 file.} other {You can only attach up to # files.}}" 682 713 msgstr "" 683 714 684 - #: src/components/compose.jsx:866 715 + #: src/components/compose.jsx:760 685 716 msgid "Pop out" 686 717 msgstr "Pop out" 687 718 688 - #: src/components/compose.jsx:873 719 + #: src/components/compose.jsx:767 689 720 msgid "Minimize" 690 721 msgstr "Minimize" 691 722 692 - #: src/components/compose.jsx:909 723 + #: src/components/compose.jsx:803 693 724 msgid "Looks like you closed the parent window." 694 725 msgstr "Looks like you closed the parent window." 695 726 696 - #: src/components/compose.jsx:916 727 + #: src/components/compose.jsx:810 697 728 msgid "Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later." 698 729 msgstr "Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later." 699 730 700 - #: src/components/compose.jsx:921 731 + #: src/components/compose.jsx:815 701 732 msgid "Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?" 702 733 msgstr "Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?" 703 734 704 - #: src/components/compose.jsx:964 735 + #: src/components/compose.jsx:858 705 736 msgid "Pop in" 706 737 msgstr "Pop in" 707 738 708 739 #. placeholder {0}: replyToStatus.account.acct || replyToStatus.account.username 709 740 #. placeholder {1}: rtf.format(-replyToStatusMonthsAgo, 'month') 710 - #: src/components/compose.jsx:974 741 + #: src/components/compose.jsx:868 711 742 msgid "Replying to @{0}’s post (<0>{1}</0>)" 712 743 msgstr "" 713 744 714 745 #. placeholder {0}: replyToStatus.account.acct || replyToStatus.account.username 715 - #: src/components/compose.jsx:984 746 + #: src/components/compose.jsx:878 716 747 msgid "Replying to @{0}’s post" 717 748 msgstr "" 718 749 719 - #: src/components/compose.jsx:997 750 + #: src/components/compose.jsx:891 720 751 msgid "Editing source post" 721 752 msgstr "" 722 753 723 - #: src/components/compose.jsx:1050 754 + #: src/components/compose.jsx:944 724 755 msgid "Poll must have at least 2 options" 725 756 msgstr "Poll must have at least 2 options" 726 757 727 - #: src/components/compose.jsx:1054 758 + #: src/components/compose.jsx:948 728 759 msgid "Some poll choices are empty" 729 760 msgstr "Some poll choices are empty" 730 761 731 - #: src/components/compose.jsx:1067 762 + #: src/components/compose.jsx:961 732 763 msgid "Some media have no descriptions. Continue?" 733 764 msgstr "Some media have no descriptions. Continue?" 734 765 735 - #: src/components/compose.jsx:1119 766 + #: src/components/compose.jsx:1013 736 767 msgid "Attachment #{i} failed" 737 768 msgstr "Attachment #{i} failed" 738 769 739 - #: src/components/compose.jsx:1215 770 + #: src/components/compose.jsx:1109 740 771 #: src/components/status.jsx:2103 741 772 #: src/components/timeline.jsx:1015 742 773 msgid "Content warning" 743 774 msgstr "" 744 775 745 - #: src/components/compose.jsx:1231 776 + #: src/components/compose.jsx:1125 746 777 msgid "Content warning or sensitive media" 747 778 msgstr "Content warning or sensitive media" 748 779 749 - #: src/components/compose.jsx:1267 780 + #: src/components/compose.jsx:1161 750 781 #: src/components/status.jsx:87 751 782 #: src/pages/settings.jsx:318 752 783 msgid "Public" 753 784 msgstr "" 754 785 755 - #: src/components/compose.jsx:1272 786 + #: src/components/compose.jsx:1166 756 787 #: src/components/nav-menu.jsx:349 757 788 #: src/components/shortcuts-settings.jsx:165 758 789 #: src/components/status.jsx:88 759 790 msgid "Local" 760 791 msgstr "" 761 792 762 - #: src/components/compose.jsx:1276 793 + #: src/components/compose.jsx:1170 763 794 #: src/components/status.jsx:89 764 795 #: src/pages/settings.jsx:321 765 796 msgid "Unlisted" 766 797 msgstr "" 767 798 768 - #: src/components/compose.jsx:1279 799 + #: src/components/compose.jsx:1173 769 800 #: src/components/status.jsx:90 770 801 #: src/pages/settings.jsx:324 771 802 msgid "Followers only" 772 803 msgstr "" 773 804 774 - #: src/components/compose.jsx:1282 805 + #: src/components/compose.jsx:1176 775 806 #: src/components/status.jsx:91 776 807 #: src/components/status.jsx:1983 777 808 msgid "Private mention" 778 809 msgstr "" 779 810 780 - #: src/components/compose.jsx:1291 811 + #: src/components/compose.jsx:1185 781 812 msgid "Post your reply" 782 813 msgstr "Post your reply" 783 814 784 - #: src/components/compose.jsx:1293 815 + #: src/components/compose.jsx:1187 785 816 msgid "Edit your post" 786 817 msgstr "Edit your post" 787 818 788 - #: src/components/compose.jsx:1294 819 + #: src/components/compose.jsx:1188 789 820 msgid "What are you doing?" 790 821 msgstr "What are you doing?" 791 822 792 - #: src/components/compose.jsx:1373 823 + #: src/components/compose.jsx:1267 793 824 msgid "Mark media as sensitive" 794 825 msgstr "" 795 826 796 - #: src/components/compose.jsx:1410 827 + #: src/components/compose.jsx:1304 797 828 msgid "Posting on <0/>" 798 829 msgstr "Posting on <0/>" 799 830 800 - #: src/components/compose.jsx:1441 801 - #: src/components/compose.jsx:3306 831 + #: src/components/compose.jsx:1335 832 + #: src/components/mention-modal.jsx:220 802 833 #: src/components/shortcuts-settings.jsx:715 803 834 #: src/pages/list.jsx:388 804 835 msgid "Add" 805 836 msgstr "" 806 837 807 - #: src/components/compose.jsx:1669 838 + #: src/components/compose.jsx:1563 808 839 msgid "Schedule" 809 840 msgstr "Schedule" 810 841 811 - #: src/components/compose.jsx:1671 842 + #: src/components/compose.jsx:1565 812 843 #: src/components/keyboard-shortcuts-help.jsx:155 813 844 #: src/components/status.jsx:965 814 845 #: src/components/status.jsx:1751 ··· 817 848 msgid "Reply" 818 849 msgstr "" 819 850 820 - #: src/components/compose.jsx:1673 851 + #: src/components/compose.jsx:1567 821 852 msgid "Update" 822 853 msgstr "Update" 823 854 824 - #: src/components/compose.jsx:1674 855 + #: src/components/compose.jsx:1568 825 856 msgctxt "Submit button in composer" 826 857 msgid "Post" 827 858 msgstr "Post" 828 859 829 - #: src/components/compose.jsx:1800 860 + #: src/components/compose.jsx:1694 830 861 msgid "Downloading GIF…" 831 862 msgstr "Downloading GIF…" 832 863 833 - #: src/components/compose.jsx:1828 864 + #: src/components/compose.jsx:1722 834 865 msgid "Failed to download GIF" 835 866 msgstr "Failed to download GIF" 836 867 837 - #: src/components/compose.jsx:2053 838 - #: src/components/compose.jsx:2146 839 - #: src/components/nav-menu.jsx:244 840 - msgid "More…" 868 + #. placeholder {0}: i18n.number(emojis.length - max) 869 + #: src/components/custom-emojis-list.jsx:30 870 + #: src/components/custom-emojis-modal.jsx:98 871 + msgid "{0} more…" 841 872 msgstr "" 842 873 843 - #: src/components/compose.jsx:2582 844 - msgid "Uploaded" 845 - msgstr "" 846 - 847 - #: src/components/compose.jsx:2595 848 - msgid "Image description" 849 - msgstr "Image description" 850 - 851 - #: src/components/compose.jsx:2596 852 - msgid "Video description" 853 - msgstr "Video description" 854 - 855 - #: src/components/compose.jsx:2597 856 - msgid "Audio description" 857 - msgstr "Audio description" 858 - 859 - #. placeholder {0}: prettyBytes( imageSize, ) 860 - #. placeholder {0}: prettyBytes( videoSize, ) 861 - #. placeholder {1}: prettyBytes(imageSizeLimit) 862 - #. placeholder {1}: prettyBytes(videoSizeLimit) 863 - #: src/components/compose.jsx:2632 864 - #: src/components/compose.jsx:2652 865 - msgid "File size too large. Uploading might encounter issues. Try reduce the file size from {0} to {1} or lower." 866 - msgstr "File size too large. Uploading might encounter issues. Try reduce the file size from {0} to {1} or lower." 867 - 868 - #. placeholder {0}: i18n.number( width, ) 869 - #. placeholder {1}: i18n.number(height) 870 - #. placeholder {2}: i18n.number(newWidth) 871 - #. placeholder {3}: i18n.number( newHeight, ) 872 - #: src/components/compose.jsx:2644 873 - #: src/components/compose.jsx:2664 874 - msgid "Dimension too large. Uploading might encounter issues. Try reduce dimension from {0}×{1}px to {2}×{3}px." 875 - msgstr "Dimension too large. Uploading might encounter issues. Try reduce dimension from {0}×{1}px to {2}×{3}px." 876 - 877 - #: src/components/compose.jsx:2672 878 - msgid "Frame rate too high. Uploading might encounter issues." 879 - msgstr "Frame rate too high. Uploading might encounter issues." 880 - 881 - #: src/components/compose.jsx:2732 882 - #: src/components/compose.jsx:2982 883 - #: src/components/shortcuts-settings.jsx:726 884 - #: src/pages/catchup.jsx:1081 885 - #: src/pages/filters.jsx:413 886 - msgid "Remove" 887 - msgstr "" 888 - 889 - #: src/components/compose.jsx:2749 890 - #: src/compose.jsx:84 891 - msgid "Error" 892 - msgstr "" 893 - 894 - #: src/components/compose.jsx:2774 895 - msgid "Edit image description" 896 - msgstr "Edit image description" 897 - 898 - #: src/components/compose.jsx:2775 899 - msgid "Edit video description" 900 - msgstr "Edit video description" 901 - 902 - #: src/components/compose.jsx:2776 903 - msgid "Edit audio description" 904 - msgstr "Edit audio description" 905 - 906 - #: src/components/compose.jsx:2821 907 - #: src/components/compose.jsx:2870 908 - msgid "Generating description. Please wait…" 909 - msgstr "Generating description. Please wait…" 910 - 911 - #. placeholder {0}: e.message 912 - #: src/components/compose.jsx:2841 913 - msgid "Failed to generate description: {0}" 914 - msgstr "Failed to generate description: {0}" 915 - 916 - #: src/components/compose.jsx:2842 917 - msgid "Failed to generate description" 918 - msgstr "Failed to generate description" 919 - 920 - #: src/components/compose.jsx:2854 921 - #: src/components/compose.jsx:2860 922 - #: src/components/compose.jsx:2906 923 - msgid "Generate description…" 924 - msgstr "" 925 - 926 - #. placeholder {0}: e?.message ? `: ${e.message}` : '' 927 - #: src/components/compose.jsx:2893 928 - msgid "Failed to generate description{0}" 929 - msgstr "Failed to generate description{0}" 930 - 931 - #. placeholder {0}: localeCode2Text(lang) 932 - #: src/components/compose.jsx:2908 933 - msgid "({0}) <0>— experimental</0>" 934 - msgstr "" 935 - 936 - #: src/components/compose.jsx:2927 937 - msgid "Done" 938 - msgstr "" 939 - 940 - #. placeholder {0}: i + 1 941 - #: src/components/compose.jsx:2963 942 - msgid "Choice {0}" 943 - msgstr "Choice {0}" 944 - 945 - #: src/components/compose.jsx:3010 946 - msgid "Multiple choices" 947 - msgstr "" 948 - 949 - #: src/components/compose.jsx:3013 950 - msgid "Duration" 951 - msgstr "" 952 - 953 - #: src/components/compose.jsx:3044 954 - msgid "Remove poll" 955 - msgstr "" 956 - 957 - #: src/components/compose.jsx:3265 958 - msgid "Search accounts" 959 - msgstr "Search accounts" 960 - 961 - #: src/components/compose.jsx:3319 962 - #: src/components/generic-accounts.jsx:236 963 - msgid "Error loading accounts" 964 - msgstr "" 965 - 966 - #: src/components/compose.jsx:3463 874 + #: src/components/custom-emojis-modal.jsx:240 967 875 msgid "Custom emojis" 968 876 msgstr "" 969 877 970 - #: src/components/compose.jsx:3483 878 + #: src/components/custom-emojis-modal.jsx:260 971 879 msgid "Search emoji" 972 880 msgstr "Search emoji" 973 881 974 - #: src/components/compose.jsx:3514 882 + #: src/components/custom-emojis-modal.jsx:291 975 883 msgid "Error loading custom emojis" 976 884 msgstr "" 977 885 978 - #: src/components/compose.jsx:3525 886 + #: src/components/custom-emojis-modal.jsx:302 979 887 msgid "Recently used" 980 888 msgstr "Recently used" 981 889 982 - #: src/components/compose.jsx:3526 890 + #: src/components/custom-emojis-modal.jsx:303 983 891 msgid "Others" 984 892 msgstr "Others" 985 - 986 - #. placeholder {0}: i18n.number(emojis.length - max) 987 - #: src/components/compose.jsx:3564 988 - msgid "{0} more…" 989 - msgstr "" 990 - 991 - #: src/components/compose.jsx:3702 992 - msgid "Search GIFs" 993 - msgstr "Search GIFs" 994 - 995 - #: src/components/compose.jsx:3717 996 - msgid "Powered by GIPHY" 997 - msgstr "Powered by GIPHY" 998 - 999 - #: src/components/compose.jsx:3725 1000 - msgid "Type to search GIFs" 1001 - msgstr "" 1002 - 1003 - #: src/components/compose.jsx:3823 1004 - #: src/components/media-modal.jsx:469 1005 - #: src/components/timeline.jsx:928 1006 - msgid "Previous" 1007 - msgstr "" 1008 - 1009 - #: src/components/compose.jsx:3841 1010 - #: src/components/media-modal.jsx:488 1011 - #: src/components/timeline.jsx:945 1012 - msgid "Next" 1013 - msgstr "" 1014 - 1015 - #: src/components/compose.jsx:3858 1016 - msgid "Error loading GIFs" 1017 - msgstr "" 1018 893 1019 894 #: src/components/drafts.jsx:62 1020 895 #: src/pages/settings.jsx:702 ··· 1116 991 msgid "The end." 1117 992 msgstr "" 1118 993 994 + #: src/components/generic-accounts.jsx:236 995 + #: src/components/mention-modal.jsx:233 996 + msgid "Error loading accounts" 997 + msgstr "" 998 + 1119 999 #: src/components/generic-accounts.jsx:240 1120 1000 msgid "Nothing to show" 1121 1001 msgstr "" 1122 1002 1003 + #: src/components/gif-picker-modal.jsx:86 1004 + msgid "Search GIFs" 1005 + msgstr "Search GIFs" 1006 + 1007 + #: src/components/gif-picker-modal.jsx:101 1008 + msgid "Powered by GIPHY" 1009 + msgstr "Powered by GIPHY" 1010 + 1011 + #: src/components/gif-picker-modal.jsx:109 1012 + msgid "Type to search GIFs" 1013 + msgstr "" 1014 + 1015 + #: src/components/gif-picker-modal.jsx:207 1016 + #: src/components/media-modal.jsx:469 1017 + #: src/components/timeline.jsx:928 1018 + msgid "Previous" 1019 + msgstr "" 1020 + 1021 + #: src/components/gif-picker-modal.jsx:225 1022 + #: src/components/media-modal.jsx:488 1023 + #: src/components/timeline.jsx:945 1024 + msgid "Next" 1025 + msgstr "" 1026 + 1027 + #: src/components/gif-picker-modal.jsx:242 1028 + msgid "Error loading GIFs" 1029 + msgstr "" 1030 + 1123 1031 #: src/components/keyboard-shortcuts-help.jsx:47 1124 1032 #: src/components/nav-menu.jsx:368 1125 1033 #: src/pages/catchup.jsx:1622 ··· 1352 1260 msgid "Speak" 1353 1261 msgstr "" 1354 1262 1263 + #: src/components/media-attachment.jsx:150 1264 + msgid "Uploaded" 1265 + msgstr "" 1266 + 1267 + #: src/components/media-attachment.jsx:163 1268 + msgid "Image description" 1269 + msgstr "Image description" 1270 + 1271 + #: src/components/media-attachment.jsx:164 1272 + msgid "Video description" 1273 + msgstr "Video description" 1274 + 1275 + #: src/components/media-attachment.jsx:165 1276 + msgid "Audio description" 1277 + msgstr "Audio description" 1278 + 1279 + #. placeholder {0}: prettyBytes( imageSize, ) 1280 + #. placeholder {0}: prettyBytes( videoSize, ) 1281 + #. placeholder {1}: prettyBytes(imageSizeLimit) 1282 + #. placeholder {1}: prettyBytes(videoSizeLimit) 1283 + #: src/components/media-attachment.jsx:200 1284 + #: src/components/media-attachment.jsx:220 1285 + msgid "File size too large. Uploading might encounter issues. Try reduce the file size from {0} to {1} or lower." 1286 + msgstr "File size too large. Uploading might encounter issues. Try reduce the file size from {0} to {1} or lower." 1287 + 1288 + #. placeholder {0}: i18n.number( width, ) 1289 + #. placeholder {1}: i18n.number(height) 1290 + #. placeholder {2}: i18n.number(newWidth) 1291 + #. placeholder {3}: i18n.number( newHeight, ) 1292 + #: src/components/media-attachment.jsx:212 1293 + #: src/components/media-attachment.jsx:232 1294 + msgid "Dimension too large. Uploading might encounter issues. Try reduce dimension from {0}×{1}px to {2}×{3}px." 1295 + msgstr "Dimension too large. Uploading might encounter issues. Try reduce dimension from {0}×{1}px to {2}×{3}px." 1296 + 1297 + #: src/components/media-attachment.jsx:240 1298 + msgid "Frame rate too high. Uploading might encounter issues." 1299 + msgstr "Frame rate too high. Uploading might encounter issues." 1300 + 1301 + #: src/components/media-attachment.jsx:317 1302 + #: src/compose.jsx:84 1303 + msgid "Error" 1304 + msgstr "" 1305 + 1306 + #: src/components/media-attachment.jsx:342 1307 + msgid "Edit image description" 1308 + msgstr "Edit image description" 1309 + 1310 + #: src/components/media-attachment.jsx:343 1311 + msgid "Edit video description" 1312 + msgstr "Edit video description" 1313 + 1314 + #: src/components/media-attachment.jsx:344 1315 + msgid "Edit audio description" 1316 + msgstr "Edit audio description" 1317 + 1318 + #: src/components/media-attachment.jsx:389 1319 + #: src/components/media-attachment.jsx:438 1320 + msgid "Generating description. Please wait…" 1321 + msgstr "Generating description. Please wait…" 1322 + 1323 + #. placeholder {0}: e.message 1324 + #: src/components/media-attachment.jsx:409 1325 + msgid "Failed to generate description: {0}" 1326 + msgstr "Failed to generate description: {0}" 1327 + 1328 + #: src/components/media-attachment.jsx:410 1329 + msgid "Failed to generate description" 1330 + msgstr "Failed to generate description" 1331 + 1332 + #: src/components/media-attachment.jsx:422 1333 + #: src/components/media-attachment.jsx:428 1334 + #: src/components/media-attachment.jsx:474 1335 + msgid "Generate description…" 1336 + msgstr "" 1337 + 1338 + #. placeholder {0}: e?.message ? `: ${e.message}` : '' 1339 + #: src/components/media-attachment.jsx:461 1340 + msgid "Failed to generate description{0}" 1341 + msgstr "Failed to generate description{0}" 1342 + 1343 + #. placeholder {0}: localeCode2Text(lang) 1344 + #: src/components/media-attachment.jsx:476 1345 + msgid "({0}) <0>— experimental</0>" 1346 + msgstr "" 1347 + 1348 + #: src/components/media-attachment.jsx:495 1349 + msgid "Done" 1350 + msgstr "" 1351 + 1355 1352 #: src/components/media-modal.jsx:374 1356 1353 msgid "Open original media in new window" 1357 1354 msgstr "" ··· 1397 1394 #: src/components/media.jsx:479 1398 1395 msgid "Open file" 1399 1396 msgstr "Open file" 1397 + 1398 + #: src/components/mention-modal.jsx:179 1399 + msgid "Search accounts" 1400 + msgstr "Search accounts" 1400 1401 1401 1402 #: src/components/modals.jsx:75 1402 1403 msgid "Post scheduled" ··· 1732 1733 1733 1734 #: src/components/poll.jsx:113 1734 1735 msgid "Voted" 1735 - msgstr "" 1736 + msgstr "Voted" 1736 1737 1737 1738 #: src/components/poll.jsx:119 1738 1739 msgid "{optionVotesCount, plural, one {# vote} other {# votes}}" ··· 1742 1743 #: src/components/poll.jsx:222 1743 1744 #: src/components/poll.jsx:226 1744 1745 msgid "Hide results" 1745 - msgstr "" 1746 + msgstr "Hide results" 1746 1747 1747 1748 #: src/components/poll.jsx:188 1748 1749 msgid "Vote" 1749 - msgstr "" 1750 + msgstr "Vote" 1750 1751 1751 1752 #: src/components/poll.jsx:208 1752 1753 #: src/components/poll.jsx:210 ··· 1759 1760 #: src/components/poll.jsx:222 1760 1761 #: src/components/poll.jsx:226 1761 1762 msgid "Show results" 1762 - msgstr "" 1763 + msgstr "Show results" 1763 1764 1764 1765 #. placeholder {0}: shortenNumber(votesCount) 1765 1766 #. placeholder {1}: shortenNumber(votesCount) ··· 1771 1772 #. placeholder {1}: shortenNumber(votersCount) 1772 1773 #: src/components/poll.jsx:248 1773 1774 msgid "{votersCount, plural, one {<0>{0}</0> voter} other {<1>{1}</1> voters}}" 1774 - msgstr "" 1775 + msgstr "{votersCount, plural, one {<0>{0}</0> voter} other {<1>{1}</1> voters}}" 1775 1776 1776 1777 #: src/components/poll.jsx:268 1777 1778 msgid "Ended <0/>" 1778 - msgstr "" 1779 + msgstr "Ended <0/>" 1779 1780 1780 1781 #: src/components/poll.jsx:272 1781 1782 msgid "Ended" 1782 - msgstr "" 1783 + msgstr "Ended" 1783 1784 1784 1785 #: src/components/poll.jsx:275 1785 1786 msgid "Ending <0/>" 1786 - msgstr "" 1787 + msgstr "Ending <0/>" 1787 1788 1788 1789 #: src/components/poll.jsx:279 1789 1790 msgid "Ending" 1790 - msgstr "" 1791 + msgstr "Ending" 1791 1792 1792 1793 #: src/components/post-embed-modal.jsx:201 1793 1794 #: src/components/status.jsx:1237
+23
src/utils/custom-emojis.js
··· 1 + import Fuse from 'fuse.js'; 2 + 3 + import pmem from './pmem'; 4 + 5 + async function _getCustomEmojis(instance, masto) { 6 + const emojis = await masto.v1.customEmojis.list(); 7 + const visibleEmojis = emojis.filter((e) => e.visibleInPicker); 8 + const searcher = new Fuse(visibleEmojis, { 9 + keys: ['shortcode'], 10 + findAllMatches: true, 11 + }); 12 + return [visibleEmojis, searcher]; 13 + } 14 + 15 + const getCustomEmojis = pmem(_getCustomEmojis, { 16 + // Limit by time to reduce memory usage 17 + // Cached by instance 18 + matchesArg: (cacheKeyArg, keyArg) => cacheKeyArg.instance === keyArg.instance, 19 + maxAge: 30 * 60 * 1000, // 30 minutes 20 + }); 21 + 22 + export { getCustomEmojis, _getCustomEmojis }; 23 + export default getCustomEmojis;
+5
src/utils/url-regex.js
··· 1 + import urlRegex from '../data/url-regex'; 2 + 3 + const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags); 4 + 5 + export default urlRegexObj;