Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 39 additions & 34 deletions actions/deleteLeaflet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
);
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
8 changes: 4 additions & 4 deletions actions/getIdentityData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(
Expand Down
16 changes: 13 additions & 3 deletions app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DashboardPageLayout
Expand Down Expand Up @@ -115,7 +120,12 @@ function HomeLeafletList(props: {
.map((row) => ({
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 {
Expand Down
140 changes: 124 additions & 16 deletions app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,31 @@ 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;
}) => {
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 (
<>
<Menu
Expand All @@ -66,7 +81,7 @@ export const LeafletOptions = (props: {
{state === "normal" ? (
!props.loggedIn ? (
<LoggedOutOptions setState={setState} />
) : pubStatus?.documentUri && isPublicationOwner ? (
) : pubStatus?.documentUri && canManagePost ? (
<PublishedPostOptions setState={setState} />
) : (
<DefaultOptions setState={setState} archived={props.archived} />
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -186,6 +202,7 @@ const PublishedPostOptions = (props: {
}) => {
const pubStatus = useLeafletPublicationStatus();
const toaster = useToaster();
const { markUnpublished } = useArchiveMutations();
const postLink = pubStatus?.postShareLink ?? "";
const isFullUrl = postLink.includes("http");

Expand All @@ -206,9 +223,20 @@ const PublishedPostOptions = (props: {
<hr className="border-border-light" />
<MenuItem
onSelect={async () => {
if (pubStatus?.documentUri) {
await unpublishPost(pubStatus.documentUri);
if (!pubStatus?.documentUri) return;
let result = await unpublishPost(pubStatus.documentUri);
if (!result.success) {
toaster({
content: (
<div className="font-bold">
Couldn&apos;t unpublish: {result.error.message}
</div>
),
type: "error",
});
return;
}
markUnpublished(pubStatus.token.id, pubStatus.documentUri);
toaster({
content: <div className="font-bold">Unpublished Post!</div>,
type: "success",
Expand All @@ -219,7 +247,9 @@ const PublishedPostOptions = (props: {
<div className="flex flex-col">
Unpublish Post
<div className="text-tertiary text-sm font-normal!">
Move this post back into drafts
{pubStatus?.publicationUri
? "Move this post back into drafts"
: "Turn this post back into a draft"}
</div>
</div>
</MenuItem>
Expand Down Expand Up @@ -258,11 +288,34 @@ const DeleteAreYouSureForm = (props: { backToMenu: () => void }) => {
</ButtonTertiary>
<ButtonPrimary
onClick={async () => {
if (tokenId) removeFromLists(tokenId);
if (pubStatus?.documentUri) {
await deletePost(pubStatus.documentUri);
let result = await deletePost(pubStatus.documentUri);
if (!result.success) {
toaster({
content: (
<div className="font-bold">
Couldn&apos;t delete: {result.error.message}
</div>
),
type: "error",
});
return;
}
}
if (tokenId) removeFromLists(tokenId, pubStatus?.documentUri);
try {
if (pubStatus?.token) await deleteLeaflet(pubStatus.token);
} catch {
toaster({
content: (
<div className="font-bold">
Couldn&apos;t delete this {itemType.toLowerCase()}
</div>
),
type: "error",
});
return;
}
if (pubStatus?.token) deleteLeaflet(pubStatus.token);

toaster({
content: <div className="font-bold">Deleted {itemType}!</div>,
Expand Down Expand Up @@ -324,6 +377,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(
Expand All @@ -339,14 +398,63 @@ 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(
(p) => p.permission_tokens?.id !== tokenId,
);
data.contributor_leaflets = (data.contributor_leaflets ?? []).filter(
(r) => r.permission_tokens?.id !== tokenId,
);
});
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,
);
Expand Down
5 changes: 5 additions & 0 deletions components/PageSWRDataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down