diff --git a/app/api/rpc/[command]/get_user_mention_services.ts b/app/api/rpc/[command]/get_user_mention_services.ts index 5003c33d..8a3012f6 100644 --- a/app/api/rpc/[command]/get_user_mention_services.ts +++ b/app/api/rpc/[command]/get_user_mention_services.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; import { getIdentityData } from "actions/getIdentityData"; +import type * as MentionConfig from "lexicons/api/types/parts/page/mention/config"; +import type * as MentionService from "lexicons/api/types/parts/page/mention/service"; export type GetUserMentionServicesReturnType = Awaited< ReturnType<(typeof get_user_mention_services)["handler"]> @@ -19,7 +21,7 @@ export const get_user_mention_services = makeRoute({ .eq("identity_did", user?.atp_did) .single(); - const services = (config?.record as any)?.services as string[] | undefined; + const services = (config?.record as MentionConfig.Record)?.services; if (!services?.length) return { result: { services: [] } }; const { data: serviceRows, error } = await supabase @@ -34,12 +36,12 @@ export const get_user_mention_services = makeRoute({ return { result: { services: (serviceRows || []).map((s) => { - const record = s.record as any; + const record = s.record as MentionService.Record; return { uri: s.uri, - name: record?.name as string, - description: record?.description as string | undefined, - endpoint_url: record?.endpoint as string, + name: record.name, + description: record.description, + did: record.did, }; }), }, diff --git a/app/api/rpc/[command]/proxy_mention_search.ts b/app/api/rpc/[command]/proxy_mention_search.ts index 5db19540..60272193 100644 --- a/app/api/rpc/[command]/proxy_mention_search.ts +++ b/app/api/rpc/[command]/proxy_mention_search.ts @@ -4,6 +4,8 @@ import type { Env } from "./route"; import { getIdentityData } from "actions/getIdentityData"; import { restoreOAuthSession } from "src/atproto-oauth"; import { AtpBaseClient } from "lexicons/api"; +import type * as SearchService from "lexicons/api/types/parts/page/mention/searchService"; +import type * as MentionService from "lexicons/api/types/parts/page/mention/service"; export type ProxyMentionSearchReturnType = Awaited< ReturnType<(typeof proxy_mention_search)["handler"]> @@ -30,7 +32,8 @@ export const proxy_mention_search = makeRoute({ if (!service) throw new Error("Mention service not found"); - const did = (service.record as any)?.did as string; + const record = service.record as MentionService.Record; + const did = record.did; if (!did) throw new Error("Service has no DID"); const sessionResult = await restoreOAuthSession(identity.atp_did); @@ -46,17 +49,19 @@ export const proxy_mention_search = makeRoute({ search, }); - const results = Array.isArray(response.data?.results) - ? response.data.results + const data = response.data as SearchService.OutputSchema | undefined; + const results: SearchService.Result[] = Array.isArray(data?.results) + ? data.results : []; return { result: { - results: results.slice(0, 50).map((r: any) => ({ - uri: String(r.uri || ""), - name: String(r.name || ""), - href: r.href ? String(r.href) : undefined, - icon: r.icon ? String(r.icon) : undefined, + results: results.slice(0, 50).map((r) => ({ + uri: r.uri, + name: r.name, + href: r.href, + icon: r.icon, + embed: r.embed, })), }, }; diff --git a/appview/index.ts b/appview/index.ts index e795436f..2cba2277 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -374,11 +374,10 @@ async function handleEvent(evt: Event) { // } if (evt.collection === "parts.page.mention.service") { if (evt.event === "create" || evt.event === "update") { - let record = evt.record as any; let { error } = await supabase.from("mention_services").upsert({ uri: evt.uri.toString(), identity_did: evt.did, - record: record as Json, + record: evt.record as Json, }); if (error) console.log("Error upserting mention service:", error); } @@ -391,12 +390,12 @@ async function handleEvent(evt: Event) { } if (evt.collection === "parts.page.mention.config") { if (evt.event === "create" || evt.event === "update") { - let record = evt.record as any; + let record = evt.record as Record | undefined; if (!Array.isArray(record?.services)) return; let { error } = await supabase.from("mention_service_configs").upsert({ uri: evt.uri.toString(), identity_did: evt.did, - record: record as Json, + record: evt.record as Json, }); if (error) console.log("Error upserting mention config:", error); } diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index fd6a6be5..885b896d 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -30,6 +30,8 @@ import { blockTextSize } from "src/utils/blockTextSize"; import { Mention, MentionAutocomplete } from "components/Mention"; import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; +import { v7 } from "uuid"; +import { generateKeyBetween } from "fractional-indexing"; const HeadingStyle = { 1: "font-bold [font-family:var(--theme-heading-font)]", @@ -230,8 +232,9 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { mentionCoords, openMentionAutocomplete, handleMentionSelect, + handleMentionEmbed, handleMentionOpenChange, - } = useMentionState(props.entityID); + } = useMentionState(props.entityID, props); let { mountRef, actionTimeout } = useMountProsemirror({ props, @@ -308,6 +311,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { onOpenChange={handleMentionOpenChange} view={viewRef} onSelect={handleMentionSelect} + onEmbed={handleMentionEmbed} coords={mentionCoords} /> )} @@ -503,11 +507,14 @@ const CommandOptions = (props: BlockProps & { className?: string }) => { ); }; -const useMentionState = (entityID: string) => { +const useMentionState = (entityID: string, blockProps: BlockProps) => { let view = useEditorStates((s) => s.editorStates[entityID])?.view; let viewRef = useRef(view || null); viewRef.current = view || null; + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + const [mentionOpen, setMentionOpen] = useState(false); const [mentionCoords, setMentionCoords] = useState<{ top: number; @@ -570,6 +577,73 @@ const useMentionState = (entityID: string) => { [entityID, mentionInsertPos], ); + const handleMentionEmbed = useCallback( + (mention: Mention & { type: "service_result" }) => { + if (!rep || !mention.embed) return; + + const editorState = + useEditorStates.getState().editorStates[entityID]?.editor; + // Check if the block is empty (only the @ character) + const blockIsEmpty = + editorState && editorState.doc.textContent.replace("@", "").trim() === ""; + + let targetEntityID: string; + if (blockIsEmpty) { + // Replace the current block + targetEntityID = blockProps.entityID; + rep.mutate.assertFact({ + entity: targetEntityID, + attribute: "block/type", + data: { type: "block-type-union", value: "embed" }, + }); + rep.mutate.retractAttribute({ + entity: targetEntityID, + attribute: "block/text", + }); + } else { + // Create a new block below + targetEntityID = v7(); + rep.mutate.addBlock({ + permission_set: entity_set.set, + factID: v7(), + type: "embed", + newEntityID: targetEntityID, + parent: blockProps.parent, + position: generateKeyBetween( + blockProps.position, + blockProps.nextPosition, + ), + }); + // Remove the @ from the current block's editor + const view = useEditorStates.getState().editorStates[entityID]?.view; + if (view && mentionInsertPos !== null) { + const from = mentionInsertPos - 1; + const to = mentionInsertPos; + const tr = view.state.tr.delete(from, to); + view.dispatch(tr); + } + } + + // Set embed attributes + rep.mutate.assertFact([ + { + entity: targetEntityID, + attribute: "embed/url", + data: { type: "string", value: mention.embed.src }, + }, + { + entity: targetEntityID, + attribute: "embed/height", + data: { + type: "number", + value: mention.embed.height || 360, + }, + }, + ]); + }, + [rep, entityID, blockProps, entity_set.set, mentionInsertPos], + ); + const handleMentionOpenChange = useCallback((open: boolean) => { setMentionOpen(open); if (!open) { @@ -584,6 +658,7 @@ const useMentionState = (entityID: string) => { mentionCoords, openMentionAutocomplete, handleMentionSelect, + handleMentionEmbed, handleMentionOpenChange, }; }; diff --git a/components/Mention.tsx b/components/Mention.tsx index 5d97e3ff..bcaac604 100644 --- a/components/Mention.tsx +++ b/components/Mention.tsx @@ -12,6 +12,7 @@ import useSWR from "swr"; import * as Popover from "@radix-ui/react-popover"; import { EditorView } from "prosemirror-view"; import { callRPC } from "app/api/rpc/client"; +import type * as SearchService from "lexicons/api/types/parts/page/mention/searchService"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { GoBackSmall } from "components/Icons/GoBackSmall"; import { SearchTiny } from "components/Icons/SearchTiny"; @@ -24,17 +25,19 @@ export function MentionAutocomplete(props: { onOpenChange: (open: boolean) => void; view: React.RefObject; onSelect: (mention: Mention) => void; + onEmbed?: (mention: Mention & { type: "service_result" }) => void; coords: { top: number; left: number } | null; placeholder?: string; }) { const [searchQuery, setSearchQuery] = useState(""); - const [noResults, setNoResults] = useState(false); const inputRef = useRef(null); const contentRef = useRef(null); - const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = + const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope, searchComplete } = useMentionSuggestions(searchQuery, props.open); + const noResults = searchComplete && searchQuery !== "" && suggestions.length === 0; + const sortedSuggestions = useMemo(() => { const order: Mention["type"][] = [ "did", @@ -72,23 +75,9 @@ export function MentionAutocomplete(props: { setSearchQuery(""); setScope({ type: "default" }); setSuggestionIndex(0); - setNoResults(false); } }, [props.open, setScope, setSuggestionIndex]); - // Handle timeout for showing "No results found" - useEffect(() => { - if (searchQuery && suggestions.length === 0) { - setNoResults(false); - const timer = setTimeout(() => { - setNoResults(true); - }, 2000); - return () => clearTimeout(timer); - } else { - setNoResults(false); - } - }, [searchQuery, suggestions.length]); - // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { @@ -139,6 +128,14 @@ export function MentionAutocomplete(props: { const selectedSuggestion = sortedSuggestions[suggestionIndex]; if (selectedSuggestion?.type === "service") { handleScopeChange(serviceScopeFromMention(selectedSuggestion)); + } else if ( + (e.ctrlKey || e.metaKey) && + selectedSuggestion?.type === "service_result" && + selectedSuggestion.embed && + props.onEmbed + ) { + props.onEmbed(selectedSuggestion); + props.onOpenChange(false); } else if (selectedSuggestion) { props.onSelect(selectedSuggestion); props.onOpenChange(false); @@ -324,6 +321,15 @@ export function MentionAutocomplete(props: { onMouseDown={(e) => e.preventDefault()} name={result.name} icon={result.icon} + hasEmbed={!!result.embed} + onEmbedClick={ + result.embed && props.onEmbed + ? () => { + props.onEmbed!(result); + props.onOpenChange(false); + } + : undefined + } selected={index === suggestionIndex} /> ) : ( @@ -509,6 +515,8 @@ const ServiceEntry = (props: { const ServiceSearchResult = (props: { name: string; icon?: string; + hasEmbed?: boolean; + onEmbedClick?: () => void; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; @@ -524,7 +532,16 @@ const ServiceSearchResult = (props: { /> ) : undefined } - result={
{props.name}
} + result={ + props.hasEmbed && props.onEmbedClick ? ( + <> +
{props.name}
+ Embed + + ) : ( +
{props.name}
+ ) + } onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} @@ -581,6 +598,7 @@ export type Mention = name: string; href?: string; icon?: string; + embed?: SearchService.EmbedInfo; }; export type MentionScope = @@ -659,12 +677,7 @@ function useMentionServices(enabled: boolean): Array { try { const result = await callRPC(`get_user_mention_services`, {}); return result.result.services.map( - (s: { - uri: string; - name: string; - description?: string; - endpoint_url: string; - }) => ({ + (s: { uri: string; name: string; description?: string }) => ({ type: "service" as const, serviceUri: s.uri, name: s.name, @@ -684,6 +697,7 @@ function useMentionSuggestions(query: string | null, open: boolean) { const [suggestionIndex, setSuggestionIndex] = useState(0); const [suggestions, setSuggestions] = useState>([]); const [scope, setScope] = useState({ type: "default" }); + const [searchComplete, setSearchComplete] = useState(false); const externalServices = useMentionServices(open); const allServices = useMemo( () => @@ -702,6 +716,7 @@ function useMentionSuggestions(query: string | null, open: boolean) { useEffect(() => { let stale = false; + setSearchComplete(false); // Default scope with services: show local filter instantly, debounce network fallback if (hasServices && scope.type === "default") { const filtered = allServices.filter((s) => @@ -710,9 +725,11 @@ function useMentionSuggestions(query: string | null, open: boolean) { : true, ); setSuggestions(filtered); + setSearchComplete(true); // If local filter found matches, no need for network search if (!query || filtered.length > 0) return; + setSearchComplete(false); } const handler = setTimeout(async () => { @@ -739,20 +756,26 @@ function useMentionSuggestions(query: string | null, open: boolean) { })); } else if (scope.type === "service") { // Search within a mention service + if (!query) { + if (!stale) { + setSuggestions([]); + setSearchComplete(true); + } + return; + } const res = await callRPC(`proxy_mention_search`, { service_uri: scope.serviceUri, - search: query || "", + search: query, }); - const items = res?.result?.results ?? []; - results = items.map( - (r: { uri: string; name: string; href?: string; icon?: string }) => ({ - type: "service_result" as const, - uri: r.uri, - name: r.name, - href: r.href, - icon: r.icon, - }), - ); + const items: SearchService.Result[] = res?.result?.results ?? []; + results = items.map((r) => ({ + type: "service_result" as const, + uri: r.uri, + name: r.name, + href: r.href, + icon: r.icon, + embed: r.embed, + })); } else if (hasServices) { // Default scope with services: local filter showed no matches, fall back to identity search results = await searchIdentities(query || "", 10); @@ -767,6 +790,7 @@ function useMentionSuggestions(query: string | null, open: boolean) { if (!stale) { setSuggestions(results); + setSearchComplete(true); } }, 300); @@ -789,5 +813,6 @@ function useMentionSuggestions(query: string | null, open: boolean) { setSuggestionIndex, scope, setScope: setScopeAndClear, + searchComplete, }; } diff --git a/feeds/index.ts b/feeds/index.ts index f7d6f497..5ca4ccb9 100644 --- a/feeds/index.ts +++ b/feeds/index.ts @@ -39,7 +39,7 @@ app.get("/.well-known/did.json", (c) => { // Mention search services, keyed by rkey const mentionServices: Record< string, - (search: string, limit: number) => Promise<{ uri: string; name: string; href?: string }[]> + (search: string, limit: number) => Promise<{ uri: string; name: string; href?: string; icon?: string; embed?: { src: string; width?: number; height?: number } }[]> > = { wikipedia, pokemon, diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 28df11cf..3bb77d03 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1114,6 +1114,35 @@ export const schemaDict = { description: 'Optional icon URL for the mentioned entity, displayed next to the mention', }, + embed: { + type: 'ref', + ref: 'lex:parts.page.mention.searchService#embedInfo', + description: + 'Optional embed info for creating an embed block instead of an inline mention', + }, + }, + }, + embedInfo: { + type: 'object', + required: ['src'], + properties: { + src: { + type: 'string', + format: 'uri', + description: 'Source URL for the iframe embed', + }, + width: { + type: 'integer', + minimum: 16, + maximum: 3200, + description: 'Default width of the embed in pixels', + }, + height: { + type: 'integer', + minimum: 16, + maximum: 3200, + description: 'Default height of the embed in pixels', + }, }, }, }, diff --git a/lexicons/api/types/parts/page/mention/searchService.ts b/lexicons/api/types/parts/page/mention/searchService.ts index 7fd69520..cf95ad25 100644 --- a/lexicons/api/types/parts/page/mention/searchService.ts +++ b/lexicons/api/types/parts/page/mention/searchService.ts @@ -54,6 +54,7 @@ export interface Result { href?: string /** Optional icon URL for the mentioned entity, displayed next to the mention */ icon?: string + embed?: EmbedInfo } const hashResult = 'result' @@ -65,3 +66,23 @@ export function isResult(v: V) { export function validateResult(v: V) { return validate(v, id, hashResult) } + +export interface EmbedInfo { + $type?: 'parts.page.mention.searchService#embedInfo' + /** Source URL for the iframe embed */ + src: string + /** Default width of the embed in pixels */ + width?: number + /** Default height of the embed in pixels */ + height?: number +} + +const hashEmbedInfo = 'embedInfo' + +export function isEmbedInfo(v: V) { + return is$typed(v, id, hashEmbedInfo) +} + +export function validateEmbedInfo(v: V) { + return validate(v, id, hashEmbedInfo) +} diff --git a/lexicons/parts/page/mention/searchService.json b/lexicons/parts/page/mention/searchService.json index 2779701b..e1ef1792 100644 --- a/lexicons/parts/page/mention/searchService.json +++ b/lexicons/parts/page/mention/searchService.json @@ -74,6 +74,36 @@ "type": "string", "format": "uri", "description": "Optional icon URL for the mentioned entity, displayed next to the mention" + }, + "embed": { + "type": "ref", + "ref": "#embedInfo", + "description": "Optional embed info for creating an embed block instead of an inline mention" + } + } + }, + "embedInfo": { + "type": "object", + "required": [ + "src" + ], + "properties": { + "src": { + "type": "string", + "format": "uri", + "description": "Source URL for the iframe embed" + }, + "width": { + "type": "integer", + "minimum": 16, + "maximum": 3200, + "description": "Default width of the embed in pixels" + }, + "height": { + "type": "integer", + "minimum": 16, + "maximum": 3200, + "description": "Default height of the embed in pixels" } } } diff --git a/lexicons/src/mentionService.ts b/lexicons/src/mentionService.ts index 6069280d..4cf77c94 100644 --- a/lexicons/src/mentionService.ts +++ b/lexicons/src/mentionService.ts @@ -96,6 +96,35 @@ export const PagePartsMentionSearchService: LexiconDoc = { description: "Optional icon URL for the mentioned entity, displayed next to the mention", }, + embed: { + type: "ref", + ref: "#embedInfo", + description: + "Optional embed info for creating an embed block instead of an inline mention", + }, + }, + }, + embedInfo: { + type: "object", + required: ["src"], + properties: { + src: { + type: "string", + format: "uri", + description: "Source URL for the iframe embed", + }, + width: { + type: "integer", + minimum: 16, + maximum: 3200, + description: "Default width of the embed in pixels", + }, + height: { + type: "integer", + minimum: 16, + maximum: 3200, + description: "Default height of the embed in pixels", + }, }, }, }, diff --git a/mentions/services/pokemon.ts b/mentions/services/pokemon.ts index 960eeb67..aaaaff94 100644 --- a/mentions/services/pokemon.ts +++ b/mentions/services/pokemon.ts @@ -1,4 +1,6 @@ -type Result = { uri: string; name: string; href?: string }; +import type { MentionResult } from "./types"; + +type Result = MentionResult; let cachedPokemon: { name: string; url: string }[] | null = null; diff --git a/mentions/services/types.ts b/mentions/services/types.ts new file mode 100644 index 00000000..9f14ac91 --- /dev/null +++ b/mentions/services/types.ts @@ -0,0 +1,11 @@ +export type MentionResult = { + uri: string; + name: string; + href?: string; + icon?: string; + embed?: { + src: string; + width?: number; + height?: number; + }; +}; diff --git a/mentions/services/wikipedia.ts b/mentions/services/wikipedia.ts index 92f1e177..8146b8c6 100644 --- a/mentions/services/wikipedia.ts +++ b/mentions/services/wikipedia.ts @@ -1,6 +1,6 @@ -type Result = { uri: string; name: string; href?: string; icon?: string }; +import type { MentionResult } from "./types"; -export async function wikipedia(search: string, limit: number): Promise { +export async function wikipedia(search: string, limit: number): Promise { if (!search.trim()) return []; const url = new URL("https://en.wikipedia.org/w/api.php"); @@ -17,10 +17,18 @@ export async function wikipedia(search: string, limit: number): Promise ({ - uri: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, - name: title, - href: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, - icon: "https://en.wikipedia.org/static/apple-touch/wikipedia.png", - })); + return titles.map((title, i) => { + const articleUrl = urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`; + return { + uri: articleUrl, + name: title, + href: articleUrl, + icon: "https://en.wikipedia.org/static/apple-touch/wikipedia.png", + embed: { + src: `https://en.m.wikipedia.org/wiki/${encodeURIComponent(title)}?useskin=minerva`, + width: 600, + height: 400, + }, + }; + }); }