tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
WIP styling the popover for mentions
cozylittle.house
4 months ago
e19c9470
ade0ec9b
+305
-286
3 changed files
expand all
collapse all
unified
split
app
[leaflet_id]
publish
BskyPostEditorProsemirror.tsx
components
Blocks
TextBlock
index.tsx
Mention.tsx
+4
-281
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
1
1
"use client";
2
2
-
import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
3
-
import {
4
4
-
useState,
5
5
-
useCallback,
6
6
-
useRef,
7
7
-
useLayoutEffect,
8
8
-
useEffect,
9
9
-
} from "react";
10
10
-
import { createPortal } from "react-dom";
11
11
-
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
12
12
-
import * as Popover from "@radix-ui/react-popover";
13
13
-
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
2
2
+
import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
3
+
import { useState, useCallback, useRef, useLayoutEffect } from "react";
4
4
+
import { EditorState } from "prosemirror-state";
14
5
import { EditorView } from "prosemirror-view";
15
6
import { Schema, MarkSpec, Mark } from "prosemirror-model";
16
7
import { baseKeymap } from "prosemirror-commands";
···
19
10
import { inputRules, InputRule } from "prosemirror-inputrules";
20
11
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
21
12
import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox";
22
22
-
import { callRPC } from "app/api/rpc/client";
23
13
import { schema } from "components/Blocks/TextBlock/schema";
14
14
+
import { Mention, MentionAutocomplete } from "components/Mention";
24
15
25
16
// Schema with only links, mentions, and hashtags marks
26
17
const bskyPostSchema = new Schema({
···
290
281
<IOSBS view={viewRef} />
291
282
</div>
292
283
);
293
293
-
}
294
294
-
295
295
-
export function MentionAutocomplete(props: {
296
296
-
editorState: EditorState;
297
297
-
view: React.RefObject<EditorView | null>;
298
298
-
onSelect: (mention: Mention, range: { from: number; to: number }) => void;
299
299
-
onMentionStateChange: (
300
300
-
active: boolean,
301
301
-
range: { from: number; to: number } | null,
302
302
-
selectedMention: Mention | null,
303
303
-
) => void;
304
304
-
}) {
305
305
-
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
306
306
-
const [mentionRange, setMentionRange] = useState<{
307
307
-
from: number;
308
308
-
to: number;
309
309
-
} | null>(null);
310
310
-
const [mentionCoords, setMentionCoords] = useState<{
311
311
-
top: number;
312
312
-
left: number;
313
313
-
} | null>(null);
314
314
-
315
315
-
const { suggestionIndex, setSuggestionIndex, suggestions } =
316
316
-
useMentionSuggestions(mentionQuery);
317
317
-
318
318
-
// Check for mention pattern whenever editor state changes
319
319
-
useEffect(() => {
320
320
-
const { $from } = props.editorState.selection;
321
321
-
const textBefore = $from.parent.textBetween(
322
322
-
Math.max(0, $from.parentOffset - 50),
323
323
-
$from.parentOffset,
324
324
-
null,
325
325
-
"\ufffc",
326
326
-
);
327
327
-
328
328
-
// Look for @ followed by word characters before cursor
329
329
-
const match = textBefore.match(/(?:^|\s)@([\w.]*)$/);
330
330
-
331
331
-
if (match && props.view.current) {
332
332
-
const queryBefore = match[1];
333
333
-
const from = $from.pos - queryBefore.length - 1;
334
334
-
335
335
-
// Get text after cursor to find the rest of the handle
336
336
-
const textAfter = $from.parent.textBetween(
337
337
-
$from.parentOffset,
338
338
-
Math.min($from.parent.content.size, $from.parentOffset + 50),
339
339
-
null,
340
340
-
"\ufffc",
341
341
-
);
342
342
-
343
343
-
// Match word characters after cursor until space or end
344
344
-
const afterMatch = textAfter.match(/^([\w.]*)/);
345
345
-
const queryAfter = afterMatch ? afterMatch[1] : "";
346
346
-
347
347
-
// Combine the full handle
348
348
-
const query = queryBefore + queryAfter;
349
349
-
const to = $from.pos + queryAfter.length;
350
350
-
351
351
-
setMentionQuery(query);
352
352
-
setMentionRange({ from, to });
353
353
-
354
354
-
// Get coordinates for the autocomplete popup
355
355
-
const coords = props.view.current.coordsAtPos(from);
356
356
-
setMentionCoords({
357
357
-
top: coords.bottom + window.scrollY,
358
358
-
left: coords.left + window.scrollX,
359
359
-
});
360
360
-
setSuggestionIndex(0);
361
361
-
} else {
362
362
-
setMentionQuery(null);
363
363
-
setMentionRange(null);
364
364
-
setMentionCoords(null);
365
365
-
}
366
366
-
}, [props.editorState, props.view, setSuggestionIndex]);
367
367
-
368
368
-
// Update parent's mention state
369
369
-
useEffect(() => {
370
370
-
const active = mentionQuery !== null && suggestions.length > 0;
371
371
-
const selectedMention =
372
372
-
active && suggestions[suggestionIndex]
373
373
-
? suggestions[suggestionIndex]
374
374
-
: null;
375
375
-
props.onMentionStateChange(active, mentionRange, selectedMention);
376
376
-
}, [mentionQuery, suggestions, suggestionIndex, mentionRange]);
377
377
-
378
378
-
// Handle keyboard navigation for arrow keys only
379
379
-
useEffect(() => {
380
380
-
if (!mentionQuery || !props.view.current) return;
381
381
-
382
382
-
const handleKeyDown = (e: KeyboardEvent) => {
383
383
-
if (suggestions.length === 0) return;
384
384
-
385
385
-
if (e.key === "ArrowUp") {
386
386
-
e.preventDefault();
387
387
-
e.stopPropagation();
388
388
-
389
389
-
if (suggestionIndex > 0) {
390
390
-
setSuggestionIndex((i) => i - 1);
391
391
-
}
392
392
-
} else if (e.key === "ArrowDown") {
393
393
-
e.preventDefault();
394
394
-
e.stopPropagation();
395
395
-
396
396
-
if (suggestionIndex < suggestions.length - 1) {
397
397
-
setSuggestionIndex((i) => i + 1);
398
398
-
}
399
399
-
}
400
400
-
};
401
401
-
402
402
-
const dom = props.view.current.dom;
403
403
-
dom.addEventListener("keydown", handleKeyDown, true);
404
404
-
405
405
-
return () => {
406
406
-
dom.removeEventListener("keydown", handleKeyDown, true);
407
407
-
};
408
408
-
}, [
409
409
-
mentionQuery,
410
410
-
suggestions,
411
411
-
suggestionIndex,
412
412
-
props.view,
413
413
-
setSuggestionIndex,
414
414
-
]);
415
415
-
416
416
-
if (!mentionCoords || suggestions.length === 0) return null;
417
417
-
418
418
-
// The styles in this component should match the Menu styles in components/Layout.tsx
419
419
-
420
420
-
let menuItemStyle = `menuItem py-0.5! text-secondary flex-col! gap-0! leading-tight text-sm truncate`;
421
421
-
let menuItemSubtextStyle = `text-tertiary italic text-xs font-normal min-w-0 truncate`;
422
422
-
423
423
-
let menuItemSelectedStyle = `bg-[var(--accent-light)]`;
424
424
-
425
425
-
return (
426
426
-
<Popover.Root open>
427
427
-
{createPortal(
428
428
-
<Popover.Anchor
429
429
-
style={{
430
430
-
top: mentionCoords.top,
431
431
-
left: mentionCoords.left,
432
432
-
position: "absolute",
433
433
-
}}
434
434
-
/>,
435
435
-
document.body,
436
436
-
)}
437
437
-
<Popover.Portal>
438
438
-
<Popover.Content
439
439
-
side="bottom"
440
440
-
align="start"
441
441
-
sideOffset={4}
442
442
-
collisionPadding={20}
443
443
-
onOpenAutoFocus={(e) => e.preventDefault()}
444
444
-
className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-1 border border-border rounded-md shadow-md sm:max-w-xs w-[1000px]`}
445
445
-
>
446
446
-
<ul className="list-none p-0 text-sm">
447
447
-
{suggestions.map((result, index) => {
448
448
-
if (result.type === "did")
449
449
-
return (
450
450
-
<div
451
451
-
className={`
452
452
-
${menuItemStyle}
453
453
-
${index === suggestionIndex ? menuItemSelectedStyle : ""}
454
454
-
455
455
-
`}
456
456
-
key={result.did}
457
457
-
onClick={() => {
458
458
-
if (mentionRange) {
459
459
-
props.onSelect(result, mentionRange);
460
460
-
setMentionQuery(null);
461
461
-
setMentionRange(null);
462
462
-
setMentionCoords(null);
463
463
-
}
464
464
-
}}
465
465
-
onMouseDown={(e) => e.preventDefault()}
466
466
-
>
467
467
-
{result.displayName
468
468
-
? result.displayName
469
469
-
: `@${result.handle}`}
470
470
-
{result.displayName && (
471
471
-
<div className={menuItemSubtextStyle}>
472
472
-
@{result.handle}
473
473
-
</div>
474
474
-
)}
475
475
-
</div>
476
476
-
);
477
477
-
if (result.type == "publication") {
478
478
-
return (
479
479
-
<div
480
480
-
className={`
481
481
-
${menuItemStyle}
482
482
-
${index === suggestionIndex ? menuItemSelectedStyle : ""}
483
483
-
`}
484
484
-
key={result.uri}
485
485
-
onClick={() => {
486
486
-
if (mentionRange) {
487
487
-
props.onSelect(result, mentionRange);
488
488
-
setMentionQuery(null);
489
489
-
setMentionRange(null);
490
490
-
setMentionCoords(null);
491
491
-
}
492
492
-
}}
493
493
-
onMouseDown={(e) => e.preventDefault()}
494
494
-
>
495
495
-
{result.name}
496
496
-
<div className={menuItemSubtextStyle}>
497
497
-
Leaflet Publication
498
498
-
</div>
499
499
-
</div>
500
500
-
);
501
501
-
}
502
502
-
})}
503
503
-
</ul>
504
504
-
</Popover.Content>
505
505
-
</Popover.Portal>
506
506
-
</Popover.Root>
507
507
-
);
508
508
-
}
509
509
-
510
510
-
export type Mention =
511
511
-
| { type: "did"; handle: string; did: string; displayName?: string }
512
512
-
| { type: "publication"; uri: string; name: string };
513
513
-
function useMentionSuggestions(query: string | null) {
514
514
-
const [suggestionIndex, setSuggestionIndex] = useState(0);
515
515
-
const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
516
516
-
517
517
-
useDebouncedEffect(
518
518
-
async () => {
519
519
-
if (!query) {
520
520
-
setSuggestions([]);
521
521
-
return;
522
522
-
}
523
523
-
524
524
-
const agent = new Agent("https://public.api.bsky.app");
525
525
-
const [result, publications] = await Promise.all([
526
526
-
agent.searchActorsTypeahead({
527
527
-
q: query,
528
528
-
limit: 8,
529
529
-
}),
530
530
-
callRPC(`search_publication_names`, { query, limit: 8 }),
531
531
-
]);
532
532
-
setSuggestions([
533
533
-
...result.data.actors.map((actor) => ({
534
534
-
type: "did" as const,
535
535
-
handle: actor.handle,
536
536
-
did: actor.did,
537
537
-
displayName: actor.displayName,
538
538
-
})),
539
539
-
...publications.result.publications.map((p) => ({
540
540
-
type: "publication" as const,
541
541
-
uri: p.uri,
542
542
-
name: p.name,
543
543
-
})),
544
544
-
]);
545
545
-
},
546
546
-
300,
547
547
-
[query],
548
548
-
);
549
549
-
550
550
-
useEffect(() => {
551
551
-
if (suggestionIndex > suggestions.length - 1) {
552
552
-
setSuggestionIndex(Math.max(0, suggestions.length - 1));
553
553
-
}
554
554
-
}, [suggestionIndex, suggestions.length]);
555
555
-
556
556
-
return {
557
557
-
suggestions,
558
558
-
suggestionIndex,
559
559
-
setSuggestionIndex,
560
560
-
};
561
284
}
562
285
563
286
/**
+3
-5
components/Blocks/TextBlock/index.tsx
···
25
25
import { DotLoader } from "components/utils/DotLoader";
26
26
import { useMountProsemirror } from "./mountProsemirror";
27
27
import { schema } from "./schema";
28
28
-
import {
29
29
-
addMentionToEditor,
30
30
-
Mention,
31
31
-
MentionAutocomplete,
32
32
-
} from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
28
28
+
29
29
+
import { Mention, MentionAutocomplete } from "components/Mention";
30
30
+
import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
33
31
34
32
const HeadingStyle = {
35
33
1: "text-xl font-bold",
+298
components/Mention.tsx
···
1
1
+
"use client";
2
2
+
import { Agent } from "@atproto/api";
3
3
+
import { useState, useEffect } from "react";
4
4
+
import { createPortal } from "react-dom";
5
5
+
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
6
+
import * as Popover from "@radix-ui/react-popover";
7
7
+
import { EditorState } from "prosemirror-state";
8
8
+
import { EditorView } from "prosemirror-view";
9
9
+
import { callRPC } from "app/api/rpc/client";
10
10
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
11
11
+
import { onMouseDown } from "src/utils/iosInputMouseDown";
12
12
+
13
13
+
export function MentionAutocomplete(props: {
14
14
+
editorState: EditorState;
15
15
+
view: React.RefObject<EditorView | null>;
16
16
+
onSelect: (mention: Mention, range: { from: number; to: number }) => void;
17
17
+
onMentionStateChange: (
18
18
+
active: boolean,
19
19
+
range: { from: number; to: number } | null,
20
20
+
selectedMention: Mention | null,
21
21
+
) => void;
22
22
+
}) {
23
23
+
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
24
24
+
const [mentionRange, setMentionRange] = useState<{
25
25
+
from: number;
26
26
+
to: number;
27
27
+
} | null>(null);
28
28
+
const [mentionCoords, setMentionCoords] = useState<{
29
29
+
top: number;
30
30
+
left: number;
31
31
+
} | null>(null);
32
32
+
33
33
+
const { suggestionIndex, setSuggestionIndex, suggestions } =
34
34
+
useMentionSuggestions(mentionQuery);
35
35
+
36
36
+
// Check for mention pattern whenever editor state changes
37
37
+
useEffect(() => {
38
38
+
const { $from } = props.editorState.selection;
39
39
+
const textBefore = $from.parent.textBetween(
40
40
+
Math.max(0, $from.parentOffset - 50),
41
41
+
$from.parentOffset,
42
42
+
null,
43
43
+
"\ufffc",
44
44
+
);
45
45
+
46
46
+
// Look for @ followed by word characters before cursor
47
47
+
const match = textBefore.match(/(?:^|\s)@([\w.]*)$/);
48
48
+
49
49
+
if (match && props.view.current) {
50
50
+
const queryBefore = match[1];
51
51
+
const from = $from.pos - queryBefore.length - 1;
52
52
+
53
53
+
// Get text after cursor to find the rest of the handle
54
54
+
const textAfter = $from.parent.textBetween(
55
55
+
$from.parentOffset,
56
56
+
Math.min($from.parent.content.size, $from.parentOffset + 50),
57
57
+
null,
58
58
+
"\ufffc",
59
59
+
);
60
60
+
61
61
+
// Match word characters after cursor until space or end
62
62
+
const afterMatch = textAfter.match(/^([\w.]*)/);
63
63
+
const queryAfter = afterMatch ? afterMatch[1] : "";
64
64
+
65
65
+
// Combine the full handle
66
66
+
const query = queryBefore + queryAfter;
67
67
+
const to = $from.pos + queryAfter.length;
68
68
+
69
69
+
setMentionQuery(query);
70
70
+
setMentionRange({ from, to });
71
71
+
72
72
+
// Get coordinates for the autocomplete popup
73
73
+
const coords = props.view.current.coordsAtPos(from);
74
74
+
setMentionCoords({
75
75
+
top: coords.bottom + window.scrollY,
76
76
+
left: coords.left + window.scrollX,
77
77
+
});
78
78
+
setSuggestionIndex(0);
79
79
+
} else {
80
80
+
setMentionQuery(null);
81
81
+
setMentionRange(null);
82
82
+
setMentionCoords(null);
83
83
+
}
84
84
+
}, [props.editorState, props.view, setSuggestionIndex]);
85
85
+
86
86
+
// Update parent's mention state
87
87
+
useEffect(() => {
88
88
+
const active = mentionQuery !== null && suggestions.length > 0;
89
89
+
const selectedMention =
90
90
+
active && suggestions[suggestionIndex]
91
91
+
? suggestions[suggestionIndex]
92
92
+
: null;
93
93
+
props.onMentionStateChange(active, mentionRange, selectedMention);
94
94
+
}, [mentionQuery, suggestions, suggestionIndex, mentionRange]);
95
95
+
96
96
+
// Handle keyboard navigation for arrow keys only
97
97
+
useEffect(() => {
98
98
+
if (!mentionQuery || !props.view.current) return;
99
99
+
100
100
+
const handleKeyDown = (e: KeyboardEvent) => {
101
101
+
if (suggestions.length === 0) return;
102
102
+
103
103
+
if (e.key === "ArrowUp") {
104
104
+
e.preventDefault();
105
105
+
e.stopPropagation();
106
106
+
107
107
+
if (suggestionIndex > 0) {
108
108
+
setSuggestionIndex((i) => i - 1);
109
109
+
}
110
110
+
} else if (e.key === "ArrowDown") {
111
111
+
e.preventDefault();
112
112
+
e.stopPropagation();
113
113
+
114
114
+
if (suggestionIndex < suggestions.length - 1) {
115
115
+
setSuggestionIndex((i) => i + 1);
116
116
+
}
117
117
+
}
118
118
+
};
119
119
+
120
120
+
const dom = props.view.current.dom;
121
121
+
dom.addEventListener("keydown", handleKeyDown, true);
122
122
+
123
123
+
return () => {
124
124
+
dom.removeEventListener("keydown", handleKeyDown, true);
125
125
+
};
126
126
+
}, [
127
127
+
mentionQuery,
128
128
+
suggestions,
129
129
+
suggestionIndex,
130
130
+
props.view,
131
131
+
setSuggestionIndex,
132
132
+
]);
133
133
+
134
134
+
if (!mentionCoords || suggestions.length === 0) return null;
135
135
+
let headerStyle = "text-xs text-tertiary font-bold italic pt-1 px-2";
136
136
+
137
137
+
return (
138
138
+
<Popover.Root open>
139
139
+
{createPortal(
140
140
+
<Popover.Anchor
141
141
+
style={{
142
142
+
top: mentionCoords.top,
143
143
+
left: mentionCoords.left,
144
144
+
position: "absolute",
145
145
+
}}
146
146
+
/>,
147
147
+
document.body,
148
148
+
)}
149
149
+
<Popover.Portal>
150
150
+
<Popover.Content
151
151
+
side="bottom"
152
152
+
align="start"
153
153
+
sideOffset={4}
154
154
+
collisionPadding={20}
155
155
+
onOpenAutoFocus={(e) => e.preventDefault()}
156
156
+
className={`dropdownMenu z-20 bg-bg-page flex flex-col p-1 gap-1 border border-border rounded-md shadow-md sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width)
157
157
+
max-h-(--radix-popover-content-available-height)
158
158
+
overflow-y-scroll`}
159
159
+
>
160
160
+
<ul className="list-none p-0 text-sm">
161
161
+
<div className={headerStyle}>People</div>
162
162
+
{suggestions
163
163
+
.filter((result) => result.type === "did")
164
164
+
.map((result, index) => {
165
165
+
return (
166
166
+
<Result
167
167
+
key={result.did}
168
168
+
onClick={() => {
169
169
+
if (mentionRange) {
170
170
+
props.onSelect(result, mentionRange);
171
171
+
setMentionQuery(null);
172
172
+
setMentionRange(null);
173
173
+
setMentionCoords(null);
174
174
+
}
175
175
+
}}
176
176
+
onMouseDown={(e) => e.preventDefault()}
177
177
+
result={
178
178
+
result.displayName
179
179
+
? result.displayName
180
180
+
: `@${result.handle}`
181
181
+
}
182
182
+
subtext={
183
183
+
result.displayName ? `@${result.handle}` : undefined
184
184
+
}
185
185
+
selected={index === suggestionIndex}
186
186
+
/>
187
187
+
);
188
188
+
})}
189
189
+
<hr className="border-border-light mx-1 my-1" />
190
190
+
<div className={headerStyle}>Publications</div>
191
191
+
{suggestions
192
192
+
.filter((result) => result.type === "publication")
193
193
+
.map((result, index) => {
194
194
+
return (
195
195
+
<Result
196
196
+
key={result.uri}
197
197
+
onClick={() => {
198
198
+
if (mentionRange) {
199
199
+
props.onSelect(result, mentionRange);
200
200
+
setMentionQuery(null);
201
201
+
setMentionRange(null);
202
202
+
setMentionCoords(null);
203
203
+
}
204
204
+
}}
205
205
+
onMouseDown={(e) => e.preventDefault()}
206
206
+
result={result.name}
207
207
+
selected={index === suggestionIndex}
208
208
+
/>
209
209
+
);
210
210
+
})}
211
211
+
</ul>
212
212
+
</Popover.Content>
213
213
+
</Popover.Portal>
214
214
+
</Popover.Root>
215
215
+
);
216
216
+
}
217
217
+
218
218
+
const Result = (props: {
219
219
+
result: React.ReactNode;
220
220
+
subtext?: React.ReactNode;
221
221
+
onClick: () => void;
222
222
+
onMouseDown: (e: React.MouseEvent) => void;
223
223
+
selected?: boolean;
224
224
+
}) => {
225
225
+
return (
226
226
+
<div
227
227
+
className={`
228
228
+
menuItem flex-col! gap-0!
229
229
+
text-secondary leading-tight text-sm truncate
230
230
+
${props.subtext ? "py-1!" : "py-2!"}
231
231
+
${props.selected ? "bg-[var(--accent-light)]" : ""}`}
232
232
+
onClick={() => props.onClick()}
233
233
+
onMouseDown={(e) => props.onMouseDown(e)}
234
234
+
>
235
235
+
<div className={`flex gap-2 items-center `}>
236
236
+
<div className="truncate w-full grow min-w-0 ">{props.result}</div>
237
237
+
</div>
238
238
+
{props.subtext && (
239
239
+
<div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-0.5">
240
240
+
{props.subtext}
241
241
+
</div>
242
242
+
)}
243
243
+
</div>
244
244
+
);
245
245
+
};
246
246
+
247
247
+
export type Mention =
248
248
+
| { type: "did"; handle: string; did: string; displayName?: string }
249
249
+
| { type: "publication"; uri: string; name: string };
250
250
+
function useMentionSuggestions(query: string | null) {
251
251
+
const [suggestionIndex, setSuggestionIndex] = useState(0);
252
252
+
const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
253
253
+
254
254
+
useDebouncedEffect(
255
255
+
async () => {
256
256
+
if (!query) {
257
257
+
setSuggestions([]);
258
258
+
return;
259
259
+
}
260
260
+
261
261
+
const agent = new Agent("https://public.api.bsky.app");
262
262
+
const [result, publications] = await Promise.all([
263
263
+
agent.searchActorsTypeahead({
264
264
+
q: query,
265
265
+
limit: 8,
266
266
+
}),
267
267
+
callRPC(`search_publication_names`, { query, limit: 8 }),
268
268
+
]);
269
269
+
setSuggestions([
270
270
+
...result.data.actors.map((actor) => ({
271
271
+
type: "did" as const,
272
272
+
handle: actor.handle,
273
273
+
did: actor.did,
274
274
+
displayName: actor.displayName,
275
275
+
})),
276
276
+
...publications.result.publications.map((p) => ({
277
277
+
type: "publication" as const,
278
278
+
uri: p.uri,
279
279
+
name: p.name,
280
280
+
})),
281
281
+
]);
282
282
+
},
283
283
+
300,
284
284
+
[query],
285
285
+
);
286
286
+
287
287
+
useEffect(() => {
288
288
+
if (suggestionIndex > suggestions.length - 1) {
289
289
+
setSuggestionIndex(Math.max(0, suggestions.length - 1));
290
290
+
}
291
291
+
}, [suggestionIndex, suggestions.length]);
292
292
+
293
293
+
return {
294
294
+
suggestions,
295
295
+
suggestionIndex,
296
296
+
setSuggestionIndex,
297
297
+
};
298
298
+
}