From 1e8cc6069448afd70846cf6b41121df38dbe0d0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:57:40 +0000 Subject: [PATCH 1/2] Give publication members the same draft/post options on /home as on the dashboard Confirmed contributors of a publication previously got owner-only gating in the leaflet options menu and its server actions, so drafts and posts of publications they belong to behaved differently on /home than in the publication dashboard's draft and post lists: - Published posts fell into the draft menu (no Unpublish/Delete Post) because the menu keyed off the document URI containing the viewer's DID, which only matches the publication owner, even though deletePost/unpublishPost already authorize confirmed contributors. - Delete Forever on a publication draft was shown but failed server-side, since deleteLeaflet only allowed the publication owner. - Archive only set the viewer's homepage flag; the draft stayed in the publication's draft list because archivePost/unarchivePost only updated leaflets_in_publications for the owner. Now membership (owner or confirmed contributor, via identity.publications / contributor_publications against the leaflet's publication URI) drives the menu, deleteLeaflet accepts confirmed contributors, and archiving updates the publication row for members. Delete is hidden on publication drafts for non-members instead of failing after the fact. Contributed leaflets on /home also surface the publication's archived state instead of hardcoding false, so archived contributor drafts move into the archived filter like owned ones. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017wxGwFe3uCZ9MhpmvRDo8n --- actions/deleteLeaflet.ts | 73 ++++++++++--------- .../(home-pages)/(writer)/home/HomeLayout.tsx | 16 +++- .../home/LeafletList/LeafletOptions.tsx | 43 ++++++++--- components/PageSWRDataProvider.tsx | 5 ++ 4 files changed, 91 insertions(+), 46 deletions(-) diff --git a/actions/deleteLeaflet.ts b/actions/deleteLeaflet.ts index 73c700a2c..5b3e73930 100644 --- a/actions/deleteLeaflet.ts +++ b/actions/deleteLeaflet.ts @@ -12,6 +12,7 @@ import { PermissionToken } from "src/replicache"; import { pool } from "supabase/pool"; import { getIdentityData } from "./getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; +import { isConfirmedContributor } from "src/contributorPermissions"; export async function deleteLeaflet(permission_token: PermissionToken) { const client = await pool.connect(); @@ -45,9 +46,18 @@ export async function deleteLeaflet(permission_token: PermissionToken) { const isOwner = leafletInPubs.some( (pub: any) => pub.publications.identity_did === identity.atp_did, ); - if (!isOwner) { + let isMember = isOwner; + if (!isMember && identity.atp_did) { + for (const pub of leafletInPubs) { + if (await isConfirmedContributor(pub.publication, identity.atp_did)) { + isMember = true; + break; + } + } + } + if (!isMember) { throw new Error( - "Unauthorized: You must own the publication to delete this leaflet", + "Unauthorized: You must be a member of the publication to delete this leaflet", ); } } @@ -107,23 +117,7 @@ export async function archivePost(token: string) { .eq("token", token) .eq("identity", identity.id); - // Check if leaflet is in any publications where user is the creator - let { data: leafletInPubs } = await supabaseServerClient - .from("leaflets_in_publications") - .select("publication, publications!inner(identity_did)") - .eq("leaflet", token); - - if (leafletInPubs) { - for (let pub of leafletInPubs) { - if (pub.publications.identity_did === identity.atp_did) { - await supabaseServerClient - .from("leaflets_in_publications") - .update({ archived: true }) - .eq("leaflet", token) - .eq("publication", pub.publication); - } - } - } + await setArchivedInPublications(token, identity.atp_did, true); refresh(); return; @@ -140,24 +134,35 @@ export async function unarchivePost(token: string) { .eq("token", token) .eq("identity", identity.id); - // Check if leaflet is in any publications where user is the creator + await setArchivedInPublications(token, identity.atp_did, false); + + refresh(); + return; +} + +// Archive/unarchive the leaflet in any publications where the user is a +// member (owner or confirmed contributor), so it moves in and out of the +// publication dashboard's draft list too. +async function setArchivedInPublications( + token: string, + atp_did: string | null, + archived: boolean, +) { let { data: leafletInPubs } = await supabaseServerClient .from("leaflets_in_publications") .select("publication, publications!inner(identity_did)") .eq("leaflet", token); - - if (leafletInPubs) { - for (let pub of leafletInPubs) { - if (pub.publications.identity_did === identity.atp_did) { - await supabaseServerClient - .from("leaflets_in_publications") - .update({ archived: false }) - .eq("leaflet", token) - .eq("publication", pub.publication); - } - } + if (!leafletInPubs || !atp_did) return; + + for (let pub of leafletInPubs) { + let isMember = + pub.publications.identity_did === atp_did || + (await isConfirmedContributor(pub.publication, atp_did)); + if (!isMember) continue; + await supabaseServerClient + .from("leaflets_in_publications") + .update({ archived }) + .eq("leaflet", token) + .eq("publication", pub.publication); } - - refresh(); - return; } diff --git a/app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx b/app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx index 0fd1e5cbd..d4db8d924 100644 --- a/app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx +++ b/app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx @@ -63,9 +63,14 @@ export const HomeContent = (props: { (identity?.contributor_publications?.length ?? 0) > 0; let hasArchived = identity && - identity.permission_token_on_homepage.filter( + (identity.permission_token_on_homepage.some( (leaflet) => leaflet.archived === true, - ).length > 0; + ) || + (identity.contributor_leaflets ?? []).some((row) => + row.permission_tokens.leaflets_in_publications?.some( + (l) => l.archived, + ), + )); return ( ({ added_at: row.created_at, token: row.permission_tokens as unknown as PermissionToken, - archived: false, + // Contributed leaflets have no personal homepage row, so archived + // state comes from the publication's draft list + archived: + row.permission_tokens.leaflets_in_publications?.some( + (l) => l.archived, + ) ?? false, })); leaflets = [...owned, ...contributed]; } else { diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx b/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx index 7826b684a..670a4f2dc 100644 --- a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx +++ b/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx @@ -32,6 +32,23 @@ import { import { ShareButton } from "app/(app)/[leaflet_id]/actions/ShareOptions"; import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; +// A member (owner or confirmed contributor) of the publication a leaflet +// belongs to gets the same manage options everywhere this menu is shown — +// /home and the publication dashboard's draft and post lists. Standalone +// documents are managed by whoever's PDS hosts them. +function useCanManagePost() { + const pubStatus = useLeafletPublicationStatus(); + const { identity } = useIdentityData(); + if (!identity?.atp_did) return false; + if (pubStatus?.documentUri?.includes(identity.atp_did)) return true; + const pubUri = pubStatus?.publicationUri; + if (!pubUri) return false; + return ( + identity.publications.some((p) => p.uri === pubUri) || + identity.contributor_publications.some((p) => p.uri === pubUri) + ); +} + export const LeafletOptions = (props: { archived?: boolean | null; loggedIn?: boolean; @@ -39,9 +56,7 @@ export const LeafletOptions = (props: { const pubStatus = useLeafletPublicationStatus(); let [state, setState] = useState<"normal" | "areYouSure">("normal"); let [open, setOpen] = useState(false); - let { identity } = useIdentityData(); - let isPublicationOwner = - !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); + let canManagePost = useCanManagePost(); return ( <> - ) : pubStatus?.documentUri && isPublicationOwner ? ( + ) : pubStatus?.documentUri && canManagePost ? ( ) : ( @@ -86,14 +101,15 @@ const DefaultOptions = (props: { const pubStatus = useLeafletPublicationStatus(); const toaster = useToaster(); const { setArchived } = useArchiveMutations(); - const { identity } = useIdentityData(); + const canManagePost = useCanManagePost(); const tokenId = pubStatus?.token.id; const itemType = pubStatus?.draftInPublication ? "Draft" : "Leaflet"; - // Check if this is a published post/document and if user is the owner - const isPublishedPostOwner = - !!identity?.atp_did && !!pubStatus?.documentUri?.includes(identity.atp_did); - const canDelete = !pubStatus?.documentUri || isPublishedPostOwner; + // Deleting a leaflet in a publication, or a published document, requires + // being a member of the publication or the document's owner. Standalone + // unpublished leaflets are deletable by anyone with the edit token. + const canDelete = + pubStatus?.documentUri || pubStatus?.publicationUri ? canManagePost : true; return ( <> @@ -324,6 +340,12 @@ function useArchiveMutations() { (p) => p.permission_tokens?.id === tokenId, ); if (item) item.archived = archived; + for (const row of data.contributor_leaflets ?? []) { + if (row.permission_tokens?.id !== tokenId) continue; + for (const lip of row.permission_tokens.leaflets_in_publications ?? + []) + lip.archived = archived; + } }); mutatePublicationData(mutatePub, (data) => { const item = data.publication?.leaflets_in_publications.find( @@ -338,6 +360,9 @@ function useArchiveMutations() { data.permission_token_on_homepage.filter( (p) => p.permission_tokens?.id !== tokenId, ); + data.contributor_leaflets = (data.contributor_leaflets ?? []).filter( + (r) => r.permission_tokens?.id !== tokenId, + ); }); mutatePublicationData(mutatePub, (data) => { if (!data.publication) return; diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index 6d109d43d..4516155b8 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -160,6 +160,11 @@ export function useLeafletPublicationStatus() { // Draft state - in a publication but not yet published draftInPublication: data.leaflets_in_publications?.[0]?.publication ?? undefined, + // The publication this leaflet belongs to, whether published or not + publicationUri: + publishedInPublication?.publication ?? + data.leaflets_in_publications?.[0]?.publication ?? + undefined, // Published state isPublished: !!(publishedInPublication || publishedStandalone), publishedAt: From b47142861a24a7ccb4107b3048b637ff4a3e92de Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 24 Jul 2026 18:46:48 -0400 Subject: [PATCH 2/2] Fix published-post options on /home and make dashboard unpublish/delete take effect The /home and looseleafs lists never recognized a leaflet as published because the identity query fetched leaflets_in_publications and leaflets_to_documents without their documents embed, which useLeafletPublicationStatus needs to compute documentUri/isPublished. So published publication posts fell into the draft menu instead of getting Copy Post Link / Unpublish / Delete Post, and published looseleafs showed neither those options nor the "Published" label. Fetching the documents embed (for both owned and contributed leaflets) fixes all three surfaces, since they share LeafletList and LeafletOptions. On the publication dashboard's post list, Unpublish and Delete appeared to do nothing: the server actions succeeded, but the list renders from the publication SWR cache's `documents`, which nothing updated, and the success toast fired regardless of the action result. Now unpublish clears the published-document references in both the identity and publication caches (resurfacing the leaflet in the dashboard's draft list, mirroring how get_publication_data derives drafts), delete also drops the post from `documents`, deleteLeaflet is awaited, and a failed action shows an error toast instead of claiming success. The unpublish subtext now says "Turn this post back into a draft" for standalone looseleafs, since they have no publication drafts list to move into. Co-Authored-By: Claude Fable 5 --- actions/getIdentityData.ts | 8 +- .../home/LeafletList/LeafletOptions.tsx | 97 +++++++++++++++++-- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index ae0f0910a..c063ce3b3 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -37,8 +37,8 @@ async function uncachedGetIdentityData() { title, description, permission_token_rights(*), - leaflets_to_documents(*), - leaflets_in_publications(*, publications(*)) + leaflets_to_documents(*, documents(*)), + leaflets_in_publications(*, publications(*), documents(*)) ) ), user_subscriptions(plan, status, current_period_end), @@ -50,8 +50,8 @@ async function uncachedGetIdentityData() { permission_tokens!leaflet_contributors_leaflet_fkey!inner( id, root_entity, title, description, permission_token_rights(*), - leaflets_to_documents(*), - leaflets_in_publications(*, publications(*)) + leaflets_to_documents(*, documents(*)), + leaflets_in_publications(*, publications(*), documents(*)) ) ), publication_contributors!publication_contributors_contributor_did_fkey( diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx b/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx index 4cf79e8c1..97dc94cb0 100644 --- a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx +++ b/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx @@ -202,6 +202,7 @@ const PublishedPostOptions = (props: { }) => { const pubStatus = useLeafletPublicationStatus(); const toaster = useToaster(); + const { markUnpublished } = useArchiveMutations(); const postLink = pubStatus?.postShareLink ?? ""; const isFullUrl = postLink.includes("http"); @@ -222,9 +223,20 @@ const PublishedPostOptions = (props: {
{ - if (pubStatus?.documentUri) { - await unpublishPost(pubStatus.documentUri); + if (!pubStatus?.documentUri) return; + let result = await unpublishPost(pubStatus.documentUri); + if (!result.success) { + toaster({ + content: ( +
+ Couldn't unpublish: {result.error.message} +
+ ), + type: "error", + }); + return; } + markUnpublished(pubStatus.token.id, pubStatus.documentUri); toaster({ content:
Unpublished Post!
, type: "success", @@ -235,7 +247,9 @@ const PublishedPostOptions = (props: {
Unpublish Post
- Move this post back into drafts + {pubStatus?.publicationUri + ? "Move this post back into drafts" + : "Turn this post back into a draft"}
@@ -274,11 +288,34 @@ const DeleteAreYouSureForm = (props: { backToMenu: () => void }) => { { - if (tokenId) removeFromLists(tokenId); if (pubStatus?.documentUri) { - await deletePost(pubStatus.documentUri); + let result = await deletePost(pubStatus.documentUri); + if (!result.success) { + toaster({ + content: ( +
+ Couldn't delete: {result.error.message} +
+ ), + type: "error", + }); + return; + } + } + if (tokenId) removeFromLists(tokenId, pubStatus?.documentUri); + try { + if (pubStatus?.token) await deleteLeaflet(pubStatus.token); + } catch { + toaster({ + content: ( +
+ Couldn't delete this {itemType.toLowerCase()} +
+ ), + type: "error", + }); + return; } - if (pubStatus?.token) deleteLeaflet(pubStatus.token); toaster({ content:
Deleted {itemType}!
, @@ -361,7 +398,51 @@ function useArchiveMutations() { if (draft) (draft._raw as { archived?: boolean }).archived = archived; }); }, - removeFromLists: (tokenId: string) => { + // The post's document was deleted server-side, so the leaflet reverts to + // a draft: clear the published-document references and, for publication + // posts, resurface the leaflet in the dashboard's draft list. + markUnpublished: (tokenId: string, documentUri: string) => { + mutateIdentityData(mutateIdentity, (data) => { + const tokens = [ + ...data.permission_token_on_homepage.map((p) => p.permission_tokens), + ...(data.contributor_leaflets ?? []).map((r) => r.permission_tokens), + ]; + for (const pt of tokens) { + if (!pt || pt.id !== tokenId) continue; + pt.leaflets_to_documents = (pt.leaflets_to_documents ?? []).filter( + (d) => d.document !== documentUri, + ); + for (const lip of pt.leaflets_in_publications ?? []) { + if (lip.doc !== documentUri) continue; + lip.doc = null; + lip.documents = null; + } + } + }); + mutatePublicationData(mutatePub, (data) => { + if (!data.publication) return; + data.documents = data.documents.filter((d) => d.uri !== documentUri); + for (const lip of data.publication.leaflets_in_publications) { + if (lip.doc !== documentUri) continue; + lip.doc = null; + lip.documents = null; + // get_publication_data derives `drafts` server-side from the + // leaflets without documents, so mirror that here. + if ( + !(lip as { archived?: boolean }).archived && + !data.drafts.some((d) => d.leaflet === lip.leaflet) + ) { + data.drafts.push({ + leaflet: lip.leaflet, + title: lip.title, + permission_tokens: lip.permission_tokens, + _raw: lip, + }); + } + } + }); + }, + removeFromLists: (tokenId: string, documentUri?: string) => { mutateIdentityData(mutateIdentity, (data) => { data.permission_token_on_homepage = data.permission_token_on_homepage.filter( @@ -372,6 +453,8 @@ function useArchiveMutations() { ); }); mutatePublicationData(mutatePub, (data) => { + if (documentUri) + data.documents = data.documents.filter((d) => d.uri !== documentUri); const draftIndex = data.drafts.findIndex( (d) => d.permission_tokens?.id === tokenId, );