tangled
alpha
login
or
join now
alice.mosphere.at
/
phanpy
0
fork
atom
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
Make text-expander work for CW & poll fields
Lim Chee Aun
7 months ago
6e6b2ecf
96756765
+442
-414
7 changed files
expand all
collapse all
unified
split
src
components
compose-poll.jsx
compose-textarea.jsx
compose.css
compose.jsx
status.css
text-expander.jsx
locales
en.po
+21
-17
src/components/compose-poll.jsx
reviewed
···
3
3
import i18nDuration from '../utils/i18n-duration';
4
4
5
5
import Icon from './icon';
6
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
35
-
<input
36
36
-
required
37
37
-
type="text"
38
38
-
value={option}
39
39
-
disabled={disabled}
40
40
-
maxlength={maxCharactersPerOption}
41
41
-
placeholder={t`Choice ${i + 1}`}
42
42
-
lang={lang}
43
43
-
spellCheck="true"
44
44
-
dir="auto"
45
45
-
data-allow-custom-emoji="true"
46
46
-
onInput={(e) => {
47
47
-
const { value } = e.target;
48
48
-
options[i] = value;
49
49
-
onInput(poll);
50
50
-
}}
51
51
-
/>
36
36
+
<TextExpander keys=":" class="poll-field-container">
37
37
+
<input
38
38
+
required
39
39
+
type="text"
40
40
+
value={option}
41
41
+
disabled={disabled}
42
42
+
maxlength={maxCharactersPerOption}
43
43
+
placeholder={t`Choice ${i + 1}`}
44
44
+
lang={lang}
45
45
+
spellCheck="true"
46
46
+
autocomplete="off"
47
47
+
dir="auto"
48
48
+
data-allow-custom-emoji="true"
49
49
+
onInput={(e) => {
50
50
+
const { value } = e.target;
51
51
+
options[i] = value;
52
52
+
onInput(poll);
53
53
+
}}
54
54
+
/>
55
55
+
</TextExpander>
52
56
<button
53
57
type="button"
54
58
class="plain2 poll-button"
+6
-308
src/components/compose-textarea.jsx
reviewed
···
1
1
-
import '@github/text-expander-element';
2
2
-
3
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
8
-
import { api } from '../utils/api';
9
5
import { langDetector } from '../utils/browser-translator';
10
10
-
import getCustomEmojis from '../utils/custom-emojis';
11
11
-
import emojifyText from '../utils/emojify-text';
12
6
import escapeHTML from '../utils/escape-html';
13
13
-
import getDomain from '../utils/get-domain';
14
14
-
import isRTL from '../utils/is-rtl';
15
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
19
-
const menu = document.createElement('ul');
20
20
-
menu.role = 'listbox';
21
21
-
menu.className = 'text-expander-menu';
22
22
-
23
23
-
// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
24
24
-
const windowMargin = 16;
25
25
-
const observer = new IntersectionObserver((entries) => {
26
26
-
entries.forEach((entry) => {
27
27
-
if (entry.isIntersecting) {
28
28
-
const { left, width } = entry.boundingClientRect;
29
29
-
const { innerWidth } = window;
30
30
-
if (left + width > innerWidth) {
31
31
-
const insetInlineStart = isRTL() ? 'right' : 'left';
32
32
-
menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px';
33
33
-
}
34
34
-
}
35
35
-
});
36
36
-
});
37
37
-
observer.observe(menu);
10
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
123
-
function encodeHTML(str) {
124
124
-
return str.replace(/[&<>"']/g, function (char) {
125
125
-
return '&#' + char.charCodeAt(0) + ';';
126
126
-
});
127
127
-
}
128
128
-
129
96
const Textarea = forwardRef((props, ref) => {
130
130
-
const { t } = useLingui();
131
131
-
const { masto, instance } = api();
132
97
const [text, setText] = useState(ref.current?.value || '');
133
133
-
const {
134
134
-
maxCharacters,
135
135
-
performSearch = () => {},
136
136
-
onTrigger = () => {},
137
137
-
...textareaProps
138
138
-
} = props;
139
139
-
// const snapStates = useSnapshot(states);
140
140
-
// const charCount = snapStates.composerCharacterCount;
141
141
-
142
142
-
// const customEmojis = useRef();
143
143
-
const searcherRef = useRef();
144
144
-
useEffect(() => {
145
145
-
getCustomEmojis(instance, masto)
146
146
-
.then((r) => {
147
147
-
const [emojis, searcher] = r;
148
148
-
searcherRef.current = searcher;
149
149
-
})
150
150
-
.catch((e) => {
151
151
-
console.error(e);
152
152
-
});
153
153
-
}, []);
98
98
+
const { maxCharacters, onTrigger = null, ...textareaProps } = props;
154
99
155
100
const textExpanderRef = useRef();
156
156
-
const textExpanderTextRef = useRef('');
157
157
-
const hasTextExpanderRef = useRef(false);
158
158
-
useEffect(() => {
159
159
-
let handleChange,
160
160
-
handleValue,
161
161
-
handleCommited,
162
162
-
handleActivate,
163
163
-
handleDeactivate;
164
164
-
if (textExpanderRef.current) {
165
165
-
handleChange = (e) => {
166
166
-
// console.log('text-expander-change', e);
167
167
-
const { key, provide, text } = e.detail;
168
168
-
textExpanderTextRef.current = text;
169
169
-
170
170
-
if (text === '') {
171
171
-
provide(
172
172
-
Promise.resolve({
173
173
-
matched: false,
174
174
-
}),
175
175
-
);
176
176
-
return;
177
177
-
}
178
178
-
179
179
-
if (key === ':') {
180
180
-
// const emojis = customEmojis.current.filter((emoji) =>
181
181
-
// emoji.shortcode.startsWith(text),
182
182
-
// );
183
183
-
const results = searcherRef.current?.search(text, {
184
184
-
limit: 5,
185
185
-
});
186
186
-
let html = '';
187
187
-
results.forEach(({ item: emoji }) => {
188
188
-
const { shortcode, url } = emoji;
189
189
-
html += `
190
190
-
<li role="option" data-value="${encodeHTML(shortcode)}">
191
191
-
<img src="${encodeHTML(
192
192
-
url,
193
193
-
)}" width="16" height="16" alt="" loading="lazy" />
194
194
-
${encodeHTML(shortcode)}
195
195
-
</li>`;
196
196
-
});
197
197
-
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
198
198
-
// console.log({ emojis, html });
199
199
-
menu.innerHTML = html;
200
200
-
provide(
201
201
-
Promise.resolve({
202
202
-
matched: results.length > 0,
203
203
-
fragment: menu,
204
204
-
}),
205
205
-
);
206
206
-
return;
207
207
-
}
208
208
-
209
209
-
const type = {
210
210
-
'@': 'accounts',
211
211
-
'#': 'hashtags',
212
212
-
}[key];
213
213
-
provide(
214
214
-
new Promise((resolve) => {
215
215
-
const searchResults = performSearch({
216
216
-
type,
217
217
-
q: text,
218
218
-
limit: 5,
219
219
-
});
220
220
-
searchResults.then((value) => {
221
221
-
if (text !== textExpanderTextRef.current) {
222
222
-
return;
223
223
-
}
224
224
-
console.log({ value, type, v: value[type] });
225
225
-
const results = value[type] || value;
226
226
-
console.log('RESULTS', value, results);
227
227
-
let html = '';
228
228
-
results.forEach((result) => {
229
229
-
const {
230
230
-
name,
231
231
-
avatarStatic,
232
232
-
displayName,
233
233
-
username,
234
234
-
acct,
235
235
-
emojis,
236
236
-
history,
237
237
-
roles,
238
238
-
url,
239
239
-
} = result;
240
240
-
const displayNameWithEmoji = emojifyText(displayName, emojis);
241
241
-
const accountInstance = getDomain(url);
242
242
-
// const item = menuItem.cloneNode();
243
243
-
if (acct) {
244
244
-
html += `
245
245
-
<li role="option" data-value="${encodeHTML(acct)}">
246
246
-
<span class="avatar">
247
247
-
<img src="${encodeHTML(
248
248
-
avatarStatic,
249
249
-
)}" width="16" height="16" alt="" loading="lazy" />
250
250
-
</span>
251
251
-
<span>
252
252
-
<b>${displayNameWithEmoji || username}</b>
253
253
-
<br><span class="bidi-isolate">@${encodeHTML(
254
254
-
acct,
255
255
-
)}</span>
256
256
-
${
257
257
-
roles?.map(
258
258
-
(role) => ` <span class="tag collapsed">
259
259
-
${role.name}
260
260
-
${
261
261
-
!!accountInstance &&
262
262
-
`<span class="more-insignificant">
263
263
-
${accountInstance}
264
264
-
</span>`
265
265
-
}
266
266
-
</span>`,
267
267
-
) || ''
268
268
-
}
269
269
-
</span>
270
270
-
</li>
271
271
-
`;
272
272
-
} else {
273
273
-
const total = history?.reduce?.(
274
274
-
(acc, cur) => acc + +cur.uses,
275
275
-
0,
276
276
-
);
277
277
-
html += `
278
278
-
<li role="option" data-value="${encodeHTML(name)}">
279
279
-
<span class="grow">#<b>${encodeHTML(name)}</b></span>
280
280
-
${
281
281
-
total
282
282
-
? `<span class="count">${shortenNumber(total)}</span>`
283
283
-
: ''
284
284
-
}
285
285
-
</li>
286
286
-
`;
287
287
-
}
288
288
-
});
289
289
-
if (type === 'accounts') {
290
290
-
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
291
291
-
}
292
292
-
menu.innerHTML = html;
293
293
-
console.log('MENU', results, menu);
294
294
-
resolve({
295
295
-
matched: results.length > 0,
296
296
-
fragment: menu,
297
297
-
});
298
298
-
});
299
299
-
}),
300
300
-
);
301
301
-
};
302
302
-
303
303
-
textExpanderRef.current.addEventListener(
304
304
-
'text-expander-change',
305
305
-
handleChange,
306
306
-
);
307
307
-
308
308
-
handleValue = (e) => {
309
309
-
const { key, item } = e.detail;
310
310
-
const { value, more } = item.dataset;
311
311
-
if (key === ':') {
312
312
-
e.detail.value = value ? `:${value}:` : ''; // zero-width space
313
313
-
if (more) {
314
314
-
// Prevent adding space after the above value
315
315
-
e.detail.continue = true;
316
316
-
317
317
-
setTimeout(() => {
318
318
-
onTrigger?.({
319
319
-
name: 'custom-emojis',
320
320
-
defaultSearchTerm: more,
321
321
-
});
322
322
-
}, 300);
323
323
-
}
324
324
-
} else if (key === '@') {
325
325
-
e.detail.value = value ? `@${value}` : ''; // zero-width space
326
326
-
if (more) {
327
327
-
e.detail.continue = true;
328
328
-
setTimeout(() => {
329
329
-
onTrigger?.({
330
330
-
name: 'mention',
331
331
-
defaultSearchTerm: more,
332
332
-
});
333
333
-
}, 300);
334
334
-
}
335
335
-
} else {
336
336
-
e.detail.value = `${key}${value}`;
337
337
-
}
338
338
-
};
339
339
-
340
340
-
textExpanderRef.current.addEventListener(
341
341
-
'text-expander-value',
342
342
-
handleValue,
343
343
-
);
344
344
-
345
345
-
handleCommited = (e) => {
346
346
-
const { input } = e.detail;
347
347
-
setText(input.value);
348
348
-
// fire input event
349
349
-
if (ref.current) {
350
350
-
const event = new Event('input', { bubbles: true });
351
351
-
ref.current.dispatchEvent(event);
352
352
-
}
353
353
-
};
354
354
-
355
355
-
textExpanderRef.current.addEventListener(
356
356
-
'text-expander-committed',
357
357
-
handleCommited,
358
358
-
);
359
359
-
360
360
-
handleActivate = () => {
361
361
-
hasTextExpanderRef.current = true;
362
362
-
};
363
363
-
364
364
-
textExpanderRef.current.addEventListener(
365
365
-
'text-expander-activate',
366
366
-
handleActivate,
367
367
-
);
368
368
-
369
369
-
handleDeactivate = () => {
370
370
-
hasTextExpanderRef.current = false;
371
371
-
};
372
372
-
373
373
-
textExpanderRef.current.addEventListener(
374
374
-
'text-expander-deactivate',
375
375
-
handleDeactivate,
376
376
-
);
377
377
-
}
378
378
-
379
379
-
return () => {
380
380
-
if (textExpanderRef.current) {
381
381
-
textExpanderRef.current.removeEventListener(
382
382
-
'text-expander-change',
383
383
-
handleChange,
384
384
-
);
385
385
-
textExpanderRef.current.removeEventListener(
386
386
-
'text-expander-value',
387
387
-
handleValue,
388
388
-
);
389
389
-
textExpanderRef.current.removeEventListener(
390
390
-
'text-expander-committed',
391
391
-
handleCommited,
392
392
-
);
393
393
-
textExpanderRef.current.removeEventListener(
394
394
-
'text-expander-activate',
395
395
-
handleActivate,
396
396
-
);
397
397
-
textExpanderRef.current.removeEventListener(
398
398
-
'text-expander-deactivate',
399
399
-
handleDeactivate,
400
400
-
);
401
401
-
}
402
402
-
};
403
403
-
}, []);
404
101
405
102
useEffect(() => {
406
103
// Resize observer for textarea
···
466
163
}, 2000);
467
164
468
165
return (
469
469
-
<text-expander
166
166
+
<TextExpander
470
167
ref={textExpanderRef}
471
168
keys="@ # :"
472
169
class="compose-field-container"
170
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
490
-
const hasTextExpander = hasTextExpanderRef.current;
188
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
558
-
</text-expander>
256
256
+
</TextExpander>
559
257
);
560
258
});
561
259
+14
-4
src/components/compose.css
reviewed
···
175
175
#compose-container .toolbar.stretch {
176
176
justify-content: stretch;
177
177
}
178
178
-
#compose-container .toolbar .spoiler-text-field {
179
179
-
flex: 1;
180
180
-
min-width: 0;
178
178
+
#compose-container .toolbar {
179
179
+
.spoiler-text-field-container {
180
180
+
flex: 1;
181
181
+
min-width: 0;
182
182
+
183
183
+
.spoiler-text-field {
184
184
+
width: 100%;
185
185
+
}
186
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
513
-
#compose-container .poll-choice input {
519
519
+
#compose-container .poll-choice .poll-field-container {
514
520
flex-grow: 1;
515
521
min-width: 0;
522
522
+
523
523
+
input {
524
524
+
width: 100%;
525
525
+
}
516
526
}
517
527
518
528
#compose-container .poll-button {
+34
-29
src/components/compose.jsx
reviewed
···
54
54
MIN_SCHEDULED_AT,
55
55
} from './ScheduledAtField';
56
56
import Status from './status';
57
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
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
1158
-
<input
1159
1159
-
ref={spoilerTextRef}
1160
1160
-
type="text"
1161
1161
-
name="spoilerText"
1162
1162
-
placeholder={t`Content warning`}
1163
1163
-
data-allow-custom-emoji="true"
1164
1164
-
disabled={uiState === 'loading'}
1165
1165
-
class="spoiler-text-field"
1166
1166
-
lang={language}
1167
1167
-
spellCheck="true"
1168
1168
-
dir="auto"
1169
1169
-
style={{
1170
1170
-
opacity: sensitive ? 1 : 0,
1171
1171
-
pointerEvents: sensitive ? 'auto' : 'none',
1172
1172
-
}}
1173
1173
-
onInput={() => {
1174
1174
-
updateCharCount();
1160
1160
+
<TextExpander
1161
1161
+
keys=":"
1162
1162
+
class="spoiler-text-field-container"
1163
1163
+
onTrigger={(action) => {
1164
1164
+
if (action?.name === 'custom-emojis') {
1165
1165
+
setShowEmoji2Picker({
1166
1166
+
targetElement: spoilerTextRef,
1167
1167
+
defaultSearchTerm: action?.defaultSearchTerm || null,
1168
1168
+
});
1169
1169
+
}
1175
1170
}}
1176
1176
-
/>
1171
1171
+
>
1172
1172
+
<input
1173
1173
+
ref={spoilerTextRef}
1174
1174
+
type="text"
1175
1175
+
name="spoilerText"
1176
1176
+
placeholder={t`Content warning`}
1177
1177
+
data-allow-custom-emoji="true"
1178
1178
+
disabled={uiState === 'loading'}
1179
1179
+
class="spoiler-text-field"
1180
1180
+
lang={language}
1181
1181
+
spellCheck="true"
1182
1182
+
autocomplete="off"
1183
1183
+
dir="auto"
1184
1184
+
style={{
1185
1185
+
opacity: sensitive ? 1 : 0,
1186
1186
+
pointerEvents: sensitive ? 'auto' : 'none',
1187
1187
+
}}
1188
1188
+
onInput={() => {
1189
1189
+
updateCharCount();
1190
1190
+
}}
1191
1191
+
/>
1192
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
1252
-
performSearch={(params) => {
1253
1253
-
const { type, q, limit } = params;
1254
1254
-
if (type === 'accounts') {
1255
1255
-
return masto.v1.accounts.search.list({
1256
1256
-
q,
1257
1257
-
limit,
1258
1258
-
resolve: false,
1259
1259
-
});
1260
1260
-
}
1261
1261
-
return masto.v2.search.list(params);
1262
1262
-
}}
1263
1268
onTrigger={(action) => {
1264
1269
if (action?.name === 'custom-emojis') {
1265
1270
setShowEmoji2Picker({
+1
-1
src/components/status.css
reviewed
···
2086
2086
var(--bg-color) 50%,
2087
2087
var(--bg-faded-color)
2088
2088
);
2089
2089
-
overflow: hidden;
2089
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
reviewed
···
1
1
+
import '@github/text-expander-element';
2
2
+
3
3
+
import { useLingui } from '@lingui/react/macro';
4
4
+
import { forwardRef, useImperativeHandle } from 'preact/compat';
5
5
+
import { useEffect, useRef } from 'preact/hooks';
6
6
+
7
7
+
import { api } from '../utils/api';
8
8
+
import getCustomEmojis from '../utils/custom-emojis';
9
9
+
import emojifyText from '../utils/emojify-text';
10
10
+
import getDomain from '../utils/get-domain';
11
11
+
import isRTL from '../utils/is-rtl';
12
12
+
import shortenNumber from '../utils/shorten-number';
13
13
+
14
14
+
const menu = document.createElement('ul');
15
15
+
menu.role = 'listbox';
16
16
+
menu.className = 'text-expander-menu';
17
17
+
18
18
+
// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
19
19
+
const windowMargin = 16;
20
20
+
const observer = new IntersectionObserver((entries) => {
21
21
+
entries.forEach((entry) => {
22
22
+
if (entry.isIntersecting) {
23
23
+
const { left, width } = entry.boundingClientRect;
24
24
+
const { innerWidth } = window;
25
25
+
if (left + width > innerWidth) {
26
26
+
const insetInlineStart = isRTL() ? 'right' : 'left';
27
27
+
menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px';
28
28
+
}
29
29
+
}
30
30
+
});
31
31
+
});
32
32
+
observer.observe(menu);
33
33
+
34
34
+
function encodeHTML(str) {
35
35
+
return str.replace(/[&<>"']/g, function (char) {
36
36
+
return '&#' + char.charCodeAt(0) + ';';
37
37
+
});
38
38
+
}
39
39
+
40
40
+
function TextExpander({ onTrigger = null, ...props }, ref) {
41
41
+
const { t } = useLingui();
42
42
+
const textExpanderRef = useRef();
43
43
+
const { masto, instance } = api();
44
44
+
const searcherRef = useRef();
45
45
+
const textExpanderTextRef = useRef('');
46
46
+
const hasTextExpanderRef = useRef(false);
47
47
+
48
48
+
// Expose the activated state to parent components
49
49
+
useImperativeHandle(ref, () => ({
50
50
+
activated: () => hasTextExpanderRef.current,
51
51
+
}));
52
52
+
53
53
+
// Setup emoji search if not already set up
54
54
+
useEffect(() => {
55
55
+
if (searcherRef.current) return; // Already set up
56
56
+
57
57
+
getCustomEmojis(instance, masto)
58
58
+
.then(([, searcher]) => {
59
59
+
searcherRef.current = searcher;
60
60
+
})
61
61
+
.catch((e) => {
62
62
+
console.error(e);
63
63
+
});
64
64
+
}, [instance, masto]);
65
65
+
66
66
+
useEffect(() => {
67
67
+
const textExpander = textExpanderRef.current;
68
68
+
if (!textExpander) return;
69
69
+
70
70
+
const handleChange = (e) => {
71
71
+
const { key, provide, text } = e.detail;
72
72
+
textExpanderTextRef.current = text;
73
73
+
74
74
+
if (text === '') {
75
75
+
provide(
76
76
+
Promise.resolve({
77
77
+
matched: false,
78
78
+
}),
79
79
+
);
80
80
+
return;
81
81
+
}
82
82
+
83
83
+
if (key === ':') {
84
84
+
const showMore = !!onTrigger;
85
85
+
const results = searcherRef.current?.search(text, {
86
86
+
limit: 5,
87
87
+
});
88
88
+
89
89
+
let html = '';
90
90
+
results?.forEach(({ item: emoji }) => {
91
91
+
const { shortcode, url } = emoji;
92
92
+
html += `
93
93
+
<li role="option" data-value="${encodeHTML(shortcode)}">
94
94
+
<img src="${encodeHTML(
95
95
+
url,
96
96
+
)}" width="16" height="16" alt="" loading="lazy" />
97
97
+
${encodeHTML(shortcode)}
98
98
+
</li>`;
99
99
+
});
100
100
+
if (showMore) {
101
101
+
html += `<li role="option" data-value="" data-more="${text}">${'More…'}</li>`;
102
102
+
}
103
103
+
menu.innerHTML = html;
104
104
+
105
105
+
provide(
106
106
+
Promise.resolve({
107
107
+
matched: (results?.length || 0) > 0,
108
108
+
fragment: menu,
109
109
+
}),
110
110
+
);
111
111
+
return;
112
112
+
}
113
113
+
114
114
+
// Handle @ mentions and # hashtags
115
115
+
const type = {
116
116
+
'@': 'accounts',
117
117
+
'#': 'hashtags',
118
118
+
}[key];
119
119
+
120
120
+
if (type) {
121
121
+
provide(
122
122
+
new Promise(async (resolve) => {
123
123
+
try {
124
124
+
let searchResults;
125
125
+
if (type === 'accounts') {
126
126
+
searchResults = await masto.v1.accounts.search.list({
127
127
+
q: text,
128
128
+
limit: 5,
129
129
+
resolve: false,
130
130
+
});
131
131
+
} else {
132
132
+
const response = await masto.v2.search.list({
133
133
+
type,
134
134
+
q: text,
135
135
+
limit: 5,
136
136
+
});
137
137
+
searchResults = response[type] || response;
138
138
+
}
139
139
+
140
140
+
if (text !== textExpanderTextRef.current) {
141
141
+
return;
142
142
+
}
143
143
+
144
144
+
const results = searchResults;
145
145
+
let html = '';
146
146
+
results.forEach((result) => {
147
147
+
const {
148
148
+
name,
149
149
+
avatarStatic,
150
150
+
displayName,
151
151
+
username,
152
152
+
acct,
153
153
+
emojis,
154
154
+
history,
155
155
+
roles,
156
156
+
url,
157
157
+
} = result;
158
158
+
const displayNameWithEmoji = emojifyText(displayName, emojis);
159
159
+
const accountInstance = getDomain(url);
160
160
+
161
161
+
if (acct) {
162
162
+
html += `
163
163
+
<li role="option" data-value="${encodeHTML(acct)}">
164
164
+
<span class="avatar">
165
165
+
<img src="${encodeHTML(
166
166
+
avatarStatic,
167
167
+
)}" width="16" height="16" alt="" loading="lazy" />
168
168
+
</span>
169
169
+
<span>
170
170
+
<b>${displayNameWithEmoji || username}</b>
171
171
+
<br><span class="bidi-isolate">@${encodeHTML(
172
172
+
acct,
173
173
+
)}</span>
174
174
+
${
175
175
+
roles?.map(
176
176
+
(role) => ` <span class="tag collapsed">
177
177
+
${role.name}
178
178
+
${
179
179
+
!!accountInstance &&
180
180
+
`<span class="more-insignificant">
181
181
+
${accountInstance}
182
182
+
</span>`
183
183
+
}
184
184
+
</span>`,
185
185
+
) || ''
186
186
+
}
187
187
+
</span>
188
188
+
</li>
189
189
+
`;
190
190
+
} else {
191
191
+
const total = history?.reduce?.(
192
192
+
(acc, cur) => acc + +cur.uses,
193
193
+
0,
194
194
+
);
195
195
+
html += `
196
196
+
<li role="option" data-value="${encodeHTML(name)}">
197
197
+
<span class="grow">#<b>${encodeHTML(name)}</b></span>
198
198
+
${
199
199
+
total
200
200
+
? `<span class="count">${shortenNumber(total)}</span>`
201
201
+
: ''
202
202
+
}
203
203
+
</li>
204
204
+
`;
205
205
+
}
206
206
+
});
207
207
+
if (type === 'accounts') {
208
208
+
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
209
209
+
}
210
210
+
menu.innerHTML = html;
211
211
+
resolve({
212
212
+
matched: results.length > 0,
213
213
+
fragment: menu,
214
214
+
});
215
215
+
} catch (error) {
216
216
+
console.error('Search error:', error);
217
217
+
resolve({
218
218
+
matched: false,
219
219
+
});
220
220
+
}
221
221
+
}),
222
222
+
);
223
223
+
return;
224
224
+
}
225
225
+
226
226
+
// No other keys supported
227
227
+
provide(
228
228
+
Promise.resolve({
229
229
+
matched: false,
230
230
+
}),
231
231
+
);
232
232
+
};
233
233
+
234
234
+
const handleValue = (e) => {
235
235
+
const { key, item } = e.detail;
236
236
+
const { value, more } = item.dataset;
237
237
+
238
238
+
if (key === ':') {
239
239
+
e.detail.value = value ? `:${value}:` : ''; // zero-width space
240
240
+
if (more) {
241
241
+
// Prevent adding space after the above value
242
242
+
e.detail.continue = true;
243
243
+
244
244
+
setTimeout(() => {
245
245
+
// Trigger custom emoji picker modal for more options
246
246
+
onTrigger?.({
247
247
+
name: 'custom-emojis',
248
248
+
defaultSearchTerm: more,
249
249
+
});
250
250
+
}, 300);
251
251
+
}
252
252
+
} else if (key === '@') {
253
253
+
e.detail.value = value ? `@${value}` : ''; // zero-width space
254
254
+
if (more) {
255
255
+
e.detail.continue = true;
256
256
+
setTimeout(() => {
257
257
+
onTrigger?.({
258
258
+
name: 'mention',
259
259
+
defaultSearchTerm: more,
260
260
+
});
261
261
+
}, 300);
262
262
+
}
263
263
+
} else {
264
264
+
e.detail.value = `${key}${value}`;
265
265
+
}
266
266
+
};
267
267
+
268
268
+
const handleCommited = (e) => {
269
269
+
const { input } = e.detail;
270
270
+
271
271
+
if (input) {
272
272
+
const event = new Event('input', { bubbles: true });
273
273
+
input.dispatchEvent(event);
274
274
+
}
275
275
+
};
276
276
+
277
277
+
const handleActivate = () => {
278
278
+
hasTextExpanderRef.current = true;
279
279
+
};
280
280
+
281
281
+
const handleDeactivate = () => {
282
282
+
hasTextExpanderRef.current = false;
283
283
+
};
284
284
+
285
285
+
textExpander.addEventListener('text-expander-change', handleChange);
286
286
+
textExpander.addEventListener('text-expander-value', handleValue);
287
287
+
textExpander.addEventListener('text-expander-committed', handleCommited);
288
288
+
textExpander.addEventListener('text-expander-activate', handleActivate);
289
289
+
textExpander.addEventListener('text-expander-deactivate', handleDeactivate);
290
290
+
291
291
+
return () => {
292
292
+
textExpander.removeEventListener('text-expander-change', handleChange);
293
293
+
textExpander.removeEventListener('text-expander-value', handleValue);
294
294
+
textExpander.removeEventListener(
295
295
+
'text-expander-committed',
296
296
+
handleCommited,
297
297
+
);
298
298
+
textExpander.removeEventListener(
299
299
+
'text-expander-activate',
300
300
+
handleActivate,
301
301
+
);
302
302
+
textExpander.removeEventListener(
303
303
+
'text-expander-deactivate',
304
304
+
handleDeactivate,
305
305
+
);
306
306
+
};
307
307
+
}, [searcherRef.current, onTrigger, t, masto]);
308
308
+
309
309
+
return <text-expander ref={textExpanderRef} {...props} />;
310
310
+
}
311
311
+
312
312
+
export default forwardRef(TextExpander);
+54
-55
src/locales/en.po
reviewed
···
248
248
249
249
#: src/components/account-sheet.jsx:38
250
250
#: src/components/add-remove-lists-sheet.jsx:45
251
251
-
#: src/components/compose.jsx:832
251
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
357
-
#: src/components/compose-poll.jsx:41
357
357
+
#: src/components/compose-poll.jsx:43
358
358
msgid "Choice {0}"
359
359
msgstr "Choice {0}"
360
360
361
361
-
#: src/components/compose-poll.jsx:61
361
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
369
-
#: src/components/compose-poll.jsx:89
369
369
+
#: src/components/compose-poll.jsx:93
370
370
msgid "Multiple choices"
371
371
msgstr ""
372
372
373
373
-
#: src/components/compose-poll.jsx:92
373
373
+
#: src/components/compose-poll.jsx:96
374
374
msgid "Duration"
375
375
msgstr ""
376
376
377
377
-
#: src/components/compose-poll.jsx:123
377
377
+
#: src/components/compose-poll.jsx:127
378
378
msgid "Remove poll"
379
379
msgstr ""
380
380
381
381
-
#: src/components/compose-textarea.jsx:197
382
382
-
#: src/components/compose-textarea.jsx:290
383
383
-
#: src/components/nav-menu.jsx:244
384
384
-
msgid "More…"
385
385
-
msgstr ""
386
386
-
387
387
-
#: src/components/compose.jsx:99
381
381
+
#: src/components/compose.jsx:100
388
382
msgid "Take photo or video"
389
383
msgstr "Take photo or video"
390
384
391
391
-
#: src/components/compose.jsx:100
385
385
+
#: src/components/compose.jsx:101
392
386
msgid "Add media"
393
387
msgstr "Add media"
394
388
395
395
-
#: src/components/compose.jsx:101
389
389
+
#: src/components/compose.jsx:102
396
390
msgid "Add custom emoji"
397
391
msgstr ""
398
392
399
399
-
#: src/components/compose.jsx:102
393
393
+
#: src/components/compose.jsx:103
400
394
msgid "Add GIF"
401
395
msgstr "Add GIF"
402
396
403
403
-
#: src/components/compose.jsx:103
397
397
+
#: src/components/compose.jsx:104
404
398
msgid "Add poll"
405
399
msgstr ""
406
400
407
407
-
#: src/components/compose.jsx:104
401
401
+
#: src/components/compose.jsx:105
408
402
msgid "Schedule post"
409
403
msgstr "Schedule post"
410
404
411
411
-
#: src/components/compose.jsx:357
405
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
418
-
#: src/components/compose.jsx:595
412
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
422
-
#: src/components/compose.jsx:605
423
423
-
#: src/components/compose.jsx:623
424
424
-
#: src/components/compose.jsx:1696
416
416
+
#: src/components/compose.jsx:607
417
417
+
#: src/components/compose.jsx:625
418
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
429
-
#: src/components/compose.jsx:813
423
423
+
#: src/components/compose.jsx:815
430
424
msgid "Pop out"
431
425
msgstr "Pop out"
432
426
433
433
-
#: src/components/compose.jsx:820
427
427
+
#: src/components/compose.jsx:822
434
428
msgid "Minimize"
435
429
msgstr "Minimize"
436
430
437
437
-
#: src/components/compose.jsx:856
431
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
441
-
#: src/components/compose.jsx:863
435
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
445
-
#: src/components/compose.jsx:868
439
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
449
-
#: src/components/compose.jsx:911
443
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
455
-
#: src/components/compose.jsx:921
449
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
460
-
#: src/components/compose.jsx:931
454
454
+
#: src/components/compose.jsx:933
461
455
msgid "Replying to @{0}’s post"
462
456
msgstr ""
463
457
464
464
-
#: src/components/compose.jsx:944
458
458
+
#: src/components/compose.jsx:946
465
459
msgid "Editing source post"
466
460
msgstr ""
467
461
468
468
-
#: src/components/compose.jsx:997
462
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
472
-
#: src/components/compose.jsx:1001
466
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
476
-
#: src/components/compose.jsx:1014
470
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
480
-
#: src/components/compose.jsx:1066
474
474
+
#: src/components/compose.jsx:1068
481
475
msgid "Attachment #{i} failed"
482
476
msgstr "Attachment #{i} failed"
483
477
484
484
-
#: src/components/compose.jsx:1162
478
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
490
-
#: src/components/compose.jsx:1179
484
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
494
-
#: src/components/compose.jsx:1215
488
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
500
-
#: src/components/compose.jsx:1220
494
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
507
-
#: src/components/compose.jsx:1224
501
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
513
-
#: src/components/compose.jsx:1227
507
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
519
-
#: src/components/compose.jsx:1230
513
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
525
-
#: src/components/compose.jsx:1240
519
519
+
#: src/components/compose.jsx:1256
526
520
msgid "Post your reply"
527
521
msgstr "Post your reply"
528
522
529
529
-
#: src/components/compose.jsx:1242
523
523
+
#: src/components/compose.jsx:1258
530
524
msgid "Edit your post"
531
525
msgstr "Edit your post"
532
526
533
533
-
#: src/components/compose.jsx:1243
527
527
+
#: src/components/compose.jsx:1259
534
528
msgid "What are you doing?"
535
529
msgstr "What are you doing?"
536
530
537
537
-
#: src/components/compose.jsx:1323
531
531
+
#: src/components/compose.jsx:1328
538
532
msgid "Mark media as sensitive"
539
533
msgstr ""
540
534
541
541
-
#: src/components/compose.jsx:1360
535
535
+
#: src/components/compose.jsx:1365
542
536
msgid "Posting on <0/>"
543
537
msgstr "Posting on <0/>"
544
538
545
545
-
#: src/components/compose.jsx:1391
539
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
552
-
#: src/components/compose.jsx:1623
546
546
+
#: src/components/compose.jsx:1628
553
547
msgid "Schedule"
554
548
msgstr "Schedule"
555
549
556
556
-
#: src/components/compose.jsx:1625
550
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
565
-
#: src/components/compose.jsx:1627
559
559
+
#: src/components/compose.jsx:1632
566
560
msgid "Update"
567
561
msgstr "Update"
568
562
569
569
-
#: src/components/compose.jsx:1628
563
563
+
#: src/components/compose.jsx:1633
570
564
msgctxt "Submit button in composer"
571
565
msgid "Post"
572
566
msgstr "Post"
573
567
574
574
-
#: src/components/compose.jsx:1708
568
568
+
#: src/components/compose.jsx:1713
575
569
msgid "Downloading GIF…"
576
570
msgstr "Downloading GIF…"
577
571
578
578
-
#: src/components/compose.jsx:1736
572
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
1245
+
msgstr ""
1246
1246
+
1247
1247
+
#: src/components/nav-menu.jsx:244
1248
1248
+
#: src/components/text-expander.jsx:208
1249
1249
+
msgid "More…"
1251
1250
msgstr ""
1252
1251
1253
1252
#: src/components/nav-menu.jsx:253