diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index 50b54727..553deb67 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -127,11 +127,6 @@ function createHashtagInputRule() { return tr; }); } -export type MentionState = { - active: boolean; - range: { from: number; to: number } | null; - selectedMention: Mention | null; -}; export function BlueskyPostEditorProsemirror(props: { editorStateRef: React.RefObject; initialContent?: string; @@ -140,49 +135,78 @@ export function BlueskyPostEditorProsemirror(props: { const mountRef = useRef(null); const viewRef = useRef(null); const [editorState, setEditorState] = useState(null); - const [mentionState, setMentionState] = useState({ - active: false, - range: null, - selectedMention: null, - }); + const [mentionOpen, setMentionOpen] = useState(false); + const [mentionCoords, setMentionCoords] = useState<{ + top: number; + left: number; + } | null>(null); + const [mentionInsertPos, setMentionInsertPos] = useState(null); + + const openMentionAutocomplete = useCallback(() => { + if (!viewRef.current) return; + const view = viewRef.current; + const pos = view.state.selection.from; + setMentionInsertPos(pos); + const coords = view.coordsAtPos(pos - 1); + setMentionCoords({ + top: coords.bottom + window.scrollY, + left: coords.left + window.scrollX, + }); + setMentionOpen(true); + }, []); const handleMentionSelect = useCallback( - (mention: Mention, range: { from: number; to: number }) => { + (mention: Mention) => { if (mention.type !== "did") return; - if (!viewRef.current) return; + if (!viewRef.current || mentionInsertPos === null) return; const view = viewRef.current; - const { from, to } = range; + const from = mentionInsertPos - 1; + const to = mentionInsertPos; const tr = view.state.tr; - // Delete the query text (keep the @) - tr.delete(from + 1, to); + // Delete the @ symbol + tr.delete(from, to); - // Insert the mention text after the @ - const mentionText = mention.handle; - tr.insertText(mentionText, from + 1); + // Insert @handle + const mentionText = "@" + mention.handle; + tr.insertText(mentionText, from); - // Apply mention mark to @ and handle + // Apply mention mark tr.addMark( from, - from + 1 + mentionText.length, + from + mentionText.length, bskyPostSchema.marks.mention.create({ did: mention.did }), ); // Add a space after the mention - tr.insertText(" ", from + 1 + mentionText.length); + tr.insertText(" ", from + mentionText.length); view.dispatch(tr); view.focus(); }, - [], + [mentionInsertPos], ); - const mentionStateRef = useRef(mentionState); - mentionStateRef.current = mentionState; + const handleMentionOpenChange = useCallback((open: boolean) => { + setMentionOpen(open); + if (!open) { + setMentionCoords(null); + setMentionInsertPos(null); + } + }, []); useLayoutEffect(() => { if (!mountRef.current) return; + // Input rule to trigger mention autocomplete when @ is typed + const mentionInputRule = new InputRule( + /(?:^|\s)@$/, + (state, match, start, end) => { + setTimeout(() => openMentionAutocomplete(), 0); + return null; + }, + ); + const initialState = EditorState.create({ schema: bskyPostSchema, doc: props.initialContent @@ -195,28 +219,11 @@ export function BlueskyPostEditorProsemirror(props: { }) : undefined, plugins: [ - inputRules({ rules: [createHashtagInputRule()] }), + inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }), keymap({ "Mod-z": undo, "Mod-y": redo, "Shift-Mod-z": redo, - Enter: (state, dispatch) => { - // Check if mention autocomplete is active - const currentMentionState = mentionStateRef.current; - if ( - currentMentionState.active && - currentMentionState.selectedMention && - currentMentionState.range - ) { - handleMentionSelect( - currentMentionState.selectedMention, - currentMentionState.range, - ); - return true; - } - // Otherwise let the default Enter behavior happen (new paragraph) - return false; - }, }), keymap(baseKeymap), autolink({ @@ -251,20 +258,17 @@ export function BlueskyPostEditorProsemirror(props: { view.destroy(); viewRef.current = null; }; - }, [handleMentionSelect]); + }, [openMentionAutocomplete]); return (
- {editorState && ( - { - setMentionState({ active, range, selectedMention }); - }} - /> - )} + {editorState?.doc.textContent.length === 0 && (
Write a post to share your writing! @@ -388,14 +392,15 @@ export const addMentionToEditor = ( ); tr.insertText(" ", from + 1 + mention.handle.length); } - if (mention.type === "publication") { - tr.insertText(mention.name, from + 1); + if (mention.type === "publication" || mention.type === "post") { + let name = mention.type == "post" ? mention.title : mention.name; + tr.insertText(name, from + 1); tr.addMark( from, - from + 1 + mention.name.length, + from + 1 + name.length, schema.marks.atMention.create({ atURI: mention.uri }), ); - tr.insertText(" ", from + 1 + mention.name.length); + tr.insertText(" ", from + 1 + name.length); } // Insert the mention text after the @ diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index d323c646..4767ffb4 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -12,6 +12,7 @@ import { import { get_leaflet_data } from "./get_leaflet_data"; import { get_publication_data } from "./get_publication_data"; import { search_publication_names } from "./search_publication_names"; +import { search_publication_documents } from "./search_publication_documents"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -37,6 +38,7 @@ let Routes = [ get_leaflet_data, get_publication_data, search_publication_names, + search_publication_documents, ]; export async function POST( req: Request, diff --git a/app/api/rpc/[command]/search_publication_documents.ts b/app/api/rpc/[command]/search_publication_documents.ts new file mode 100644 index 00000000..382820f7 --- /dev/null +++ b/app/api/rpc/[command]/search_publication_documents.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; + +export type SearchPublicationDocumentsReturnType = Awaited< + ReturnType<(typeof search_publication_documents)["handler"]> +>; + +export const search_publication_documents = makeRoute({ + route: "search_publication_documents", + input: z.object({ + publication_uri: z.string(), + query: z.string(), + limit: z.number().optional().default(10), + }), + handler: async ( + { publication_uri, query, limit }, + { supabase }: Pick, + ) => { + // Get documents in the publication, filtering by title using JSON operator + const { data: documents, error } = await supabase + .from("documents_in_publications") + .select("document, documents!inner(uri, data)") + .eq("publication", publication_uri) + .ilike("documents.data->>title", `%${query}%`) + .limit(limit); + + if (error) { + throw new Error( + `Failed to search publication documents: ${error.message}`, + ); + } + + const result = documents.map((d) => ({ + uri: d.documents.uri, + title: (d.documents.data as { title?: string })?.title || "Untitled", + })); + + return { result: { documents: result } }; + }, +}); diff --git a/app/api/rpc/[command]/search_publication_names.ts b/app/api/rpc/[command]/search_publication_names.ts index 9f1c899e..cd042311 100644 --- a/app/api/rpc/[command]/search_publication_names.ts +++ b/app/api/rpc/[command]/search_publication_names.ts @@ -16,17 +16,22 @@ export const search_publication_names = makeRoute({ { query, limit }, { supabase }: Pick, ) => { - // Search publications by name (case-insensitive partial match) + // Search publications by name in record (case-insensitive partial match) const { data: publications, error } = await supabase .from("publications") - .select("uri, name, identity_did, record") - .ilike("name", `%${query}%`) + .select("uri, record") + .ilike("record->>name", `%${query}%`) .limit(limit); if (error) { throw new Error(`Failed to search publications: ${error.message}`); } - return { result: { publications } }; + const result = publications.map((p) => ({ + uri: p.uri, + name: (p.record as { name?: string })?.name || "Untitled", + })); + + return { result: { publications: result } }; }, }); diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx index 2074a7c8..90f7b050 100644 --- a/components/Blocks/BlockCommandBar.tsx +++ b/components/Blocks/BlockCommandBar.tsx @@ -197,7 +197,7 @@ const CommandResult = (props: { return ( + )} + {scope.type === "publication" && ( + + {scope.name} + + )} +
+ + { + setSearchQuery(e.target.value); + setSuggestionIndex(0); + }} + onKeyDown={handleKeyDown} + autoFocus + placeholder={ + scope.type === "publication" + ? "Search posts..." + : "Search people & publications..." + } + className="flex-1 min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" + /> +
+
+ {sortedSuggestions.length === 0 ? ( +
+ {searchQuery + ? "No results found" + : scope.type === "publication" + ? "Type to search posts" + : "Type to search"} +
+ ) : ( +
    + {sortedSuggestions.map((result, index) => { + const prevResult = sortedSuggestions[index - 1]; + const showHeader = + prevResult && prevResult.type !== result.type; + + return ( + + {showHeader && ( + <> + {index > 0 && ( +
    + )} +
    + {getHeader(result.type)} +
    + + )} + {result.type === "did" ? ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + displayName={result.displayName} + handle={result.handle} + selected={index === suggestionIndex} + /> + ) : result.type === "publication" ? ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + pubName={result.name} + selected={index === suggestionIndex} + onPostsClick={() => { + handleScopeChange({ + type: "publication", + uri: result.uri, + name: result.name, + }); + }} + /> + ) : ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + title={result.title} + selected={index === suggestionIndex} + /> + )} +
    + ); + })} +
+ )} @@ -251,7 +293,7 @@ const Result = (props: { menuItem w-full flex-col! gap-0! text-secondary leading-snug text-sm ${props.subtext ? "py-1!" : "py-2!"} - ${props.selected ? "bg-[var(--accent-light)]" : ""}`} + ${props.selected ? "bg-[var(--accent-light)]!" : ""}`} onClick={() => { props.onClick(); }} @@ -314,58 +356,100 @@ const PublicationResult = (props: { onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; + onPostsClick: () => void; }) => { return (
{props.pubName}
- {}}>Posts + Posts } - {...props} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + +const PostResult = (props: { + title: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + return ( + {props.title}
} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} /> ); }; export type Mention = | { type: "did"; handle: string; did: string; displayName?: string } + | { type: "publication"; uri: string; name: string } + | { type: "post"; uri: string; title: string }; + +export type MentionScope = + | { type: "default" } | { type: "publication"; uri: string; name: string }; function useMentionSuggestions(query: string | null) { const [suggestionIndex, setSuggestionIndex] = useState(0); const [suggestions, setSuggestions] = useState>([]); + const [scope, setScope] = useState({ type: "default" }); useDebouncedEffect( async () => { - if (!query) { + if (!query && scope.type === "default") { setSuggestions([]); return; } - const agent = new Agent("https://public.api.bsky.app"); - const [result, publications] = await Promise.all([ - agent.searchActorsTypeahead({ - q: query, - limit: 8, - }), - callRPC(`search_publication_names`, { query, limit: 8 }), - ]); - setSuggestions([ - ...result.data.actors.map((actor) => ({ - type: "did" as const, - handle: actor.handle, - did: actor.did, - displayName: actor.displayName, - })), - ...publications.result.publications.map((p) => ({ - type: "publication" as const, - uri: p.uri, - name: p.name, - })), - ]); + if (scope.type === "publication") { + // Search within the publication's documents + const documents = await callRPC(`search_publication_documents`, { + publication_uri: scope.uri, + query: query || "", + limit: 10, + }); + setSuggestions( + documents.result.documents.map((d) => ({ + type: "post" as const, + uri: d.uri, + title: d.title, + })), + ); + } else { + // Default scope: search people and publications + const agent = new Agent("https://public.api.bsky.app"); + const [result, publications] = await Promise.all([ + agent.searchActorsTypeahead({ + q: query || "", + limit: 8, + }), + callRPC(`search_publication_names`, { query: query || "", limit: 8 }), + ]); + setSuggestions([ + ...result.data.actors.map((actor) => ({ + type: "did" as const, + handle: actor.handle, + did: actor.did, + displayName: actor.displayName, + })), + ...publications.result.publications.map((p) => ({ + type: "publication" as const, + uri: p.uri, + name: p.name, + })), + ]); + } }, 300, - [query], + [query, scope], ); useEffect(() => { @@ -378,5 +462,7 @@ function useMentionSuggestions(query: string | null) { suggestions, suggestionIndex, setSuggestionIndex, + scope, + setScope, }; }