this repo has no description
at main 317 lines 9.7 kB view raw
1import '@github/text-expander-element'; 2 3import { useLingui } from '@lingui/react/macro'; 4import { forwardRef, useImperativeHandle } from 'preact/compat'; 5import { useEffect, useRef } from 'preact/hooks'; 6 7import { api } from '../utils/api'; 8import getCustomEmojis from '../utils/custom-emojis'; 9import emojifyText from '../utils/emojify-text'; 10import getDomain from '../utils/get-domain'; 11import isRTL from '../utils/is-rtl'; 12import shortenNumber from '../utils/shorten-number'; 13 14const menu = document.createElement('ul'); 15menu.role = 'listbox'; 16menu.className = 'text-expander-menu'; 17 18// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it 19const windowMargin = 16; 20const 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}); 32observer.observe(menu); 33 34function encodeHTML(str) { 35 return str.replace(/[&<>"']/g, function (char) { 36 return '&#' + char.charCodeAt(0) + ';'; 37 }); 38} 39 40function 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 setStyle: (style) => { 51 if (textExpanderRef.current) { 52 Object.assign(textExpanderRef.current.style, style); 53 } 54 }, 55 activated: () => hasTextExpanderRef.current, 56 })); 57 58 // Setup emoji search if not already set up 59 useEffect(() => { 60 if (searcherRef.current) return; // Already set up 61 62 getCustomEmojis(instance, masto) 63 .then(([, searcher]) => { 64 searcherRef.current = searcher; 65 }) 66 .catch((e) => { 67 console.error(e); 68 }); 69 }, [instance, masto]); 70 71 useEffect(() => { 72 const textExpander = textExpanderRef.current; 73 if (!textExpander) return; 74 75 const handleChange = (e) => { 76 const { key, provide, text } = e.detail; 77 textExpanderTextRef.current = text; 78 79 if (text === '') { 80 provide( 81 Promise.resolve({ 82 matched: false, 83 }), 84 ); 85 return; 86 } 87 88 if (key === ':') { 89 const showMore = !!onTrigger; 90 const results = searcherRef.current?.search(text, { 91 limit: 5, 92 }); 93 94 let html = ''; 95 results?.forEach(({ item: emoji }) => { 96 const { shortcode, url } = emoji; 97 html += ` 98 <li role="option" data-value="${encodeHTML(shortcode)}"> 99 <img src="${encodeHTML( 100 url, 101 )}" width="16" height="16" alt="" loading="lazy" /> 102 ${encodeHTML(shortcode)} 103 </li>`; 104 }); 105 if (showMore) { 106 html += `<li role="option" data-value="" data-more="${text}">${'More…'}</li>`; 107 } 108 menu.innerHTML = html; 109 110 provide( 111 Promise.resolve({ 112 matched: (results?.length || 0) > 0, 113 fragment: menu, 114 }), 115 ); 116 return; 117 } 118 119 // Handle @ mentions and # hashtags 120 const type = { 121 '@': 'accounts', 122 '#': 'hashtags', 123 }[key]; 124 125 if (type) { 126 provide( 127 new Promise(async (resolve) => { 128 try { 129 let searchResults; 130 if (type === 'accounts') { 131 searchResults = await masto.v1.accounts.search.list({ 132 q: text, 133 limit: 5, 134 resolve: false, 135 }); 136 } else { 137 const response = await masto.v2.search.list({ 138 type, 139 q: text, 140 limit: 5, 141 }); 142 searchResults = response[type] || response; 143 } 144 145 if (text !== textExpanderTextRef.current) { 146 return; 147 } 148 149 const results = searchResults; 150 let html = ''; 151 results.forEach((result) => { 152 const { 153 name, 154 avatarStatic, 155 displayName, 156 username, 157 acct, 158 emojis, 159 history, 160 roles, 161 url, 162 } = result; 163 const displayNameWithEmoji = emojifyText(displayName, emojis); 164 const accountInstance = getDomain(url); 165 166 if (acct) { 167 html += ` 168 <li role="option" data-value="${encodeHTML(acct)}"> 169 <span class="avatar"> 170 <img src="${encodeHTML( 171 avatarStatic, 172 )}" width="16" height="16" alt="" loading="lazy" /> 173 </span> 174 <span> 175 <b>${displayNameWithEmoji || username}</b> 176 <br><span class="bidi-isolate">@${encodeHTML( 177 acct, 178 )}</span> 179 ${ 180 roles?.map( 181 (role) => ` <span class="tag collapsed"> 182 ${role.name} 183 ${ 184 !!accountInstance && 185 `<span class="more-insignificant"> 186 ${accountInstance} 187 </span>` 188 } 189 </span>`, 190 ) || '' 191 } 192 </span> 193 </li> 194 `; 195 } else { 196 const total = history?.reduce?.( 197 (acc, cur) => acc + +cur.uses, 198 0, 199 ); 200 html += ` 201 <li role="option" data-value="${encodeHTML(name)}"> 202 <span class="grow">#<b>${encodeHTML(name)}</b></span> 203 ${ 204 total 205 ? `<span class="count">${shortenNumber(total)}</span>` 206 : '' 207 } 208 </li> 209 `; 210 } 211 }); 212 if (type === 'accounts') { 213 html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`; 214 } 215 menu.innerHTML = html; 216 resolve({ 217 matched: results.length > 0, 218 fragment: menu, 219 }); 220 } catch (error) { 221 console.error('Search error:', error); 222 resolve({ 223 matched: false, 224 }); 225 } 226 }), 227 ); 228 return; 229 } 230 231 // No other keys supported 232 provide( 233 Promise.resolve({ 234 matched: false, 235 }), 236 ); 237 }; 238 239 const handleValue = (e) => { 240 const { key, item } = e.detail; 241 const { value, more } = item.dataset; 242 243 if (key === ':') { 244 e.detail.value = value ? `:${value}:` : '​'; // zero-width space 245 if (more) { 246 // Prevent adding space after the above value 247 e.detail.continue = true; 248 249 setTimeout(() => { 250 // Trigger custom emoji picker modal for more options 251 onTrigger?.({ 252 name: 'custom-emojis', 253 defaultSearchTerm: more, 254 }); 255 }, 300); 256 } 257 } else if (key === '@') { 258 e.detail.value = value ? `@${value}` : '​'; // zero-width space 259 if (more) { 260 e.detail.continue = true; 261 setTimeout(() => { 262 onTrigger?.({ 263 name: 'mention', 264 defaultSearchTerm: more, 265 }); 266 }, 300); 267 } 268 } else { 269 e.detail.value = `${key}${value}`; 270 } 271 }; 272 273 const handleCommited = (e) => { 274 const { input } = e.detail; 275 276 if (input) { 277 const event = new Event('input', { bubbles: true }); 278 input.dispatchEvent(event); 279 } 280 }; 281 282 const handleActivate = () => { 283 hasTextExpanderRef.current = true; 284 }; 285 286 const handleDeactivate = () => { 287 hasTextExpanderRef.current = false; 288 }; 289 290 textExpander.addEventListener('text-expander-change', handleChange); 291 textExpander.addEventListener('text-expander-value', handleValue); 292 textExpander.addEventListener('text-expander-committed', handleCommited); 293 textExpander.addEventListener('text-expander-activate', handleActivate); 294 textExpander.addEventListener('text-expander-deactivate', handleDeactivate); 295 296 return () => { 297 textExpander.removeEventListener('text-expander-change', handleChange); 298 textExpander.removeEventListener('text-expander-value', handleValue); 299 textExpander.removeEventListener( 300 'text-expander-committed', 301 handleCommited, 302 ); 303 textExpander.removeEventListener( 304 'text-expander-activate', 305 handleActivate, 306 ); 307 textExpander.removeEventListener( 308 'text-expander-deactivate', 309 handleDeactivate, 310 ); 311 }; 312 }, [searcherRef.current, onTrigger, t, masto]); 313 314 return <text-expander ref={textExpanderRef} {...props} />; 315} 316 317export default forwardRef(TextExpander);