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
refactor autocomplete and implement post mentions
awarm.space
3 months ago
86f92eed
6d31f1cb
+492
-309
10 changed files
expand all
collapse all
unified
split
app
[leaflet_id]
publish
BskyPostEditorProsemirror.tsx
api
rpc
[command]
route.ts
search_publication_documents.ts
search_publication_names.ts
components
Blocks
BlockCommandBar.tsx
TextBlock
index.tsx
inputRules.ts
keymap.ts
mountProsemirror.ts
Mention.tsx
+62
-57
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
127
127
return tr;
128
128
});
129
129
}
130
130
-
export type MentionState = {
131
131
-
active: boolean;
132
132
-
range: { from: number; to: number } | null;
133
133
-
selectedMention: Mention | null;
134
134
-
};
135
130
export function BlueskyPostEditorProsemirror(props: {
136
131
editorStateRef: React.RefObject<EditorState | null>;
137
132
initialContent?: string;
···
140
135
const mountRef = useRef<HTMLDivElement | null>(null);
141
136
const viewRef = useRef<EditorView | null>(null);
142
137
const [editorState, setEditorState] = useState<EditorState | null>(null);
143
143
-
const [mentionState, setMentionState] = useState<MentionState>({
144
144
-
active: false,
145
145
-
range: null,
146
146
-
selectedMention: null,
147
147
-
});
138
138
+
const [mentionOpen, setMentionOpen] = useState(false);
139
139
+
const [mentionCoords, setMentionCoords] = useState<{
140
140
+
top: number;
141
141
+
left: number;
142
142
+
} | null>(null);
143
143
+
const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
144
144
+
145
145
+
const openMentionAutocomplete = useCallback(() => {
146
146
+
if (!viewRef.current) return;
147
147
+
const view = viewRef.current;
148
148
+
const pos = view.state.selection.from;
149
149
+
setMentionInsertPos(pos);
150
150
+
const coords = view.coordsAtPos(pos - 1);
151
151
+
setMentionCoords({
152
152
+
top: coords.bottom + window.scrollY,
153
153
+
left: coords.left + window.scrollX,
154
154
+
});
155
155
+
setMentionOpen(true);
156
156
+
}, []);
148
157
149
158
const handleMentionSelect = useCallback(
150
150
-
(mention: Mention, range: { from: number; to: number }) => {
159
159
+
(mention: Mention) => {
151
160
if (mention.type !== "did") return;
152
152
-
if (!viewRef.current) return;
161
161
+
if (!viewRef.current || mentionInsertPos === null) return;
153
162
const view = viewRef.current;
154
154
-
const { from, to } = range;
163
163
+
const from = mentionInsertPos - 1;
164
164
+
const to = mentionInsertPos;
155
165
const tr = view.state.tr;
156
166
157
157
-
// Delete the query text (keep the @)
158
158
-
tr.delete(from + 1, to);
167
167
+
// Delete the @ symbol
168
168
+
tr.delete(from, to);
159
169
160
160
-
// Insert the mention text after the @
161
161
-
const mentionText = mention.handle;
162
162
-
tr.insertText(mentionText, from + 1);
170
170
+
// Insert @handle
171
171
+
const mentionText = "@" + mention.handle;
172
172
+
tr.insertText(mentionText, from);
163
173
164
164
-
// Apply mention mark to @ and handle
174
174
+
// Apply mention mark
165
175
tr.addMark(
166
176
from,
167
167
-
from + 1 + mentionText.length,
177
177
+
from + mentionText.length,
168
178
bskyPostSchema.marks.mention.create({ did: mention.did }),
169
179
);
170
180
171
181
// Add a space after the mention
172
172
-
tr.insertText(" ", from + 1 + mentionText.length);
182
182
+
tr.insertText(" ", from + mentionText.length);
173
183
174
184
view.dispatch(tr);
175
185
view.focus();
176
186
},
177
177
-
[],
187
187
+
[mentionInsertPos],
178
188
);
179
189
180
180
-
const mentionStateRef = useRef(mentionState);
181
181
-
mentionStateRef.current = mentionState;
190
190
+
const handleMentionOpenChange = useCallback((open: boolean) => {
191
191
+
setMentionOpen(open);
192
192
+
if (!open) {
193
193
+
setMentionCoords(null);
194
194
+
setMentionInsertPos(null);
195
195
+
}
196
196
+
}, []);
182
197
183
198
useLayoutEffect(() => {
184
199
if (!mountRef.current) return;
185
200
201
201
+
// Input rule to trigger mention autocomplete when @ is typed
202
202
+
const mentionInputRule = new InputRule(
203
203
+
/(?:^|\s)@$/,
204
204
+
(state, match, start, end) => {
205
205
+
setTimeout(() => openMentionAutocomplete(), 0);
206
206
+
return null;
207
207
+
},
208
208
+
);
209
209
+
186
210
const initialState = EditorState.create({
187
211
schema: bskyPostSchema,
188
212
doc: props.initialContent
···
195
219
})
196
220
: undefined,
197
221
plugins: [
198
198
-
inputRules({ rules: [createHashtagInputRule()] }),
222
222
+
inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }),
199
223
keymap({
200
224
"Mod-z": undo,
201
225
"Mod-y": redo,
202
226
"Shift-Mod-z": redo,
203
203
-
Enter: (state, dispatch) => {
204
204
-
// Check if mention autocomplete is active
205
205
-
const currentMentionState = mentionStateRef.current;
206
206
-
if (
207
207
-
currentMentionState.active &&
208
208
-
currentMentionState.selectedMention &&
209
209
-
currentMentionState.range
210
210
-
) {
211
211
-
handleMentionSelect(
212
212
-
currentMentionState.selectedMention,
213
213
-
currentMentionState.range,
214
214
-
);
215
215
-
return true;
216
216
-
}
217
217
-
// Otherwise let the default Enter behavior happen (new paragraph)
218
218
-
return false;
219
219
-
},
220
227
}),
221
228
keymap(baseKeymap),
222
229
autolink({
···
251
258
view.destroy();
252
259
viewRef.current = null;
253
260
};
254
254
-
}, [handleMentionSelect]);
261
261
+
}, [openMentionAutocomplete]);
255
262
256
263
return (
257
264
<div className="relative w-full h-full group">
258
258
-
{editorState && (
259
259
-
<MentionAutocomplete
260
260
-
editorState={editorState}
261
261
-
view={viewRef}
262
262
-
onSelect={handleMentionSelect}
263
263
-
onMentionStateChange={(active, range, selectedMention) => {
264
264
-
setMentionState({ active, range, selectedMention });
265
265
-
}}
266
266
-
/>
267
267
-
)}
265
265
+
<MentionAutocomplete
266
266
+
open={mentionOpen}
267
267
+
onOpenChange={handleMentionOpenChange}
268
268
+
view={viewRef}
269
269
+
onSelect={handleMentionSelect}
270
270
+
coords={mentionCoords}
271
271
+
/>
268
272
{editorState?.doc.textContent.length === 0 && (
269
273
<div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
270
274
Write a post to share your writing!
···
388
392
);
389
393
tr.insertText(" ", from + 1 + mention.handle.length);
390
394
}
391
391
-
if (mention.type === "publication") {
392
392
-
tr.insertText(mention.name, from + 1);
395
395
+
if (mention.type === "publication" || mention.type === "post") {
396
396
+
let name = mention.type == "post" ? mention.title : mention.name;
397
397
+
tr.insertText(name, from + 1);
393
398
tr.addMark(
394
399
from,
395
395
-
from + 1 + mention.name.length,
400
400
+
from + 1 + name.length,
396
401
schema.marks.atMention.create({ atURI: mention.uri }),
397
402
);
398
398
-
tr.insertText(" ", from + 1 + mention.name.length);
403
403
+
tr.insertText(" ", from + 1 + name.length);
399
404
}
400
405
401
406
// Insert the mention text after the @
+2
app/api/rpc/[command]/route.ts
···
12
12
import { get_leaflet_data } from "./get_leaflet_data";
13
13
import { get_publication_data } from "./get_publication_data";
14
14
import { search_publication_names } from "./search_publication_names";
15
15
+
import { search_publication_documents } from "./search_publication_documents";
15
16
16
17
let supabase = createClient<Database>(
17
18
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
37
38
get_leaflet_data,
38
39
get_publication_data,
39
40
search_publication_names,
41
41
+
search_publication_documents,
40
42
];
41
43
export async function POST(
42
44
req: Request,
+41
app/api/rpc/[command]/search_publication_documents.ts
···
1
1
+
import { z } from "zod";
2
2
+
import { makeRoute } from "../lib";
3
3
+
import type { Env } from "./route";
4
4
+
5
5
+
export type SearchPublicationDocumentsReturnType = Awaited<
6
6
+
ReturnType<(typeof search_publication_documents)["handler"]>
7
7
+
>;
8
8
+
9
9
+
export const search_publication_documents = makeRoute({
10
10
+
route: "search_publication_documents",
11
11
+
input: z.object({
12
12
+
publication_uri: z.string(),
13
13
+
query: z.string(),
14
14
+
limit: z.number().optional().default(10),
15
15
+
}),
16
16
+
handler: async (
17
17
+
{ publication_uri, query, limit },
18
18
+
{ supabase }: Pick<Env, "supabase">,
19
19
+
) => {
20
20
+
// Get documents in the publication, filtering by title using JSON operator
21
21
+
const { data: documents, error } = await supabase
22
22
+
.from("documents_in_publications")
23
23
+
.select("document, documents!inner(uri, data)")
24
24
+
.eq("publication", publication_uri)
25
25
+
.ilike("documents.data->>title", `%${query}%`)
26
26
+
.limit(limit);
27
27
+
28
28
+
if (error) {
29
29
+
throw new Error(
30
30
+
`Failed to search publication documents: ${error.message}`,
31
31
+
);
32
32
+
}
33
33
+
34
34
+
const result = documents.map((d) => ({
35
35
+
uri: d.documents.uri,
36
36
+
title: (d.documents.data as { title?: string })?.title || "Untitled",
37
37
+
}));
38
38
+
39
39
+
return { result: { documents: result } };
40
40
+
},
41
41
+
});
+9
-4
app/api/rpc/[command]/search_publication_names.ts
···
16
16
{ query, limit },
17
17
{ supabase }: Pick<Env, "supabase">,
18
18
) => {
19
19
-
// Search publications by name (case-insensitive partial match)
19
19
+
// Search publications by name in record (case-insensitive partial match)
20
20
const { data: publications, error } = await supabase
21
21
.from("publications")
22
22
-
.select("uri, name, identity_did, record")
23
23
-
.ilike("name", `%${query}%`)
22
22
+
.select("uri, record")
23
23
+
.ilike("record->>name", `%${query}%`)
24
24
.limit(limit);
25
25
26
26
if (error) {
27
27
throw new Error(`Failed to search publications: ${error.message}`);
28
28
}
29
29
30
30
-
return { result: { publications } };
30
30
+
const result = publications.map((p) => ({
31
31
+
uri: p.uri,
32
32
+
name: (p.record as { name?: string })?.name || "Untitled",
33
33
+
}));
34
34
+
35
35
+
return { result: { publications: result } };
31
36
},
32
37
});
+1
-1
components/Blocks/BlockCommandBar.tsx
···
197
197
198
198
return (
199
199
<button
200
200
-
className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]"}`}
200
200
+
className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`}
201
201
onMouseOver={() => {
202
202
props.setHighlighted(props.name);
203
203
}}
+76
-21
components/Blocks/TextBlock/index.tsx
···
188
188
let editorState = useEditorStates(
189
189
(s) => s.editorStates[props.entityID],
190
190
)?.editor;
191
191
-
let { viewRef, handleMentionSelect, setMentionState, mentionStateRef } =
192
192
-
useMentionState(props.entityID);
191
191
+
const {
192
192
+
viewRef,
193
193
+
mentionOpen,
194
194
+
mentionCoords,
195
195
+
openMentionAutocomplete,
196
196
+
handleMentionSelect,
197
197
+
handleMentionOpenChange,
198
198
+
} = useMentionState(props.entityID);
193
199
194
200
let { mountRef, actionTimeout } = useMountProsemirror({
195
201
props,
196
196
-
mentionStateRef,
202
202
+
openMentionAutocomplete,
197
203
});
198
204
199
205
return (
···
230
236
}
231
237
}}
232
238
onFocus={() => {
239
239
+
handleMentionOpenChange(false);
233
240
setTimeout(() => {
234
241
useUIState.getState().setSelectedBlock(props);
235
242
useUIState.setState(() => ({
···
255
262
${props.className}`}
256
263
ref={mountRef}
257
264
/>
258
258
-
{editorState && focused && (
265
265
+
{focused && (
259
266
<MentionAutocomplete
260
260
-
editorState={editorState}
267
267
+
open={mentionOpen}
268
268
+
onOpenChange={handleMentionOpenChange}
261
269
view={viewRef}
262
270
onSelect={handleMentionSelect}
263
263
-
onMentionStateChange={(active, range, selectedMention) => {
264
264
-
setMentionState({ active, range, selectedMention });
265
265
-
}}
271
271
+
coords={mentionCoords}
266
272
/>
267
273
)}
268
274
{editorState?.doc.textContent.length === 0 &&
···
459
465
let view = useEditorStates((s) => s.editorStates[entityID])?.view;
460
466
let viewRef = useRef(view || null);
461
467
viewRef.current = view || null;
462
462
-
const [mentionState, setMentionState] = useState<{
463
463
-
active: boolean;
464
464
-
range: { from: number; to: number } | null;
465
465
-
selectedMention: Mention | null;
466
466
-
}>({ active: false, range: null, selectedMention: null });
467
467
-
const mentionStateRef = useRef(mentionState);
468
468
-
mentionStateRef.current = mentionState;
468
468
+
469
469
+
const [mentionOpen, setMentionOpen] = useState(false);
470
470
+
const [mentionCoords, setMentionCoords] = useState<{
471
471
+
top: number;
472
472
+
left: number;
473
473
+
} | null>(null);
474
474
+
const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
475
475
+
476
476
+
// Close autocomplete when this block is no longer focused
477
477
+
const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID);
478
478
+
useEffect(() => {
479
479
+
if (!isFocused) {
480
480
+
setMentionOpen(false);
481
481
+
setMentionCoords(null);
482
482
+
setMentionInsertPos(null);
483
483
+
}
484
484
+
}, [isFocused]);
485
485
+
486
486
+
const openMentionAutocomplete = useCallback(() => {
487
487
+
const view = useEditorStates.getState().editorStates[entityID]?.view;
488
488
+
if (!view) return;
489
489
+
490
490
+
// Get the position right after the @ we just inserted
491
491
+
const pos = view.state.selection.from;
492
492
+
setMentionInsertPos(pos);
493
493
+
494
494
+
// Get coordinates for the popup
495
495
+
const coords = view.coordsAtPos(pos - 1); // Position of the @
496
496
+
setMentionCoords({
497
497
+
top: coords.bottom + window.scrollY,
498
498
+
left: coords.left + window.scrollX,
499
499
+
});
500
500
+
setMentionOpen(true);
501
501
+
}, [entityID]);
469
502
470
503
const handleMentionSelect = useCallback(
471
471
-
(mention: Mention, range: { from: number; to: number }) => {
472
472
-
let view = useEditorStates.getState().editorStates[entityID]?.view;
473
473
-
if (!view) return;
474
474
-
addMentionToEditor(mention, range, view);
504
504
+
(mention: Mention) => {
505
505
+
const view = useEditorStates.getState().editorStates[entityID]?.view;
506
506
+
if (!view || mentionInsertPos === null) return;
507
507
+
508
508
+
// The @ is at mentionInsertPos - 1, we need to replace it with the mention
509
509
+
const from = mentionInsertPos - 1;
510
510
+
const to = mentionInsertPos;
511
511
+
512
512
+
addMentionToEditor(mention, { from, to }, view);
513
513
+
view.focus();
475
514
},
476
476
-
[],
515
515
+
[entityID, mentionInsertPos],
477
516
);
478
478
-
return { mentionStateRef, handleMentionSelect, viewRef, setMentionState };
517
517
+
518
518
+
const handleMentionOpenChange = useCallback((open: boolean) => {
519
519
+
setMentionOpen(open);
520
520
+
if (!open) {
521
521
+
setMentionCoords(null);
522
522
+
setMentionInsertPos(null);
523
523
+
}
524
524
+
}, []);
525
525
+
526
526
+
return {
527
527
+
viewRef,
528
528
+
mentionOpen,
529
529
+
mentionCoords,
530
530
+
openMentionAutocomplete,
531
531
+
handleMentionSelect,
532
532
+
handleMentionOpenChange,
533
533
+
};
479
534
};
+9
components/Blocks/TextBlock/inputRules.ts
···
14
14
export const inputrules = (
15
15
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
16
16
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
17
17
+
openMentionAutocomplete?: () => void,
17
18
) =>
18
19
inputRules({
19
20
//Strikethrough
···
180
181
data: { type: "number", value: headingLevel },
181
182
});
182
183
return tr;
184
184
+
}),
185
185
+
186
186
+
// Mention - @ at start of line or after space
187
187
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
188
188
+
if (!openMentionAutocomplete) return null;
189
189
+
// Schedule opening the autocomplete after the transaction is applied
190
190
+
setTimeout(() => openMentionAutocomplete(), 0);
191
191
+
return null; // Let the @ be inserted normally
183
192
}),
184
193
],
185
194
});
+1
-20
components/Blocks/TextBlock/keymap.ts
···
24
24
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
25
25
import { isTextBlock } from "src/utils/isTextBlock";
26
26
import { UndoManager } from "src/undoManager";
27
27
-
import {
28
28
-
addMentionToEditor,
29
29
-
MentionState,
30
30
-
} from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
31
31
-
32
27
type PropsRef = RefObject<
33
28
BlockProps & {
34
29
entity_set: { set: string };
···
39
34
propsRef: PropsRef,
40
35
repRef: RefObject<Replicache<ReplicacheMutators> | null>,
41
36
um: UndoManager,
42
42
-
mentionStateRef: RefObject<MentionState>,
37
37
+
openMentionAutocomplete: () => void,
43
38
) =>
44
39
({
45
40
"Meta-b": toggleMark(schema.marks.strong),
···
143
138
"Shift-Backspace": backspace(propsRef, repRef),
144
139
Enter: (state, dispatch, view) => {
145
140
return um.withUndoGroup(() => {
146
146
-
const currentMentionState = mentionStateRef.current;
147
147
-
if (
148
148
-
currentMentionState.active &&
149
149
-
currentMentionState.selectedMention &&
150
150
-
currentMentionState.range
151
151
-
) {
152
152
-
if (view)
153
153
-
addMentionToEditor(
154
154
-
currentMentionState.selectedMention,
155
155
-
currentMentionState.range,
156
156
-
view,
157
157
-
);
158
158
-
return true;
159
159
-
}
160
141
return enter(propsRef, repRef)(state, dispatch, view);
161
142
});
162
143
},
+4
-5
components/Blocks/TextBlock/mountProsemirror.ts
···
23
23
import { useHandlePaste } from "./useHandlePaste";
24
24
import { BlockProps } from "../Block";
25
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
26
-
import { MentionState } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
27
26
28
27
export function useMountProsemirror({
29
28
props,
30
30
-
mentionStateRef,
29
29
+
openMentionAutocomplete,
31
30
}: {
32
31
props: BlockProps;
33
33
-
mentionStateRef: React.RefObject<MentionState>;
32
32
+
openMentionAutocomplete: () => void;
34
33
}) {
35
34
let { entityID, parent } = props;
36
35
let rep = useReplicache();
···
55
54
propsRef,
56
55
repRef,
57
56
rep.undoManager,
58
58
-
mentionStateRef,
57
57
+
openMentionAutocomplete,
59
58
);
60
59
const editor = EditorState.create({
61
60
schema: schema,
62
61
plugins: [
63
62
ySyncPlugin(value),
64
63
keymap(km),
65
65
-
inputrules(propsRef, repRef),
64
64
+
inputrules(propsRef, repRef, openMentionAutocomplete),
66
65
keymap(baseKeymap),
67
66
highlightSelectionPlugin,
68
67
autolink({
+287
-201
components/Mention.tsx
···
1
1
"use client";
2
2
import { Agent } from "@atproto/api";
3
3
-
import { useState, useEffect, Fragment } from "react";
3
3
+
import { useState, useEffect, Fragment, useRef, useCallback } 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
7
import { EditorView } from "prosemirror-view";
9
8
import { callRPC } from "app/api/rpc/client";
10
9
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
11
11
-
import { onMouseDown } from "src/utils/iosInputMouseDown";
10
10
+
import { GoBackSmall } from "components/Icons/GoBackSmall";
11
11
+
import { SearchTiny } from "components/Icons/SearchTiny";
12
12
13
13
export function MentionAutocomplete(props: {
14
14
-
editorState: EditorState;
14
14
+
open: boolean;
15
15
+
onOpenChange: (open: boolean) => void;
15
16
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;
17
17
+
onSelect: (mention: Mention) => void;
18
18
+
coords: { top: number; left: number } | null;
22
19
}) {
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;
20
20
+
const [searchQuery, setSearchQuery] = useState("");
21
21
+
const inputRef = useRef<HTMLInputElement>(null);
22
22
+
const contentRef = useRef<HTMLDivElement>(null);
68
23
69
69
-
setMentionQuery(query);
70
70
-
setMentionRange({ from, to });
24
24
+
const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } =
25
25
+
useMentionSuggestions(searchQuery);
71
26
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
-
});
27
27
+
// Clear search when scope changes
28
28
+
const handleScopeChange = useCallback(
29
29
+
(newScope: MentionScope) => {
30
30
+
setSearchQuery("");
78
31
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]);
32
32
+
setScope(newScope);
33
33
+
},
34
34
+
[setScope, setSuggestionIndex],
35
35
+
);
85
36
86
86
-
// Update parent's mention state
37
37
+
// Focus input when opened
87
38
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]);
39
39
+
if (props.open && inputRef.current) {
40
40
+
// Small delay to ensure the popover is mounted
41
41
+
setTimeout(() => inputRef.current?.focus(), 0);
42
42
+
}
43
43
+
}, [props.open]);
95
44
96
96
-
// Handle keyboard navigation for arrow keys only
45
45
+
// Reset state when closed
97
46
useEffect(() => {
98
98
-
if (!mentionQuery || !props.view.current) return;
47
47
+
if (!props.open) {
48
48
+
setSearchQuery("");
49
49
+
setScope({ type: "default" });
50
50
+
setSuggestionIndex(0);
51
51
+
}
52
52
+
}, [props.open, setScope, setSuggestionIndex]);
99
53
100
100
-
const handleKeyDown = (e: KeyboardEvent) => {
101
101
-
if (suggestions.length === 0) return;
54
54
+
// Handle keyboard navigation
55
55
+
const handleKeyDown = (e: React.KeyboardEvent) => {
56
56
+
if (e.key === "Escape") {
57
57
+
e.preventDefault();
58
58
+
props.onOpenChange(false);
59
59
+
props.view.current?.focus();
60
60
+
return;
61
61
+
}
102
62
103
103
-
if (e.key === "ArrowUp") {
104
104
-
e.preventDefault();
105
105
-
e.stopPropagation();
63
63
+
if (e.key === "Backspace" && searchQuery === "") {
64
64
+
// Backspace at the start of input closes autocomplete and refocuses editor
65
65
+
e.preventDefault();
66
66
+
props.onOpenChange(false);
67
67
+
props.view.current?.focus();
68
68
+
return;
69
69
+
}
106
70
107
107
-
if (suggestionIndex > 0) {
108
108
-
setSuggestionIndex((i) => i - 1);
109
109
-
}
110
110
-
} else if (e.key === "ArrowDown") {
71
71
+
// Reverse arrow key direction when popover is rendered above
72
72
+
const isReversed = contentRef.current?.dataset.side === "top";
73
73
+
const upKey = isReversed ? "ArrowDown" : "ArrowUp";
74
74
+
const downKey = isReversed ? "ArrowUp" : "ArrowDown";
75
75
+
76
76
+
if (e.key === upKey) {
77
77
+
e.preventDefault();
78
78
+
if (suggestionIndex > 0) {
79
79
+
setSuggestionIndex((i) => i - 1);
80
80
+
}
81
81
+
} else if (e.key === downKey) {
82
82
+
e.preventDefault();
83
83
+
if (suggestionIndex < suggestions.length - 1) {
84
84
+
setSuggestionIndex((i) => i + 1);
85
85
+
}
86
86
+
} else if (e.key === "Tab") {
87
87
+
const selectedSuggestion = suggestions[suggestionIndex];
88
88
+
if (selectedSuggestion?.type === "publication") {
111
89
e.preventDefault();
112
112
-
e.stopPropagation();
113
113
-
114
114
-
if (suggestionIndex < suggestions.length - 1) {
115
115
-
setSuggestionIndex((i) => i + 1);
116
116
-
}
90
90
+
handleScopeChange({
91
91
+
type: "publication",
92
92
+
uri: selectedSuggestion.uri,
93
93
+
name: selectedSuggestion.name,
94
94
+
});
117
95
}
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
-
]);
96
96
+
} else if (e.key === "Enter") {
97
97
+
e.preventDefault();
98
98
+
const selectedSuggestion = suggestions[suggestionIndex];
99
99
+
if (selectedSuggestion) {
100
100
+
props.onSelect(selectedSuggestion);
101
101
+
props.onOpenChange(false);
102
102
+
}
103
103
+
} else if (
104
104
+
e.key === " " &&
105
105
+
searchQuery === "" &&
106
106
+
scope.type === "default"
107
107
+
) {
108
108
+
// Space immediately after opening closes the autocomplete
109
109
+
e.preventDefault();
110
110
+
props.onOpenChange(false);
111
111
+
// Insert a space after the @ in the editor
112
112
+
if (props.view.current) {
113
113
+
const view = props.view.current;
114
114
+
const tr = view.state.tr.insertText(" ");
115
115
+
view.dispatch(tr);
116
116
+
view.focus();
117
117
+
}
118
118
+
}
119
119
+
};
133
120
134
134
-
if (!mentionCoords || suggestions.length === 0) return null;
121
121
+
if (!props.open || !props.coords) return null;
135
122
136
123
const getHeader = (type: Mention["type"]) => {
137
124
switch (type) {
···
139
126
return "People";
140
127
case "publication":
141
128
return "Publications";
129
129
+
case "post":
130
130
+
return "Posts";
142
131
}
143
132
};
144
133
145
134
const sortedSuggestions = [...suggestions].sort((a, b) => {
146
146
-
const order: Mention["type"][] = ["did", "publication"];
135
135
+
const order: Mention["type"][] = ["did", "publication", "post"];
147
136
return order.indexOf(a.type) - order.indexOf(b.type);
148
137
});
149
138
···
152
141
{createPortal(
153
142
<Popover.Anchor
154
143
style={{
155
155
-
top: mentionCoords.top,
156
156
-
left: mentionCoords.left,
144
144
+
top: props.coords.top - 24,
145
145
+
left: props.coords.left,
146
146
+
height: 24,
157
147
position: "absolute",
158
148
}}
159
149
/>,
···
161
151
)}
162
152
<Popover.Portal>
163
153
<Popover.Content
164
164
-
side="bottom"
154
154
+
ref={contentRef}
165
155
align="start"
166
156
sideOffset={4}
167
157
collisionPadding={20}
168
158
onOpenAutoFocus={(e) => e.preventDefault()}
169
169
-
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)
159
159
+
className={`dropdownMenu group/mention-menu z-20 bg-bg-page
160
160
+
flex data-[side=top]:flex-col-reverse flex-col
161
161
+
p-1 gap-1
162
162
+
border border-border rounded-md shadow-md
163
163
+
sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width)
170
164
max-h-(--radix-popover-content-available-height)
171
165
overflow-y-scroll`}
172
166
>
173
173
-
<ul className="list-none p-0 text-sm">
174
174
-
{sortedSuggestions.map((result, index) => {
175
175
-
const prevResult = sortedSuggestions[index - 1];
176
176
-
const showHeader = prevResult && prevResult.type !== result.type;
177
177
-
178
178
-
const [key, resultText, subtext] =
179
179
-
result.type === "did"
180
180
-
? [
181
181
-
result.did,
182
182
-
result.displayName || `@${result.handle}`,
183
183
-
result.displayName ? `@${result.handle}` : undefined,
184
184
-
]
185
185
-
: [result.uri, result.name, undefined];
167
167
+
{/* Search input */}
168
168
+
<div className="flex items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light">
169
169
+
{scope.type === "publication" && (
170
170
+
<button
171
171
+
className="p-1 rounded hover:bg-accent-light text-tertiary hover:text-accent-contrast shrink-0"
172
172
+
onClick={() => handleScopeChange({ type: "default" })}
173
173
+
onMouseDown={(e) => e.preventDefault()}
174
174
+
>
175
175
+
<GoBackSmall className="w-4 h-4" />
176
176
+
</button>
177
177
+
)}
178
178
+
{scope.type === "publication" && (
179
179
+
<span className="text-sm font-bold text-secondary truncate shrink-0 max-w-[100px]">
180
180
+
{scope.name}
181
181
+
</span>
182
182
+
)}
183
183
+
<div className="flex items-center gap-1 flex-1 min-w-0">
184
184
+
<SearchTiny className="w-4 h-4 text-tertiary shrink-0" />
185
185
+
<input
186
186
+
ref={inputRef}
187
187
+
type="text"
188
188
+
value={searchQuery}
189
189
+
onChange={(e) => {
190
190
+
setSearchQuery(e.target.value);
191
191
+
setSuggestionIndex(0);
192
192
+
}}
193
193
+
onKeyDown={handleKeyDown}
194
194
+
autoFocus
195
195
+
placeholder={
196
196
+
scope.type === "publication"
197
197
+
? "Search posts..."
198
198
+
: "Search people & publications..."
199
199
+
}
200
200
+
className="flex-1 min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary"
201
201
+
/>
202
202
+
</div>
203
203
+
</div>
204
204
+
{sortedSuggestions.length === 0 ? (
205
205
+
<div className="text-sm text-tertiary italic px-3 py-2">
206
206
+
{searchQuery
207
207
+
? "No results found"
208
208
+
: scope.type === "publication"
209
209
+
? "Type to search posts"
210
210
+
: "Type to search"}
211
211
+
</div>
212
212
+
) : (
213
213
+
<ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse">
214
214
+
{sortedSuggestions.map((result, index) => {
215
215
+
const prevResult = sortedSuggestions[index - 1];
216
216
+
const showHeader =
217
217
+
prevResult && prevResult.type !== result.type;
186
218
187
187
-
return (
188
188
-
<>
189
189
-
{showHeader && (
190
190
-
<>
191
191
-
{index > 0 && (
192
192
-
<hr className="border-border-light mx-1 my-1" />
193
193
-
)}
194
194
-
<div className="text-xs text-tertiary font-bold pt-1 px-2">
195
195
-
{getHeader(result.type)}
196
196
-
</div>
197
197
-
</>
198
198
-
)}
199
199
-
{result.type === "did" ? (
200
200
-
<DidResult
201
201
-
key={result.did}
202
202
-
onClick={() => {
203
203
-
if (mentionRange) {
204
204
-
props.onSelect(result, mentionRange);
205
205
-
setMentionQuery(null);
206
206
-
setMentionRange(null);
207
207
-
setMentionCoords(null);
208
208
-
}
209
209
-
}}
210
210
-
onMouseDown={(e) => e.preventDefault()}
211
211
-
displayName={result.displayName}
212
212
-
handle={result.handle}
213
213
-
selected={index === suggestionIndex}
214
214
-
/>
215
215
-
) : (
216
216
-
<PublicationResult
217
217
-
key={result.uri}
218
218
-
onClick={() => {
219
219
-
if (mentionRange) {
220
220
-
props.onSelect(result, mentionRange);
221
221
-
setMentionQuery(null);
222
222
-
setMentionRange(null);
223
223
-
setMentionCoords(null);
224
224
-
}
225
225
-
}}
226
226
-
onMouseDown={(e) => e.preventDefault()}
227
227
-
pubName={result.name}
228
228
-
selected={index === suggestionIndex}
229
229
-
/>
230
230
-
)}
231
231
-
</>
232
232
-
);
233
233
-
})}
234
234
-
</ul>
219
219
+
return (
220
220
+
<Fragment
221
221
+
key={result.type === "did" ? result.did : result.uri}
222
222
+
>
223
223
+
{showHeader && (
224
224
+
<>
225
225
+
{index > 0 && (
226
226
+
<hr className="border-border-light mx-1 my-1" />
227
227
+
)}
228
228
+
<div className="text-xs text-tertiary font-bold pt-1 px-2">
229
229
+
{getHeader(result.type)}
230
230
+
</div>
231
231
+
</>
232
232
+
)}
233
233
+
{result.type === "did" ? (
234
234
+
<DidResult
235
235
+
onClick={() => {
236
236
+
props.onSelect(result);
237
237
+
props.onOpenChange(false);
238
238
+
}}
239
239
+
onMouseDown={(e) => e.preventDefault()}
240
240
+
displayName={result.displayName}
241
241
+
handle={result.handle}
242
242
+
selected={index === suggestionIndex}
243
243
+
/>
244
244
+
) : result.type === "publication" ? (
245
245
+
<PublicationResult
246
246
+
onClick={() => {
247
247
+
props.onSelect(result);
248
248
+
props.onOpenChange(false);
249
249
+
}}
250
250
+
onMouseDown={(e) => e.preventDefault()}
251
251
+
pubName={result.name}
252
252
+
selected={index === suggestionIndex}
253
253
+
onPostsClick={() => {
254
254
+
handleScopeChange({
255
255
+
type: "publication",
256
256
+
uri: result.uri,
257
257
+
name: result.name,
258
258
+
});
259
259
+
}}
260
260
+
/>
261
261
+
) : (
262
262
+
<PostResult
263
263
+
onClick={() => {
264
264
+
props.onSelect(result);
265
265
+
props.onOpenChange(false);
266
266
+
}}
267
267
+
onMouseDown={(e) => e.preventDefault()}
268
268
+
title={result.title}
269
269
+
selected={index === suggestionIndex}
270
270
+
/>
271
271
+
)}
272
272
+
</Fragment>
273
273
+
);
274
274
+
})}
275
275
+
</ul>
276
276
+
)}
235
277
</Popover.Content>
236
278
</Popover.Portal>
237
279
</Popover.Root>
···
251
293
menuItem w-full flex-col! gap-0!
252
294
text-secondary leading-snug text-sm
253
295
${props.subtext ? "py-1!" : "py-2!"}
254
254
-
${props.selected ? "bg-[var(--accent-light)]" : ""}`}
296
296
+
${props.selected ? "bg-[var(--accent-light)]!" : ""}`}
255
297
onClick={() => {
256
298
props.onClick();
257
299
}}
···
314
356
onClick: () => void;
315
357
onMouseDown: (e: React.MouseEvent) => void;
316
358
selected?: boolean;
359
359
+
onPostsClick: () => void;
317
360
}) => {
318
361
return (
319
362
<Result
320
363
result={
321
364
<>
322
365
<div className="truncate w-full grow min-w-0">{props.pubName}</div>
323
323
-
<ScopeButton onClick={() => {}}>Posts</ScopeButton>
366
366
+
<ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton>
324
367
</>
325
368
}
326
326
-
{...props}
369
369
+
onClick={props.onClick}
370
370
+
onMouseDown={props.onMouseDown}
371
371
+
selected={props.selected}
372
372
+
/>
373
373
+
);
374
374
+
};
375
375
+
376
376
+
const PostResult = (props: {
377
377
+
title: string;
378
378
+
onClick: () => void;
379
379
+
onMouseDown: (e: React.MouseEvent) => void;
380
380
+
selected?: boolean;
381
381
+
}) => {
382
382
+
return (
383
383
+
<Result
384
384
+
result={<div className="truncate w-full">{props.title}</div>}
385
385
+
onClick={props.onClick}
386
386
+
onMouseDown={props.onMouseDown}
387
387
+
selected={props.selected}
327
388
/>
328
389
);
329
390
};
330
391
331
392
export type Mention =
332
393
| { type: "did"; handle: string; did: string; displayName?: string }
394
394
+
| { type: "publication"; uri: string; name: string }
395
395
+
| { type: "post"; uri: string; title: string };
396
396
+
397
397
+
export type MentionScope =
398
398
+
| { type: "default" }
333
399
| { type: "publication"; uri: string; name: string };
334
400
function useMentionSuggestions(query: string | null) {
335
401
const [suggestionIndex, setSuggestionIndex] = useState(0);
336
402
const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
403
403
+
const [scope, setScope] = useState<MentionScope>({ type: "default" });
337
404
338
405
useDebouncedEffect(
339
406
async () => {
340
340
-
if (!query) {
407
407
+
if (!query && scope.type === "default") {
341
408
setSuggestions([]);
342
409
return;
343
410
}
344
411
345
345
-
const agent = new Agent("https://public.api.bsky.app");
346
346
-
const [result, publications] = await Promise.all([
347
347
-
agent.searchActorsTypeahead({
348
348
-
q: query,
349
349
-
limit: 8,
350
350
-
}),
351
351
-
callRPC(`search_publication_names`, { query, limit: 8 }),
352
352
-
]);
353
353
-
setSuggestions([
354
354
-
...result.data.actors.map((actor) => ({
355
355
-
type: "did" as const,
356
356
-
handle: actor.handle,
357
357
-
did: actor.did,
358
358
-
displayName: actor.displayName,
359
359
-
})),
360
360
-
...publications.result.publications.map((p) => ({
361
361
-
type: "publication" as const,
362
362
-
uri: p.uri,
363
363
-
name: p.name,
364
364
-
})),
365
365
-
]);
412
412
+
if (scope.type === "publication") {
413
413
+
// Search within the publication's documents
414
414
+
const documents = await callRPC(`search_publication_documents`, {
415
415
+
publication_uri: scope.uri,
416
416
+
query: query || "",
417
417
+
limit: 10,
418
418
+
});
419
419
+
setSuggestions(
420
420
+
documents.result.documents.map((d) => ({
421
421
+
type: "post" as const,
422
422
+
uri: d.uri,
423
423
+
title: d.title,
424
424
+
})),
425
425
+
);
426
426
+
} else {
427
427
+
// Default scope: search people and publications
428
428
+
const agent = new Agent("https://public.api.bsky.app");
429
429
+
const [result, publications] = await Promise.all([
430
430
+
agent.searchActorsTypeahead({
431
431
+
q: query || "",
432
432
+
limit: 8,
433
433
+
}),
434
434
+
callRPC(`search_publication_names`, { query: query || "", limit: 8 }),
435
435
+
]);
436
436
+
setSuggestions([
437
437
+
...result.data.actors.map((actor) => ({
438
438
+
type: "did" as const,
439
439
+
handle: actor.handle,
440
440
+
did: actor.did,
441
441
+
displayName: actor.displayName,
442
442
+
})),
443
443
+
...publications.result.publications.map((p) => ({
444
444
+
type: "publication" as const,
445
445
+
uri: p.uri,
446
446
+
name: p.name,
447
447
+
})),
448
448
+
]);
449
449
+
}
366
450
},
367
451
300,
368
368
-
[query],
452
452
+
[query, scope],
369
453
);
370
454
371
455
useEffect(() => {
···
378
462
suggestions,
379
463
suggestionIndex,
380
464
setSuggestionIndex,
465
465
+
scope,
466
466
+
setScope,
381
467
};
382
468
}