From 82c9754d223073e34a2afb3607f7a5a694c7bc83 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 7 Apr 2026 13:48:13 -0700 Subject: [PATCH] wip --- .../[command]/get_user_mention_services.ts | 87 ++++++++++++++----- app/api/rpc/[command]/proxy_mention_search.ts | 33 +++++-- .../[publication]/[rkey]/PostContent.tsx | 7 +- components/Blocks/EmbedBlock.tsx | 79 ++++++++++++++++- components/Pages/IframePageView.tsx | 16 +++- feeds/index.ts | 4 +- src/hooks/useIframeChannel.ts | 32 +++++-- src/partsPageChannel.ts | 31 ++++++- 8 files changed, 238 insertions(+), 51 deletions(-) diff --git a/app/api/rpc/[command]/get_user_mention_services.ts b/app/api/rpc/[command]/get_user_mention_services.ts index 3e28996a..b78b8161 100644 --- a/app/api/rpc/[command]/get_user_mention_services.ts +++ b/app/api/rpc/[command]/get_user_mention_services.ts @@ -5,6 +5,18 @@ 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"]> >; @@ -15,36 +27,65 @@ export const get_user_mention_services = makeRoute({ handler: async (_input, { supabase }: Pick) => { let user = await getIdentityData(); if (!user?.atp_did) return { result: { services: [] } }; - const { data: config } = await supabase - .from("mention_service_configs") - .select("record") - .eq("identity_did", user?.atp_did) - .single(); + 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, + }); + } - const services = (config?.record as MentionConfig.Record)?.services; - if (!services?.length) return { result: { services: [] } }; + if (!services.length) return { result: { services: [] } }; - const { data: serviceRows, error } = await supabase - .from("mention_services") - .select("uri, record") - .in("uri", 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); + } + } - if (error) { - throw new Error(`Failed to fetch mention services: ${error.message}`); + // 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: (serviceRows || []).map((s) => { - const record = s.record as MentionService.Record; - return { - uri: s.uri, - name: record.name, - description: record.description, - did: record.did, - canBeScopedToDid: record.canBeScopedToDid ?? false, - }; - }), + 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 index bdc1e195..8d3b313c 100644 --- a/app/api/rpc/[command]/proxy_mention_search.ts +++ b/app/api/rpc/[command]/proxy_mention_search.ts @@ -7,6 +7,13 @@ 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"]> >; @@ -26,15 +33,24 @@ export const proxy_mention_search = makeRoute({ const identity = await getIdentityData(); if (!identity?.atp_did) throw new Error("Not authenticated"); - const { data: service } = await supabase - .from("mention_services") - .select("record") - .eq("uri", service_uri) - .single(); - - if (!service) throw new Error("Mention service not found"); + 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 record = service.record as MentionService.Record; const did = record.did; if (!did) throw new Error("Service has no DID"); @@ -44,6 +60,7 @@ export const proxy_mention_search = makeRoute({ 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, diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index c8057156..12f234d8 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -453,15 +453,20 @@ function PublishedIframeBlock(props: { onOpen: (url) => { openPageAction(parentPage, { type: "iframe", url }); }, + onReplaceWith: () => {}, + onAddBelow: () => {}, }); + let iframeSrc = new URL(props.url); + iframeSrc.searchParams.set("parts.page.mode", "view"); + return ( diff --git a/components/Pages/IframePageView.tsx b/components/Pages/IframePageView.tsx index 3c9cbb6b..18c8cce9 100644 --- a/components/Pages/IframePageView.tsx +++ b/components/Pages/IframePageView.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useMemo } from "react"; import { useIframeChannel } from "src/hooks/useIframeChannel"; import { PageWrapper } from "./Page"; @@ -9,7 +9,17 @@ export function IframePageView(props: { pageOptions?: React.ReactNode; onOpen: (url: string) => void; }) { - let { iframeRef } = useIframeChannel({ onOpen: props.onOpen }); + let { iframeRef } = useIframeChannel({ + onOpen: props.onOpen, + onReplaceWith: () => {}, + onAddBelow: () => {}, + }); + + let iframeSrc = useMemo(() => { + let src = new URL(props.url); + src.searchParams.set("parts.page.mode", "edit"); + return src.toString(); + }, [props.url]); return ( diff --git a/feeds/index.ts b/feeds/index.ts index 72fd67a7..b2066589 100644 --- a/feeds/index.ts +++ b/feeds/index.ts @@ -1,6 +1,6 @@ import { Hono, HonoRequest } from "hono"; import { serve } from "@hono/node-server"; -import { DidResolver } from "@atproto/identity"; +import { DidResolver, MemoryCache } from "@atproto/identity"; import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; import { supabaseServerClient } from "supabase/serverClient"; import { @@ -190,7 +190,7 @@ app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { }); }); -const didResolver = new DidResolver({}); +const didResolver = new DidResolver({ didCache: new MemoryCache() }); const validateAuth = async ( req: HonoRequest, serviceDid: string, diff --git a/src/hooks/useIframeChannel.ts b/src/hooks/useIframeChannel.ts index d2c54d26..e22d5391 100644 --- a/src/hooks/useIframeChannel.ts +++ b/src/hooks/useIframeChannel.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef } from "react"; import { newMessagePortRpcSession } from "capnweb"; -import { PartsPageHost } from "src/partsPageChannel"; +import { PartsPageHost, PartsPageHandlers } from "src/partsPageChannel"; -export function useIframeChannel(options: { onOpen: (url: string) => void }) { +export function useIframeChannel(options: PartsPageHandlers) { let iframeElRef = useRef(null); - let onOpenRef = useRef(options.onOpen); - onOpenRef.current = options.onOpen; + let handlersRef = useRef(options); + handlersRef.current = options; let sessionRef = useRef void }) { let cleanup = useCallback(() => { if (sessionRef.current) { + console.log("[parts.page] disposing RPC session"); sessionRef.current[Symbol.dispose](); sessionRef.current = null; } @@ -35,12 +36,29 @@ export function useIframeChannel(options: { onOpen: (url: string) => void }) { let host = new PartsPageHost({ onOpen: (url) => { console.log("[parts.page] open command from", src, url); - onOpenRef.current(url); + handlersRef.current.onOpen(url); + }, + onReplaceWith: (block) => { + console.log("[parts.page] replaceWith command from", src, block); + handlersRef.current.onReplaceWith(block); + }, + onAddBelow: (block) => { + console.log("[parts.page] addBelow command from", src, block); + handlersRef.current.onAddBelow(block); }, }); - sessionRef.current = newMessagePortRpcSession(port1, host); - console.log("[parts.page] channel established with", src); + console.log("[parts.page] creating RPC session for", src); + try { + sessionRef.current = newMessagePortRpcSession(port1, host); + console.log("[parts.page] RPC session created", { + src, + session: sessionRef.current, + }); + } catch (e) { + console.error("[parts.page] RPC session creation failed", src, e); + throw e; + } iframe.contentWindow.postMessage({ type: "parts.page.channel" }, "*", [ port2, diff --git a/src/partsPageChannel.ts b/src/partsPageChannel.ts index 9cf73748..a88aca05 100644 --- a/src/partsPageChannel.ts +++ b/src/partsPageChannel.ts @@ -1,14 +1,37 @@ import { RpcTarget } from "capnweb"; +export type EmbedBlockData = + | { type: "text"; content: string } + | { + type: "embed"; + url: string; + height?: number; + aspectRatio?: string; + }; + +export type PartsPageHandlers = { + onOpen: (url: string) => void; + onReplaceWith: (block: EmbedBlockData) => void; + onAddBelow: (block: EmbedBlockData) => void; +}; + export class PartsPageHost extends RpcTarget { - #onOpen: (url: string) => void; + #handlers: PartsPageHandlers; - constructor(handlers: { onOpen: (url: string) => void }) { + constructor(handlers: PartsPageHandlers) { super(); - this.#onOpen = handlers.onOpen; + this.#handlers = handlers; } open(url: string) { - this.#onOpen(url); + this.#handlers.onOpen(url); + } + + replaceWith(block: EmbedBlockData) { + this.#handlers.onReplaceWith(block); + } + + addBelow(block: EmbedBlockData) { + this.#handlers.onAddBelow(block); } } -- 2.51.2