this repo has no description
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);