Skip to content
Draft
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
165 changes: 165 additions & 0 deletions packages/web/src/components/Composer/DraftList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
<h6 className="px-3 font-bold text-gray-500 text-sm md:px-0">Drafts</h6>
<div className="space-y-1">
{draftList.map((draft) => (
<button
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left",
"hover:bg-gray-100 dark:hover:bg-gray-800"
)}
key={draft.id}
onClick={() => handleOpenDraft(draft)}
type="button"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<DocumentTextIcon className="size-5 shrink-0 text-gray-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">
{draft.postContent || "Untitled draft"}
</p>
<p className="text-gray-500 text-xs">
{dayjs(draft.updatedAt).fromNow()}
{draft.parentPost ? " · Reply" : ""}
{draft.quotedPost ? " · Quote" : ""}
{draft.group
? ` · ${draft.group.metadata?.name || "Unnamed group"}`
: ""}
</p>
</div>
</div>
<button
className="ml-2 shrink-0 rounded-full p-1.5 text-gray-400 hover:bg-gray-200 hover:text-red-500 dark:hover:bg-gray-700"
onClick={(e) => handleDeleteDraft(e, draft.id)}
type="button"
>
<TrashIcon className="size-4" />
</button>
</button>
))}
</div>
</div>
);
};

export default memo(DraftList);
34 changes: 19 additions & 15 deletions packages/web/src/components/Composer/NewPost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,21 +29,24 @@ const NewPost = ({ group }: NewPostProps) => {
}

return (
<Card
className="cursor-pointer space-y-3 px-3 py-4 md:px-5"
onClick={handleOpenComposer}
>
<div className="flex items-center space-x-3">
<Image
alt={currentAccount?.address}
className="size-11 cursor-pointer rounded-full border border-gray-200 bg-gray-200 object-cover dark:border-gray-800"
height={44}
src={getAvatar(currentAccount)}
width={44}
/>
<span className="text-gray-500 dark:text-gray-200">What's new?</span>
</div>
</Card>
<>
<Card
className="cursor-pointer space-y-3 px-3 py-4 md:px-5"
onClick={handleOpenComposer}
>
<div className="flex items-center space-x-3">
<Image
alt={currentAccount?.address}
className="size-11 cursor-pointer rounded-full border border-gray-200 bg-gray-200 object-cover dark:border-gray-800"
height={44}
src={getAvatar(currentAccount)}
width={44}
/>
<span className="text-gray-500 dark:text-gray-200">What's new?</span>
</div>
</Card>
<DraftList group={group} />
</>
);
};

Expand Down
90 changes: 86 additions & 4 deletions packages/web/src/components/Composer/NewPublication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -71,20 +75,26 @@ 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();

// New post modal store
const { setShow: setShowNewPostModal } = useNewPostModalStore();

// Draft store
const { saveDraft, removeDraft } = useDraftStore();
const [draftId] = useState(() => initialDraftId || generateUUID());

// Post store
const {
postContent,
Expand All @@ -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 } =
Expand All @@ -116,7 +127,7 @@ const NewPublication = ({
usePostPollStore();

// License store
const { setLicense } = usePostLicenseStore();
const { license, setLicense } = usePostLicenseStore();

// Collect module store
const { collectAction, reset: resetCollectSettings } = useCollectActionStore(
Expand All @@ -133,7 +144,7 @@ const NewPublication = ({
setGroupGate,
setCollectorsOnly
} = usePostRulesStore();
const { setContentWarning } = usePostContentWarningStore();
const { contentWarning, setContentWarning } = usePostContentWarningStore();

// States
const [isSubmitting, setIsSubmitting] = useState(false);
Expand Down Expand Up @@ -185,6 +196,7 @@ const NewPublication = ({
};

const onCompleted = () => {
removeDraft(draftId);
reset();
};

Expand Down Expand Up @@ -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,
Expand Down
Loading