diff --git a/js/web/src/lib/did.ts b/js/web/src/lib/did.ts new file mode 100644 index 000000000..45171077d --- /dev/null +++ b/js/web/src/lib/did.ts @@ -0,0 +1,49 @@ +// DID-document resolution helpers. Copied from +// js/components/src/utils/did.ts because the components package is +// React-Native-coupled and the web build doesn't depend on it. The +// implementation is platform-agnostic — pure fetch() — and is a good +// candidate to move into @streamplace/core in a future refactor. + +export interface DIDDocument { + id: string; + service?: Array<{ + id: string; + type?: string; + serviceEndpoint?: string; + }>; + [key: string]: any; +} + +export async function resolveDIDDocument(did: string): Promise { + let didDocUrl: string; + + if (did.startsWith("did:web:")) { + // For did:web, construct the URL directly + const domain = did.replace("did:web:", "").replace(/:/g, "/"); + didDocUrl = `https://${domain}/.well-known/did.json`; + } else if (did.startsWith("did:plc:")) { + // For did:plc, use plc.directory + didDocUrl = `https://plc.directory/${did}`; + } else { + throw new Error(`Unsupported DID method: ${did}`); + } + + const response = await fetch(didDocUrl); + if (!response.ok) { + throw new Error( + `Failed to resolve DID document for ${did}: ${response.status}`, + ); + } + + return response.json(); +} + +export function getPDSServiceEndpoint(didDoc: DIDDocument): string { + const pdsService = didDoc.service?.find((s) => s.id === "#atproto_pds"); + + if (!pdsService?.serviceEndpoint) { + throw new Error("No PDS service endpoint found in DID document"); + } + + return pdsService.serviceEndpoint; +} diff --git a/js/web/src/lib/store/hooks.ts b/js/web/src/lib/store/hooks.ts index dd47c024e..df7506814 100644 --- a/js/web/src/lib/store/hooks.ts +++ b/js/web/src/lib/store/hooks.ts @@ -1,5 +1,4 @@ -// Selector hooks for the slices wired up in commit A. Additional -// selectors (bluesky, contentMetadata) land with commit B. +// Selector hooks for the combined store. Mirrors js/app/store/hooks.ts. import { useStore } from "./index"; // Base @@ -21,6 +20,59 @@ export const useUserMuted = () => useStore((state) => state.userMuted); export const useChatWarned = () => useStore((state) => state.chatWarned); export const useMySegments = () => useStore((state) => state.mySegments); +// Bluesky / OAuth +export const useOAuthSession = () => useStore((state) => state.oauthSession); +export const usePDS = () => useStore((state) => state.pds); +export const useLogin = () => useStore((state) => state.loginState); +export const useProfiles = () => useStore((state) => state.profiles); +export const useStoredKey = () => useStore((state) => state.storedKey); +export const useKeyRecords = () => + useStore((state) => state.streamKeysResponse); +export const useServerSettings = () => + useStore((state) => state.serverSettings); +export const useUserProfile = () => { + const oauthSession = useOAuthSession(); + const profiles = useProfiles(); + const did = oauthSession?.did; + if (!did) return null; + return profiles[did]; +}; +export const useIsReady = () => { + const authStatus = useStore((state) => state.authStatus); + const oauthSession = useOAuthSession(); + const profile = useUserProfile(); + + if (authStatus === "start") { + return false; + } else if (authStatus === "loggedOut") { + return true; + } + if (!oauthSession) { + return false; + } + if (!profile) { + return false; + } + return true; +}; +export const useNewLivestream = () => useStore((state) => state.newLivestream); +export const useChatProfile = () => useStore((state) => state.chatProfile); +export const useCachedProfiles = () => useStore((state) => state.profileCache); + +// ContentMetadata +export const useContentMetadata = () => + useStore((state) => ({ + creating: state.creating, + updating: state.updating, + error: state.error, + lastCreatedRecord: state.lastCreatedRecord, + })); +export const useIsCreating = () => useStore((state) => state.creating); +export const useIsUpdating = () => useStore((state) => state.updating); +export const useContentMetadataError = () => useStore((state) => state.error); +export const useLastCreatedRecord = () => + useStore((state) => state.lastCreatedRecord); + // Platform export const useNotificationToken = () => useStore((state) => state.notificationToken); diff --git a/js/web/src/lib/store/index.ts b/js/web/src/lib/store/index.ts index 9e5e0b1c6..68d56c30e 100644 --- a/js/web/src/lib/store/index.ts +++ b/js/web/src/lib/store/index.ts @@ -1,8 +1,12 @@ // Combined Zustand store. Mirrors the shape of js/app/store/index.ts. // Slices are spread, not namespaced; selectors read from the flat state. -// Additional slices (bluesky, contentMetadata) are added in commit B. import { create } from "zustand"; import { BaseSlice, createBaseSlice } from "./slices/baseSlice"; +import { BlueskySlice, createBlueskySlice } from "./slices/blueskySlice"; +import { + ContentMetadataSlice, + createContentMetadataSlice, +} from "./slices/contentMetadataSlice"; import { createPlatformSlice, PlatformSlice } from "./slices/platformSlice"; import { createSidebarSlice, SidebarSlice } from "./slices/sidebarSlice"; import { @@ -13,16 +17,22 @@ import { export type AppStore = BaseSlice & SidebarSlice & StreamplaceSlice & + BlueskySlice & + ContentMetadataSlice & PlatformSlice; export const useStore = create()((...a) => ({ ...createBaseSlice(...a), ...createSidebarSlice(...a), ...createStreamplaceSlice(...a), + ...createBlueskySlice(...a), + ...createContentMetadataSlice(...a), ...createPlatformSlice(...a), })); export * from "./slices/baseSlice"; +export * from "./slices/blueskySlice"; +export * from "./slices/contentMetadataSlice"; export * from "./slices/platformSlice"; export * from "./slices/sidebarSlice"; export * from "./slices/streamplaceSlice"; diff --git a/js/web/src/lib/store/slices/blueskySlice.ts b/js/web/src/lib/store/slices/blueskySlice.ts new file mode 100644 index 000000000..0e9ae625f --- /dev/null +++ b/js/web/src/lib/store/slices/blueskySlice.ts @@ -0,0 +1,815 @@ +// Bluesky/ATProto authentication, profile, follow, and per-user record +// actions (chat profile, server settings). Ported from +// js/app/store/slices/blueskySlice.ts. +// +// Differences from the app source: +// - storage is imported from "../../storage" (the web's localStorage +// wrapper) instead of @streamplace/components. +// - createOAuthClient is imported from the web's lib/oauth, which +// returns a BrowserOAuthClient (the same type the app's web variant +// surfaces from features/bluesky/oauthClientImport). +// - clearQueryParams is a tiny inline helper instead of the app's +// utils/clear-query-params, since the app util is React-Native-gated. +// - The following actions are NOT ported in this commit and are +// placeholders for later phases. They depend on libraries that are +// not yet in js/web's deps: +// - createStreamKeyRecord, clearStreamKeyRecord, +// getStreamKeyRecords, deleteStreamKeyRecord (Phase 3 settings +// / Phase 4 go-live; needs @atproto/crypto + viem). +// - createLivestreamRecord, updateLivestreamRecord, golivePost, +// and the uploadThumbnail helper (Phase 4 go-live). +// - createBlockRecord (low priority; revisit if/when needed). +// The corresponding state fields (streamKeysResponse, newLivestream, +// newKey, etc.) are kept so the slice type stays symmetric with the +// app's; they simply aren't mutated yet. +import { Agent } from "@atproto/api"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; +import { OAuthSession } from "@atproto/oauth-client-browser"; +import { + PlaceStreamChatProfile, + PlaceStreamLivestream, + PlaceStreamServerSettings, + StreamplaceAgent, +} from "streamplace"; +import { StateCreator } from "zustand"; +import createOAuthClient from "../../oauth"; +import { storage } from "../../storage"; +import { AppStore } from "../index"; +import { DID_KEY, STORED_KEY_KEY, StreamKey } from "./baseSlice"; + +type NewLivestream = { + loading: boolean; + error: string | null; + record: PlaceStreamLivestream.Record | null; +}; + +export interface BlueskySlice { + authStatus: "start" | "loggedIn" | "loggedOut"; + oauthState: null | string; + oauthSession?: null | OAuthSession; + pdsAgent: null | StreamplaceAgent; + anonPDSAgent: null | StreamplaceAgent; + profiles: { [key: string]: ProfileViewDetailed }; + profileCache: { [key: string]: ProfileViewDetailed }; + client: null | Awaited>; + loginState: { + loading: boolean; + error: null | string; + }; + pds: { + url: string; + loading: boolean; + error: null | string; + }; + newKey: null | StreamKey; + storedKey: null | StreamKey; + isDeletingKey: boolean; + streamKeysResponse: { + loading: boolean; + error: null | string; + records: null | OutputSchema; + }; + newLivestream: null | NewLivestream; + chatProfile: { + loading: boolean; + error: null | string; + profile: null | PlaceStreamChatProfile.Record; + }; + serverSettings: null | PlaceStreamServerSettings.Record; + returnRoute: null | { name: string; params?: any }; + notification: { + message: string; + type: "error" | "success" | "info"; + } | null; + // actions + clearNotification: () => void; + loadOAuthClient: () => Promise; + oauthError: (error: string, description: string) => void; + login: ( + handle: string, + openLoginLink: (url: string) => Promise, + ) => Promise; + logout: () => Promise; + getProfile: (actor: string) => Promise; + getProfiles: (actors: string[]) => Promise; + oauthCallback: (url: string) => Promise; + setReturnRoute: (route: { name: string; params?: any } | null) => void; + showLoginModal: boolean; + openLoginModal: (returnRoute?: { name: string; params?: any }) => void; + closeLoginModal: () => void; + showPdsModal: boolean; + openPdsModal: () => void; + closePdsModal: () => void; + // TODO(phase 3/4): stream-key + go-live + block actions. See comment + // at the top of this file. + golivePost: ( + text: string, + now: Date, + thumbnail?: any, + ) => Promise<{ uri: string; cid: string }>; + createBlockRecord: (subjectDID: string) => Promise; + createStreamKeyRecord: (store: boolean) => Promise; + clearStreamKeyRecord: () => void; + getStreamKeyRecords: () => Promise; + deleteStreamKeyRecord: (rkey: string) => Promise; + setPDS: (pds: string) => Promise; + createLivestreamRecord: ( + title: string, + customThumbnail?: Blob, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], + ) => Promise; + updateLivestreamRecord: ( + title: string, + livestream: any, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], + ) => Promise; + getChatProfileRecordFromPDS: () => Promise; + createChatProfileRecord: ( + red: number, + green: number, + blue: number, + ) => Promise; + followUser: (subjectDID: string) => Promise; + unfollowUser: (subjectDID: string, followUri?: string) => Promise; + getServerSettingsFromPDS: () => Promise; + createServerSettingsRecord: (debugRecording: boolean) => Promise; +} + +// Inline OAuth-callback URL scrubber. The app's `utils/clear-query-params` +// is Platform.OS-gated and short-circuits on native; the web always wants +// to scrub these. +function clearQueryParams(par: string[] = ["iss", "state", "code"]) { + if (typeof document === "undefined") return; + const u = new URL(document.location.href); + if (u.search === "") return; + const params = new URLSearchParams(u.search); + par.forEach((p) => params.delete(p)); + u.search = params.toString(); + window.history.replaceState(null, "", u.toString()); +} + +export const createBlueskySlice: StateCreator< + AppStore, + [], + [], + BlueskySlice +> = (set, get) => ({ + authStatus: "start", + oauthState: null, + oauthSession: undefined, + pdsAgent: null, + anonPDSAgent: null, + profiles: {}, + profileCache: {}, + client: null, + loginState: { + loading: false, + error: null, + }, + pds: { + url: "bsky.social", + loading: false, + error: null, + }, + newKey: null, + storedKey: null, + isDeletingKey: false, + streamKeysResponse: { + loading: true, + error: null, + records: null, + }, + newLivestream: null, + chatProfile: { + loading: false, + error: null, + profile: null, + }, + serverSettings: null, + returnRoute: null, + showLoginModal: false, + showPdsModal: false, + notification: null, + + clearNotification: () => { + clearQueryParams(); + set({ notification: null }); + }, + + setReturnRoute: async (route: { name: string; params?: any } | null) => { + if (route) { + await storage.setItem("returnRoute", JSON.stringify(route)); + } else { + await storage.removeItem("returnRoute"); + } + set({ returnRoute: route }); + }, + + openLoginModal: async (returnRoute?: { name: string; params?: any }) => { + if (returnRoute) { + await storage.setItem("returnRoute", JSON.stringify(returnRoute)); + } + set({ showLoginModal: true, returnRoute: returnRoute || null }); + }, + + closeLoginModal: () => { + set({ showLoginModal: false }); + }, + + openPdsModal: () => { + set({ showPdsModal: true }); + }, + + closePdsModal: () => { + set({ showPdsModal: false }); + }, + + loadOAuthClient: async () => { + set({ authStatus: "start" }); + try { + const streamplaceUrl = get().url; + const client = await createOAuthClient(streamplaceUrl); + const anonPDSAgent = new StreamplaceAgent(streamplaceUrl); + const maybeDIDs = await Promise.all([ + storage.getItem(DID_KEY), + storage.getItem("@@atproto/oauth-client-browser(sub)"), + ]); + const did = maybeDIDs.find((d) => d !== null) || null; + let session: OAuthSession | null = null; + if (did) { + try { + session = await client.restore(did); + } catch (e) { + console.error("Error restoring session", e); + await storage.removeItem(DID_KEY); + await storage.removeItem("@@atproto/oauth-client-browser(sub)"); + } + } + if (session) { + storage.setItem(DID_KEY, session.did).catch((e) => { + console.error("Error setting did", e); + }); + set({ + client, + authStatus: "loggedIn", + oauthSession: session, + pdsAgent: new StreamplaceAgent(session), + anonPDSAgent, + }); + } else { + set({ + oauthSession: session, + authStatus: "loggedOut", + client, + anonPDSAgent, + }); + } + } catch (error) { + console.error("loadOAuthClient error", error); + } + }, + + oauthError: (error: string, description: string) => { + const message = description || error || "authentication failed"; + set({ + loginState: { + loading: false, + error: message, + }, + authStatus: "loggedOut", + notification: { + message, + type: "error", + }, + }); + }, + + login: async ( + handle: string, + openLoginLink: (url: string) => Promise, + ) => { + set({ + loginState: { + loading: true, + error: null, + }, + }); + try { + const state = get() as BlueskySlice; + await state.loadOAuthClient(); + const updatedState = get() as BlueskySlice; + if (!updatedState.client) { + throw new Error("No client"); + } + const u = await updatedState.client.authorize(handle, {}); + console.log("Opening link"); + await openLoginLink(u.toString()); + // brief delay so the user doesn't see the form text flash back + await new Promise((resolve) => setTimeout(resolve, 5000)); + set({ + loginState: { + loading: false, + error: null, + }, + }); + } catch (error: any) { + console.error("login rejected", error); + set({ + loginState: { + loading: false, + error: error?.message ?? null, + }, + notification: { + message: error?.message || "unknown error", + type: "error", + }, + }); + } + }, + + logout: async () => { + await storage.removeItem("did"); + await storage.removeItem(STORED_KEY_KEY); + const state = get() as BlueskySlice; + if (!state.oauthSession) { + throw new Error("No oauth session"); + } + await state.oauthSession.signOut(); + set({ + oauthSession: null, + pdsAgent: null, + authStatus: "loggedOut", + }); + }, + + getProfile: async (actor: string) => { + try { + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const result = await state.pdsAgent.getProfile({ actor }); + clearQueryParams(); + set((s) => ({ + authStatus: "loggedIn", + profiles: { + ...(s as BlueskySlice).profiles, + [actor]: result.data, + }, + })); + } catch (error) { + clearQueryParams(); + set({ authStatus: "loggedOut" }); + } + }, + + getProfiles: async (actors: string[]) => { + if (actors.length > 25) { + throw Error("Requested too many actors! (max 25 actors)"); + } + try { + const bskyAgent = new Agent("https://public.api.bsky.app"); + const payload = await bskyAgent.getProfiles({ actors }); + let parsedProfiles = {}; + payload.data.profiles.forEach((p) => { + parsedProfiles[p.did] = p; + }); + set((s) => ({ + profileCache: { + ...(s as BlueskySlice).profileCache, + ...parsedProfiles, + }, + })); + } catch (error) { + console.error("getProfiles error", error); + } + }, + + oauthCallback: async (url: string) => { + set({ authStatus: "start" }); + try { + if (!url.includes("?")) { + throw new Error("No query params"); + } + const params = new URLSearchParams(url.split("?")[1]); + if (!(params.has("code") && params.has("state") && params.has("iss"))) { + if (params.has("error")) { + const blueskySlice = get() as BlueskySlice; + blueskySlice.oauthError( + params.get("error") ?? "", + params.get("error_description") ?? "", + ); + } + throw new Error("Missing params, got: " + url); + } + const streamplaceUrl = get().url; + const client = await createOAuthClient(streamplaceUrl); + try { + const ret = await client.callback(params); + await storage.setItem(DID_KEY, ret.session.did); + set({ + client, + oauthSession: ret.session, + pdsAgent: new StreamplaceAgent(ret.session), + authStatus: "loggedIn", + }); + } catch (e: any) { + let message = e.message; + let cause = e.cause; + while (cause) { + message = `${message}: ${cause.message}`; + cause = cause.cause; + } + console.error("oauthCallback error", message); + set({ + authStatus: "loggedOut", + notification: { + message, + type: "error", + }, + }); + throw e; + } + } catch (error: any) { + console.error("oauthCallback rejected", error); + const message = error?.message || "authentication failed"; + set({ + authStatus: "loggedOut", + notification: { + message, + type: "error", + }, + }); + } + }, + + // TODO(phase 4): golivePost needs RichText from @atproto/api and is + // called from createLivestreamRecord. Both port together. + golivePost: async (_text: string, _now: Date, _thumbnail?: any) => { + throw new Error("golivePost not yet ported (Phase 4)"); + }, + + // TODO: revisit if/when block UI is added in web. + createBlockRecord: async (_subjectDID: string) => { + throw new Error("createBlockRecord not ported"); + }, + + // TODO(phase 3/4): stream-key actions need @atproto/crypto + viem. + // Generate-keypair code is also used by createLivestreamRecord for + // blob uploads, so adding the deps unblocks both at once. + createStreamKeyRecord: async (_store: boolean) => { + throw new Error("createStreamKeyRecord not yet ported (Phase 3/4)"); + }, + clearStreamKeyRecord: () => { + set({ newKey: null }); + }, + getStreamKeyRecords: async () => { + set({ + streamKeysResponse: { + loading: true, + error: null, + records: null, + }, + }); + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + set({ + streamKeysResponse: { + loading: false, + error: "No agent", + records: null, + }, + }); + return; + } + const did = state.oauthSession?.did; + if (!did) { + set({ + streamKeysResponse: { + loading: false, + error: "No DID", + records: null, + }, + }); + return; + } + try { + const result = await state.pdsAgent.com.atproto.repo.listRecords({ + repo: did, + collection: "place.stream.key", + limit: 100, + }); + set({ + streamKeysResponse: { + loading: false, + error: null, + records: result.data, + }, + }); + } catch (error: any) { + set({ + streamKeysResponse: { + loading: false, + error: error?.message ?? null, + records: null, + }, + }); + } + }, + deleteStreamKeyRecord: async (rkey: string) => { + set({ isDeletingKey: true }); + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + set({ isDeletingKey: false }); + return; + } + const did = state.oauthSession?.did; + if (!did) { + set({ isDeletingKey: false }); + return; + } + try { + await state.pdsAgent.com.atproto.repo.deleteRecord({ + repo: did, + collection: "place.stream.key", + rkey, + }); + const records = state.streamKeysResponse.records + ? state.streamKeysResponse.records.records.filter( + (r) => r.uri.split("/").pop() !== rkey, + ) + : []; + set({ + isDeletingKey: false, + streamKeysResponse: { + ...state.streamKeysResponse, + records: { + ...state.streamKeysResponse.records!, + records, + }, + }, + }); + } catch (error) { + console.error("deleteStreamKeyRecord rejected", error); + set({ isDeletingKey: false }); + } + }, + + setPDS: async (pds: string) => { + set({ + pds: { + ...(get() as BlueskySlice).pds, + loading: true, + }, + }); + try { + await storage.setItem("pdsURL", pds); + set({ + pds: { + ...(get() as BlueskySlice).pds, + loading: false, + url: pds, + }, + }); + } catch (error: any) { + set({ + pds: { + ...(get() as BlueskySlice).pds, + loading: false, + error: error?.message ?? null, + }, + }); + } + }, + + // TODO(phase 4): go-live. Needs golivePost (RichText) + blob upload + // helper. Both are pure @atproto/api code, no extra deps. + createLivestreamRecord: async () => { + throw new Error("createLivestreamRecord not yet ported (Phase 4)"); + }, + updateLivestreamRecord: async () => { + throw new Error("updateLivestreamRecord not yet ported (Phase 4)"); + }, + + getChatProfileRecordFromPDS: async () => { + set({ + chatProfile: { + loading: true, + error: null, + profile: null, + }, + }); + try { + const state = get() as BlueskySlice; + const did = state.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const res = await state.pdsAgent.com.atproto.repo.getRecord({ + repo: did, + collection: "place.stream.chat.profile", + rkey: "self", + }); + if (!res.success) { + throw new Error("Failed to get chat profile record"); + } + + if (PlaceStreamChatProfile.isRecord(res.data.value)) { + set({ + chatProfile: { + loading: false, + error: null, + profile: res.data.value, + }, + }); + } else { + console.log("not a record", res.data.value); + } + } catch (error) { + console.error("getChatProfileRecordFromPDS error", error); + } + }, + + createChatProfileRecord: async (red: number, green: number, blue: number) => { + set({ + chatProfile: { + loading: true, + error: null, + profile: null, + }, + }); + try { + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const did = state.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + + const existingProfile = (get() as BlueskySlice).chatProfile?.profile; + const chatProfile: PlaceStreamChatProfile.Record = { + ...existingProfile, + $type: "place.stream.chat.profile", + color: { + red: red, + green: green, + blue: blue, + }, + }; + + const res = await state.pdsAgent.com.atproto.repo.putRecord({ + repo: did, + collection: "place.stream.chat.profile", + record: chatProfile, + rkey: "self", + }); + if (!res.success) { + throw new Error("Failed to create chat profile record"); + } + set({ + chatProfile: { + loading: false, + error: null, + profile: chatProfile, + }, + }); + } catch (error) { + console.error("createChatProfileRecord rejected", error); + set({ + chatProfile: { + loading: false, + error: error?.message ?? null, + profile: null, + }, + }); + } + }, + + followUser: async (subjectDID: string) => { + try { + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const did = state.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + await state.pdsAgent.follow(subjectDID); + console.log("followUser fulfilled", { subjectDID }); + } catch (error) { + console.error("followUser rejected", error); + } + }, + + unfollowUser: async (subjectDID: string, followUri?: string) => { + try { + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const did = state.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + + if (followUri) { + await state.pdsAgent.deleteFollow(followUri); + } else { + const streamplaceUrl = get().url; + const res = await fetch( + `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(did)}`, + { + credentials: "include", + }, + ); + const data = await res.json(); + + if (!data.follow || !data.follow.uri) { + throw new Error("Follow record not found"); + } + + await state.pdsAgent.deleteFollow(data.follow.uri); + } + + console.log("unfollowUser fulfilled", { subjectDID }); + } catch (error) { + console.error("unfollowUser rejected", error); + } + }, + + getServerSettingsFromPDS: async () => { + try { + const state = get() as BlueskySlice; + const did = state.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const streamplaceUrl = get().url; + const u = new URL(streamplaceUrl); + const res = await state.pdsAgent.com.atproto.repo.getRecord({ + repo: did, + collection: "place.stream.server.settings", + rkey: u.host, + }); + if (!res.success) { + throw new Error("Failed to get chat profile record"); + } + + if (PlaceStreamServerSettings.isRecord(res.data.value)) { + set({ + serverSettings: res.data.value as PlaceStreamServerSettings.Record, + }); + } else { + console.log("not a record", res.data.value); + } + } catch (error) { + console.error("getServerSettingsFromPDS rejected", error); + } + }, + + createServerSettingsRecord: async (debugRecording: boolean) => { + try { + const state = get() as BlueskySlice; + if (!state.pdsAgent) { + throw new Error("No agent"); + } + const did = state.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + const streamplaceUrl = get().url; + const u = new URL(streamplaceUrl); + const serverSettings: PlaceStreamServerSettings.Record = { + $type: "place.stream.server.settings", + debugRecording: debugRecording, + }; + + const res = await state.pdsAgent.com.atproto.repo.putRecord({ + repo: did, + collection: "place.stream.server.settings", + record: serverSettings, + rkey: u.host, + }); + if (!res.success) { + throw new Error("Failed to create server settings record"); + } + set({ + serverSettings: serverSettings, + }); + } catch (error) { + console.error("createServerSettingsRecord rejected", error); + } + }, +}); diff --git a/js/web/src/lib/store/slices/contentMetadataSlice.ts b/js/web/src/lib/store/slices/contentMetadataSlice.ts new file mode 100644 index 000000000..0814575fe --- /dev/null +++ b/js/web/src/lib/store/slices/contentMetadataSlice.ts @@ -0,0 +1,259 @@ +// Per-livestream content metadata (content warnings, distribution +// policy, content rights). Ported from +// js/app/store/slices/contentMetadataSlice.ts. The slice reads PDS +// agents and the active session from the bluesky slice via get(). +// `resolveDIDDocument` / `getPDSServiceEndpoint` are imported from +// the web's lib/did helper (a copy of @streamplace/components/utils/did +// because that package is React-Native-coupled). +import { StateCreator } from "zustand"; +import { getPDSServiceEndpoint, resolveDIDDocument } from "../../did"; +import { AppStore } from "../index"; +import { BlueskySlice } from "./blueskySlice"; + +export interface ContentMetadataSlice { + creating: boolean; + updating: boolean; + error: string | null; + lastCreatedRecord: any | null; + // actions + createContentMetadata: (params: { + contentWarnings?: string[]; + distributionPolicy?: { deleteAfter?: number }; + contentRights?: { + creator?: string; + copyrightNotice?: string; + copyrightYear?: number; + license?: string; + creditLine?: string; + }; + }) => Promise; + updateContentMetadata: (params: { + rkey?: string; + livestreamRef?: { uri: string; cid: string }; + contentWarnings?: string[]; + distributionPolicy?: { deleteAfter?: number }; + contentRights?: { + creator?: string; + copyrightNotice?: string; + copyrightYear?: number; + license?: string; + creditLine?: string; + }; + }) => Promise; + getContentMetadata: (params?: { + userDid?: string; + rkey?: string; + }) => Promise; + clearError: () => void; +} + +export const createContentMetadataSlice: StateCreator< + AppStore, + [], + [], + ContentMetadataSlice +> = (set, get) => ({ + creating: false, + updating: false, + error: null, + lastCreatedRecord: null, + + createContentMetadata: async ({ + contentWarnings = [], + distributionPolicy = { deleteAfter: undefined }, + contentRights = {}, + }) => { + set({ creating: true, error: null }); + try { + // need access to bluesky slice - combined store provides it + const state = get() as any; + const bluesky: BlueskySlice = state; + + if (!bluesky.pdsAgent) { + throw new Error("No agent"); + } + + const did = bluesky.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + + const metadataRecord = { + $type: "place.stream.metadata.configuration", + createdAt: new Date().toISOString(), + ...(contentWarnings.length > 0 && { + contentWarnings: { warnings: contentWarnings }, + }), + ...(distributionPolicy.deleteAfter && { distributionPolicy }), + ...(contentRights && + Object.keys(contentRights).length > 0 && { + contentRights, + }), + }; + + const result = await bluesky.pdsAgent.com.atproto.repo.createRecord({ + repo: did, + collection: "place.stream.metadata.configuration", + rkey: "self", + record: metadataRecord, + }); + + const rkey = result.data.uri.split("/").pop(); + + set({ + creating: false, + error: null, + lastCreatedRecord: { + record: metadataRecord, + uri: result.data.uri, + cid: result.data.cid, + rkey, + }, + }); + } catch (error: any) { + set({ + creating: false, + error: error?.message ?? "Failed to create content metadata", + }); + } + }, + + updateContentMetadata: async ({ + rkey, + livestreamRef, + contentWarnings = [], + distributionPolicy = { deleteAfter: undefined }, + contentRights = {}, + }) => { + set({ updating: true, error: null }); + try { + const state = get() as any; + const bluesky: BlueskySlice = state; + + if (!bluesky.pdsAgent) { + throw new Error("No agent"); + } + + const did = bluesky.oauthSession?.did; + if (!did) { + throw new Error("No DID"); + } + + const metadataRecord = { + $type: "place.stream.metadata.configuration", + ...(livestreamRef && { livestreamRef }), + createdAt: new Date().toISOString(), + ...(contentWarnings.length > 0 && { + contentWarnings: { warnings: contentWarnings }, + }), + ...(distributionPolicy.deleteAfter && { distributionPolicy }), + ...(contentRights && + Object.keys(contentRights).length > 0 && { + contentRights, + }), + }; + + const result = await bluesky.pdsAgent.com.atproto.repo.putRecord({ + repo: did, + collection: "place.stream.metadata.configuration", + rkey: rkey || "self", + record: metadataRecord, + }); + + set({ + updating: false, + error: null, + lastCreatedRecord: { + record: metadataRecord, + uri: `at://${did}/place.stream.metadata.configuration/${ + rkey || "self" + }`, + cid: result.data.cid, + }, + }); + } catch (error: any) { + set({ + updating: false, + error: error?.message ?? "Failed to update content metadata", + }); + } + }, + + getContentMetadata: async ({ userDid, rkey = "self" } = {}) => { + set({ error: null }); + try { + const state = get() as any; + const bluesky: BlueskySlice = state; + + if (!bluesky.pdsAgent) { + throw new Error("No agent"); + } + + const targetDid = userDid || bluesky.oauthSession?.did; + if (!targetDid) { + throw new Error("No DID provided or user not authenticated"); + } + + try { + let targetPDS: string | null = null; + try { + const didDoc = await resolveDIDDocument(targetDid); + targetPDS = getPDSServiceEndpoint(didDoc); + } catch { + // best-effort: fall through to default agent + } + + let agent = bluesky.pdsAgent; + if (targetPDS && targetPDS !== (bluesky.pdsAgent as any)?.host) { + const { StreamplaceAgent } = await import("streamplace"); + agent = new StreamplaceAgent(targetPDS) as any; + } + + const result = await agent.com.atproto.repo.getRecord({ + repo: targetDid, + collection: "place.stream.metadata.configuration", + rkey, + }); + + if (!result.success) { + throw new Error("Failed to get content metadata record"); + } + + set({ + error: null, + lastCreatedRecord: { + userDid: targetDid, + record: result.data.value, + uri: result.data.uri, + cid: result.data.cid, + }, + }); + } catch (error: any) { + if ( + error.message?.includes("not found") || + error.message?.includes("RecordNotFound") + ) { + set({ + error: null, + lastCreatedRecord: { + userDid: targetDid, + record: null, + uri: null, + cid: null, + }, + }); + return; + } + throw error; + } + } catch (error: any) { + set({ + error: error?.message ?? "Failed to get content metadata", + }); + } + }, + + clearError: () => { + set({ error: null }); + }, +});