diff --git a/packages/web/src/components/Composer/DraftList.tsx b/packages/web/src/components/Composer/DraftList.tsx
new file mode 100644
index 00000000..14002062
--- /dev/null
+++ b/packages/web/src/components/Composer/DraftList.tsx
@@ -0,0 +1,165 @@
+import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/outline";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+import { memo, useCallback } from "react";
+import cn from "@/helpers/cn";
+import { useDraftModalStore } from "@/store/non-persisted/modal/useDraftModalStore";
+import { useNewPostModalStore } from "@/store/non-persisted/modal/useNewPostModalStore";
+import { usePostAttachmentStore } from "@/store/non-persisted/post/usePostAttachmentStore";
+import { usePostAudioStore } from "@/store/non-persisted/post/usePostAudioStore";
+import { usePostContentWarningStore } from "@/store/non-persisted/post/usePostContentWarningStore";
+import { usePostLicenseStore } from "@/store/non-persisted/post/usePostLicenseStore";
+import { usePostPollStore } from "@/store/non-persisted/post/usePostPollStore";
+import { usePostRulesStore } from "@/store/non-persisted/post/usePostRulesStore";
+import { usePostStore } from "@/store/non-persisted/post/usePostStore";
+import { usePostVideoStore } from "@/store/non-persisted/post/usePostVideoStore";
+import { useDraftStore } from "@/store/persisted/useDraftStore";
+import type { PostDraft } from "@/types/draft";
+import { toNewAttachment } from "@/types/draft";
+
+dayjs.extend(relativeTime);
+
+interface DraftListProps {
+ group?: { address: string; feed?: { address: string } | null };
+ parentPostId?: string;
+}
+
+const DraftList = ({ group, parentPostId }: DraftListProps) => {
+ const { drafts, removeDraft } = useDraftStore();
+ const { setShowDraftModal } = useDraftModalStore();
+ const { setShow: setShowNewPostModal } = useNewPostModalStore();
+
+ const { setPostContent, setQuotedPost, setParentPost } = usePostStore();
+ const { setAttachments } = usePostAttachmentStore();
+ const { setAudioPost } = usePostAudioStore();
+ const { setVideoThumbnail, setVideoDurationInSeconds } = usePostVideoStore();
+ const { setContentWarning } = usePostContentWarningStore();
+ const { setPollConfig, setShowPollEditor } = usePostPollStore();
+ const { setLicense } = usePostLicenseStore();
+ const {
+ setCollectorsOnly,
+ setFollowersOnly,
+ setFollowingOnly,
+ setGroupGate
+ } = usePostRulesStore();
+
+ const draftList = Object.values(drafts).sort(
+ (a, b) => b.updatedAt - a.updatedAt
+ );
+
+ const loadDraftIntoStores = useCallback(
+ (draft: PostDraft) => {
+ setPostContent(draft.postContent);
+ setAttachments(draft.attachments.map(toNewAttachment));
+ setAudioPost(draft.audioPost);
+ setVideoThumbnail({ ...draft.videoThumbnail, uploading: false });
+ setVideoDurationInSeconds(draft.videoDurationInSeconds);
+ setQuotedPost(draft.quotedPost);
+ setParentPost(draft.parentPost);
+ setContentWarning(draft.contentWarning);
+ setPollConfig(draft.pollConfig);
+ setShowPollEditor(draft.showPollEditor);
+ setLicense(draft.license);
+ setCollectorsOnly(draft.collectorsOnly);
+ setFollowersOnly(draft.followersOnly);
+ setFollowingOnly(draft.followingOnly);
+ setGroupGate(draft.groupGate);
+ },
+ [
+ setPostContent,
+ setAttachments,
+ setAudioPost,
+ setVideoThumbnail,
+ setVideoDurationInSeconds,
+ setQuotedPost,
+ setParentPost,
+ setContentWarning,
+ setPollConfig,
+ setShowPollEditor,
+ setLicense,
+ setCollectorsOnly,
+ setFollowersOnly,
+ setFollowingOnly,
+ setGroupGate
+ ]
+ );
+
+ const handleOpenDraft = useCallback(
+ (draft: PostDraft) => {
+ const contextMatches =
+ draft.parentPost?.id === parentPostId &&
+ draft.group?.address === group?.address;
+
+ if (contextMatches) {
+ loadDraftIntoStores(draft);
+ setShowNewPostModal(true);
+ } else {
+ setShowDraftModal(true, draft);
+ }
+ },
+ [
+ group,
+ parentPostId,
+ loadDraftIntoStores,
+ setShowNewPostModal,
+ setShowDraftModal
+ ]
+ );
+
+ const handleDeleteDraft = useCallback(
+ (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ removeDraft(id);
+ },
+ [removeDraft]
+ );
+
+ if (draftList.length === 0) {
+ return null;
+ }
+
+ return (
+
+
Drafts
+
+ {draftList.map((draft) => (
+
+ ))}
+
+
+ );
+};
+
+export default memo(DraftList);
diff --git a/packages/web/src/components/Composer/NewPost.tsx b/packages/web/src/components/Composer/NewPost.tsx
index e7d4ca92..396aeaff 100644
--- a/packages/web/src/components/Composer/NewPost.tsx
+++ b/packages/web/src/components/Composer/NewPost.tsx
@@ -4,6 +4,7 @@ import { Card, Image } from "@/components/Shared/UI";
import getAvatar from "@/helpers/getAvatar";
import { useBannedAccountsStore } from "@/store/non-persisted/admin/useBannedAccountsStore";
import { useAccountStore } from "@/store/persisted/useAccountStore";
+import DraftList from "./DraftList";
import NewPublication from "./NewPublication";
interface NewPostProps {
@@ -28,21 +29,24 @@ const NewPost = ({ group }: NewPostProps) => {
}
return (
-
-
-
- What's new?
-
-
+ <>
+
+
+
+ What's new?
+
+
+
+ >
);
};
diff --git a/packages/web/src/components/Composer/NewPublication.tsx b/packages/web/src/components/Composer/NewPublication.tsx
index ac8e0c1f..efaa564b 100644
--- a/packages/web/src/components/Composer/NewPublication.tsx
+++ b/packages/web/src/components/Composer/NewPublication.tsx
@@ -29,6 +29,7 @@ import cn from "@/helpers/cn";
import collectActionParams from "@/helpers/collectActionParams";
import { componentToPng } from "@/helpers/componentToPng";
import errorToast from "@/helpers/errorToast";
+import generateUUID from "@/helpers/generateUUID";
import getAccount from "@/helpers/getAccount";
import getMentions from "@/helpers/getMentions";
import getPostData from "@/helpers/getPostData";
@@ -62,6 +63,9 @@ import {
usePostVideoStore
} from "@/store/non-persisted/post/usePostVideoStore";
import { useAccountStore } from "@/store/persisted/useAccountStore";
+import { useDraftStore } from "@/store/persisted/useDraftStore";
+import type { PostDraft } from "@/types/draft";
+import { toDraftAttachment } from "@/types/draft";
import type { IGif } from "@/types/giphy";
import type { NewAttachment } from "@/types/misc";
import { Editor, useEditorContext, withEditorContext } from "./Editor";
@@ -71,13 +75,15 @@ interface NewPublicationProps {
post?: PostFragment;
group?: GroupFragment;
isModal?: boolean;
+ draftId?: string;
}
const NewPublication = ({
className,
post,
group,
- isModal
+ isModal,
+ draftId: initialDraftId
}: NewPublicationProps) => {
const { currentAccount } = useAccountStore();
const { bannedAccounts } = useBannedAccountsStore();
@@ -85,6 +91,10 @@ const NewPublication = ({
// New post modal store
const { setShow: setShowNewPostModal } = useNewPostModalStore();
+ // Draft store
+ const { saveDraft, removeDraft } = useDraftStore();
+ const [draftId] = useState(() => initialDraftId || generateUUID());
+
// Post store
const {
postContent,
@@ -105,7 +115,8 @@ const NewPublication = ({
const { audioPost, setAudioPost } = usePostAudioStore();
// Video store
- const { setVideoThumbnail, videoThumbnail } = usePostVideoStore();
+ const { setVideoThumbnail, videoThumbnail, videoDurationInSeconds } =
+ usePostVideoStore();
// Attachment store
const { addAttachments, attachments, isUploading, setAttachments } =
@@ -116,7 +127,7 @@ const NewPublication = ({
usePostPollStore();
// License store
- const { setLicense } = usePostLicenseStore();
+ const { license, setLicense } = usePostLicenseStore();
// Collect module store
const { collectAction, reset: resetCollectSettings } = useCollectActionStore(
@@ -133,7 +144,7 @@ const NewPublication = ({
setGroupGate,
setCollectorsOnly
} = usePostRulesStore();
- const { setContentWarning } = usePostContentWarningStore();
+ const { contentWarning, setContentWarning } = usePostContentWarningStore();
// States
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -185,6 +196,7 @@ const NewPublication = ({
};
const onCompleted = () => {
+ removeDraft(draftId);
reset();
};
@@ -262,6 +274,76 @@ const NewPublication = ({
setPostContentError("");
}, [postContent]);
+ // Autosave draft (debounced via debouncedPostContent)
+ const debouncedAttachments = useDebounce(attachments, 2000);
+ const debouncedAudioPost = useDebounce(audioPost, 2000);
+ const debouncedPollConfig = useDebounce(pollConfig, 2000);
+ const draftCreatedAt = useRef(Date.now());
+
+ useEffect(() => {
+ if (editingPost) {
+ return;
+ }
+
+ const hasContent =
+ debouncedPostContent.length > 0 ||
+ debouncedAttachments.length > 0 ||
+ showPollEditor;
+
+ if (!hasContent) {
+ return;
+ }
+
+ const draft: PostDraft = {
+ attachments: debouncedAttachments.map(toDraftAttachment),
+ audioPost: debouncedAudioPost,
+ collectAction,
+ collectorsOnly,
+ contentWarning,
+ createdAt: draftCreatedAt.current,
+ followersOnly,
+ followingOnly,
+ group: selectedGroup,
+ groupGate,
+ id: draftId,
+ license,
+ parentPost: post,
+ pollConfig: debouncedPollConfig,
+ postContent: debouncedPostContent,
+ quotedPost,
+ showPollEditor,
+ updatedAt: Date.now(),
+ videoDurationInSeconds,
+ videoThumbnail: {
+ mimeType: videoThumbnail.mimeType,
+ url: videoThumbnail.url
+ }
+ };
+
+ saveDraft(draft);
+ }, [
+ editingPost,
+ draftId,
+ debouncedPostContent,
+ debouncedAttachments,
+ debouncedAudioPost,
+ videoThumbnail,
+ videoDurationInSeconds,
+ quotedPost,
+ post,
+ selectedGroup,
+ contentWarning,
+ debouncedPollConfig,
+ showPollEditor,
+ collectAction,
+ license,
+ collectorsOnly,
+ followersOnly,
+ followingOnly,
+ groupGate,
+ saveDraft
+ ]);
+
const {
data: canComment,
isLoading: canCommentIsLoading,
diff --git a/packages/web/src/components/Shared/GlobalModals.tsx b/packages/web/src/components/Shared/GlobalModals.tsx
index f18763fd..8671bc27 100644
--- a/packages/web/src/components/Shared/GlobalModals.tsx
+++ b/packages/web/src/components/Shared/GlobalModals.tsx
@@ -1,4 +1,5 @@
import { useMediaQuery } from "@uidotdev/usehooks";
+import { useEffect } from "react";
import NewPublication from "@/components/Composer/NewPublication";
import SuperFollow from "@/components/Shared/Account/SuperFollow";
import SwitchAccounts from "@/components/Shared/Account/SwitchAccounts";
@@ -16,6 +17,7 @@ import getAccount from "@/helpers/getAccount";
import { IS_MOBILE } from "@/helpers/mediaQueries";
import { useAuthModalStore } from "@/store/non-persisted/modal/useAuthModalStore";
import { useCreateGroupStore } from "@/store/non-persisted/modal/useCreateGroupStore";
+import { useDraftModalStore } from "@/store/non-persisted/modal/useDraftModalStore";
import { useFundModalStore } from "@/store/non-persisted/modal/useFundModalStore";
import { useNewPostModalStore } from "@/store/non-persisted/modal/useNewPostModalStore";
import { usePinPostModalStore } from "@/store/non-persisted/modal/usePinPostModalStore";
@@ -25,7 +27,14 @@ import { useSuperFollowModalStore } from "@/store/non-persisted/modal/useSuperFo
import { useSuperJoinModalStore } from "@/store/non-persisted/modal/useSuperJoinModalStore";
import { useSwitchAccountModalStore } from "@/store/non-persisted/modal/useSwitchAccountModalStore";
import { usePostAttachmentStore } from "@/store/non-persisted/post/usePostAttachmentStore";
+import { usePostAudioStore } from "@/store/non-persisted/post/usePostAudioStore";
+import { usePostContentWarningStore } from "@/store/non-persisted/post/usePostContentWarningStore";
+import { usePostLicenseStore } from "@/store/non-persisted/post/usePostLicenseStore";
+import { usePostPollStore } from "@/store/non-persisted/post/usePostPollStore";
+import { usePostRulesStore } from "@/store/non-persisted/post/usePostRulesStore";
import { usePostStore } from "@/store/non-persisted/post/usePostStore";
+import { usePostVideoStore } from "@/store/non-persisted/post/usePostVideoStore";
+import { toNewAttachment } from "@/types/draft";
import Auth from "./Auth";
const GlobalModals = () => {
@@ -44,6 +53,18 @@ const GlobalModals = () => {
setNotificationShare
} = usePostStore();
const { setAttachments } = usePostAttachmentStore();
+ const { setAudioPost } = usePostAudioStore();
+ const { setVideoThumbnail, setVideoDurationInSeconds } = usePostVideoStore();
+ const { setContentWarning } = usePostContentWarningStore();
+ const { setPollConfig, setShowPollEditor } = usePostPollStore();
+ const { setLicense } = usePostLicenseStore();
+ const {
+ setCollectorsOnly,
+ setFollowersOnly,
+ setFollowingOnly,
+ setGroupGate
+ } = usePostRulesStore();
+ const { draft, showDraftModal, setShowDraftModal } = useDraftModalStore();
const { authModalType, showAuthModal, setShowAuthModal } =
useAuthModalStore();
const {
@@ -80,6 +101,24 @@ const GlobalModals = () => {
const isSmallDevice = useMediaQuery(IS_MOBILE);
+ const loadDraftIntoStores = (d: NonNullable) => {
+ setPostContent(d.postContent);
+ setAttachments(d.attachments.map(toNewAttachment));
+ setAudioPost(d.audioPost);
+ setVideoThumbnail({ ...d.videoThumbnail, uploading: false });
+ setVideoDurationInSeconds(d.videoDurationInSeconds);
+ setQuotedPost(d.quotedPost);
+ setParentPost(d.parentPost);
+ setContentWarning(d.contentWarning);
+ setPollConfig(d.pollConfig);
+ setShowPollEditor(d.showPollEditor);
+ setLicense(d.license);
+ setCollectorsOnly(d.collectorsOnly);
+ setFollowersOnly(d.followersOnly);
+ setFollowingOnly(d.followingOnly);
+ setGroupGate(d.groupGate);
+ };
+
return (
<>
{
post={parentPost}
/>
+ {draft ? (
+ {
+ setPostContent("");
+ setQuotedPost(undefined);
+ setParentPost(undefined);
+ setNotificationShare(undefined);
+ setAttachments([]);
+ }}
+ onClose={() => setShowDraftModal(false)}
+ preventClose={true}
+ show={showDraftModal}
+ size={isSmallDevice ? "full" : "md"}
+ title={
+ draft.parentPost
+ ? `Reply to @${getAccount(draft.parentPost.author).username}`
+ : draft.quotedPost
+ ? "Quote post"
+ : "Draft"
+ }
+ >
+
+
+ ) : null}
setShowFundModal({ showFundModal: false })}
show={showFundModal}
@@ -189,4 +255,33 @@ const GlobalModals = () => {
);
};
+/**
+ * Separate component so that NewPublication gets its own EditorContext when
+ * rendered inside the draft modal (independent from the main new-post modal).
+ */
+const DraftModalContent = ({
+ draft,
+ loadDraftIntoStores
+}: {
+ draft: NonNullable["draft"]>;
+ loadDraftIntoStores: (
+ d: NonNullable["draft"]>
+ ) => void;
+}) => {
+ // Load draft data into stores on mount
+ useEffect(() => {
+ loadDraftIntoStores(draft);
+ }, [draft.id]);
+
+ return (
+
+ );
+};
+
export default GlobalModals;
diff --git a/packages/web/src/data/storage.ts b/packages/web/src/data/storage.ts
index 00bc504a..e4c84923 100644
--- a/packages/web/src/data/storage.ts
+++ b/packages/web/src/data/storage.ts
@@ -1,6 +1,7 @@
export const Localstorage = {
AccountStore: "account.store",
AuthStore: "auth.store",
+ DraftStore: "draft.store",
HomeTabStore: "home-tab.store",
NotificationStore: "notification.store",
PreferencesStore: "preferences.store",
diff --git a/packages/web/src/store/non-persisted/modal/useDraftModalStore.ts b/packages/web/src/store/non-persisted/modal/useDraftModalStore.ts
new file mode 100644
index 00000000..027663ed
--- /dev/null
+++ b/packages/web/src/store/non-persisted/modal/useDraftModalStore.ts
@@ -0,0 +1,17 @@
+import { createTrackedStore } from "@/store/createTrackedStore";
+import type { PostDraft } from "@/types/draft";
+
+interface State {
+ draft?: PostDraft;
+ showDraftModal: boolean;
+ setShowDraftModal: (showDraftModal: boolean, draft?: PostDraft) => void;
+}
+
+const { useStore: useDraftModalStore } = createTrackedStore((set) => ({
+ draft: undefined,
+ setShowDraftModal: (showDraftModal, draft) =>
+ set(() => ({ draft, showDraftModal })),
+ showDraftModal: false
+}));
+
+export { useDraftModalStore };
diff --git a/packages/web/src/store/persisted/useDraftStore.ts b/packages/web/src/store/persisted/useDraftStore.ts
new file mode 100644
index 00000000..cce56bce
--- /dev/null
+++ b/packages/web/src/store/persisted/useDraftStore.ts
@@ -0,0 +1,27 @@
+import { Localstorage } from "@/data/storage";
+import { createPersistedTrackedStore } from "@/store/createTrackedStore";
+import type { PostDraft } from "@/types/draft";
+
+interface State {
+ drafts: Record;
+ saveDraft: (draft: PostDraft) => void;
+ removeDraft: (id: string) => void;
+}
+
+const { store, useStore: useDraftStore } = createPersistedTrackedStore(
+ (set) => ({
+ drafts: {},
+ removeDraft: (id) =>
+ set((state) => {
+ const { [id]: _, ...rest } = state.drafts;
+ return { drafts: rest };
+ }),
+ saveDraft: (draft) =>
+ set((state) => ({
+ drafts: { ...state.drafts, [draft.id]: draft }
+ }))
+ }),
+ { name: Localstorage.DraftStore }
+);
+
+export { store as draftStoreInstance, useDraftStore };
diff --git a/packages/web/src/types/draft.ts b/packages/web/src/types/draft.ts
new file mode 100644
index 00000000..fb2b98c6
--- /dev/null
+++ b/packages/web/src/types/draft.ts
@@ -0,0 +1,92 @@
+import type {
+ ContentWarning,
+ GroupFragment,
+ MetadataLicenseType,
+ PostFragment
+} from "@palus/indexer";
+import type { PollConfig } from "@/store/non-persisted/post/usePostPollStore";
+import type { NewAttachment } from "@/types/misc";
+import type { CollectActionType } from "@/types/palus";
+
+export interface DraftAttachment {
+ id?: string;
+ mimeType: string;
+ previewUri: string;
+ type: "Audio" | "Image" | "Video";
+ uri?: string;
+}
+
+export interface DraftAudioPost {
+ artist: string;
+ cover: string;
+ mimeType: string;
+ title: string;
+}
+
+export interface DraftVideoThumbnail {
+ mimeType: string;
+ url: string;
+}
+
+export interface PostDraft {
+ id: string;
+ createdAt: number;
+ updatedAt: number;
+
+ // Post content
+ postContent: string;
+
+ // Attachments (without File objects since they can't be serialized)
+ attachments: DraftAttachment[];
+
+ // Audio metadata
+ audioPost: DraftAudioPost;
+
+ // Video metadata
+ videoThumbnail: DraftVideoThumbnail;
+ videoDurationInSeconds: string;
+
+ // References
+ quotedPost?: PostFragment;
+ parentPost?: PostFragment;
+ group?: GroupFragment;
+
+ // Content warning
+ contentWarning?: ContentWarning;
+
+ // Poll
+ pollConfig: PollConfig;
+ showPollEditor: boolean;
+
+ // Collect action
+ collectAction: CollectActionType;
+
+ // License
+ license: MetadataLicenseType | null;
+
+ // Rules
+ collectorsOnly: boolean;
+ followersOnly: boolean;
+ followingOnly: boolean;
+ groupGate?: string;
+}
+
+export const toDraftAttachment = (
+ attachment: NewAttachment
+): DraftAttachment => ({
+ id: attachment.id,
+ mimeType: attachment.mimeType,
+ previewUri: attachment.previewUri,
+ type: attachment.type,
+ uri: attachment.uri
+});
+
+export const toNewAttachment = (
+ attachment: DraftAttachment
+): NewAttachment => ({
+ id: attachment.id,
+ mimeType: attachment.mimeType,
+ previewUri: attachment.previewUri,
+ type: attachment.type,
+ uri: attachment.uri
+});