From f05c7d1fd0273b9c3ceb3af01a531467104f53c1 Mon Sep 17 00:00:00 2001 From: Essential Randomness Date: Mon, 29 Jun 2026 21:28:47 -0700 Subject: [PATCH] add support for sharing and unsharing content --- src/lib/community/shared-content.ts | 22 ++++- src/lib/community/types.ts | 4 +- src/lib/live-handlers.ts | 17 +++- src/lib/opensocial/content-sharing.ts | 55 ++++++++++- .../generated/community/opensocial.ts | 1 + .../community/opensocial/deleteRecord.defs.ts | 41 ++++++++ .../community/opensocial/deleteRecord.ts | 6 ++ src/lib/opensocial/signed-service.ts | 93 +++++++++++++++++++ src/lib/opensocial/xrpc.ts | 2 +- 9 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 src/lib/opensocial/generated/community/opensocial/deleteRecord.defs.ts create mode 100644 src/lib/opensocial/generated/community/opensocial/deleteRecord.ts create mode 100644 src/lib/opensocial/signed-service.ts diff --git a/src/lib/community/shared-content.ts b/src/lib/community/shared-content.ts index 3b8707b..0da3c7a 100644 --- a/src/lib/community/shared-content.ts +++ b/src/lib/community/shared-content.ts @@ -73,6 +73,8 @@ export interface SharedDocumentRef { sharedBy?: string; sharedAt: Date; source: string; + shareRecordUri: string; + shareRecordRkey: string; } export interface SharedEventRef { @@ -116,7 +118,11 @@ export function classifySharedContent(value: unknown): SharedContentKind { export function parseSharedDocumentRef( value: Record, - ctx: { source: string }, + ctx: { + source: string; + shareRecordUri: string; + shareRecordRkey: string; + }, ): SharedDocumentRef | null { if (classifySharedContent(value) !== 'document') return null; @@ -132,6 +138,8 @@ export function parseSharedDocumentRef( sharedBy: asNonEmptyString(value.sharedBy), sharedAt, source: ctx.source, + shareRecordUri: ctx.shareRecordUri, + shareRecordRkey: ctx.shareRecordRkey, }; } @@ -172,8 +180,11 @@ export async function hydrateSharedDocument( const docValue = await deps.fetchRecord(ref.documentUri); if (!docValue) return null; - const author = await deps.getProfile(ref.sharedBy || parsed.host); - const url = await resolveDocumentUrl( + const [author, sharedBy] = await Promise.all([ + deps.getProfile(parsed.host), + ref.sharedBy ? deps.getProfile(ref.sharedBy) : Promise.resolve(undefined), + ]); + const url = await resolveStandardDocumentUrl( docValue, parsed.host, parsed.rkey, @@ -191,8 +202,11 @@ export async function hydrateSharedDocument( publishedAt: publishedAt ?? undefined, sharedAt: ref.sharedAt, author, + sharedBy, source: ref.source, documentUri: ref.documentUri, + shareRecordUri: ref.shareRecordUri, + shareRecordRkey: ref.shareRecordRkey, tags: (docValue.tags as string[]) ?? undefined, }; } @@ -236,7 +250,7 @@ export async function hydrateSharedEvent( }; } -async function resolveDocumentUrl( +export async function resolveStandardDocumentUrl( docValue: Record, repo: string, rkey: string, diff --git a/src/lib/community/types.ts b/src/lib/community/types.ts index 97a1883..bfa9e3c 100644 --- a/src/lib/community/types.ts +++ b/src/lib/community/types.ts @@ -33,8 +33,11 @@ export interface SharedPost { publishedAt?: Date; sharedAt: Date; author: AtProfile; + sharedBy?: AtProfile; source: string; documentUri: string; + shareRecordUri: string; + shareRecordRkey: string; tags?: string[]; } @@ -56,4 +59,3 @@ export interface AtProfile { displayName?: string; avatar?: string; } - diff --git a/src/lib/live-handlers.ts b/src/lib/live-handlers.ts index 58a6484..23ada44 100644 --- a/src/lib/live-handlers.ts +++ b/src/lib/live-handlers.ts @@ -103,8 +103,11 @@ export const feedOutputSchema = z.object({ publishedAt: z.coerce.date().optional(), sharedAt: z.coerce.date(), author: authorSchema, + sharedBy: authorSchema.optional(), source: z.string(), documentUri: z.string(), + shareRecordUri: z.string().optional(), + shareRecordRkey: z.string().optional(), tags: z.array(z.string()).optional(), }); @@ -138,13 +141,19 @@ type EventEntry = z.input; // Pre-enrichment shape returned by per-collection feed transformers. They emit // the raw AtProto profile (avatar as plain URL string); the wrapper in // live.config.ts collapses that into the schema's discriminated union. -export type RawFeedEntry = Omit & { +export type RawFeedEntry = Omit & { author: { did: string; handle: string; displayName?: string; avatar?: string; }; + sharedBy?: { + did: string; + handle: string; + displayName?: string; + avatar?: string; + }; }; export type LoaderArgs = AtProtoRecordCallbackArgs; @@ -172,6 +181,8 @@ export const feedFilters: Record = { 'community.opensocial.sharedContent': (ctx) => { const ref = parseSharedDocumentRef(ctx.value as Record, { source: sourceLabel(ctx), + shareRecordUri: ctx.uri, + shareRecordRkey: ctx.rkey, }); return !!ref && ref.documentUri.startsWith('at://'); }, @@ -204,6 +215,8 @@ export const feedTransformers: Record> = { 'community.opensocial.sharedContent': async (ctx) => { const ref = parseSharedDocumentRef(ctx.value as Record, { source: sourceLabel(ctx), + shareRecordUri: ctx.uri, + shareRecordRkey: ctx.rkey, }); if (!ref) return null; const post = await hydrateSharedDocument(ref, { @@ -217,7 +230,7 @@ export const feedTransformers: Record> = { if (!post) return null; const { textContent, ...rest } = post; return { - id: ref.documentUri, + id: ctx.uri, data: { ...rest, excerpt: textContent ? excerpt(textContent) : undefined, diff --git a/src/lib/opensocial/content-sharing.ts b/src/lib/opensocial/content-sharing.ts index 70e4aa2..dc7b6b5 100644 --- a/src/lib/opensocial/content-sharing.ts +++ b/src/lib/opensocial/content-sharing.ts @@ -1,10 +1,12 @@ -import { asDatetimeString, isDidString } from "@atproto/lex"; +import { XrpcError, asDatetimeString, isDidString } from "@atproto/lex"; import type { ShareCandidate } from "../community/share-candidates.js"; import { createRecord as createRecordMethod, + deleteRecord as deleteRecordMethod, sharedContent as sharedContentSchema, } from "./generated/community/opensocial.js"; +import { OpenSocialCommunityError } from "./membership.js"; import { createSignedLexClient } from "./xrpc.js"; const DEFAULT_SERVICE = "https://api.opensocial.community"; @@ -12,6 +14,8 @@ export const SHARED_CONTENT_COLLECTION = "community.opensocial.sharedContent"; type CreateRecordBody = createRecordMethod.$defs.$InputBody; type CreateRecordOutput = createRecordMethod.$defs.$OutputBody; +type DeleteRecordBody = deleteRecordMethod.$defs.$InputBody; +type DeleteRecordOutput = deleteRecordMethod.$defs.$OutputBody; function createOpenSocialClient() { const appId = import.meta.env.OPENSOCIAL_APP_ID; @@ -62,8 +66,51 @@ export async function shareContentWithCommunity(input: { record, }; - const res = await createOpenSocialClient().xrpc(createRecordMethod.main, { - body, - }); + const res = await xrpc(() => + createOpenSocialClient().xrpc(createRecordMethod.main, { + body, + }), + ); + return res.body; +} + +export async function unshareContentWithCommunity(input: { + communityDid: string; + userDid: string; + shareRecordRkey: string; +}): Promise { + const body: DeleteRecordBody = { + communityDid: did(input.communityDid, "communityDid"), + userDid: did(input.userDid, "userDid"), + collection: SHARED_CONTENT_COLLECTION, + rkey: input.shareRecordRkey, + }; + + const res = await xrpc(() => + createOpenSocialClient().xrpc(deleteRecordMethod.main, { + body, + }), + ); return res.body; } + +async function xrpc(call: () => Promise): Promise { + try { + return await call(); + } catch (err) { + throw toOpenSocialCommunityError(err); + } +} + +function toOpenSocialCommunityError(err: unknown): OpenSocialCommunityError { + if (err instanceof OpenSocialCommunityError) return err; + if (err instanceof XrpcError) { + const downstream = err.toDownstreamError(); + return new OpenSocialCommunityError( + downstream.status, + downstream.body.error, + downstream.body.message || downstream.body.error, + ); + } + throw err; +} diff --git a/src/lib/opensocial/generated/community/opensocial.ts b/src/lib/opensocial/generated/community/opensocial.ts index 29ccbbf..25db5dc 100644 --- a/src/lib/opensocial/generated/community/opensocial.ts +++ b/src/lib/opensocial/generated/community/opensocial.ts @@ -3,6 +3,7 @@ */ export * as createRecord from "./opensocial/createRecord.js"; +export * as deleteRecord from "./opensocial/deleteRecord.js"; export * as getPermissions from "./opensocial/getPermissions.js"; export * as joinCommunity from "./opensocial/joinCommunity.js"; export * as leaveCommunity from "./opensocial/leaveCommunity.js"; diff --git a/src/lib/opensocial/generated/community/opensocial/deleteRecord.defs.ts b/src/lib/opensocial/generated/community/opensocial/deleteRecord.defs.ts new file mode 100644 index 0000000..5f2c4bd --- /dev/null +++ b/src/lib/opensocial/generated/community/opensocial/deleteRecord.defs.ts @@ -0,0 +1,41 @@ +/* + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. + */ + +import { l } from '@atproto/lex' + +const $nsid = 'community.opensocial.deleteRecord' + +export { $nsid } + +/** Delete a record from a community's PDS repo. */ +const main = l.procedure( + $nsid, + l.params(), + l.jsonPayload({ + communityDid: l.string({ format: 'did' }), + userDid: l.string({ format: 'did' }), + collection: l.string(), + rkey: l.string(), + }), + l.jsonPayload({}), + ['CommunityNotFound', 'PermissionDenied', 'RecordNotFound'], +) +export { main } + +export type $Params = l.InferMethodParams +export type $Input = l.InferMethodInput +export type $InputBody = l.InferMethodInputBody< + typeof main, + B +> +export type $Output = l.InferMethodOutput +export type $OutputBody = l.InferMethodOutputBody< + typeof main, + B +> + +export const $lxm = main.nsid, + $params = main.parameters, + $input = main.input, + $output = main.output diff --git a/src/lib/opensocial/generated/community/opensocial/deleteRecord.ts b/src/lib/opensocial/generated/community/opensocial/deleteRecord.ts new file mode 100644 index 0000000..ad9a591 --- /dev/null +++ b/src/lib/opensocial/generated/community/opensocial/deleteRecord.ts @@ -0,0 +1,6 @@ +/* + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. + */ + +export * from './deleteRecord.defs.js' +export * as $defs from './deleteRecord.defs.js' diff --git a/src/lib/opensocial/signed-service.ts b/src/lib/opensocial/signed-service.ts new file mode 100644 index 0000000..a892184 --- /dev/null +++ b/src/lib/opensocial/signed-service.ts @@ -0,0 +1,93 @@ +// Vendored from @fujocoded/atproto-lex-client/signed-service so this repo +// does not depend on a sibling checkout for opensocial request signing. + +import type { Agent } from "@atproto/lex"; + +export interface SignedServiceRequest { + readonly method: string; + readonly url: string; + readonly body?: string; + readonly headers: Headers; +} + +export type SignedServiceRequestSigner = ( + request: SignedServiceRequest, +) => HeadersInit | Promise; + +export interface SignedServiceAgentOptions { + serviceUrl: string | URL; + did?: Agent["did"]; + headers?: HeadersInit; + fetch?: typeof globalThis.fetch; + signRequest: SignedServiceRequestSigner; +} + +export function createSignedServiceAgent( + options: SignedServiceAgentOptions, +): Agent { + const fetchImpl = getFetch(options.fetch); + + return { + did: options.did, + async fetchHandler(path, init) { + const method = (init.method ?? "GET").toUpperCase(); + const url = new URL(path, options.serviceUrl); + const headers = mergeHeaders(options.headers, init.headers); + const body = readSignableBody(init.body); + const signedHeaders = await options.signRequest({ + method, + url: url.href, + body, + headers: new Headers(headers), + }); + + return fetchImpl(url, { + ...init, + method, + headers: mergeHeaders(headers, signedHeaders), + }); + }, + }; +} + +function mergeHeaders(...headersInit: (HeadersInit | undefined)[]): Headers { + const headers = new Headers(); + + for (const init of headersInit) { + if (init === undefined) { + continue; + } + + new Headers(init).forEach((value, key) => { + headers.set(key, value); + }); + } + + return headers; +} + +function getFetch( + fetchImpl: typeof globalThis.fetch | undefined, +): typeof globalThis.fetch { + const resolvedFetch = fetchImpl ?? globalThis.fetch; + + if (typeof resolvedFetch !== "function") { + throw new TypeError("fetch() is not available in this environment"); + } + + return resolvedFetch; +} + +function readSignableBody(body: RequestInit["body"]): string | undefined { + if (body === undefined || body === null) { + return undefined; + } + + if (typeof body === "string") { + return body; + } + + throw new TypeError( + "Signed service requests only support string bodies. Serialize the body before signing or use an unsigned/binary-specific agent.", + ); +} diff --git a/src/lib/opensocial/xrpc.ts b/src/lib/opensocial/xrpc.ts index c0ab9ba..c3f49b6 100644 --- a/src/lib/opensocial/xrpc.ts +++ b/src/lib/opensocial/xrpc.ts @@ -2,7 +2,7 @@ // signed-service transport with this app's CIMD HTTP Message Signature signer. import { Client } from "@atproto/lex"; -import { createSignedServiceAgent } from "@fujocoded/atproto-lex-client/signed-service"; +import { createSignedServiceAgent } from "./signed-service.js"; import { signRequest } from "../cmid-signing/index.js"; -- 2.51.2