this repo has no description

Make text-expander work for CW & poll fields

+442 -414
+21 -17
src/components/compose-poll.jsx
··· 3 3 import i18nDuration from '../utils/i18n-duration'; 4 4 5 5 import Icon from './icon'; 6 + import TextExpander from './text-expander'; 6 7 7 8 export const expiryOptions = { 8 9 300: i18nDuration(5, 'minute'), ··· 32 33 <div class="poll-choices"> 33 34 {options.map((option, i) => ( 34 35 <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 - data-allow-custom-emoji="true" 46 - onInput={(e) => { 47 - const { value } = e.target; 48 - options[i] = value; 49 - onInput(poll); 50 - }} 51 - /> 36 + <TextExpander keys=":" class="poll-field-container"> 37 + <input 38 + required 39 + type="text" 40 + value={option} 41 + disabled={disabled} 42 + maxlength={maxCharactersPerOption} 43 + placeholder={t`Choice ${i + 1}`} 44 + lang={lang} 45 + spellCheck="true" 46 + autocomplete="off" 47 + dir="auto" 48 + data-allow-custom-emoji="true" 49 + onInput={(e) => { 50 + const { value } = e.target; 51 + options[i] = value; 52 + onInput(poll); 53 + }} 54 + /> 55 + </TextExpander> 52 56 <button 53 57 type="button" 54 58 class="plain2 poll-button"
+6 -308
src/components/compose-textarea.jsx
··· 1 - import '@github/text-expander-element'; 2 - 3 - import { useLingui } from '@lingui/react/macro'; 4 1 import { forwardRef } from 'preact/compat'; 5 2 import { useEffect, useRef, useState } from 'preact/hooks'; 6 3 import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 7 4 8 - import { api } from '../utils/api'; 9 5 import { langDetector } from '../utils/browser-translator'; 10 - import getCustomEmojis from '../utils/custom-emojis'; 11 - import emojifyText from '../utils/emojify-text'; 12 6 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 7 import states from '../utils/states'; 17 8 import urlRegexObj from '../utils/url-regex'; 18 9 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); 10 + import TextExpander from './text-expander'; 38 11 39 12 // https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69 40 13 const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i; ··· 120 93 return null; 121 94 }; 122 95 123 - function encodeHTML(str) { 124 - return str.replace(/[&<>"']/g, function (char) { 125 - return '&#' + char.charCodeAt(0) + ';'; 126 - }); 127 - } 128 - 129 96 const Textarea = forwardRef((props, ref) => { 130 - const { t } = useLingui(); 131 - const { masto, instance } = api(); 132 97 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 - }, []); 98 + const { maxCharacters, onTrigger = null, ...textareaProps } = props; 154 99 155 100 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 101 405 102 useEffect(() => { 406 103 // Resize observer for textarea ··· 466 163 }, 2000); 467 164 468 165 return ( 469 - <text-expander 166 + <TextExpander 470 167 ref={textExpanderRef} 471 168 keys="@ # :" 472 169 class="compose-field-container" 170 + onTrigger={onTrigger} 473 171 > 474 172 <textarea 475 173 class="compose-field" ··· 487 185 onKeyDown={(e) => { 488 186 // Get line before cursor position after pressing 'Enter' 489 187 const { key, target } = e; 490 - const hasTextExpander = hasTextExpanderRef.current; 188 + const hasTextExpander = textExpanderRef.current?.activated(); 491 189 if ( 492 190 key === 'Enter' && 493 191 !(e.ctrlKey || e.metaKey || hasTextExpander) && ··· 555 253 class="compose-highlight" 556 254 aria-hidden="true" 557 255 /> 558 - </text-expander> 256 + </TextExpander> 559 257 ); 560 258 }); 561 259
+14 -4
src/components/compose.css
··· 175 175 #compose-container .toolbar.stretch { 176 176 justify-content: stretch; 177 177 } 178 - #compose-container .toolbar .spoiler-text-field { 179 - flex: 1; 180 - min-width: 0; 178 + #compose-container .toolbar { 179 + .spoiler-text-field-container { 180 + flex: 1; 181 + min-width: 0; 182 + 183 + .spoiler-text-field { 184 + width: 100%; 185 + } 186 + } 181 187 } 182 188 #compose-container .toolbar-button { 183 189 display: inline-block; ··· 510 516 justify-content: stretch; 511 517 flex-direction: row-reverse; 512 518 } 513 - #compose-container .poll-choice input { 519 + #compose-container .poll-choice .poll-field-container { 514 520 flex-grow: 1; 515 521 min-width: 0; 522 + 523 + input { 524 + width: 100%; 525 + } 516 526 } 517 527 518 528 #compose-container .poll-button {
+34 -29
src/components/compose.jsx
··· 54 54 MIN_SCHEDULED_AT, 55 55 } from './ScheduledAtField'; 56 56 import Status from './status'; 57 + import TextExpander from './text-expander'; 57 58 58 59 const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { 59 60 const [code, common, native] = l; ··· 156 157 157 158 const textareaRef = useRef(); 158 159 const spoilerTextRef = useRef(); 160 + 159 161 const [visibility, setVisibility] = useState('public'); 160 162 const [sensitive, setSensitive] = useState(false); 161 163 const [language, setLanguage] = useState( ··· 1155 1157 }} 1156 1158 > 1157 1159 <div class="toolbar stretch"> 1158 - <input 1159 - ref={spoilerTextRef} 1160 - type="text" 1161 - name="spoilerText" 1162 - placeholder={t`Content warning`} 1163 - data-allow-custom-emoji="true" 1164 - disabled={uiState === 'loading'} 1165 - class="spoiler-text-field" 1166 - lang={language} 1167 - spellCheck="true" 1168 - dir="auto" 1169 - style={{ 1170 - opacity: sensitive ? 1 : 0, 1171 - pointerEvents: sensitive ? 'auto' : 'none', 1172 - }} 1173 - onInput={() => { 1174 - updateCharCount(); 1160 + <TextExpander 1161 + keys=":" 1162 + class="spoiler-text-field-container" 1163 + onTrigger={(action) => { 1164 + if (action?.name === 'custom-emojis') { 1165 + setShowEmoji2Picker({ 1166 + targetElement: spoilerTextRef, 1167 + defaultSearchTerm: action?.defaultSearchTerm || null, 1168 + }); 1169 + } 1175 1170 }} 1176 - /> 1171 + > 1172 + <input 1173 + ref={spoilerTextRef} 1174 + type="text" 1175 + name="spoilerText" 1176 + placeholder={t`Content warning`} 1177 + data-allow-custom-emoji="true" 1178 + disabled={uiState === 'loading'} 1179 + class="spoiler-text-field" 1180 + lang={language} 1181 + spellCheck="true" 1182 + autocomplete="off" 1183 + dir="auto" 1184 + style={{ 1185 + opacity: sensitive ? 1 : 0, 1186 + pointerEvents: sensitive ? 'auto' : 'none', 1187 + }} 1188 + onInput={() => { 1189 + updateCharCount(); 1190 + }} 1191 + /> 1192 + </TextExpander> 1177 1193 <label 1178 1194 class={`toolbar-button ${sensitive ? 'highlight' : ''}`} 1179 1195 title={t`Content warning or sensitive media`} ··· 1249 1265 updateCharCount(); 1250 1266 }} 1251 1267 maxCharacters={maxCharacters} 1252 - performSearch={(params) => { 1253 - const { type, q, limit } = params; 1254 - if (type === 'accounts') { 1255 - return masto.v1.accounts.search.list({ 1256 - q, 1257 - limit, 1258 - resolve: false, 1259 - }); 1260 - } 1261 - return masto.v2.search.list(params); 1262 - }} 1263 1268 onTrigger={(action) => { 1264 1269 if (action?.name === 'custom-emojis') { 1265 1270 setShowEmoji2Picker({
+1 -1
src/components/status.css
··· 2086 2086 var(--bg-color) 50%, 2087 2087 var(--bg-faded-color) 2088 2088 ); 2089 - overflow: hidden; 2089 + /* overflow: hidden; */ 2090 2090 box-shadow: inset 0 0 0 1px var(--bg-color); 2091 2091 min-width: 50%; 2092 2092 }
+312
src/components/text-expander.jsx
··· 1 + import '@github/text-expander-element'; 2 + 3 + import { useLingui } from '@lingui/react/macro'; 4 + import { forwardRef, useImperativeHandle } from 'preact/compat'; 5 + import { useEffect, useRef } from 'preact/hooks'; 6 + 7 + import { api } from '../utils/api'; 8 + import getCustomEmojis from '../utils/custom-emojis'; 9 + import emojifyText from '../utils/emojify-text'; 10 + import getDomain from '../utils/get-domain'; 11 + import isRTL from '../utils/is-rtl'; 12 + import shortenNumber from '../utils/shorten-number'; 13 + 14 + const menu = document.createElement('ul'); 15 + menu.role = 'listbox'; 16 + menu.className = 'text-expander-menu'; 17 + 18 + // Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it 19 + const windowMargin = 16; 20 + const observer = new IntersectionObserver((entries) => { 21 + entries.forEach((entry) => { 22 + if (entry.isIntersecting) { 23 + const { left, width } = entry.boundingClientRect; 24 + const { innerWidth } = window; 25 + if (left + width > innerWidth) { 26 + const insetInlineStart = isRTL() ? 'right' : 'left'; 27 + menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px'; 28 + } 29 + } 30 + }); 31 + }); 32 + observer.observe(menu); 33 + 34 + function encodeHTML(str) { 35 + return str.replace(/[&<>"']/g, function (char) { 36 + return '&#' + char.charCodeAt(0) + ';'; 37 + }); 38 + } 39 + 40 + function TextExpander({ onTrigger = null, ...props }, ref) { 41 + const { t } = useLingui(); 42 + const textExpanderRef = useRef(); 43 + const { masto, instance } = api(); 44 + const searcherRef = useRef(); 45 + const textExpanderTextRef = useRef(''); 46 + const hasTextExpanderRef = useRef(false); 47 + 48 + // Expose the activated state to parent components 49 + useImperativeHandle(ref, () => ({ 50 + activated: () => hasTextExpanderRef.current, 51 + })); 52 + 53 + // Setup emoji search if not already set up 54 + useEffect(() => { 55 + if (searcherRef.current) return; // Already set up 56 + 57 + getCustomEmojis(instance, masto) 58 + .then(([, searcher]) => { 59 + searcherRef.current = searcher; 60 + }) 61 + .catch((e) => { 62 + console.error(e); 63 + }); 64 + }, [instance, masto]); 65 + 66 + useEffect(() => { 67 + const textExpander = textExpanderRef.current; 68 + if (!textExpander) return; 69 + 70 + const handleChange = (e) => { 71 + const { key, provide, text } = e.detail; 72 + textExpanderTextRef.current = text; 73 + 74 + if (text === '') { 75 + provide( 76 + Promise.resolve({ 77 + matched: false, 78 + }), 79 + ); 80 + return; 81 + } 82 + 83 + if (key === ':') { 84 + const showMore = !!onTrigger; 85 + const results = searcherRef.current?.search(text, { 86 + limit: 5, 87 + }); 88 + 89 + let html = ''; 90 + results?.forEach(({ item: emoji }) => { 91 + const { shortcode, url } = emoji; 92 + html += ` 93 + <li role="option" data-value="${encodeHTML(shortcode)}"> 94 + <img src="${encodeHTML( 95 + url, 96 + )}" width="16" height="16" alt="" loading="lazy" /> 97 + ${encodeHTML(shortcode)} 98 + </li>`; 99 + }); 100 + if (showMore) { 101 + html += `<li role="option" data-value="" data-more="${text}">${'More…'}</li>`; 102 + } 103 + menu.innerHTML = html; 104 + 105 + provide( 106 + Promise.resolve({ 107 + matched: (results?.length || 0) > 0, 108 + fragment: menu, 109 + }), 110 + ); 111 + return; 112 + } 113 + 114 + // Handle @ mentions and # hashtags 115 + const type = { 116 + '@': 'accounts', 117 + '#': 'hashtags', 118 + }[key]; 119 + 120 + if (type) { 121 + provide( 122 + new Promise(async (resolve) => { 123 + try { 124 + let searchResults; 125 + if (type === 'accounts') { 126 + searchResults = await masto.v1.accounts.search.list({ 127 + q: text, 128 + limit: 5, 129 + resolve: false, 130 + }); 131 + } else { 132 + const response = await masto.v2.search.list({ 133 + type, 134 + q: text, 135 + limit: 5, 136 + }); 137 + searchResults = response[type] || response; 138 + } 139 + 140 + if (text !== textExpanderTextRef.current) { 141 + return; 142 + } 143 + 144 + const results = searchResults; 145 + let html = ''; 146 + results.forEach((result) => { 147 + const { 148 + name, 149 + avatarStatic, 150 + displayName, 151 + username, 152 + acct, 153 + emojis, 154 + history, 155 + roles, 156 + url, 157 + } = result; 158 + const displayNameWithEmoji = emojifyText(displayName, emojis); 159 + const accountInstance = getDomain(url); 160 + 161 + if (acct) { 162 + html += ` 163 + <li role="option" data-value="${encodeHTML(acct)}"> 164 + <span class="avatar"> 165 + <img src="${encodeHTML( 166 + avatarStatic, 167 + )}" width="16" height="16" alt="" loading="lazy" /> 168 + </span> 169 + <span> 170 + <b>${displayNameWithEmoji || username}</b> 171 + <br><span class="bidi-isolate">@${encodeHTML( 172 + acct, 173 + )}</span> 174 + ${ 175 + roles?.map( 176 + (role) => ` <span class="tag collapsed"> 177 + ${role.name} 178 + ${ 179 + !!accountInstance && 180 + `<span class="more-insignificant"> 181 + ${accountInstance} 182 + </span>` 183 + } 184 + </span>`, 185 + ) || '' 186 + } 187 + </span> 188 + </li> 189 + `; 190 + } else { 191 + const total = history?.reduce?.( 192 + (acc, cur) => acc + +cur.uses, 193 + 0, 194 + ); 195 + html += ` 196 + <li role="option" data-value="${encodeHTML(name)}"> 197 + <span class="grow">#<b>${encodeHTML(name)}</b></span> 198 + ${ 199 + total 200 + ? `<span class="count">${shortenNumber(total)}</span>` 201 + : '' 202 + } 203 + </li> 204 + `; 205 + } 206 + }); 207 + if (type === 'accounts') { 208 + html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`; 209 + } 210 + menu.innerHTML = html; 211 + resolve({ 212 + matched: results.length > 0, 213 + fragment: menu, 214 + }); 215 + } catch (error) { 216 + console.error('Search error:', error); 217 + resolve({ 218 + matched: false, 219 + }); 220 + } 221 + }), 222 + ); 223 + return; 224 + } 225 + 226 + // No other keys supported 227 + provide( 228 + Promise.resolve({ 229 + matched: false, 230 + }), 231 + ); 232 + }; 233 + 234 + const handleValue = (e) => { 235 + const { key, item } = e.detail; 236 + const { value, more } = item.dataset; 237 + 238 + if (key === ':') { 239 + e.detail.value = value ? `:${value}:` : '​'; // zero-width space 240 + if (more) { 241 + // Prevent adding space after the above value 242 + e.detail.continue = true; 243 + 244 + setTimeout(() => { 245 + // Trigger custom emoji picker modal for more options 246 + onTrigger?.({ 247 + name: 'custom-emojis', 248 + defaultSearchTerm: more, 249 + }); 250 + }, 300); 251 + } 252 + } else if (key === '@') { 253 + e.detail.value = value ? `@${value}` : '​'; // zero-width space 254 + if (more) { 255 + e.detail.continue = true; 256 + setTimeout(() => { 257 + onTrigger?.({ 258 + name: 'mention', 259 + defaultSearchTerm: more, 260 + }); 261 + }, 300); 262 + } 263 + } else { 264 + e.detail.value = `${key}${value}`; 265 + } 266 + }; 267 + 268 + const handleCommited = (e) => { 269 + const { input } = e.detail; 270 + 271 + if (input) { 272 + const event = new Event('input', { bubbles: true }); 273 + input.dispatchEvent(event); 274 + } 275 + }; 276 + 277 + const handleActivate = () => { 278 + hasTextExpanderRef.current = true; 279 + }; 280 + 281 + const handleDeactivate = () => { 282 + hasTextExpanderRef.current = false; 283 + }; 284 + 285 + textExpander.addEventListener('text-expander-change', handleChange); 286 + textExpander.addEventListener('text-expander-value', handleValue); 287 + textExpander.addEventListener('text-expander-committed', handleCommited); 288 + textExpander.addEventListener('text-expander-activate', handleActivate); 289 + textExpander.addEventListener('text-expander-deactivate', handleDeactivate); 290 + 291 + return () => { 292 + textExpander.removeEventListener('text-expander-change', handleChange); 293 + textExpander.removeEventListener('text-expander-value', handleValue); 294 + textExpander.removeEventListener( 295 + 'text-expander-committed', 296 + handleCommited, 297 + ); 298 + textExpander.removeEventListener( 299 + 'text-expander-activate', 300 + handleActivate, 301 + ); 302 + textExpander.removeEventListener( 303 + 'text-expander-deactivate', 304 + handleDeactivate, 305 + ); 306 + }; 307 + }, [searcherRef.current, onTrigger, t, masto]); 308 + 309 + return <text-expander ref={textExpanderRef} {...props} />; 310 + } 311 + 312 + export default forwardRef(TextExpander);
+54 -55
src/locales/en.po
··· 248 248 249 249 #: src/components/account-sheet.jsx:38 250 250 #: src/components/add-remove-lists-sheet.jsx:45 251 - #: src/components/compose.jsx:832 251 + #: src/components/compose.jsx:834 252 252 #: src/components/custom-emojis-modal.jsx:234 253 253 #: src/components/drafts.jsx:57 254 254 #: src/components/edit-profile-sheet.jsx:87 ··· 354 354 msgstr "Add to thread" 355 355 356 356 #. placeholder {0}: i + 1 357 - #: src/components/compose-poll.jsx:41 357 + #: src/components/compose-poll.jsx:43 358 358 msgid "Choice {0}" 359 359 msgstr "Choice {0}" 360 360 361 - #: src/components/compose-poll.jsx:61 361 + #: src/components/compose-poll.jsx:65 362 362 #: src/components/media-attachment.jsx:300 363 363 #: src/components/shortcuts-settings.jsx:726 364 364 #: src/pages/catchup.jsx:1081 ··· 366 366 msgid "Remove" 367 367 msgstr "" 368 368 369 - #: src/components/compose-poll.jsx:89 369 + #: src/components/compose-poll.jsx:93 370 370 msgid "Multiple choices" 371 371 msgstr "" 372 372 373 - #: src/components/compose-poll.jsx:92 373 + #: src/components/compose-poll.jsx:96 374 374 msgid "Duration" 375 375 msgstr "" 376 376 377 - #: src/components/compose-poll.jsx:123 377 + #: src/components/compose-poll.jsx:127 378 378 msgid "Remove poll" 379 379 msgstr "" 380 380 381 - #: src/components/compose-textarea.jsx:197 382 - #: src/components/compose-textarea.jsx:290 383 - #: src/components/nav-menu.jsx:244 384 - msgid "More…" 385 - msgstr "" 386 - 387 - #: src/components/compose.jsx:99 381 + #: src/components/compose.jsx:100 388 382 msgid "Take photo or video" 389 383 msgstr "Take photo or video" 390 384 391 - #: src/components/compose.jsx:100 385 + #: src/components/compose.jsx:101 392 386 msgid "Add media" 393 387 msgstr "Add media" 394 388 395 - #: src/components/compose.jsx:101 389 + #: src/components/compose.jsx:102 396 390 msgid "Add custom emoji" 397 391 msgstr "" 398 392 399 - #: src/components/compose.jsx:102 393 + #: src/components/compose.jsx:103 400 394 msgid "Add GIF" 401 395 msgstr "Add GIF" 402 396 403 - #: src/components/compose.jsx:103 397 + #: src/components/compose.jsx:104 404 398 msgid "Add poll" 405 399 msgstr "" 406 400 407 - #: src/components/compose.jsx:104 401 + #: src/components/compose.jsx:105 408 402 msgid "Schedule post" 409 403 msgstr "Schedule post" 410 404 411 - #: src/components/compose.jsx:357 405 + #: src/components/compose.jsx:359 412 406 msgid "You have unsaved changes. Discard this post?" 413 407 msgstr "You have unsaved changes. Discard this post?" 414 408 415 409 #. placeholder {0}: unsupportedFiles.length 416 410 #. placeholder {1}: unsupportedFiles[0].name 417 411 #. placeholder {2}: lf.format( unsupportedFiles.map((f) => f.name), ) 418 - #: src/components/compose.jsx:595 412 + #: src/components/compose.jsx:597 419 413 msgid "{0, plural, one {File {1} is not supported.} other {Files {2} are not supported.}}" 420 414 msgstr "{0, plural, one {File {1} is not supported.} other {Files {2} are not supported.}}" 421 415 422 - #: src/components/compose.jsx:605 423 - #: src/components/compose.jsx:623 424 - #: src/components/compose.jsx:1696 416 + #: src/components/compose.jsx:607 417 + #: src/components/compose.jsx:625 418 + #: src/components/compose.jsx:1701 425 419 #: src/components/file-picker-input.jsx:38 426 420 msgid "{maxMediaAttachments, plural, one {You can only attach up to 1 file.} other {You can only attach up to # files.}}" 427 421 msgstr "" 428 422 429 - #: src/components/compose.jsx:813 423 + #: src/components/compose.jsx:815 430 424 msgid "Pop out" 431 425 msgstr "Pop out" 432 426 433 - #: src/components/compose.jsx:820 427 + #: src/components/compose.jsx:822 434 428 msgid "Minimize" 435 429 msgstr "Minimize" 436 430 437 - #: src/components/compose.jsx:856 431 + #: src/components/compose.jsx:858 438 432 msgid "Looks like you closed the parent window." 439 433 msgstr "Looks like you closed the parent window." 440 434 441 - #: src/components/compose.jsx:863 435 + #: src/components/compose.jsx:865 442 436 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." 443 437 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." 444 438 445 - #: src/components/compose.jsx:868 439 + #: src/components/compose.jsx:870 446 440 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?" 447 441 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?" 448 442 449 - #: src/components/compose.jsx:911 443 + #: src/components/compose.jsx:913 450 444 msgid "Pop in" 451 445 msgstr "Pop in" 452 446 453 447 #. placeholder {0}: replyToStatus.account.acct || replyToStatus.account.username 454 448 #. placeholder {1}: rtf.format(-replyToStatusMonthsAgo, 'month') 455 - #: src/components/compose.jsx:921 449 + #: src/components/compose.jsx:923 456 450 msgid "Replying to @{0}’s post (<0>{1}</0>)" 457 451 msgstr "" 458 452 459 453 #. placeholder {0}: replyToStatus.account.acct || replyToStatus.account.username 460 - #: src/components/compose.jsx:931 454 + #: src/components/compose.jsx:933 461 455 msgid "Replying to @{0}’s post" 462 456 msgstr "" 463 457 464 - #: src/components/compose.jsx:944 458 + #: src/components/compose.jsx:946 465 459 msgid "Editing source post" 466 460 msgstr "" 467 461 468 - #: src/components/compose.jsx:997 462 + #: src/components/compose.jsx:999 469 463 msgid "Poll must have at least 2 options" 470 464 msgstr "Poll must have at least 2 options" 471 465 472 - #: src/components/compose.jsx:1001 466 + #: src/components/compose.jsx:1003 473 467 msgid "Some poll choices are empty" 474 468 msgstr "Some poll choices are empty" 475 469 476 - #: src/components/compose.jsx:1014 470 + #: src/components/compose.jsx:1016 477 471 msgid "Some media have no descriptions. Continue?" 478 472 msgstr "Some media have no descriptions. Continue?" 479 473 480 - #: src/components/compose.jsx:1066 474 + #: src/components/compose.jsx:1068 481 475 msgid "Attachment #{i} failed" 482 476 msgstr "Attachment #{i} failed" 483 477 484 - #: src/components/compose.jsx:1162 478 + #: src/components/compose.jsx:1176 485 479 #: src/components/status.jsx:2103 486 480 #: src/components/timeline.jsx:1015 487 481 msgid "Content warning" 488 482 msgstr "" 489 483 490 - #: src/components/compose.jsx:1179 484 + #: src/components/compose.jsx:1195 491 485 msgid "Content warning or sensitive media" 492 486 msgstr "Content warning or sensitive media" 493 487 494 - #: src/components/compose.jsx:1215 488 + #: src/components/compose.jsx:1231 495 489 #: src/components/status.jsx:87 496 490 #: src/pages/settings.jsx:318 497 491 msgid "Public" 498 492 msgstr "" 499 493 500 - #: src/components/compose.jsx:1220 494 + #: src/components/compose.jsx:1236 501 495 #: src/components/nav-menu.jsx:349 502 496 #: src/components/shortcuts-settings.jsx:165 503 497 #: src/components/status.jsx:88 504 498 msgid "Local" 505 499 msgstr "" 506 500 507 - #: src/components/compose.jsx:1224 501 + #: src/components/compose.jsx:1240 508 502 #: src/components/status.jsx:89 509 503 #: src/pages/settings.jsx:321 510 504 msgid "Unlisted" 511 505 msgstr "" 512 506 513 - #: src/components/compose.jsx:1227 507 + #: src/components/compose.jsx:1243 514 508 #: src/components/status.jsx:90 515 509 #: src/pages/settings.jsx:324 516 510 msgid "Followers only" 517 511 msgstr "" 518 512 519 - #: src/components/compose.jsx:1230 513 + #: src/components/compose.jsx:1246 520 514 #: src/components/status.jsx:91 521 515 #: src/components/status.jsx:1983 522 516 msgid "Private mention" 523 517 msgstr "" 524 518 525 - #: src/components/compose.jsx:1240 519 + #: src/components/compose.jsx:1256 526 520 msgid "Post your reply" 527 521 msgstr "Post your reply" 528 522 529 - #: src/components/compose.jsx:1242 523 + #: src/components/compose.jsx:1258 530 524 msgid "Edit your post" 531 525 msgstr "Edit your post" 532 526 533 - #: src/components/compose.jsx:1243 527 + #: src/components/compose.jsx:1259 534 528 msgid "What are you doing?" 535 529 msgstr "What are you doing?" 536 530 537 - #: src/components/compose.jsx:1323 531 + #: src/components/compose.jsx:1328 538 532 msgid "Mark media as sensitive" 539 533 msgstr "" 540 534 541 - #: src/components/compose.jsx:1360 535 + #: src/components/compose.jsx:1365 542 536 msgid "Posting on <0/>" 543 537 msgstr "Posting on <0/>" 544 538 545 - #: src/components/compose.jsx:1391 539 + #: src/components/compose.jsx:1396 546 540 #: src/components/mention-modal.jsx:220 547 541 #: src/components/shortcuts-settings.jsx:715 548 542 #: src/pages/list.jsx:388 549 543 msgid "Add" 550 544 msgstr "" 551 545 552 - #: src/components/compose.jsx:1623 546 + #: src/components/compose.jsx:1628 553 547 msgid "Schedule" 554 548 msgstr "Schedule" 555 549 556 - #: src/components/compose.jsx:1625 550 + #: src/components/compose.jsx:1630 557 551 #: src/components/keyboard-shortcuts-help.jsx:155 558 552 #: src/components/status.jsx:965 559 553 #: src/components/status.jsx:1751 ··· 562 556 msgid "Reply" 563 557 msgstr "" 564 558 565 - #: src/components/compose.jsx:1627 559 + #: src/components/compose.jsx:1632 566 560 msgid "Update" 567 561 msgstr "Update" 568 562 569 - #: src/components/compose.jsx:1628 563 + #: src/components/compose.jsx:1633 570 564 msgctxt "Submit button in composer" 571 565 msgid "Post" 572 566 msgstr "Post" 573 567 574 - #: src/components/compose.jsx:1708 568 + #: src/components/compose.jsx:1713 575 569 msgid "Downloading GIF…" 576 570 msgstr "Downloading GIF…" 577 571 578 - #: src/components/compose.jsx:1736 572 + #: src/components/compose.jsx:1741 579 573 msgid "Failed to download GIF" 580 574 msgstr "Failed to download GIF" 581 575 ··· 1248 1242 #: src/pages/bookmarks.jsx:12 1249 1243 #: src/pages/bookmarks.jsx:26 1250 1244 msgid "Bookmarks" 1245 + msgstr "" 1246 + 1247 + #: src/components/nav-menu.jsx:244 1248 + #: src/components/text-expander.jsx:208 1249 + msgid "More…" 1251 1250 msgstr "" 1252 1251 1253 1252 #: src/components/nav-menu.jsx:253