diff --git a/js/components/src/livestream-store/hooks.tsx b/js/components/src/livestream-store/hooks.tsx index bb9b95646..522699946 100644 --- a/js/components/src/livestream-store/hooks.tsx +++ b/js/components/src/livestream-store/hooks.tsx @@ -1,8 +1,9 @@ // React hooks for chat/moderation state. The pure reducer and types live // in @streamplace/core; these hooks bind them to the LivestreamStore and // to React lifecycle. -import { ComAtprotoModerationCreateReport, RichText } from "@atproto/api"; +import { ComAtprotoModerationCreateReport } from "@atproto/api"; import { + detectChatFacets, type LivestreamState, type NewChatMessage, reduceChat, @@ -95,28 +96,14 @@ export const useCreateChatMessage = () => { throw new Error("Profile not found"); } - const rt = new RichText({ text: msg.text }); - await rt.detectFacets(pdsAgent); - - // filter out any facets that aren't in the allowed list - rt.facets = rt.facets?.filter((facet) => { - return ( - // if all features are in the allowed list - facet.features.every((feature) => - [ - "app.bsky.richtext.facet#link", - "app.bsky.richtext.facet#mention", - ].includes(feature.$type), - ) - ); - }); + const facets = await detectChatFacets(pdsAgent, msg.text); const record: place.stream.chat.message.Main = { $type: "place.stream.chat.message", text: msg.text, createdAt: new Date().toISOString() as any, streamer: streamerProfile.did as any, - facets: rt.facets as place.stream.chat.message.Main["facets"], + facets, ...(msg.reply ? { reply: { diff --git a/js/core/src/lib/facet.test.ts b/js/core/src/lib/facet.test.ts new file mode 100644 index 000000000..cf91e369a --- /dev/null +++ b/js/core/src/lib/facet.test.ts @@ -0,0 +1,67 @@ +import { place } from "streamplace"; +import { describe, expect, it } from "vitest"; +import { detectChatFacets } from "./facet"; + +const MENTION_DID = "did:plc:svzjldcfdhtytju6wa2vjrth"; + +// Only mention resolution reaches the agent. +const agent = { + com: { + atproto: { + identity: { + resolveHandle: async ({ handle }: { handle: string }) => ({ + data: { + did: handle === "sidechannel.cheeky.town" ? MENTION_DID : "", + }, + }), + }, + }, + }, +} as unknown as Parameters[0]; + +describe("detectChatFacets", () => { + it("types link facets, which detectFacets leaves untyped", async () => { + const facets = await detectChatFacets( + agent, + "read through the dist.town/release.software docs?", + ); + + expect(facets).toHaveLength(1); + expect(facets?.[0]).toMatchObject({ + $type: "place.stream.richtext.facet", + index: { byteStart: 17, byteEnd: 43 }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://dist.town/release.software", + }, + ], + }); + // Any other facet $type is rejected, and the record with it discarded. + expect( + place.stream.richtext.facet.main.$safeValidate(facets?.[0]).success, + ).toBe(true); + }); + + it("restamps the app.bsky facet type detectFacets writes on mentions", async () => { + const facets = await detectChatFacets( + agent, + "@sidechannel.cheeky.town hello!", + ); + + expect(facets?.[0]).toMatchObject({ + $type: "place.stream.richtext.facet", + features: [ + { $type: "app.bsky.richtext.facet#mention", did: MENTION_DID }, + ], + }); + expect( + place.stream.richtext.facet.main.$safeValidate(facets?.[0]).success, + ).toBe(true); + }); + + it("drops facets whose features the chat lexicons do not allow", async () => { + expect(await detectChatFacets(agent, "see you at #lxiv")).toBeUndefined(); + expect(await detectChatFacets(agent, "no facets here")).toBeUndefined(); + }); +}); diff --git a/js/core/src/lib/facet.ts b/js/core/src/lib/facet.ts index 3ffca0fd3..25eea2569 100644 --- a/js/core/src/lib/facet.ts +++ b/js/core/src/lib/facet.ts @@ -1,7 +1,9 @@ // Taken from ATcute's richtext-segmenter // https://github.com/mary-ext/atcute/blob/trunk/packages/bluesky/richtext-segmenter/lib/index.ts // repoed b/c we need to import types from @atproto/api not @atcute/bsky +import { RichText } from "@atproto/api"; import { Main } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; +import type { place } from "streamplace"; type UnwrapArray = T extends (infer V)[] ? V : never; @@ -126,3 +128,45 @@ export const segmentize = ( return segments; }; + +/** The facet-level $type the place.stream.richtext.facet lexicon requires. */ +const CHAT_FACET_TYPE = "place.stream.richtext.facet"; + +/** A chat facet: the place.stream.richtext.facet the chat lexicons require. */ +export type ChatFacet = place.stream.richtext.facet.Main; + +/** Feature types its features union allows: the app.bsky link and mention defs. */ +const CHAT_FACET_FEATURE_TYPES: Record = { + "app.bsky.richtext.facet#link": true, + "app.bsky.richtext.facet#mention": true, +}; + +/** + * The facets for a chat message body: links and mentions, each stamped with + * the facet-level $type the chat lexicon requires. + * + * detectFacets types facets as app.bsky's (on mentions) or leaves the field + * off entirely (on links, and so on any facet it builds itself). A record + * carrying either fails validation against place.stream.richtext.facet, which + * requires that literal: "Expected place.stream.richtext.facet (got + * app.bsky.richtext.facet)". Normalize here, where chat facets are made. + */ +export const detectChatFacets = async ( + agent: Parameters[0], + text: string, +): Promise => { + const rt = new RichText({ text }); + await rt.detectFacets(agent); + const facets = rt.facets + ?.filter((facet) => + facet.features.every( + (feature) => CHAT_FACET_FEATURE_TYPES[feature.$type] === true, + ), + ) + .map( + // detectFacets hands back plain strings where the generated facet types + // want did:/scheme: templates; the lexicon validator checks those, not tsc. + (facet) => ({ ...facet, $type: CHAT_FACET_TYPE }) as ChatFacet, + ); + return facets?.length ? facets : undefined; +}; diff --git a/js/web/src/hooks/use-chat-send.ts b/js/web/src/hooks/use-chat-send.ts index b3010f321..3d819a006 100644 --- a/js/web/src/hooks/use-chat-send.ts +++ b/js/web/src/hooks/use-chat-send.ts @@ -1,15 +1,14 @@ // Optimistic chat send: adds a local message immediately, POSTs to PDS, rolls back on failure. -import { RichText } from "@atproto/api"; -import { reduceChat, type LivestreamStore } from "@streamplace/core"; +import { + detectChatFacets, + reduceChat, + type ChatFacet, + type LivestreamStore, +} from "@streamplace/core"; import { useCallback } from "react"; import { type ChatMessageViewHydrated } from "streamplace"; import { useSession } from "../lib/session"; -const ALLOWED_FACET_TYPES = new Set([ - "app.bsky.richtext.facet#link", - "app.bsky.richtext.facet#mention", -]); - export function useChatSend(store: LivestreamStore) { const { pdsAgent, did } = useSession(); @@ -35,15 +34,9 @@ export function useChatSend(store: LivestreamStore) { // Detect links and mentions so the message renders with rich // formatting for ourselves (optimistic) and for anyone who // receives the record afterwards. - let facets: unknown[] | undefined; + let facets: ChatFacet[] | undefined; try { - const rt = new RichText({ text: trimmed }); - await rt.detectFacets(pdsAgent); - facets = rt.facets?.filter((f) => - f.features.every((ft: { $type: string }) => - ALLOWED_FACET_TYPES.has(ft.$type), - ), - ); + facets = await detectChatFacets(pdsAgent, trimmed); } catch { // Non-fatal: ship the message without facets. facets = undefined; @@ -69,9 +62,7 @@ export function useChatSend(store: LivestreamStore) { createdAt: createdAt as any, streamer: streamerDid as any, ...(replyRef ? { reply: replyRef } : {}), - ...(facets - ? { facets: facets as ChatMessageViewHydrated["record"]["facets"] } - : {}), + ...(facets ? { facets } : {}), }, indexedAt: createdAt as any, ...(replyTo