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 ( - -
- {currentAccount?.address} - What's new? -
-
+ <> + +
+ {currentAccount?.address} + 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 +});