diff --git a/apps/web/src/app/decks/_components/deck-threads-form/index.tsx b/apps/web/src/app/decks/_components/deck-threads-form/index.tsx index 00e6de7f90..d35753c0e3 100644 --- a/apps/web/src/app/decks/_components/deck-threads-form/index.tsx +++ b/apps/web/src/app/decks/_components/deck-threads-form/index.tsx @@ -12,6 +12,7 @@ import { Alert } from "@ui/alert"; import { Entry } from "@/entities"; import { useGlobalStore } from "@/core/global-store"; import { PollsContext } from "@/features/polls"; +import { usePublishHandoffWriter } from "@/app/publish/_hooks"; import { PREFIX } from "@/utils/local-storage"; import i18next from "i18next"; import { AvailableCredits, UserAvatar } from "@/features/shared"; @@ -50,10 +51,7 @@ export const DeckThreadsForm = ({ const { setShow, create, createReply } = useContext(DeckThreadsFormContext); const { clearActivePoll } = useContext(PollsContext); - const [localDraft, setLocalDraft] = useLocalStorage>( - PREFIX + "_local_draft", - {} - ); + const stageForPublish = usePublishHandoffWriter(); const [threadHost, setThreadHost] = useLocalStorage(PREFIX + "_dtf_th", "ecency.waves"); const [text, setText, clearText] = useLocalStorage(PREFIX + "_dtf_t", ""); const [image, setImage, clearImage] = useLocalStorage(PREFIX + "_dtf_i"); @@ -109,12 +107,10 @@ export const DeckThreadsForm = ({ content = `${content}
${video}`; } - // Push to draft built content with attachments + // Too long for a thread, so hand the built content with its attachments + // to the publish composer instead. if (text!!.length > 250) { - setLocalDraft({ - ...localDraft, - body: content - }); + stageForPublish(content); window.open("/publish", "_blank"); return; } diff --git a/apps/web/src/app/publish/_hooks/index.ts b/apps/web/src/app/publish/_hooks/index.ts index 35da413c9b..5ce2b345e7 100644 --- a/apps/web/src/app/publish/_hooks/index.ts +++ b/apps/web/src/app/publish/_hooks/index.ts @@ -9,3 +9,4 @@ export * from "./use-publish-autosave"; export * from "./use-upload-tracker"; export * from "./use-upload-and-insert-images"; export * from "./use-draft-tab-coordinator"; +export * from "./use-publish-handoff"; diff --git a/apps/web/src/app/publish/_hooks/use-publish-handoff.ts b/apps/web/src/app/publish/_hooks/use-publish-handoff.ts new file mode 100644 index 0000000000..97eaa2f0a7 --- /dev/null +++ b/apps/web/src/app/publish/_hooks/use-publish-handoff.ts @@ -0,0 +1,56 @@ +import { PREFIX } from "@/utils/local-storage"; +import { useCallback } from "react"; +import useLocalStorage from "react-use/lib/useLocalStorage"; +import useMount from "react-use/lib/useMount"; + +/** + * One-shot channel for handing content to the publish composer from another + * surface, currently a wave or a deck thread that outgrew its character limit + * and has to become a post instead. + * + * It deliberately does not reuse the submit page's local draft key. That key + * holds a full PostBase which the submit page rewrites on every keystroke, so + * writing a partial object into it both corrupted that draft's shape (a draft + * with no title crashed the submit page in applyTitle) and risked eating a + * post the user had in progress there. + */ +export interface PublishHandoff { + body: string; +} + +export const PUBLISH_HANDOFF_KEY = PREFIX + "_pub_handoff"; + +/** + * Stage content for the composer. The caller is expected to open /publish + * right after, in this tab or a new one. + */ +export function usePublishHandoffWriter() { + const [, setHandoff] = useLocalStorage(PUBLISH_HANDOFF_KEY); + + return useCallback((body: string) => setHandoff({ body }), [setHandoff]); +} + +/** + * Consume anything staged for the composer, exactly once. The entry is dropped + * before it is handed over so that content which the editor cannot swallow + * fails a single time rather than on every subsequent visit. + */ +export function usePublishHandoff(onReceive: (body: string) => void) { + const [handoff, , removeHandoff] = useLocalStorage(PUBLISH_HANDOFF_KEY); + + useMount(() => { + const body = handoff?.body; + + // Dropped before anything else, so that an entry the composer cannot use, + // whether malformed or simply empty, is cleared rather than re-examined on + // every later visit, and content it chokes on fails once rather than on + // every one. + removeHandoff(); + + if (!body) { + return; + } + + onReceive(body); + }); +} diff --git a/apps/web/src/app/publish/_page.tsx b/apps/web/src/app/publish/_page.tsx index c22607f5f7..0188adbbd5 100644 --- a/apps/web/src/app/publish/_page.tsx +++ b/apps/web/src/app/publish/_page.tsx @@ -6,7 +6,12 @@ import { PublishValidatePost, PublishMultiTabWarning } from "@/app/publish/_components"; -import { usePublishAutosave, usePublishEditor, usePublishState } from "@/app/publish/_hooks"; +import { + usePublishAutosave, + usePublishEditor, + usePublishHandoff, + usePublishState +} from "@/app/publish/_hooks"; import type { ImportResult } from "@/app/publish/_components/publish-import-dialog"; import { isCommunity } from "@/utils"; import routes from "@/routes"; @@ -55,6 +60,20 @@ export default function Publish() { ); const appliedCommunityRef = useRef(null); + // A wave or deck thread that outgrew its character limit stages its content + // here and opens this page. setContent alone is not enough: the editor + // prefills itself from publish state only if it initialises after this runs, + // so seed both and let whichever lands second win with the same value. + usePublishHandoff( + useCallback( + (body: string) => { + setContent(body); + setEditorContent(body); + }, + [setContent, setEditorContent] + ) + ); + const { isActiveTab, lastSaved, draftId } = usePublishAutosave(); useEffect(() => { diff --git a/apps/web/src/features/waves/hooks/use-wave-submit.ts b/apps/web/src/features/waves/hooks/use-wave-submit.ts index 24879ed34f..57815f36da 100644 --- a/apps/web/src/features/waves/hooks/use-wave-submit.ts +++ b/apps/web/src/features/waves/hooks/use-wave-submit.ts @@ -4,8 +4,7 @@ import { Entry, WaveEntry } from "@/entities"; import { DecentMemesPayload } from "@/api/decentmemes"; import { useWaveCreate } from "@/features/waves/components/wave-form/api"; import { useWaveCreateReply } from "@/features/waves/components/wave-form/api/use-wave-create-reply"; -import { useLocalStorage } from "react-use"; -import { PREFIX } from "@/utils/local-storage"; +import { usePublishHandoffWriter } from "@/app/publish/_hooks"; import { useGlobalStore } from "@/core/global-store"; import { useMutation } from "@tanstack/react-query"; import { error, success } from "@/features/shared"; @@ -37,10 +36,7 @@ export function useWaveSubmit( const { mutateAsync: create } = useWaveCreate(); const { mutateAsync: createReply } = useWaveCreateReply(); - const [localDraft, setLocalDraft] = useLocalStorage>( - PREFIX + "_local_draft", - {} - ); + const stageForPublish = usePublishHandoffWriter(); return useMutation({ mutationKey: ["wave-form-submit", username, replySource, editingEntry], @@ -87,12 +83,10 @@ export function useWaveSubmit( content = `${content}
${video}`; } - // Push to draft built content with attachments + // Too long for a wave, so hand the built content with its attachments to + // the publish composer instead. if (!isReply && textLength > characterLimit) { - setLocalDraft({ - ...localDraft, - body: content - }); + stageForPublish(content); window.open("/publish", "_blank"); return; } diff --git a/apps/web/src/specs/app/publish/publish-handoff.spec.ts b/apps/web/src/specs/app/publish/publish-handoff.spec.ts new file mode 100644 index 0000000000..a32f62966c --- /dev/null +++ b/apps/web/src/specs/app/publish/publish-handoff.spec.ts @@ -0,0 +1,95 @@ +import { + PUBLISH_HANDOFF_KEY, + usePublishHandoff, + usePublishHandoffWriter +} from "@/app/publish/_hooks/use-publish-handoff"; +import { PREFIX } from "@/utils/local-storage"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const LOCAL_DRAFT_KEY = PREFIX + "_local_draft"; + +// A wave or deck thread over its character limit stages its content and opens +// /publish. That handoff used to be written into the submit page's local draft +// key, which nothing on /publish reads, so the content was dropped, and the +// partial write left that key holding a draft with no title that crashed +// /submit on its next visit (ECENCY-NEXT-1GJC). +describe("publish handoff", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("stages content under its own key and leaves the submit draft alone", () => { + const draft = { title: "in progress", tags: ["hive"], body: "a body", description: null }; + localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify(draft)); + + const { result } = renderHook(() => usePublishHandoffWriter()); + act(() => result.current("an overflowing wave")); + + expect(JSON.parse(localStorage.getItem(PUBLISH_HANDOFF_KEY)!)).toEqual({ + body: "an overflowing wave" + }); + expect(JSON.parse(localStorage.getItem(LOCAL_DRAFT_KEY)!)).toEqual(draft); + }); + + it("delivers staged content to the composer", () => { + localStorage.setItem(PUBLISH_HANDOFF_KEY, JSON.stringify({ body: "an overflowing wave" })); + + const onReceive = vi.fn(); + renderHook(() => usePublishHandoff(onReceive)); + + expect(onReceive).toHaveBeenCalledWith("an overflowing wave"); + }); + + it("delivers once, so a later visit opens an empty composer", () => { + localStorage.setItem(PUBLISH_HANDOFF_KEY, JSON.stringify({ body: "an overflowing wave" })); + + const first = vi.fn(); + renderHook(() => usePublishHandoff(first)).unmount(); + expect(first).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(PUBLISH_HANDOFF_KEY)).toBeNull(); + + const second = vi.fn(); + renderHook(() => usePublishHandoff(second)); + expect(second).not.toHaveBeenCalled(); + }); + + it("does nothing when nothing is staged", () => { + const onReceive = vi.fn(); + renderHook(() => usePublishHandoff(onReceive)); + + expect(onReceive).not.toHaveBeenCalled(); + }); + + it("drops a staged entry with an empty body instead of leaving it behind", () => { + localStorage.setItem(PUBLISH_HANDOFF_KEY, JSON.stringify({ body: "" })); + + const onReceive = vi.fn(); + renderHook(() => usePublishHandoff(onReceive)); + + expect(onReceive).not.toHaveBeenCalled(); + // Nothing to deliver is still something to clean up, otherwise the entry is + // re-examined on every later visit and never consumed. + expect(localStorage.getItem(PUBLISH_HANDOFF_KEY)).toBeNull(); + }); + + it("drops a staged entry it cannot read a body from", () => { + localStorage.setItem(PUBLISH_HANDOFF_KEY, JSON.stringify({ note: "not a handoff" })); + + const onReceive = vi.fn(); + renderHook(() => usePublishHandoff(onReceive)); + + expect(onReceive).not.toHaveBeenCalled(); + expect(localStorage.getItem(PUBLISH_HANDOFF_KEY)).toBeNull(); + }); + + it("round-trips from writer to composer", () => { + const writer = renderHook(() => usePublishHandoffWriter()); + act(() => writer.result.current("wave text
![](https://images.ecency.com/x.png)")); + + const onReceive = vi.fn(); + renderHook(() => usePublishHandoff(onReceive)); + + expect(onReceive).toHaveBeenCalledWith("wave text
![](https://images.ecency.com/x.png)"); + }); +});