diff --git a/actions/cancelScheduledPost.ts b/actions/cancelScheduledPost.ts new file mode 100644 index 000000000..ddbf822e0 --- /dev/null +++ b/actions/cancelScheduledPost.ts @@ -0,0 +1,50 @@ +"use server"; + +import { getIdentityData } from "actions/getIdentityData"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Result, Ok, Err } from "src/result"; +import { updateScheduleColumns } from "actions/scheduledPublishDb"; + +export type CancelScheduledPostError = + | { type: "not_authenticated" } + | { type: "not_found" }; + +export async function cancelScheduledPost(args: { + leaflet_id: string; + publication_uri?: string; +}): Promise> { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return Err({ type: "not_authenticated" }); + + if (!identity.entitlements?.can_schedule_posts) { + return Err({ type: "not_found" }); + } + + if (args.publication_uri) { + const { data: pub } = await supabaseServerClient + .from("publications") + .select("identity_did") + .eq("uri", args.publication_uri) + .single(); + if (!pub || pub.identity_did !== identity.atp_did) { + return Err({ type: "not_found" }); + } + } else { + const { data: ownership } = await supabaseServerClient + .from("permission_token_on_homepage") + .select("token") + .eq("token", args.leaflet_id) + .eq("identity", identity.id) + .maybeSingle(); + if (!ownership) return Err({ type: "not_found" }); + } + + const { found } = await updateScheduleColumns( + args.leaflet_id, + args.publication_uri, + { scheduled_publish_at: null, scheduled_publish_data: null }, + ); + if (!found) return Err({ type: "not_found" }); + + return Ok({ cancelled: true }); +} diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index 1755090bb..3bac9fd06 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -1,6 +1,7 @@ "use server"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; +import { OAuthSession } from "@atproto/oauth-client-node"; import { getIdentityData } from "actions/getIdentityData"; import { AtpBaseClient, @@ -49,18 +50,7 @@ type PublishResult = | { success: true; rkey: string; record: SiteStandardDocument.Record } | { success: false; error: OAuthSessionError }; -export async function publishToPublication({ - root_entity, - publication_uri, - leaflet_id, - title, - description, - tags, - cover_image, - entitiesToDelete, - publishedAt, - postPreferences, -}: { +type PublishArgs = { root_entity: string; publication_uri?: string; leaflet_id: string; @@ -75,7 +65,11 @@ export async function publishToPublication({ showMentions?: boolean; showRecommends?: boolean; } | null; -}): Promise { +}; + +export async function publishToPublication( + args: PublishArgs, +): Promise { let identity = await getIdentityData(); if (!identity || !identity.atp_did) { return { @@ -92,7 +86,31 @@ export async function publishToPublication({ if (!sessionResult.ok) { return { success: false, error: sessionResult.error }; } - let credentialSession = sessionResult.value; + + return publishToPublicationWithSession({ + ...args, + credentialSession: sessionResult.value, + did: identity.atp_did, + }); +} + +export async function publishToPublicationWithSession({ + root_entity, + publication_uri, + leaflet_id, + title, + description, + tags, + cover_image, + entitiesToDelete, + publishedAt, + postPreferences, + credentialSession, + did, +}: PublishArgs & { + credentialSession: OAuthSession; + did: string; +}): Promise { let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -111,7 +129,7 @@ export async function publishToPublication({ .single(); console.log(error); - if (!data || identity.atp_did !== data?.identity_did) + if (!data || did !== data?.identity_did) throw new Error("No draft or not publisher"); draft = data.leaflets_in_publications[0]; existingDocUri = draft?.doc; @@ -128,13 +146,13 @@ export async function publishToPublication({ // If updating an existing document, verify the current user is the owner if (existingDocUri) { let docOwner = new AtUri(existingDocUri).host; - if (docOwner !== identity.atp_did) { + if (docOwner !== did) { return { success: false, error: { type: "oauth_session_expired" as const, message: "Not the document owner", - did: identity.atp_did, + did, }, }; } @@ -346,7 +364,9 @@ export async function publishToPublication({ }); if (publication_uri) { - // Publishing to a publication - update both tables + // Publishing to a publication - update both tables. + // Always clear scheduled_publish_* so a manual publish (or backdate) of a + // previously-scheduled post cancels the pending inngest run on wake-up. await Promise.all([ supabaseServerClient.from("documents_in_publications").upsert({ publication: publication_uri, @@ -360,6 +380,8 @@ export async function publishToPublication({ description: description, tags: resolvedTags ?? [], cover_image: cover_image ?? null, + scheduled_publish_at: null, + scheduled_publish_data: null, }), ]); } else { @@ -371,6 +393,8 @@ export async function publishToPublication({ description: description || "", tags: resolvedTags ?? [], cover_image: cover_image ?? null, + scheduled_publish_at: null, + scheduled_publish_data: null, }); // Heuristic: Remove title entities if this is the first time publishing standalone diff --git a/actions/schedulePost.ts b/actions/schedulePost.ts new file mode 100644 index 000000000..1f92dec06 --- /dev/null +++ b/actions/schedulePost.ts @@ -0,0 +1,116 @@ +"use server"; + +import { AppBskyRichtextFacet } from "@atproto/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { inngest } from "app/api/inngest/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; +import { Result, Ok, Err } from "src/result"; +import { + ScheduleUpdates, + updateScheduleColumns, +} from "actions/scheduledPublishDb"; +import type { ScheduledPublishData } from "src/utils/scheduledPublish"; + +export type SchedulePostError = + | { type: "not_authenticated" } + | { type: "not_pro" } + | { type: "invalid_schedule" } + | { type: "not_found" }; + +export async function schedulePost(args: { + leaflet_id: string; + publication_uri?: string; + scheduled_publish_at: string; + title?: string; + description?: string; + tags?: string[]; + cover_image?: string | null; + preferences?: { + showComments?: boolean; + showMentions?: boolean; + showRecommends?: boolean; + } | null; + shareState: ScheduledPublishData["shareState"]; + bskyText?: string; + bskyFacets?: AppBskyRichtextFacet.Main[]; + publicationUrl?: string; +}): Promise> { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return Err({ type: "not_authenticated" }); + + if (!identity.entitlements?.can_schedule_posts) { + return Err({ type: "not_found" }); + } + + if (!identity.entitlements?.publication_analytics) { + return Err({ type: "not_pro" }); + } + + const scheduledAt = new Date(args.scheduled_publish_at); + if (Number.isNaN(scheduledAt.getTime()) || scheduledAt <= new Date()) { + return Err({ type: "invalid_schedule" }); + } + + const data: ScheduledPublishData = { + shareState: args.shareState, + bskyText: args.bskyText, + bskyFacets: args.bskyFacets, + did: identity.atp_did, + publicationUrl: args.publicationUrl, + }; + + const updates: ScheduleUpdates = { + scheduled_publish_at: scheduledAt.toISOString(), + scheduled_publish_data: data as unknown as Json, + }; + if (args.title !== undefined) updates.title = args.title; + if (args.description !== undefined) updates.description = args.description; + if (args.tags !== undefined) updates.tags = args.tags; + if (args.cover_image !== undefined) updates.cover_image = args.cover_image; + if (args.preferences !== undefined) + updates.preferences = args.preferences as unknown as Json; + + if (args.publication_uri) { + const { data: pub } = await supabaseServerClient + .from("publications") + .select("identity_did") + .eq("uri", args.publication_uri) + .single(); + if (!pub || pub.identity_did !== identity.atp_did) { + return Err({ type: "not_found" }); + } + } else { + const { data: ownership } = await supabaseServerClient + .from("permission_token_on_homepage") + .select("token") + .eq("token", args.leaflet_id) + .eq("identity", identity.id) + .maybeSingle(); + if (!ownership) return Err({ type: "not_found" }); + } + + const { found } = await updateScheduleColumns( + args.leaflet_id, + args.publication_uri, + updates, + ); + if (!found) return Err({ type: "not_found" }); + + try { + await inngest.send({ + // Dedupe duplicate dispatches for the same (leaflet, target time) — e.g. + // double-clicks or re-saving an Edit-schedule with the same value. + id: `scheduled-publish:${args.leaflet_id}:${scheduledAt.toISOString()}`, + name: "post/scheduled-publish", + data: { + leaflet_id: args.leaflet_id, + publication_uri: args.publication_uri, + }, + }); + } catch (e) { + console.log(e); + } + + return Ok({ scheduled_publish_at: scheduledAt.toISOString() }); +} diff --git a/actions/scheduledPublishDb.ts b/actions/scheduledPublishDb.ts new file mode 100644 index 000000000..70146ab67 --- /dev/null +++ b/actions/scheduledPublishDb.ts @@ -0,0 +1,79 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; + +export type ScheduleUpdates = { + scheduled_publish_at: string | null; + scheduled_publish_data: Json | null; + title?: string; + description?: string; + tags?: string[]; + cover_image?: string | null; + preferences?: Json; +}; + +export async function updateScheduleColumns( + leaflet_id: string, + publication_uri: string | undefined, + updates: ScheduleUpdates, +): Promise<{ found: boolean }> { + if (publication_uri) { + const { data } = await supabaseServerClient + .from("leaflets_in_publications") + .update(updates) + .eq("leaflet", leaflet_id) + .eq("publication", publication_uri) + .select("leaflet"); + return { found: !!data && data.length > 0 }; + } + const { data } = await supabaseServerClient + .from("leaflets_to_documents") + .update(updates) + .eq("leaflet", leaflet_id) + .select("leaflet"); + return { found: !!data && data.length > 0 }; +} + +const SCHEDULE_COLUMNS = + "scheduled_publish_at, scheduled_publish_data, title, description, tags, cover_image, preferences, permission_tokens(root_entity)"; + +export async function loadScheduleRow( + leaflet_id: string, + publication_uri: string | undefined, +) { + if (publication_uri) { + const { data } = await supabaseServerClient + .from("leaflets_in_publications") + .select(SCHEDULE_COLUMNS) + .eq("leaflet", leaflet_id) + .eq("publication", publication_uri) + .maybeSingle(); + return data; + } + const { data } = await supabaseServerClient + .from("leaflets_to_documents") + .select(SCHEDULE_COLUMNS) + .eq("leaflet", leaflet_id) + .maybeSingle(); + return data; +} + +export async function loadScheduledAt( + leaflet_id: string, + publication_uri: string | undefined, +): Promise { + if (publication_uri) { + const { data } = await supabaseServerClient + .from("leaflets_in_publications") + .select("scheduled_publish_at") + .eq("leaflet", leaflet_id) + .eq("publication", publication_uri) + .maybeSingle(); + return data?.scheduled_publish_at ?? null; + } + const { data } = await supabaseServerClient + .from("leaflets_to_documents") + .select("scheduled_publish_at") + .eq("leaflet", leaflet_id) + .maybeSingle(); + return data?.scheduled_publish_at ?? null; +} diff --git a/app/(home-pages)/home/LeafletList/LeafletInfo.tsx b/app/(home-pages)/home/LeafletList/LeafletInfo.tsx index b18be9bf2..ef5bd4918 100644 --- a/app/(home-pages)/home/LeafletList/LeafletInfo.tsx +++ b/app/(home-pages)/home/LeafletList/LeafletInfo.tsx @@ -4,6 +4,14 @@ import { LeafletOptions } from "./LeafletOptions"; import { timeAgo } from "src/utils/timeAgo"; import { usePageTitle } from "components/utils/UpdateLeafletTitle"; import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; +import { Popover } from "components/Popover"; +import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { + SCHEDULED_DATE_FORMAT, + getFutureScheduledAt, +} from "src/utils/scheduledPublish"; +import { useCanSchedulePosts } from "src/hooks/useEntitlement"; export const LeafletInfo = (props: { title?: string; @@ -14,11 +22,14 @@ export const LeafletInfo = (props: { loggedIn: boolean; }) => { const pubStatus = useLeafletPublicationStatus(); + const canSchedule = useCanSchedulePosts(); let prettyCreatedAt = props.added_at ? timeAgo(props.added_at) : ""; let prettyPublishedAt = pubStatus?.publishedAt ? timeAgo(pubStatus.publishedAt) : ""; + const scheduledFor = getFutureScheduledAt(pubStatus?.scheduledPublishAt); + // Look up root page first, like UpdateLeafletTitle does let firstPage = useEntity(pubStatus?.leafletId ?? "", "root/page")[0]; let entityID = firstPage?.data.value || pubStatus?.leafletId || ""; @@ -35,6 +46,9 @@ export const LeafletInfo = (props: { {title}
+ {scheduledFor && canSchedule && ( + + )}
@@ -58,3 +72,25 @@ export const LeafletInfo = (props: { ); }; + +const ScheduledIcon = (props: { scheduledFor: string }) => { + const formatted = useLocalizedDate(props.scheduledFor, SCHEDULED_DATE_FORMAT); + return ( + + + + } + > +
+ Scheduled for {formatted} +
+
+ ); +}; diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index 8823ece8a..1a859ca82 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -1,5 +1,6 @@ "use client"; import { publishToPublication } from "actions/publishToPublication"; +import { cancelScheduledPost } from "actions/cancelScheduledPost"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { ActionButton } from "components/ActionBar/ActionButton"; import { @@ -9,11 +10,17 @@ import { } from "components/ActionBar/Publications"; import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; import { AddSmall } from "components/Icons/AddSmall"; +import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; import { PublishSmall } from "components/Icons/PublishSmall"; import { useIdentityData } from "components/IdentityProvider"; import { InputWithLabel } from "components/Input"; import { Menu, MenuItem } from "components/Menu"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { + SCHEDULED_DATE_FORMAT_NO_YEAR, + getFutureScheduledAt, +} from "src/utils/scheduledPublish"; import { useLeafletDomains, useLeafletPublicationData, @@ -26,6 +33,7 @@ import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useState, useMemo, useEffect } from "react"; import { useIsMobile } from "src/hooks/isMobile"; +import { useCanSchedulePosts } from "src/hooks/useEntitlement"; import { useReplicache, useEntity } from "src/replicache"; import { useSubscribe } from "src/replicache/useSubscribe"; import { Json } from "supabase/database.types"; @@ -43,11 +51,28 @@ import { useLocalPublishedAt } from "components/Pages/Backdater"; import { LoginModal } from "components/LoginButton"; export const PublishButton = (props: { entityID: string }) => { - let { data: pub } = useLeafletPublicationData(); + let { data: pub, mutate } = useLeafletPublicationData(); + let { permission_token } = useReplicache(); let params = useParams(); let router = useRouter(); + const canSchedule = useCanSchedulePosts(); if (!pub) return ; + + const scheduledFor = getFutureScheduledAt(pub.scheduled_publish_at); + + if (scheduledFor && canSchedule) { + return ( + router.push(`/${params.leaflet_id}/publish`)} + /> + ); + } + if (!pub?.doc) return ( { ); }; +const ScheduledMenuButton = (props: { + scheduledFor: string; + leafletId: string; + publicationUri: string | undefined; + onChanged: () => void; + onEdit: () => void; +}) => { + let formattedDate = useLocalizedDate( + props.scheduledFor, + SCHEDULED_DATE_FORMAT_NO_YEAR, + ); + let toaster = useToaster(); + let [isLoading, setIsLoading] = useState(false); + + return ( + } + label={isLoading ? : `Scheduled ${formattedDate}`} + /> + } + > + props.onEdit()}>Edit schedule + { + setIsLoading(true); + const result = await cancelScheduledPost({ + leaflet_id: props.leafletId, + publication_uri: props.publicationUri, + }); + setIsLoading(false); + props.onChanged(); + if (!result.ok) { + toaster({ + content: "Failed to cancel schedule", + type: "error", + }); + } + }} + > + Cancel schedule + + + ); +}; + const PublishToPublicationButton = (props: { entityID: string }) => { let { identity } = useIdentityData(); let { permission_token } = useReplicache(); diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 574c64355..88a18da8b 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -1,5 +1,6 @@ "use client"; import { publishToPublication } from "actions/publishToPublication"; +import { schedulePost } from "actions/schedulePost"; import { DotLoader } from "components/utils/DotLoader"; import { useState, useRef, type CSSProperties } from "react"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; @@ -23,8 +24,11 @@ import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; import { DatePicker, TimePicker } from "components/DatePicker"; import { Popover } from "components/Popover"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { SCHEDULED_DATE_FORMAT } from "src/utils/scheduledPublish"; import { Separator } from "react-aria-components"; -import { setHours, setMinutes } from "date-fns"; +import { isFuture, setHours, setMinutes } from "date-fns"; +import { useCanSchedulePosts, useIsPro } from "src/hooks/useEntitlement"; +import { UpgradeModal } from "app/lish/[did]/[publication]/UpgradeModal"; import { ThemeBackgroundProvider, ThemeProvider, @@ -49,33 +53,59 @@ type Props = { subscriberCount?: number; entitiesToDelete?: string[]; hasDraft: boolean; + scheduledPublishAt?: string; }; export function PublishPost(props: Props) { let [publishState, setPublishState] = useState< - { state: "default" } | { state: "success"; post_url: string } + | { state: "default" } + | { state: "success"; post_url: string } + | { state: "scheduled"; scheduled_for: string } >({ state: "default" }); + + const renderState = () => { + switch (publishState.state) { + case "default": + return ; + case "scheduled": + return ( + + ); + case "success": + return ( + + ); + } + }; + return (
- {publishState.state === "default" ? ( - - ) : ( - - )} + {renderState()}
); } const PublishPostForm = ( props: { - setPublishState: (s: { state: "success"; post_url: string }) => void; + setPublishState: ( + s: + | { state: "success"; post_url: string } + | { state: "scheduled"; scheduled_for: string }, + ) => void; } & Props, ) => { + const isPro = useIsPro(); + const canSchedule = useCanSchedulePosts(); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); let editorStateRef = useRef(null); let [state, setState] = useState<"post-details" | "share-options">( "post-details", @@ -107,7 +137,7 @@ const PublishPostForm = ( let [showTagSelector, setShowTagSelector] = useState(false); let [localPublishedAt, setLocalPublishedAt] = useState( - undefined, + props.scheduledPublishAt ? new Date(props.scheduledPublishAt) : undefined, ); // Get cover image from Replicache let replicacheCoverImage = useSubscribe(rep, (tx) => @@ -141,11 +171,51 @@ const PublishPostForm = ( } }; + const isScheduled = !!localPublishedAt && isFuture(localPublishedAt); + async function submit() { if (isLoading) return; + + if (isScheduled && !isPro) { + setShowUpgradeModal(true); + return; + } + setIsLoading(true); setOauthError(null); await rep?.push(); + + let [text, facets] = editorStateRef.current + ? editorStateToFacetedText(editorStateRef.current) + : []; + + if (isScheduled && localPublishedAt) { + let result = await schedulePost({ + leaflet_id: props.leaflet_id, + publication_uri: props.publication_uri, + scheduled_publish_at: localPublishedAt.toISOString(), + title: props.title, + description: props.description, + tags: currentTags, + cover_image: replicacheCoverImage, + preferences: postPreferences, + shareState, + bskyText: text, + bskyFacets: facets, + publicationUrl: props.record?.url, + }); + setIsLoading(false); + if (!result.ok) { + if (result.error.type === "not_pro") setShowUpgradeModal(true); + return; + } + props.setPublishState({ + state: "scheduled", + scheduled_for: result.value.scheduled_publish_at, + }); + return; + } + let result = await publishToPublication({ root_entity: props.root_entity, publication_uri: props.publication_uri, @@ -172,9 +242,6 @@ const PublishPostForm = ( ? `${props.record.url}/${result.rkey}` : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; - let [text, facets] = editorStateRef.current - ? editorStateToFacetedText(editorStateRef.current) - : []; if (shareState.bluesky) { let bskyResult = await publishPostToBsky({ facets: facets || [], @@ -197,6 +264,10 @@ const PublishPostForm = ( return (
+
{ e.preventDefault(); @@ -213,9 +284,10 @@ const PublishPostForm = ( />
-
@@ -328,6 +400,8 @@ const PublishPostForm = ( > {isLoading ? ( + ) : isScheduled ? ( + "Schedule this Post!" ) : ( "Publish this Post!" )} @@ -463,20 +537,14 @@ const PublicationSocialPreview = (props: { ); }; -const BackdateOptions = (props: { +const DateOptions = (props: { publishedAt: Date | undefined; setPublishedAt: (date: Date | undefined) => void; + canSchedule: boolean; }) => { const formattedDate = useLocalizedDate( props.publishedAt?.toISOString() || "", - { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - }, + SCHEDULED_DATE_FORMAT, ); const [timeValue, setTimeValue] = useState(() => { @@ -484,7 +552,17 @@ const BackdateOptions = (props: { return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; }); - let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; + const clampToNow = ( + date: Date, + time: string, + ): { date: Date; time: string } => { + const now = new Date(); + if (!props.canSchedule && date > now) { + const clampedTime = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`; + return { date: now, time: clampedTime }; + } + return { date, time }; + }; const handleTimeChange = (time: string) => { setTimeValue(time); @@ -492,12 +570,9 @@ const BackdateOptions = (props: { const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); const newDate = setHours(setMinutes(props.publishedAt, minutes), hours); - const currentDate = new Date(); - - if (newDate > currentDate) { - props.setPublishedAt(currentDate); - setTimeValue(currentTime); - } else props.setPublishedAt(newDate); + const clamped = clampToNow(newDate, time); + setTimeValue(clamped.time); + props.setPublishedAt(clamped.date); }; const handleDateChange = (date: Date | undefined) => { @@ -515,16 +590,18 @@ const BackdateOptions = (props: { hours, minutes, ); - const currentDate = new Date(); - if (newDate > currentDate) { - props.setPublishedAt(currentDate); - setTimeValue(currentTime); - } else props.setPublishedAt(newDate); + const clamped = clampToNow(newDate, timeValue); + setTimeValue(clamped.time); + props.setPublishedAt(clamped.date); }; + const scheduled = !!props.publishedAt && isFuture(props.publishedAt); + return (
-
Publish Date
+
+ {scheduled ? "Scheduled for" : "Publish Date"} +
date > new Date()} + disabled={ + props.canSchedule + ? undefined + : (date: Date) => date > new Date() + } />
@@ -580,6 +662,41 @@ const PublishingTo = (props: { ); }; +const SchedulePostSuccess = (props: { + scheduled_for: string; + publication_uri?: string; + record: Props["record"]; +}) => { + let uri = props.publication_uri ? new AtUri(props.publication_uri) : null; + const formattedDate = useLocalizedDate( + props.scheduled_for, + SCHEDULED_DATE_FORMAT, + ); + return ( +
+

Scheduled!

+
+ Will be published {formattedDate} +
+ {uri && props.record ? ( + + Back to Dashboard + + ) : ( + + Back to Home + + )} +
+ ); +}; + const PublishPostSuccess = (props: { post_url: string; publication_uri?: string; diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx index 13ede4e73..0f80f418a 100644 --- a/app/[leaflet_id]/publish/page.tsx +++ b/app/[leaflet_id]/publish/page.tsx @@ -2,6 +2,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { PublishPost } from "./PublishPost"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { getIdentityData } from "actions/getIdentityData"; +import { getScheduledPublishAt } from "src/utils/scheduledPublish"; import { AtpAgent } from "@atproto/api"; import { ReplicacheProvider } from "src/replicache"; @@ -119,6 +120,8 @@ export default async function PublishLeafletPage(props: Props) { data.leaflets_in_publications.length > 0 || data.leaflets_to_documents.length > 0; + let scheduledPublishAt = getScheduledPublishAt(data) ?? undefined; + return ( ); diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts index 7edce1f10..20942c208 100644 --- a/app/[leaflet_id]/publish/publishBskyPost.ts +++ b/app/[leaflet_id]/publish/publishBskyPost.ts @@ -3,26 +3,23 @@ import { AppBskyRichtextFacet, Agent as BskyAgent, - UnicodeString, } from "@atproto/api"; import sharp from "sharp"; import { TID } from "@atproto/common"; +import { OAuthSession } from "@atproto/oauth-client-node"; import { getIdentityData } from "actions/getIdentityData"; import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; -import { - getMicroLinkOgImage, - getWebpageImage, -} from "src/utils/getMicroLinkOgImage"; +import { getWebpageImage } from "src/utils/getMicroLinkOgImage"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; type PublishBskyResult = | { success: true } | { success: false; error: OAuthSessionError }; -export async function publishPostToBsky(args: { +type PublishBskyArgs = { text: string; url: string; title: string; @@ -30,24 +27,15 @@ export async function publishPostToBsky(args: { document_record: SiteStandardDocument.Record; rkey: string; facets: AppBskyRichtextFacet.Main[]; -}): Promise { - let identity = await getIdentityData(); - if (!identity || !identity.atp_did) { - return { - success: false, - error: { - type: "oauth_session_expired", - message: "Not authenticated", - did: "", - }, - }; - } + // Optional override for the bsky post rkey. Callers running inside an Inngest + // step.run should supply a memoized rkey so retries don't duplicate posts. + bskyPostRkey?: string; +}; - const sessionResult = await restoreOAuthSession(identity.atp_did); - if (!sessionResult.ok) { - return { success: false, error: sessionResult.error }; - } - let credentialSession = sessionResult.value; +export async function publishPostToBskyWithSession( + args: PublishBskyArgs & { credentialSession: OAuthSession; did: string }, +): Promise { + const { credentialSession, did } = args; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -61,7 +49,7 @@ export async function publishPostToBsky(args: { "$link" ] || args.document_record.coverImage.ref.toString(); - let coverImageResponse = await fetchAtprotoBlob(identity.atp_did, cid); + let coverImageResponse = await fetchAtprotoBlob(did, cid); if (coverImageResponse) { imageBinary = await coverImageResponse.blob(); } @@ -94,7 +82,7 @@ export async function publishPostToBsky(args: { let post = await bsky.app.bsky.feed.post.create( { repo: credentialSession.did!, - rkey: TID.nextStr(), + rkey: args.bskyPostRkey ?? TID.nextStr(), }, { text: args.text, @@ -129,3 +117,30 @@ export async function publishPostToBsky(args: { .eq("uri", result.uri); return { success: true }; } + +export async function publishPostToBsky( + args: PublishBskyArgs, +): Promise { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + + return publishPostToBskyWithSession({ + ...args, + credentialSession: sessionResult.value, + did: identity.atp_did, + }); +} diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index dcd25ce5f..1f9bb3286 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -90,6 +90,12 @@ export const events = { root_entity: string; }>(), }), + postScheduledPublish: eventType("post/scheduled-publish", { + schema: staticSchema<{ + leaflet_id: string; + publication_uri?: string; + }>(), + }), }; // Create a client to send and receive events. diff --git a/app/api/inngest/functions/scheduled_publish.ts b/app/api/inngest/functions/scheduled_publish.ts new file mode 100644 index 000000000..e05d5666f --- /dev/null +++ b/app/api/inngest/functions/scheduled_publish.ts @@ -0,0 +1,173 @@ +import { inngest, events } from "../client"; +import { TID } from "@atproto/common"; +import { restoreOAuthSession } from "src/atproto-oauth"; +import { publishToPublicationWithSession } from "actions/publishToPublication"; +import { publishPostToBskyWithSession } from "app/[leaflet_id]/publish/publishBskyPost"; +import type { ScheduledPublishData } from "src/utils/scheduledPublish"; +import { + loadScheduleRow, + loadScheduledAt, + updateScheduleColumns, +} from "actions/scheduledPublishDb"; + +type ScheduleRow = { + scheduled_publish_at: string; + scheduled_publish_data: ScheduledPublishData; + title: string; + description: string; + tags: string[] | null; + cover_image: string | null; + preferences: { + showComments?: boolean; + showMentions?: boolean; + showRecommends?: boolean; + } | null; + root_entity: string; +}; + +async function loadSchedule( + leaflet_id: string, + publication_uri: string | undefined, +): Promise { + const data = await loadScheduleRow(leaflet_id, publication_uri); + if (!data || !data.scheduled_publish_at || !data.scheduled_publish_data) { + return null; + } + const root_entity = (data.permission_tokens as { root_entity: string } | null) + ?.root_entity; + if (!root_entity) return null; + return { + scheduled_publish_at: data.scheduled_publish_at, + scheduled_publish_data: + data.scheduled_publish_data as unknown as ScheduledPublishData, + title: data.title, + description: data.description, + tags: data.tags, + cover_image: data.cover_image, + preferences: data.preferences as ScheduleRow["preferences"], + root_entity, + }; +} + +export const scheduled_publish = inngest.createFunction( + { + id: "scheduled-publish", + concurrency: [{ key: "event.data.leaflet_id", limit: 1 }], + onFailure: async ({ event }) => { + // Exhausted step retries — clear the schedule columns so the row + // doesn't sit with stale scheduled_publish_data forever and the UI + // stops advertising the post as scheduled. The error itself surfaces + // in the Inngest dashboard. + const { leaflet_id, publication_uri } = event.data.event.data; + await updateScheduleColumns(leaflet_id, publication_uri, { + scheduled_publish_at: null, + scheduled_publish_data: null, + }); + }, + triggers: [events.postScheduledPublish], + }, + async ({ event, step }) => { + const { leaflet_id, publication_uri } = event.data; + + const scheduledAt = await step.run("load-scheduled-at", async () => + loadScheduledAt(leaflet_id, publication_uri), + ); + if (!scheduledAt) return { cancelled: true }; + + await step.sleepUntil("wait-for-scheduled-time", new Date(scheduledAt)); + + // The user may have cancelled or rescheduled while we slept. A cancel + // nulls the column; a reschedule sends a new event (handled by a separate + // run that sleeps to the new time) and updates this column, so an exact + // scheduledAt mismatch means this run is stale and must exit. + const current = await step.run("verify-schedule", async () => + loadSchedule(leaflet_id, publication_uri), + ); + if (!current || current.scheduled_publish_at !== scheduledAt) { + return { cancelled: true }; + } + + const data = current.scheduled_publish_data; + + const publishResult = await step.run("publish", async () => { + const sessionResult = await restoreOAuthSession(data.did); + if (!sessionResult.ok) { + throw new Error( + `OAuth restore failed for scheduled publish: ${sessionResult.error.message}`, + ); + } + const result = await publishToPublicationWithSession({ + root_entity: current.root_entity, + publication_uri, + leaflet_id, + title: current.title, + description: current.description, + tags: current.tags ?? undefined, + cover_image: current.cover_image, + publishedAt: current.scheduled_publish_at, + postPreferences: current.preferences, + credentialSession: sessionResult.value, + did: data.did, + }); + if (!result.success) { + throw new Error( + `Scheduled publish failed: ${result.error.message}`, + ); + } + return { + rkey: result.rkey, + // Strip the BlobRef instances out so the value is JSON-serializable + // for Inngest step memoization. + record: JSON.parse(JSON.stringify(result.record)), + }; + }); + + if (data.shareState.bluesky) { + // Mint the bsky post rkey in its own memoized step so retries of the + // post-to-bsky step (e.g. transient putRecord/supabase failure after the + // post is already created) reuse the same rkey instead of creating a + // second public Bluesky post. + const bskyPostRkey = await step.run("bsky-post-rkey", () => + TID.nextStr(), + ); + await step.run("post-to-bsky", async () => { + const sessionResult = await restoreOAuthSession(data.did); + if (!sessionResult.ok) { + throw new Error( + `OAuth restore failed for bsky post: ${sessionResult.error.message}`, + ); + } + const post_url = + data.publicationUrl + ? `${data.publicationUrl.replace(/\/$/, "")}/${publishResult.rkey}` + : `https://leaflet.pub/p/${data.did}/${publishResult.rkey}`; + const bskyResult = await publishPostToBskyWithSession({ + credentialSession: sessionResult.value, + did: data.did, + text: data.bskyText ?? "", + facets: data.bskyFacets ?? [], + title: current.title, + description: current.description, + url: post_url, + document_record: publishResult.record, + rkey: publishResult.rkey, + bskyPostRkey, + }); + if (!bskyResult.success) { + throw new Error( + `Bsky post failed: ${bskyResult.error.message}`, + ); + } + }); + } + + await step.run("clear-schedule", async () => { + await updateScheduleColumns(leaflet_id, publication_uri, { + scheduled_publish_at: null, + scheduled_publish_data: null, + }); + }); + + return { success: true, rkey: publishResult.rkey }; + }, +); diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx index 3b703e8c4..ca03b79c6 100644 --- a/app/api/inngest/route.tsx +++ b/app/api/inngest/route.tsx @@ -15,6 +15,7 @@ import { import { write_records_to_pds } from "./functions/write_records_to_pds"; import { sync_document_metadata } from "./functions/sync_document_metadata"; import { send_post_broadcast } from "./functions/send_post_broadcast"; +import { scheduled_publish } from "./functions/scheduled_publish"; export const { GET, POST, PUT } = serve({ client: inngest, @@ -32,5 +33,6 @@ export const { GET, POST, PUT } = serve({ write_records_to_pds, sync_document_metadata, send_post_broadcast, + scheduled_publish, ], }); diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 714ad9926..6f0401ae4 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -64,6 +64,7 @@ export const get_publication_data = makeRoute({ .limit(1) .single(); + console.log(error); let leaflet_data = await getFactsFromHomeLeaflets.handler( { tokens: diff --git a/app/lish/[did]/[publication]/UpgradeModal.tsx b/app/lish/[did]/[publication]/UpgradeModal.tsx index daa120942..04080724e 100644 --- a/app/lish/[did]/[publication]/UpgradeModal.tsx +++ b/app/lish/[did]/[publication]/UpgradeModal.tsx @@ -72,14 +72,22 @@ export const UpgradeContent = () => { }; export const UpgradeModal = (props: { - trigger: React.ReactNode; + trigger?: React.ReactNode; asChild?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) => { return ( {props.trigger}
} + trigger={ + props.trigger ? ( +
{props.trigger}
+ ) : undefined + } > diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index 39e52d59c..4da556f30 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -18,6 +18,7 @@ import { type NormalizedDocument, type NormalizedPublication, } from "src/utils/normalizeRecords"; +import { getScheduledPublishAt } from "src/utils/scheduledPublish"; export const StaticLeafletDataContext = createContext< null | GetLeafletDataReturnType["result"]["data"] @@ -143,6 +144,8 @@ export function useLeafletPublicationStatus() { } } + const scheduledPublishAt = getScheduledPublishAt(data); + return { token: data, leafletId: data.root_entity, @@ -155,6 +158,7 @@ export function useLeafletPublicationStatus() { publishedAt: publishedInPublication?.documents?.indexed_at ?? publishedStandalone?.documents?.indexed_at, + scheduledPublishAt, documentUri, // Full URL for sharing published posts postShareLink, diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx index db93e85f8..083cf5d99 100644 --- a/components/Pages/PublicationMetadata.tsx +++ b/components/Pages/PublicationMetadata.tsx @@ -22,6 +22,11 @@ import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader import { Backdater } from "./Backdater"; import { RecommendTinyEmpty } from "components/Icons/RecommendTiny"; import { mergePreferences } from "src/utils/mergePreferences"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { + SCHEDULED_DATE_FORMAT, + getFutureScheduledAt, +} from "src/utils/scheduledPublish"; export const PublicationMetadata = (props: { noInteractions?: boolean }) => { let { rep } = useReplicache(); @@ -47,6 +52,7 @@ export const PublicationMetadata = (props: { noInteractions?: boolean }) => { normalizedPublication?.preferences, ); let publishedAt = normalizedDocument?.publishedAt; + const scheduledFor = getFutureScheduledAt(pub?.scheduled_publish_at); if (!pub) return null; @@ -107,7 +113,9 @@ export const PublicationMetadata = (props: { noInteractions?: boolean }) => { } postInfo={ <> - {pub.doc ? ( + {scheduledFor ? ( + + ) : pub.doc ? (

Published{" "} @@ -238,6 +246,11 @@ export const TextField = ({ ); }; +const ScheduledForLabel = (props: { scheduledFor: string }) => { + const formatted = useLocalizedDate(props.scheduledFor, SCHEDULED_DATE_FORMAT); + return

Will be published {formatted}

; +}; + export const PublicationMetadataPreview = () => { let { data: pub, normalizedDocument } = useLeafletPublicationData(); let publishedAt = normalizedDocument?.publishedAt; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 38cc380b2..ed62901f4 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -536,6 +536,8 @@ export const leaflets_to_documents = pgTable("leaflets_to_documents", { tags: text("tags").default('RRAY[').array(), cover_image: text("cover_image"), preferences: jsonb("preferences"), + scheduled_publish_at: timestamp("scheduled_publish_at", { withTimezone: true, mode: 'string' }), + scheduled_publish_data: jsonb("scheduled_publish_data"), }, (table) => { return { @@ -553,6 +555,8 @@ export const leaflets_in_publications = pgTable("leaflets_in_publications", { tags: text("tags").default('RRAY[').array(), cover_image: text("cover_image"), preferences: jsonb("preferences"), + scheduled_publish_at: timestamp("scheduled_publish_at", { withTimezone: true, mode: 'string' }), + scheduled_publish_data: jsonb("scheduled_publish_data"), }, (table) => { return { diff --git a/src/hooks/useEntitlement.ts b/src/hooks/useEntitlement.ts index 9f62c33e3..e9c1683bd 100644 --- a/src/hooks/useEntitlement.ts +++ b/src/hooks/useEntitlement.ts @@ -17,3 +17,7 @@ export function useCanSeePro(): boolean { export function useCanSeeNewsletterMode(): boolean { return useHasEntitlement("can_see_newsletter_mode"); } + +export function useCanSchedulePosts(): boolean { + return useHasEntitlement("can_schedule_posts"); +} diff --git a/src/utils/getPublicationMetadataFromLeafletData.ts b/src/utils/getPublicationMetadataFromLeafletData.ts index e02205204..382b08616 100644 --- a/src/utils/getPublicationMetadataFromLeafletData.ts +++ b/src/utils/getPublicationMetadataFromLeafletData.ts @@ -12,6 +12,7 @@ export type PublicationMetadata = { title: string; leaflet: string; doc: string | null; + scheduled_publish_at: string | null; publications: { identity_did: string; name: string; diff --git a/src/utils/scheduledPublish.ts b/src/utils/scheduledPublish.ts new file mode 100644 index 000000000..97934448a --- /dev/null +++ b/src/utils/scheduledPublish.ts @@ -0,0 +1,55 @@ +import type { AppBskyRichtextFacet } from "@atproto/api"; + +export const SCHEDULED_DATE_FORMAT: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, +}; + +export const SCHEDULED_DATE_FORMAT_NO_YEAR: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, +}; + +export function getFutureScheduledAt( + scheduledPublishAt: string | null | undefined, +): string | null { + if (!scheduledPublishAt) return null; + return new Date(scheduledPublishAt).getTime() > Date.now() + ? scheduledPublishAt + : null; +} + +type LeafletDataShape = { + leaflets_in_publications?: { scheduled_publish_at: string | null }[] | null; + leaflets_to_documents?: { scheduled_publish_at: string | null }[] | null; +}; + +export function getScheduledPublishAt( + data: LeafletDataShape | null | undefined, +): string | null { + return ( + data?.leaflets_in_publications?.[0]?.scheduled_publish_at ?? + data?.leaflets_to_documents?.[0]?.scheduled_publish_at ?? + null + ); +} + +export type ScheduledPublishData = { + shareState: { + bluesky: boolean; + postToReaders: boolean; + email: boolean; + quiet: boolean; + }; + bskyText?: string; + bskyFacets?: AppBskyRichtextFacet.Main[]; + did: string; + publicationUrl?: string; +}; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 60d5bb819..f005dd6dc 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -605,6 +605,8 @@ export type Database = { leaflet: string preferences: Json | null publication: string + scheduled_publish_at: string | null + scheduled_publish_data: Json | null tags: string[] | null title: string } @@ -616,6 +618,8 @@ export type Database = { leaflet: string preferences?: Json | null publication: string + scheduled_publish_at?: string | null + scheduled_publish_data?: Json | null tags?: string[] | null title?: string } @@ -627,6 +631,8 @@ export type Database = { leaflet?: string preferences?: Json | null publication?: string + scheduled_publish_at?: string | null + scheduled_publish_data?: Json | null tags?: string[] | null title?: string } @@ -663,6 +669,8 @@ export type Database = { document: string leaflet: string preferences: Json | null + scheduled_publish_at: string | null + scheduled_publish_data: Json | null tags: string[] | null title: string } @@ -674,6 +682,8 @@ export type Database = { document: string leaflet: string preferences?: Json | null + scheduled_publish_at?: string | null + scheduled_publish_data?: Json | null tags?: string[] | null title?: string } @@ -685,6 +695,8 @@ export type Database = { document?: string leaflet?: string preferences?: Json | null + scheduled_publish_at?: string | null + scheduled_publish_data?: Json | null tags?: string[] | null title?: string }