import { Cfc, computed, equals, handler, ifElse, lift, NAME, pattern, RepresentsCurrentUser, SELF, Stream, UI, wish, Writable, WriteAuthorizedBy, } from "commonfabric"; export const TRUSTED_PROFILE_HOME_SURFACE = "ProfileHome"; export const TRUSTED_PROFILE_EDIT_ACTION = "EditProfile"; type CurrentPrincipal = { readonly __ctCurrentPrincipal: true }; type OwnerProtectedProfileWrite< T, Binding, > = RepresentsCurrentUser< Cfc< WriteAuthorizedBy, { ownerPrincipal: CurrentPrincipal; } > >; type ProfileElementCell = { [NAME]?: string; }; // NOTE(CT-1628): `cell: any` here/below and the `(Pattern(...) as any).for(...)` // casts later in this file are required until the CFC wrapper / pattern-factory // types expose a typed cell ref and `.for()`. Tracked for a proper type fix. export type ProfileElement = { cell: any; tag: string; userTags: readonly string[]; title?: string; source?: "catalog" | "url" | "piece"; }; export type AddProfileElementEvent = { catalogId?: string; patternUrl?: string; title?: string; tag?: string; userTags?: readonly string[]; }; export type RemoveProfileElementEvent = { cell: any; }; /** * The single event shape for every element mutation (see `mutateElements`): * a `cell` (from the event or the instance's bound state) removes that * element; a `patternUrl` (or a bound link form) adds a link reference card; * otherwise a catalog card is added. `AddProfileElementEvent` / * `RemoveProfileElementEvent` are subsets kept for the exported stream types. */ export type MutateProfileElementsEvent = { catalogId?: string; patternUrl?: string; title?: string; tag?: string; userTags?: readonly string[]; cell?: any; // "addPiece" inputs: a link to an EXISTING deployed piece (CT-1755). The // card becomes a followable reference to the live piece rather than a local // title-only placeholder. `pieceSpace` is the target space DID; `pieceId` is // the piece id (with or without the `of:` prefix). pieceSpace?: string; pieceId?: string; }; export type SetProfileNameEvent = { name?: string; detail?: { message?: string }; key?: string; target?: { value?: string }; }; export type SetProfileAvatarEvent = { avatar?: string; detail?: { message?: string }; key?: string; target?: { value?: string }; }; export type SetProfileBioEvent = { bio?: string; detail?: { message?: string }; key?: string; target?: { value?: string }; }; export type ProfileHomeOutput = { [NAME]: string; [UI]: unknown; name: OwnerProtectedProfileWrite; avatar: OwnerProtectedProfileWrite; // A short, human-authored free-text description of the profile owner // (CT-1648). Owner-protected like name/avatar; the canonical shared-profile // bio (distinct from Home's legacy `learned.summary`). Readable from the // profile result and via `wish({ query: "#profileBio" })`. bio: OwnerProtectedProfileWrite; elements: OwnerProtectedProfileWrite; setName: Stream; setAvatar: Stream; setBio: Stream; // Both element streams accept the full mutation event (the union shape of // the one authorized writer); `AddProfileElementEvent` / // `RemoveProfileElementEvent` remain the documented per-stream subsets. addElement: Stream; removeElement: Stream; // Pin an existing deployed piece as a followable card (CT-1755). addPiece: Stream; // Flips the rendered profile view (CT-1748) between the read-only // presentation and the edit form. UI state — not owner-protected. toggleEditing: Stream; // Current view mode: false = read-only presentation, true = edit form. isEditing: boolean; initialNameApplied: string; }; export type ProfileHomeInput = { initialName?: string; }; const trimInitialName = (initialName?: string): string => (initialName ?? "").trim(); const ProfileCatalogCard = pattern<{ title: string }, ProfileElementCell>( ({ title }) => ({ [NAME]: title, [UI]: ( {title} ), }), ); const UrlPatternReference = pattern< { title: string; url: string }, ProfileElementCell >( ({ title, url }) => ({ [NAME]: title, [UI]: ( {title} {url} ), }), ); // Build a link to an EXISTING deployed piece in (possibly) another space // (CT-1755). This is the canonical serialized cross-space link shape // (`createSigilLinkFromParsedLink`'s output): a `link@1` sigil carrying the // target piece id (URI form, `of:` prefix) and space DID. Stored as a // `ProfileElement.cell`, it resolves to the live piece and renders as a // followable `` exactly like a `profiles[]` roster entry. const pieceReferenceLink = (space: string, pieceId: string): unknown => { const id = pieceId.startsWith("of:") ? pieceId : `of:${pieceId}`; return { "/": { "link@1": { id, space, path: [] } } }; }; const appendElement = ( element: ProfileElement, elements: Writable, ) => { const current = elements.get(); if (current.some((existing) => equals(existing.cell, element.cell))) { return; } elements.push(element); }; // THE single authorized writer for the owner-protected `elements` list. A // `writeAuthorizedBy` claim carries exactly one handler binding, verified // against the writing handler's implementation identity — so every element // mutation (the exported add/remove streams, the catalog/link-form buttons, // the per-row remove) must be an INSTANCE of this one implementation. // Instances differ only in bound state — including the explicit `mode` // below — which doesn't change the implementation identity. const mutateElements = handler< MutateProfileElementsEvent, { elements: Writable; // Instance intent. Each binding site declares what its events may do, so // a malformed/empty event can never cross purposes (an empty remove must // not add; an empty link form must not add a catalog card): // "add" — the exported add stream: event-driven, url or catalog. // "addCard" — the catalog button: one fixed "Profile card". // "addLink" — the link form: reads (and clears) the bound form; no-op // without a URL. // "remove" — the exported remove stream / per-row button: removes // `event.cell ?? state.cell`; no-op without one. mode: "add" | "addCard" | "addLink" | "addPiece" | "remove"; // Per-row remove binding ("remove" mode). cell?: any; // Link form binding ("addLink" mode). patternUrl?: Writable; title?: Writable; tag?: Writable; userTags?: string[]; // Pin-a-piece form binding ("addPiece" mode, CT-1755). pieceSpace?: Writable; pieceId?: Writable; } >((event, state) => { const userTags = event.userTags ?? state.userTags ?? []; switch (state.mode) { case "remove": { const removeCell = event.cell ?? state.cell; if (removeCell === undefined) return; state.elements.set( state.elements.get().filter((element) => !equals(element.cell, removeCell) ), ); return; } case "addLink": { const url = (state.patternUrl?.get() ?? "").trim(); if (url.length === 0) return; const title = (state.title?.get() ?? "").trim() || url; const tag = (state.tag?.get() ?? "").trim() || url; appendElement({ cell: (UrlPatternReference({ title, url }) as any).for(tag), source: "url", title, tag, userTags, }, state.elements); state.patternUrl?.set(""); state.title?.set(""); state.tag?.set(""); return; } case "addPiece": { // Prefer event fields (e.g. a "pin from the piece" flow that passes the // target directly); fall back to the bound form cells (the edit-mode // "Pin a piece" inputs). let space = (event.pieceSpace ?? state.pieceSpace?.get() ?? "").trim(); let rawId = (event.pieceId ?? state.pieceId?.get() ?? "").trim(); // Convenience: a full piece URL/path pasted into the space field is split // into space + id (the last two path segments). Only a DID-spaced URL // resolves cross-space — a space *name* can't be resolved to a DID from // pattern code, so paste the `did:key:…` form for another space. if (rawId.length === 0 && space.includes("/")) { const segments = space.replace(/^https?:\/\/[^/]+/, "").split("/") .filter((segment) => segment.length > 0); if (segments.length >= 2) { space = segments[segments.length - 2]; rawId = segments[segments.length - 1]; } } if (space.length === 0 || rawId.length === 0) return; const title = (event.title ?? state.title?.get() ?? "").trim() || rawId; // Tag by the piece id so the same piece can't be pinned twice (dedup in // appendElement is by `cell`; a stable tag keeps the row label sane). const tag = rawId; appendElement({ cell: pieceReferenceLink(space, rawId), source: "piece", title, tag, userTags, }, state.elements); state.pieceSpace?.set(""); state.pieceId?.set(""); state.title?.set(""); return; } case "addCard": { appendElement({ cell: (ProfileCatalogCard({ title: "Profile card" }) as any).for( "profile-card", ), source: "catalog", title: "Profile card", tag: "#profileCard", userTags, }, state.elements); return; } case "add": { const source = event.patternUrl ? "url" : "catalog"; const title = event.title ?? (source === "url" ? event.patternUrl ?? "Profile pattern" : "Profile card"); const tag = event.tag ?? event.catalogId ?? event.patternUrl ?? "profile"; const cell = source === "url" ? (UrlPatternReference({ title, url: event.patternUrl ?? "" }) as any) .for(tag) : (ProfileCatalogCard({ title }) as any).for(tag); appendElement({ cell, tag, userTags, title, source, }, state.elements); return; } } }); const setName = handler }>( (event, state) => { if (event.key !== undefined && event.key !== "Enter") { return; } const name = (event.name ?? event.detail?.message ?? event.target?.value ?? "").trim(); state.name.set(name); }, ); const setAvatar = handler }>( (event, state) => { if (event.key !== undefined && event.key !== "Enter") { return; } const avatar = (event.avatar ?? event.detail?.message ?? event.target?.value ?? "").trim(); state.avatar.set(avatar); }, ); // THE authorized writer for the owner-protected `bio` field (CT-1648). Bio is // multi-line free text, so — unlike setName — it is NOT Enter-gated and is // driven by a Save button reading the bound (unprotected) draft cell: a direct // `$value` two-way binding onto the protected `bio` cell would bypass this // handler and be rejected by CFC. Falls back through event fields so a future // "set bio" event path (or a test) can pass the value directly. const setBio = handler< SetProfileBioEvent, { bio: Writable; draft?: Writable } >( (event, state) => { const bio = (event.bio ?? event.detail?.message ?? event.target?.value ?? state.draft?.get() ?? "").trim(); state.bio.set(bio); // Clear the draft after a successful save (mirrors the form handlers // elsewhere in this file). state.draft?.set(""); }, ); // View/edit toggle for the rendered profile view (CT-1748). Plain (un-protected) // UI state: visiting a profile shows the read-only presentation; this flips to // the edit form. The flag itself is not owner-protected — anyone can flip their // own view — but CFC still gates the actual field writes behind the form. const toggleProfileEditing = handler }>( (_event, state) => { state.editing.set(!state.editing.get()); }, ); const applyInitialName = lift< { initialName?: string; name: Writable }, string >(({ initialName, name }) => { return name.get() ?? trimInitialName(initialName); }); export default pattern( ({ initialName, [SELF]: self }) => { const initialProfileName = trimInitialName(initialName); const name = new Writable< OwnerProtectedProfileWrite >(initialProfileName).for("name"); const avatar = new Writable< OwnerProtectedProfileWrite >("").for("avatar"); const bio = new Writable< OwnerProtectedProfileWrite >("").for("bio"); // Unprotected draft backing the bio textarea; saved into the protected // `bio` cell through `setBio` (CT-1648). const bioDraft = new Writable("").for("bioDraft"); const elements = new Writable< OwnerProtectedProfileWrite >([]).for("elements"); const patternUrl = new Writable("").for("patternUrl"); const elementTitle = new Writable("").for("elementTitle"); const elementTag = new Writable("").for("elementTag"); const userTagsText = new Writable("").for("userTagsText"); // "Pin a piece" form (CT-1755): a link to an existing deployed piece by // its space DID + piece id. const pieceSpaceForm = new Writable("").for("pieceSpaceForm"); const pieceIdForm = new Writable("").for("pieceIdForm"); const pieceTitleForm = new Writable("").for("pieceTitleForm"); // Rendered profile view (CT-1748): the cell view shows a read-only // presentation by default; the owner flips this to reveal the edit form. const editing = new Writable(false).for("editing"); // Is the current viewer the profile owner? `wish("#profile")` resolves the // VIEWER's own default profile (from their home); `SELF` is THIS profile's // cell. They are the same cell only when the owner is viewing their own // (default) profile — a visitor's `#profile` is a different per-user profile // cell, so this is never a false positive (a non-owner can never be granted // edit). The edit form + button are gated on this so visitors get a // read-only view; the field writes are independently CFC-owner-protected, // so this is a UX gate, not the security boundary. Known limitation: an // owner viewing one of their OWN non-default profiles reads as a visitor // here (safe false-negative — they just don't get the inline edit form). const viewerProfile = wish<{ name?: string }>({ query: "#profile" }); const isOwner = computed(() => { const viewer = viewerProfile.result; return viewer !== undefined && equals(self, viewer) === true; }); // `isEditing` is the raw view-toggle state (the user's intent), kept // independent of ownership so it stays a clean, testable signal. The edit // FORM is gated separately on `showEditForm` below; a visitor never sees the // toggle button, so for them `editing` stays false in practice anyway. const isEditing = computed(() => editing.get() === true); // The edit form is shown only to the owner who has toggled into edit mode; // visitors (and the owner before toggling) see the read-only presentation. // Ownership is re-derived inline rather than referencing `isOwner` so each // computed is self-contained (avoids nested-computed unwrap surprises). const showEditForm = computed(() => { const viewer = viewerProfile.result; const owner = viewer !== undefined && equals(self, viewer) === true; return editing.get() === true && owner; }); // Whether a non-empty bio has been authored — drives the presentation-mode // bio block (CT-1648). const hasBio = computed(() => (bio.get() ?? "").trim().length > 0); const parsedUserTags = computed(() => userTagsText.get().split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0 ) ); const initialNameApplied = applyInitialName({ initialName, name }); // A profile's display name is the person's name (falls back to "Profile" // before one is set). This drives cf-cell-link labels in the picker and // anywhere a profile link is rendered. const displayName = computed(() => { const applied = initialNameApplied; return typeof applied === "string" && applied.trim().length > 0 ? applied : "Profile"; }); return { [NAME]: displayName, name, avatar, bio, elements, setName: setName({ name }), setAvatar: setAvatar({ avatar }), setBio: setBio({ bio, draft: bioDraft }), // Both exported streams are instances of the one authorized writer, // pinned to their declared purpose via the bound mode. addElement: mutateElements({ elements, mode: "add" }), removeElement: mutateElements({ elements, mode: "remove" }), // Pin an existing deployed piece as a followable card (CT-1755). Reads // the bound form cells; an event may also pass { pieceSpace, pieceId, // title } directly (e.g. a future "pin from the piece" flow). addPiece: mutateElements({ elements, mode: "addPiece", pieceSpace: pieceSpaceForm, pieceId: pieceIdForm, title: pieceTitleForm, }), toggleEditing: toggleProfileEditing({ editing }), isEditing, initialNameApplied, [UI]: (

Profile

{ /* CT-1748: read-only presentation — what you see when you visit a profile cell. The edit form is gated behind the toggle below. */ } {ifElse( showEditForm, null, { /* Hero identity (CT-1761): bound to the profile's OWN root cell (`self`), not a derived projection — so the badge reads the runtime-attested represents-principal label and draws the real verified seal. `noNavigate` keeps it non-clickable on the profile's own page. */ } {ifElse( hasBio,

{bio}

, null, )} { /* Pinned patterns render as tile variants (clickable, navigate to the piece); the user's title/tags annotate the footer. The old bespoke card + cf-cell-link "Open" is gone. */ } {elements.map((element) => (
{element.title ?? element.tag}
{element.userTags.map((tag) => `#${tag}`).join(" ")}
))}
{ /* Only the owner gets the edit affordance; a visitor sees a read-only profile (and CFC would reject their writes anyway). */ } {ifElse( isOwner, Edit profile , null, )}
, )} {/* The existing edit form, revealed only to the owner in edit mode. */} {ifElse( showEditForm, {name} {avatar} {bio} Save bio Add profile card Add link Links are saved as reference cards. They are not deployed or run. Pin piece Pins a link to an existing deployed piece. Click the card to open the live piece. {elements.map((element) => ( {element.title ?? element.tag} {element.userTags.map((tag) => `#${tag}`).join(" ")} Remove ))} Done , null, )}
), }; }, );