From daa0c057627165942e87b10ef76126d5592d73eb Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 9 Apr 2026 22:55:01 -0400 Subject: [PATCH 1/7] add bluesky video embed playback --- .../Blocks/BlueskyPostBlock/BlueskyEmbed.tsx | 24 ++-- .../BlueskyPostBlock/BlueskyVideoPlayer.tsx | 122 ++++++++++++++++++ package-lock.json | 7 + package.json | 1 + 4 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx index a55c8092..e9bc72f8 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -15,6 +15,7 @@ import { OpenPage, openPage, } from "app/lish/[did]/[publication]/[rkey]/PostPages"; +import { BlueskyVideoPlayer } from "./BlueskyVideoPlayer"; export const BlueskyEmbed = (props: { embed: Exclude; @@ -131,22 +132,13 @@ export const BlueskyEmbed = (props: { ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height : 16 / 9; return ( -
- { -
-
- -
-
+ ); case AppBskyEmbedRecord.isView(props.embed): let recordEmbed = props.embed; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx b/components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx new file mode 100644 index 00000000..fd22bb4d --- /dev/null +++ b/components/Blocks/BlueskyPostBlock/BlueskyVideoPlayer.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from "react"; + +type Props = { + playlist: string; + thumbnail?: string; + alt?: string; + aspectRatio: number; + className?: string; +}; + +export const BlueskyVideoPlayer = (props: Props) => { + const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(false); + const videoRef = useRef(null); + const hlsRef = useRef<{ destroy: () => void } | null>(null); + + useEffect(() => { + if (!playing) return; + const video = videoRef.current; + if (!video) return; + + let cancelled = false; + + // Safari (and some iOS browsers) support HLS natively. + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = props.playlist; + video.play().catch(() => {}); + return () => { + cancelled = true; + video.removeAttribute("src"); + video.load(); + }; + } + + setLoading(true); + import("hls.js") + .then(({ default: Hls }) => { + if (cancelled) return; + if (!Hls.isSupported()) { + // Last-resort fallback: try the native element anyway. + video.src = props.playlist; + video.play().catch(() => {}); + setLoading(false); + return; + } + const hls = new Hls(); + hlsRef.current = hls; + hls.loadSource(props.playlist); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (cancelled) return; + setLoading(false); + video.play().catch(() => {}); + }); + }) + .catch(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + }; + }, [playing, props.playlist]); + + return ( +
+ {!playing && props.thumbnail && ( + {props.alt + )} + {playing && ( +
+ ); +}; diff --git a/package-lock.json b/package-lock.json index 04c59703..398787ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "drizzle-orm": "^0.30.10", "feed": "^5.1.0", "fractional-indexing": "^3.2.0", + "hls.js": "^1.6.15", "hono": "^4.7.11", "immer": "^10.2.0", "inngest": "^3.52.6", @@ -13359,6 +13360,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/hono": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.11.tgz", diff --git a/package.json b/package.json index 45aa58f0..80f5c350 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "drizzle-orm": "^0.30.10", "feed": "^5.1.0", "fractional-indexing": "^3.2.0", + "hls.js": "^1.6.15", "hono": "^4.7.11", "immer": "^10.2.0", "inngest": "^3.52.6", -- 2.51.2 From 6c7d5c1e3e38e05698d84c7c61eb59f26ddb290c Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 9 Apr 2026 22:57:23 -0400 Subject: [PATCH 2/7] Feature/mention services (#283) * wip * some fixes * get basic mentions working * proxy mention service requests through pds * add embed to mention services and fix some behavior * wip change lexicon name and some other stuff i forget * update parts.page lexicons * fix opening at mentions * more scoping stuff * render labels and descriptions * support aspect ratio for embeds * handle aspect ratios for mention embeds * support iframe pages and basic iframe channel * WIP capn web * wip * remove some logging * add colors and lexicon for embed ctx --- actions/publishToPublication.ts | 10 + app/(home-pages)/reader/idResolver.ts | 2 +- app/[leaflet_id]/page.tsx | 1 + .../publish/BskyPostEditorProsemirror.tsx | 16 +- app/api/oauth/[route]/oauth-metadata.ts | 2 +- app/api/oauth/[route]/route.ts | 2 +- .../[command]/get_user_mention_services.ts | 92 +++ app/api/rpc/[command]/proxy_mention_search.ts | 99 +++ app/api/rpc/[command]/route.ts | 4 + app/api/rpc/[command]/search_loose_leafs.ts | 43 ++ .../[rkey]/Blocks/TextBlockCore.tsx | 1 + .../[publication]/[rkey]/PostContent.tsx | 57 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 35 +- .../[publication]/[rkey]/postPageState.ts | 4 +- appview/index.ts | 7 +- components/AtMentionLink.tsx | 47 +- components/Blocks/EmbedBlock.tsx | 150 +++- .../Blocks/TextBlock/RenderYJSFragment.tsx | 4 +- components/Blocks/TextBlock/index.tsx | 99 ++- .../Blocks/TextBlock/mountProsemirror.ts | 8 +- components/Blocks/TextBlock/schema.ts | 56 +- components/Mention.tsx | 713 +++++++++++++++--- components/Pages/IframePageView.tsx | 58 ++ components/Pages/Page.tsx | 6 +- components/Pages/index.tsx | 69 +- components/RouteUIStateManger.tsx | 4 +- drizzle/schema.ts | 2 + feeds/index.ts | 50 +- lexicons/api/index.ts | 225 ++++++ lexicons/api/lexicons.ts | 275 +++++++ lexicons/api/types/parts/page/embed/ctx.ts | 19 + .../api/types/parts/page/mention/config.ts | 32 + .../api/types/parts/page/mention/search.ts | 145 ++++ .../api/types/parts/page/mention/service.ts | 35 + .../api/types/pub/leaflet/blocks/iframe.ts | 17 + .../api/types/pub/leaflet/richtext/facet.ts | 1 + lexicons/build.ts | 19 +- lexicons/parts/page/embed/ctx.json | 19 + lexicons/parts/page/mention/config.json | 28 + lexicons/parts/page/mention/search.json | 178 +++++ lexicons/parts/page/mention/service.json | 36 + lexicons/pub/leaflet/blocks/iframe.json | 19 + lexicons/pub/leaflet/richtext/facet.json | 4 + lexicons/src/blocks.ts | 12 + lexicons/src/facet.ts | 5 +- lexicons/src/mentionService.ts | 231 ++++++ mentions/services/pokemon.ts | 39 + mentions/services/types.ts | 11 + mentions/services/wikipedia.ts | 34 + package-lock.json | 7 + package.json | 3 +- src/hooks/useIframeChannel.ts | 68 ++ src/partsPageChannel.ts | 37 + src/replicache/attributes.ts | 4 + src/useUIState.ts | 29 +- src/utils/addLinkBlock.ts | 22 +- src/utils/aspectRatio.ts | 42 ++ src/utils/focusPage.ts | 26 +- src/utils/mentionUtils.ts | 45 +- src/utils/scrollIntoView.ts | 2 +- src/utils/scrollIntoViewIfNeeded.ts | 34 +- 61 files changed, 3068 insertions(+), 276 deletions(-) create mode 100644 app/api/rpc/[command]/get_user_mention_services.ts create mode 100644 app/api/rpc/[command]/proxy_mention_search.ts create mode 100644 app/api/rpc/[command]/search_loose_leafs.ts create mode 100644 components/Pages/IframePageView.tsx create mode 100644 lexicons/api/types/parts/page/embed/ctx.ts create mode 100644 lexicons/api/types/parts/page/mention/config.ts create mode 100644 lexicons/api/types/parts/page/mention/search.ts create mode 100644 lexicons/api/types/parts/page/mention/service.ts create mode 100644 lexicons/parts/page/embed/ctx.json create mode 100644 lexicons/parts/page/mention/config.json create mode 100644 lexicons/parts/page/mention/search.json create mode 100644 lexicons/parts/page/mention/service.json create mode 100644 lexicons/src/mentionService.ts create mode 100644 mentions/services/pokemon.ts create mode 100644 mentions/services/types.ts create mode 100644 mentions/services/wikipedia.ts create mode 100644 src/hooks/useIframeChannel.ts create mode 100644 src/partsPageChannel.ts create mode 100644 src/utils/aspectRatio.ts diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index b83878cc..1c21cdb3 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -712,12 +712,19 @@ async function processBlocksToPages( if (b.type === "embed") { let [url] = scan.eav(b.value, "embed/url"); let [height] = scan.eav(b.value, "embed/height"); + let [aspectRatio] = scan.eav(b.value, "embed/aspect-ratio"); if (!url) return; let block: $Typed = { $type: "pub.leaflet.blocks.iframe", url: url.data.value, height: Math.floor(height?.data.value || 600), }; + if (aspectRatio) { + let [w, h] = aspectRatio.data.value.split("/").map(Number); + if (w && h) { + block.aspectRatio = { width: w, height: h }; + } + } return block; } if (b.type == "image") { @@ -950,6 +957,9 @@ function YJSFragmentToFacets( { $type: "pub.leaflet.richtext.facet#atMention", atURI: node.getAttribute("atURI"), + ...(node.getAttribute("href") + ? { href: node.getAttribute("href") } + : {}), }, ], }; diff --git a/app/(home-pages)/reader/idResolver.ts b/app/(home-pages)/reader/idResolver.ts index f4c1af72..37d57480 100644 --- a/app/(home-pages)/reader/idResolver.ts +++ b/app/(home-pages)/reader/idResolver.ts @@ -3,7 +3,7 @@ import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; import Client from "ioredis"; // Create Redis client for DID caching let redisClient: Client | null = null; -if (process.env.REDIS_URL) { +if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { redisClient = new Client(process.env.REDIS_URL); } diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index 5a90a3b5..a7bd0022 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -55,6 +55,7 @@ export default async function LeafletPage(props: Props) { rootEntity, ); + console.log(res); return ( <> {/* Server-side font loading with preload and @font-face */} diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index 8e029198..26a74252 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -425,14 +425,20 @@ export const addMentionToEditor = ( }); tr.insert(from, didMentionNode); } - if (mention.type === "publication" || mention.type === "post") { - // Delete the @ and any query text + if ( + mention.type === "publication" || + mention.type === "post" || + mention.type === "service_result" + ) { tr.delete(from, to); - let name = mention.type == "post" ? mention.title : mention.name; - // Insert atMention inline node + const text = mention.type === "post" ? mention.title : mention.name; const atMentionNode = schema.nodes.atMention.create({ atURI: mention.uri, - text: name, + text, + ...(mention.type === "service_result" && { + href: mention.href, + icon: mention.icon, + }), }); tr.insert(from, atMentionNode); } diff --git a/app/api/oauth/[route]/oauth-metadata.ts b/app/api/oauth/[route]/oauth-metadata.ts index 6af54950..567cbbb3 100644 --- a/app/api/oauth/[route]/oauth-metadata.ts +++ b/app/api/oauth/[route]/oauth-metadata.ts @@ -8,7 +8,7 @@ const hostname = : "https://leaflet.pub"; const scope = - "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview blob:*/*"; + "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview rpc:parts.page.mention.search?aud=* blob:*/*"; const localconfig: OAuthClientMetadataInput = { client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`, client_name: `Leaflet`, diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index 9c7eb139..94c113eb 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -43,7 +43,7 @@ export async function GET( const url = await client.authorize(handle || "https://bsky.social", { scope: - "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview blob:*/*", + "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview rpc:parts.page.mention.search?aud=* blob:*/*", signal: ac.signal, state: JSON.stringify(state), }); diff --git a/app/api/rpc/[command]/get_user_mention_services.ts b/app/api/rpc/[command]/get_user_mention_services.ts new file mode 100644 index 00000000..b78b8161 --- /dev/null +++ b/app/api/rpc/[command]/get_user_mention_services.ts @@ -0,0 +1,92 @@ +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"; + +// Naive in-memory cache for user configs (keyed by identity DID) +const configCache = new Map< + string, + { services: string[]; expiresAt: number } +>(); +// Naive in-memory cache for service records (keyed by URI) +const serviceCache = new Map< + string, + { record: MentionService.Record; expiresAt: number } +>(); +const CACHE_TTL = 60_000; // 1 minute + +export type GetUserMentionServicesReturnType = Awaited< + ReturnType<(typeof get_user_mention_services)["handler"]> +>; + +export const get_user_mention_services = makeRoute({ + route: "get_user_mention_services", + input: z.object({}), + handler: async (_input, { supabase }: Pick) => { + let user = await getIdentityData(); + if (!user?.atp_did) return { result: { services: [] } }; + let services: string[]; + const cachedConfig = configCache.get(user.atp_did); + if (cachedConfig && Date.now() < cachedConfig.expiresAt) { + services = cachedConfig.services; + } else { + const { data: config } = await supabase + .from("mention_service_configs") + .select("record") + .eq("identity_did", user?.atp_did) + .single(); + services = (config?.record as MentionConfig.Record)?.services ?? []; + configCache.set(user.atp_did, { + services, + expiresAt: Date.now() + CACHE_TTL, + }); + } + + if (!services.length) return { result: { services: [] } }; + + // Check which URIs we already have cached + const uncachedUris: string[] = []; + const cachedRecords: { uri: string; record: MentionService.Record }[] = []; + for (const uri of services) { + const cached = serviceCache.get(uri); + if (cached && Date.now() < cached.expiresAt) { + cachedRecords.push({ uri, record: cached.record }); + } else { + uncachedUris.push(uri); + } + } + + // Fetch any uncached services from DB + if (uncachedUris.length > 0) { + const { data: serviceRows, error } = await supabase + .from("mention_services") + .select("uri, record") + .in("uri", uncachedUris); + if (error) { + throw new Error(`Failed to fetch mention services: ${error.message}`); + } + for (const s of serviceRows || []) { + const record = s.record as MentionService.Record; + serviceCache.set(s.uri, { + record, + expiresAt: Date.now() + CACHE_TTL, + }); + cachedRecords.push({ uri: s.uri, record }); + } + } + + return { + result: { + services: cachedRecords.map((s) => ({ + uri: s.uri, + name: s.record.name, + description: s.record.description, + did: s.record.did, + canBeScopedToDid: s.record.canBeScopedToDid ?? false, + })), + }, + }; + }, +}); diff --git a/app/api/rpc/[command]/proxy_mention_search.ts b/app/api/rpc/[command]/proxy_mention_search.ts new file mode 100644 index 00000000..8d3b313c --- /dev/null +++ b/app/api/rpc/[command]/proxy_mention_search.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +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/search"; +import type * as MentionService from "lexicons/api/types/parts/page/mention/service"; + +// Naive in-memory cache for mention service records (keyed by URI) +const serviceCache = new Map< + string, + { record: MentionService.Record; expiresAt: number } +>(); +const SERVICE_CACHE_TTL = 60_000; // 1 minute + +export type ProxyMentionSearchReturnType = Awaited< + ReturnType<(typeof proxy_mention_search)["handler"]> +>; + +export const proxy_mention_search = makeRoute({ + route: "proxy_mention_search", + input: z.object({ + service_uri: z.string(), + search: z.string(), + scope: z.string().optional(), + }), + handler: async ( + { service_uri, search, scope }, + { supabase }: Pick, + ) => { + try { + const identity = await getIdentityData(); + if (!identity?.atp_did) throw new Error("Not authenticated"); + + let record: MentionService.Record; + const cached = serviceCache.get(service_uri); + if (cached && Date.now() < cached.expiresAt) { + record = cached.record; + } else { + const { data: service } = await supabase + .from("mention_services") + .select("record") + .eq("uri", service_uri) + .single(); + if (!service) throw new Error("Mention service not found"); + record = service.record as MentionService.Record; + serviceCache.set(service_uri, { + record, + expiresAt: Date.now() + SERVICE_CACHE_TTL, + }); + } + + const did = record.did; + if (!did) throw new Error("Service has no DID"); + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) throw new Error("OAuth session expired"); + + const session = sessionResult.value; + const agent = new AtpBaseClient(session.fetchHandler.bind(session)); + agent.setHeader("atproto-proxy", `${did}#mention_search`); + console.log(did); + + const response = await agent.call("parts.page.mention.search", { + service: service_uri, + search, + ...(scope ? { scope } : {}), + }); + + 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) => ({ + uri: r.uri, + name: r.name, + description: r.description, + labels: r.labels, + href: r.href, + icon: r.icon, + embed: r.embed, + subscope: r.subscope, + })), + }, + }; + } catch (error) { + console.error("proxy_mention_search failed", { + service_uri, + search, + error, + }); + throw error; + } + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index 05912055..91f4bb74 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -19,6 +19,8 @@ import { get_hot_feed } from "./get_hot_feed"; import { get_document_interactions } from "./get_document_interactions"; import { get_publication_analytics } from "./get_publication_analytics"; import { get_publication_subscribers_timeseries } from "./get_publication_subscribers_timeseries"; +import { get_user_mention_services } from "./get_user_mention_services"; +import { proxy_mention_search } from "./proxy_mention_search"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -51,6 +53,8 @@ let Routes = [ get_document_interactions, get_publication_analytics, get_publication_subscribers_timeseries, + get_user_mention_services, + proxy_mention_search, ]; export async function POST( req: Request, diff --git a/app/api/rpc/[command]/search_loose_leafs.ts b/app/api/rpc/[command]/search_loose_leafs.ts new file mode 100644 index 00000000..b8800105 --- /dev/null +++ b/app/api/rpc/[command]/search_loose_leafs.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { deduplicateByUri } from "src/utils/deduplicateRecords"; + +export type SearchLooseLeafsReturnType = Awaited< + ReturnType<(typeof search_loose_leafs)["handler"]> +>; + +export const search_loose_leafs = makeRoute({ + route: "search_loose_leafs", + input: z.object({ + query: z.string(), + limit: z.number().optional().default(10), + }), + handler: async ( + { query, limit }, + { supabase }: Pick, + ) => { + // Search for documents that are NOT in any publication (loose leafs) + // A loose leaf is a document with no entry in documents_in_publications + const { data: rawLooseLeafs, error } = await supabase + .from("documents") + .select("uri, data") + .ilike("data->>title", `%${query}%`) + .not("uri", "in", supabase.from("documents_in_publications").select("document")) + .limit(limit); + + if (error) { + throw new Error(`Failed to search loose leafs: ${error.message}`); + } + + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces + const looseLeafs = deduplicateByUri(rawLooseLeafs || []); + + const result = looseLeafs.map((d) => ({ + uri: d.uri, + title: (d.data as { title?: string })?.title || "Untitled", + })); + + return { result: { documents: result } }; + }, +}); diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx index 292d44c7..a2a92486 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx @@ -114,6 +114,7 @@ export function TextBlockCore(props: TextBlockCoreProps) { {renderedText} diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 47318580..9458effb 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -36,6 +36,11 @@ import { ButtonPrimary } from "components/Buttons"; import { blockTextSize } from "src/utils/blockTextSize"; import { slugify } from "src/utils/slugify"; import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; +import { useIframeChannel } from "src/hooks/useIframeChannel"; +import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; +import { colorToString } from "components/ThemeManager/useColorAttribute"; +import { useDocument } from "contexts/DocumentContext"; +import { openPage as openPageAction } from "./postPageState"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; @@ -202,13 +207,10 @@ export let Block = ({ } case PubLeafletBlocksIframe.isMain(b.block): { return ( - - {/* */} - {!props.preview && permissions.write && ( + {!props.preview && permissions.write && !aspectRatio && ( <>
{ let embedUrl = link; let embedHeight = 360; + let embedAspectRatio: string | null = null; if (res.status === 200) { let data = await (res.json() as LinkPreviewMetadataResult); @@ -173,10 +270,11 @@ const BlockLinkInput = (props: BlockProps) => { let embed = data.data.links.player[0]; embedUrl = embed.href; embedHeight = embed.media?.height || 300; + embedAspectRatio = getAspectRatio(embed.media); } } - await rep.mutate.assertFact([ + let facts: Parameters[0] = [ { entity: entity, attribute: "embed/url", @@ -185,15 +283,27 @@ const BlockLinkInput = (props: BlockProps) => { value: embedUrl, }, }, - { + ]; + if (embedAspectRatio) { + facts.push({ + entity: entity, + attribute: "embed/aspect-ratio", + data: { + type: "string", + value: embedAspectRatio, + }, + }); + } else { + facts.push({ entity: entity, attribute: "embed/height", data: { type: "number", value: embedHeight, }, - }, - ]); + }); + } + await rep.mutate.assertFact(facts); } catch { // On any error, fallback to using the URL directly await rep.mutate.assertFact([ diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index fb12897f..d277d17e 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -113,8 +113,10 @@ export function RenderYJSFragment({ ) { const atURI = node.getAttribute("atURI") || ""; const text = node.getAttribute("text") || ""; + const href = node.getAttribute("href") || undefined; + const icon = node.getAttribute("icon") || undefined; return ( - + {text} ); diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index bce54fa1..d157bdfb 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -27,9 +27,12 @@ import { useMountProsemirror } from "./mountProsemirror"; import { schema } from "./schema"; import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; import { blockTextSize } from "src/utils/blockTextSize"; +import { getAspectRatio } from "src/utils/aspectRatio"; 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)]", @@ -232,8 +235,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, @@ -316,6 +320,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { onOpenChange={handleMentionOpenChange} view={viewRef} onSelect={handleMentionSelect} + onEmbed={handleMentionEmbed} coords={mentionCoords} /> )} @@ -515,11 +520,16 @@ 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(); + let blockPropsRef = useRef(blockProps); + blockPropsRef.current = blockProps; + const [mentionOpen, setMentionOpen] = useState(false); const [mentionCoords, setMentionCoords] = useState<{ top: number; @@ -582,6 +592,90 @@ const useMentionState = (entityID: string) => { [entityID, mentionInsertPos], ); + const handleMentionEmbed = useCallback( + (mention: Mention & { type: "service_result" }) => { + if (!rep || !mention.embed) return; + let props = blockPropsRef.current; + + 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 = props.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: props.parent, + position: generateKeyBetween( + props.position, + props.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 + let facts: Parameters[0] = [ + { + entity: targetEntityID, + attribute: "embed/url", + data: { type: "string", value: mention.embed.src }, + }, + ]; + let aspectRatio = getAspectRatio( + mention.embed.aspectRatio + ? mention.embed.aspectRatio + : mention.embed.width && mention.embed.height + ? { width: mention.embed.width, height: mention.embed.height } + : undefined, + ); + if (aspectRatio) { + facts.push({ + entity: targetEntityID, + attribute: "embed/aspect-ratio", + data: { type: "string", value: aspectRatio }, + }); + } else { + facts.push({ + entity: targetEntityID, + attribute: "embed/height", + data: { + type: "number", + value: mention.embed.height || 360, + }, + }); + } + rep.mutate.assertFact(facts); + }, + [rep, entityID, entity_set.set, mentionInsertPos], + ); + const handleMentionOpenChange = useCallback((open: boolean) => { setMentionOpen(open); if (!open) { @@ -596,6 +690,7 @@ const useMentionState = (entityID: string) => { mentionCoords, openMentionAutocomplete, handleMentionSelect, + handleMentionEmbed, handleMentionOpenChange, }; }; diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index 7ce2707b..37d2142d 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -143,8 +143,12 @@ export function useMountProsemirror({ // Check for atMention inline nodes if (node?.type === schema.nodes.atMention) { - const url = atUriToUrl(node.attrs.atURI); - window.open(url, "_blank", "noopener,noreferrer"); + if (node.attrs.href) + window.open(node.attrs.href, "_blank", "noopener,noreferrer"); + else { + const url = atUriToUrl(node.attrs.atURI); + window.open(url, "_blank", "noopener,noreferrer"); + } return; } diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index df1ca5e5..e40406b5 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -1,11 +1,7 @@ -import { AtUri } from "@atproto/api"; import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; import { marks } from "prosemirror-schema-basic"; import { theme } from "tailwind.config"; -import { - isDocumentCollection, - isPublicationCollection, -} from "src/utils/collectionHelpers"; +import { classifyAtUri } from "src/utils/mentionUtils"; let baseSchema = { marks: { @@ -131,6 +127,8 @@ let baseSchema = { attrs: { atURI: {}, text: { default: "" }, + href: { default: undefined }, + icon: { default: undefined }, }, group: "inline", inline: true, @@ -144,6 +142,8 @@ let baseSchema = { return { atURI: dom.getAttribute("data-at-uri"), text: dom.textContent || "", + href: dom.getAttribute("data-href") || undefined, + icon: dom.getAttribute("data-icon") || undefined, }; }, }, @@ -152,26 +152,35 @@ let baseSchema = { // NOTE: This rendering should match the AtMentionLink component in // components/AtMentionLink.tsx. If you update one, update the other. let className = "atMention mention"; - let aturi = new AtUri(node.attrs.atURI); - if (isPublicationCollection(aturi.collection)) - className += " font-bold"; - if (isDocumentCollection(aturi.collection)) className += " italic"; + const { isPublication, isDocument } = classifyAtUri(node.attrs.atURI); + if (isPublication) className += " font-bold"; + if (isDocument) className += " italic"; - // For publications and documents, show icon - if ( - isPublicationCollection(aturi.collection) || - isDocumentCollection(aturi.collection) - ) { + const attrs: Record = { + class: className, + "data-at-uri": node.attrs.atURI, + }; + if (node.attrs.href) { + attrs["data-href"] = node.attrs.href; + } + if (node.attrs.icon) { + attrs["data-icon"] = node.attrs.icon; + } + + // Show icon for publications/documents or service results + const iconSrc = + isPublication || isDocument + ? `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}` + : node.attrs.icon ?? null; + + if (iconSrc) { return [ "span", - { - class: className, - "data-at-uri": node.attrs.atURI, - }, + attrs, [ "img", { - src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, + src: iconSrc, class: "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", alt: "", @@ -184,14 +193,7 @@ let baseSchema = { ]; } - return [ - "span", - { - class: className, - "data-at-uri": node.attrs.atURI, - }, - node.attrs.text, - ]; + return ["span", attrs, node.attrs.text]; }, } as NodeSpec, footnote: { diff --git a/components/Mention.tsx b/components/Mention.tsx index 32e0075a..62fa84c2 100644 --- a/components/Mention.tsx +++ b/components/Mention.tsx @@ -1,10 +1,18 @@ "use client"; import { Agent } from "@atproto/api"; -import { useState, useEffect, Fragment, useRef, useCallback } from "react"; -import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; +import { + useState, + useEffect, + useMemo, + Fragment, + useRef, + useCallback, +} from "react"; +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/search"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { GoBackSmall } from "components/Icons/GoBackSmall"; import { SearchTiny } from "components/Icons/SearchTiny"; @@ -17,16 +25,39 @@ 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 } = - useMentionSuggestions(searchQuery); + const { + suggestionIndex, + setSuggestionIndex, + suggestions, + scope, + setScope, + searchComplete, + hasDidScopableServices, + } = useMentionSuggestions(searchQuery, props.open); + + const noResults = + searchComplete && searchQuery !== "" && suggestions.length === 0; + + const sortedSuggestions = useMemo(() => { + const order: Mention["type"][] = [ + "did", + "publication", + "post", + "service", + "service_result", + ]; + return [...suggestions].sort( + (a, b) => order.indexOf(a.type) - order.indexOf(b.type), + ); + }, [suggestions]); // Clear search when scope changes const handleScopeChange = useCallback( @@ -52,23 +83,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") { @@ -98,11 +115,11 @@ export function MentionAutocomplete(props: { } } else if (e.key === downKey) { e.preventDefault(); - if (suggestionIndex < suggestions.length - 1) { + if (suggestionIndex < sortedSuggestions.length - 1) { setSuggestionIndex((i) => i + 1); } } else if (e.key === "Tab") { - const selectedSuggestion = suggestions[suggestionIndex]; + const selectedSuggestion = sortedSuggestions[suggestionIndex]; if (selectedSuggestion?.type === "publication") { e.preventDefault(); handleScopeChange({ @@ -110,11 +127,75 @@ export function MentionAutocomplete(props: { uri: selectedSuggestion.uri, name: selectedSuggestion.name, }); + } else if (selectedSuggestion?.type === "did" && hasDidScopableServices) { + // Tab on a DID result → show services that can be scoped to this DID + e.preventDefault(); + handleScopeChange({ + type: "did_services", + did: selectedSuggestion.did, + name: selectedSuggestion.displayName || selectedSuggestion.handle, + }); + } else if ( + selectedSuggestion?.type === "service" && + scope.type === "did_services" + ) { + // Tab on a service in the DID services scope → search that service scoped to the DID + e.preventDefault(); + handleScopeChange({ + type: "service", + serviceUri: selectedSuggestion.serviceUri, + name: selectedSuggestion.name, + scope: scope.did, + serviceName: selectedSuggestion.name, + scopedDid: scope.did, + scopedDidName: scope.name, + }); + } else if (selectedSuggestion?.type === "service") { + e.preventDefault(); + handleScopeChange(serviceScopeFromMention(selectedSuggestion)); + } else if ( + selectedSuggestion?.type === "service_result" && + selectedSuggestion.subscope && + scope.type === "service" + ) { + e.preventDefault(); + handleScopeChange({ + type: "service", + serviceUri: scope.serviceUri, + name: selectedSuggestion.name, + scope: selectedSuggestion.subscope.scope, + serviceName: scope.serviceName ?? scope.name, + scopedDid: scope.scopedDid, + scopedDidName: scope.scopedDidName, + }); } } else if (e.key === "Enter") { e.preventDefault(); - const selectedSuggestion = suggestions[suggestionIndex]; - if (selectedSuggestion) { + const selectedSuggestion = sortedSuggestions[suggestionIndex]; + if ( + selectedSuggestion?.type === "service" && + scope.type === "did_services" + ) { + handleScopeChange({ + type: "service", + serviceUri: selectedSuggestion.serviceUri, + name: selectedSuggestion.name, + scope: scope.did, + serviceName: selectedSuggestion.name, + scopedDid: scope.did, + scopedDidName: scope.name, + }); + } else 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); } @@ -139,30 +220,53 @@ export function MentionAutocomplete(props: { if (!props.open || !props.coords) return null; const getHeader = (type: Mention["type"], scope?: MentionScope) => { + // When in a built-in scope, show a back header + if ( + scope?.type === "identities" || + scope?.type === "publications" || + scope?.type === "publication" || + scope?.type === "service" || + scope?.type === "did_services" + ) { + return ( + { + // When in a service entered from did_services, go back to did_services + if (scope.type === "service" && scope.scopedDid) { + handleScopeChange({ + type: "did_services", + did: scope.scopedDid, + name: scope.scopedDidName ?? scope.scopedDid, + }); + // When in a subscoped service, go back to the parent service scope + } else if (scope.type === "service" && scope.scope) { + handleScopeChange({ + type: "service", + serviceUri: scope.serviceUri, + name: scope.serviceName ?? scope.name, + }); + } else { + handleScopeChange({ type: "default" }); + } + }} + /> + ); + } switch (type) { case "did": return "People"; case "publication": return "Publications"; case "post": - if (scope) { - return ( - { - handleScopeChange({ type: "default" }); - }} - /> - ); - } else return "Posts"; + return "Posts"; + case "service": + return "Services"; + case "service_result": + return null; } }; - const sortedSuggestions = [...suggestions].sort((a, b) => { - const order: Mention["type"][] = ["did", "publication", "post"]; - return order.indexOf(a.type) - order.indexOf(b.type); - }); - return (
@@ -227,10 +327,15 @@ export function MentionAutocomplete(props: { index === 0 || (prevResult && prevResult.type !== result.type); + const key = + result.type === "did" + ? result.did + : result.type === "service" + ? result.serviceUri + : result.uri; + return ( - + {showHeader && ( <> {index > 0 && ( @@ -252,6 +357,17 @@ export function MentionAutocomplete(props: { handle={result.handle} avatar={result.avatar} selected={index === suggestionIndex} + onServicesClick={ + hasDidScopableServices + ? () => { + handleScopeChange({ + type: "did_services", + did: result.did, + name: result.displayName || result.handle, + }); + } + : undefined + } /> ) : result.type === "publication" ? ( + ) : result.type === "service" ? ( + { + if (scope.type === "did_services") { + handleScopeChange({ + type: "service", + serviceUri: result.serviceUri, + name: result.name, + scope: scope.did, + serviceName: result.name, + scopedDid: scope.did, + scopedDidName: scope.name, + }); + } else { + handleScopeChange(serviceScopeFromMention(result)); + } + }} + onMouseDown={(e) => e.preventDefault()} + name={result.name} + description={result.description} + selected={index === suggestionIndex} + /> + ) : result.type === "service_result" ? ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + name={result.name} + description={result.description} + labels={result.labels} + icon={result.icon} + hasEmbed={!!result.embed} + onEmbedClick={ + result.embed && props.onEmbed + ? () => { + props.onEmbed!(result); + props.onOpenChange(false); + } + : undefined + } + subscope={result.subscope} + onSubscopeClick={ + result.subscope && scope.type === "service" + ? () => { + handleScopeChange({ + type: "service", + serviceUri: scope.serviceUri, + name: result.name, + scope: result.subscope!.scope, + serviceName: scope.serviceName ?? scope.name, + }); + } + : undefined + } + selected={index === suggestionIndex} + /> ) : ( { @@ -321,7 +495,7 @@ const Result = (props: { {props.result}
{props.subtext && ( -
+
{props.subtext}
)} @@ -359,7 +533,9 @@ const DidResult = (props: { onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; + onServicesClick?: () => void; }) => { + const nameContent = props.displayName ? props.displayName : props.handle; return ( ) } - result={props.displayName ? props.displayName : props.handle} - subtext={props.displayName && `@${props.handle}`} + result={ + props.onServicesClick ? ( + <> +
{nameContent}
+ Search + + ) : ( + nameContent + ) + } + subtext={props.displayName && {`@${props.handle}`}} onClick={props.onClick} onMouseDown={props.onMouseDown} selected={props.selected} @@ -428,25 +613,136 @@ const PostResult = (props: { ); }; +const ServiceEntry = (props: { + name: string; + description?: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + return ( + +
{props.name}
+ Search + + } + subtext={props.description && {props.description}} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + +const ServiceSearchResult = (props: { + name: string; + description?: string; + labels?: SearchService.MentionLabel[]; + icon?: string; + hasEmbed?: boolean; + onEmbedClick?: () => void; + subscope?: SearchService.SubscopeInfo; + onSubscopeClick?: () => void; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + const hasActions = + (props.hasEmbed && props.onEmbedClick) || + (props.subscope && props.onSubscopeClick); + const hasLabels = props.labels && props.labels.length > 0; + const hasDescription = !!props.description; + const hasSubtext = hasLabels || hasDescription; + return ( + + ) : undefined + } + result={ + hasActions ? ( + <> +
{props.name}
+ {props.subscope && props.onSubscopeClick && ( + + {props.subscope.label} + + )} + {props.hasEmbed && props.onEmbedClick && ( + Embed + )} + + ) : ( +
{props.name}
+ ) + } + subtext={ + hasSubtext ? ( +
+ {hasLabels && ( +
+ {props.labels!.flatMap( + (label, i) => + label.text + ? [ + ...(i > 0 + ? [|] + : []), + {label.text}, + ] + : [], + )} +
+ )} + {hasDescription && ( + + {props.description} + + )} +
+ ) : undefined + } + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + const ScopeHeader = (props: { scope: MentionScope; handleScopeChange: () => void; }) => { if (props.scope.type === "default") return; - if (props.scope.type === "publication") - return ( - - ); + + const label = + props.scope.type === "identities" + ? "People" + : props.scope.type === "publications" + ? "Publications" + : props.scope.type === "publication" + ? `Posts from ${props.scope.name}` + : props.scope.type === "did_services" + ? `Services for ${props.scope.name}` + : `Results from ${props.scope.name}`; + + return ( + + ); }; export type Mention = @@ -458,15 +754,170 @@ export type Mention = avatar?: string; } | { type: "publication"; uri: string; name: string; url: string } - | { type: "post"; uri: string; title: string; url: string }; + | { type: "post"; uri: string; title: string; url: string } + | { + type: "service"; + serviceUri: string; + name: string; + description?: string; + canBeScopedToDid?: boolean; + } + | { + type: "service_result"; + uri: string; + name: string; + description?: string; + labels?: SearchService.MentionLabel[]; + href?: string; + icon?: string; + embed?: SearchService.EmbedInfo; + subscope?: SearchService.SubscopeInfo; + }; export type MentionScope = | { type: "default" } - | { type: "publication"; uri: string; name: string }; -function useMentionSuggestions(query: string | null) { + | { type: "identities" } + | { type: "publications" } + | { type: "publication"; uri: string; name: string } + | { + type: "service"; + serviceUri: string; + name: string; + scope?: string; + serviceName?: string; + scopedDid?: string; + scopedDidName?: string; + } + | { type: "did_services"; did: string; name: string }; + +function scopePlaceholder(scope: MentionScope, fallback?: string): string { + switch (scope.type) { + case "identities": + return "Search people..."; + case "publications": + return "Search publications..."; + case "publication": + return "Search posts..."; + case "service": + return `Search ${scope.name}...`; + case "did_services": + return `Search ${scope.name} with...`; + default: + return fallback ?? "Search people & publications..."; + } +} + +function serviceScopeFromMention( + service: Mention & { type: "service" }, +): MentionScope { + if (service.serviceUri === BUILTIN_IDENTITIES.serviceUri) + return { type: "identities" }; + if (service.serviceUri === BUILTIN_PUBLICATIONS.serviceUri) + return { type: "publications" }; + return { + type: "service", + serviceUri: service.serviceUri, + name: service.name, + }; +} + +const BUILTIN_IDENTITIES: Mention & { type: "service" } = { + type: "service", + serviceUri: "builtin:identities", + name: "Identities", + description: "Search people on Bluesky", +}; +const BUILTIN_PUBLICATIONS: Mention & { type: "service" } = { + type: "service", + serviceUri: "builtin:publications", + name: "Publications", + description: "Search publications on Leaflet", +}; + +const bskyAgent = new Agent("https://public.api.bsky.app"); + +async function searchIdentities( + query: string, + limit: number, +): Promise { + const result = await bskyAgent.searchActorsTypeahead({ q: query, limit }); + return result.data.actors.map((actor) => ({ + type: "did" as const, + handle: actor.handle, + did: actor.did, + displayName: actor.displayName, + avatar: actor.avatar, + })); +} + +async function searchPublications( + query: string, + limit: number, +): Promise { + const publications = await callRPC(`search_publication_names`, { + query, + limit, + }); + return publications.result.publications.map((p) => ({ + type: "publication" as const, + uri: p.uri, + name: p.name, + url: p.url, + })); +} + +const EMPTY_SERVICES: Array = []; +function useMentionServices(enabled: boolean): Array { + const { data } = useSWR( + enabled ? "mention_services" : null, + async () => { + try { + const result = await callRPC(`get_user_mention_services`, {}); + return result.result.services.map( + (s: { + uri: string; + name: string; + description?: string; + canBeScopedToDid?: boolean; + }) => ({ + type: "service" as const, + serviceUri: s.uri, + name: s.name, + description: s.description, + canBeScopedToDid: s.canBeScopedToDid, + }), + ); + } catch { + return EMPTY_SERVICES; + } + }, + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); + return data || EMPTY_SERVICES; +} + +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( + () => + externalServices.length > 0 + ? [BUILTIN_IDENTITIES, BUILTIN_PUBLICATIONS, ...externalServices] + : EMPTY_SERVICES, + [externalServices], + ); + const hasServices = allServices.length > 0; + const didScopableServices = useMemo( + () => + externalServices.filter( + (s) => s.type === "service" && s.canBeScopedToDid, + ), + [externalServices], + ); + const hasDidScopableServices = didScopableServices.length > 0; // Clear suggestions immediately when scope changes const setScopeAndClear = useCallback((newScope: MentionScope) => { @@ -474,58 +925,100 @@ function useMentionSuggestions(query: string | null) { setScope(newScope); }, []); - useDebouncedEffect( - async () => { - if (!query && scope.type === "default") { - setSuggestions([]); - return; - } + useEffect(() => { + let stale = false; + setSearchComplete(false); + // DID services scope: show DID-scopable services filtered locally + if (scope.type === "did_services") { + const filtered = didScopableServices.filter((s) => + s.type === "service" + ? s.name.toLowerCase().includes((query || "").toLowerCase()) + : true, + ); + setSuggestions(filtered); + setSearchComplete(true); + return; + } + + // Default scope with services: show local filter instantly, debounce network fallback + if (hasServices && scope.type === "default") { + const filtered = allServices.filter((s) => + s.type === "service" + ? s.name.toLowerCase().includes((query || "").toLowerCase()) + : 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 () => { + if (stale || !open) return; + + let results: Array; - if (scope.type === "publication") { - // Search within the publication's documents + if (scope.type === "identities") { + results = await searchIdentities(query || "", 10); + } else if (scope.type === "publications") { + results = await searchPublications(query || "", 10); + } else if (scope.type === "publication") { + // Search within a specific 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, - url: d.url, - })), - ); + results = (documents?.result?.documents ?? []).map((d) => ({ + type: "post" as const, + uri: d.uri, + title: d.title, + url: d.url, + })); + } else if (scope.type === "service") { + const res = await callRPC(`proxy_mention_search`, { + service_uri: scope.serviceUri, + search: query || "", + ...(scope.scope ? { scope: scope.scope } : {}), + }); + const items: SearchService.Result[] = res?.result?.results ?? []; + results = items.map((r) => ({ + type: "service_result" as const, + uri: r.uri, + name: r.name, + description: r.description, + labels: r.labels, + href: r.href, + icon: r.icon, + embed: r.embed, + subscope: r.subscope, + })); + } else if (hasServices) { + // Default scope with services: local filter showed no matches, fall back to identity search + results = await searchIdentities(query || "", 10); } 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, - avatar: actor.avatar, - })), - ...publications.result.publications.map((p) => ({ - type: "publication" as const, - uri: p.uri, - name: p.name, - url: p.url, - })), + // Default scope, no services: search people & publications together + const [identities, publications] = await Promise.all([ + searchIdentities(query || "", 8), + searchPublications(query || "", 8), ]); + results = [...identities, ...publications]; } - }, - 300, - [query, scope], - ); + + if (!stale) { + setSuggestions(results); + setSearchComplete(true); + } + }, 300); + + return () => { + stale = true; + clearTimeout(handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, scope, open, hasServices, allServices, didScopableServices]); useEffect(() => { if (suggestionIndex > suggestions.length - 1) { @@ -539,5 +1032,7 @@ function useMentionSuggestions(query: string | null) { setSuggestionIndex, scope, setScope: setScopeAndClear, + searchComplete, + hasDidScopableServices, }; } diff --git a/components/Pages/IframePageView.tsx b/components/Pages/IframePageView.tsx new file mode 100644 index 00000000..3f37d70f --- /dev/null +++ b/components/Pages/IframePageView.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, { useMemo } from "react"; +import { useIframeChannel } from "src/hooks/useIframeChannel"; +import { + useColorAttribute, + colorToString, +} from "components/ThemeManager/useColorAttribute"; +import { PageWrapper } from "./Page"; + +export function IframePageView(props: { + url: string; + pageOptions?: React.ReactNode; + onOpen: (url: string) => void; +}) { + let { iframeRef } = useIframeChannel({ + onOpen: props.onOpen, + onReplaceWith: () => {}, + onAddBelow: () => {}, + }); + + let bgPage = useColorAttribute(null, "theme/page-background"); + let primary = useColorAttribute(null, "theme/primary"); + let iframeSrc = useMemo(() => { + let src = new URL(props.url); + src.searchParams.set("parts.page.embed.ctx.mode", "edit"); + src.searchParams.set( + "parts.page.embed.ctx.bgColor", + `rgb(${colorToString(bgPage, "rgb")})`, + ); + src.searchParams.set( + "parts.page.embed.ctx.primaryColor", + `rgb(${colorToString(primary, "rgb")})`, + ); + return src.toString(); + }, [props.url, bgPage, primary]); + + return ( + +