Keep a guest community logo on review and on the published rule.
Sign-in was clearing the staged file before it uploaded, and review only read the server URL, so a chosen logo disappeared after the upload step.
This commit is contained in:
1 parent
2348a9e94f
commit
8212423ec3
19 files changed
+780
-61
No files matched your search
@@ -16,6 +16,7 @@ import messages from "../../../messages/en/index";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
|
||||
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||
import { flushPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar";
|
||||
|
||||
function buildPayloadWithStep(
|
||||
base: CreateFlowState,
|
||||
@@ -111,8 +112,15 @@ export function PostLoginDraftTransfer({
|
||||
return;
|
||||
}
|
||||
|
||||
const flushed = await flushPendingCommunityAvatar();
|
||||
if (cancelled) return;
|
||||
const transferred =
|
||||
flushed.status === "uploaded"
|
||||
? { ...payload, communityAvatarUrl: flushed.url }
|
||||
: payload;
|
||||
|
||||
if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
|
||||
const saveResult = await saveDraftToServer(payload);
|
||||
const saveResult = await saveDraftToServer(transferred);
|
||||
if (cancelled) return;
|
||||
|
||||
if (saveResult.ok === false) {
|
||||
@@ -127,8 +135,23 @@ export function PostLoginDraftTransfer({
|
||||
}
|
||||
}
|
||||
|
||||
clearAnonymousCreateFlowStorage();
|
||||
replaceState(payload);
|
||||
clearAnonymousCreateFlowStorage({
|
||||
preservePendingAvatar: flushed.status === "failed",
|
||||
});
|
||||
replaceState((prev) => {
|
||||
const fromPayload =
|
||||
typeof transferred.communityAvatarUrl === "string"
|
||||
? transferred.communityAvatarUrl.trim()
|
||||
: "";
|
||||
const fromPrev =
|
||||
typeof prev.communityAvatarUrl === "string"
|
||||
? prev.communityAvatarUrl.trim()
|
||||
: "";
|
||||
const communityAvatarUrl = fromPayload || fromPrev;
|
||||
return communityAvatarUrl
|
||||
? { ...transferred, communityAvatarUrl }
|
||||
: transferred;
|
||||
});
|
||||
|
||||
if (pathname) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import {
|
||||
CreateFlowUploadValidationError,
|
||||
uploadCreateFlowFile,
|
||||
} from "../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
} from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
import { flushPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
|
||||
|
||||
/** Background tries after a transient upload failure. The file stays staged. */
|
||||
const MAX_FLUSH_ATTEMPTS = 4;
|
||||
|
||||
/**
|
||||
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
|
||||
* and writes `communityAvatarUrl` on success.
|
||||
* and writes `communityAvatarUrl` on success. A transient failure retries with
|
||||
* backoff while this layout stays mounted.
|
||||
*/
|
||||
export function CreateFlowPendingAvatarFlush({
|
||||
sessionUser,
|
||||
@@ -23,7 +20,8 @@ export function CreateFlowPendingAvatarFlush({
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const { state, updateState } = useCreateFlow();
|
||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
/** One successful flush per signed-in user id (survives the effect re-running). */
|
||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||
const hasServerAvatar =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
@@ -33,29 +31,31 @@ export function CreateFlowPendingAvatarFlush({
|
||||
if (!sessionResolved || !sessionUser) return;
|
||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||
if (hasServerAvatar) return;
|
||||
if (attempt >= MAX_FLUSH_ATTEMPTS) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
void (async () => {
|
||||
const file = await readPendingCommunityAvatarFile();
|
||||
if (cancelled || !file) return;
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
if (cancelled) return;
|
||||
await clearPendingCommunityAvatarFile();
|
||||
updateState({ communityAvatarUrl: url });
|
||||
const result = await flushPendingCommunityAvatar();
|
||||
if (cancelled) return;
|
||||
if (result.status === "uploaded") {
|
||||
updateState({ communityAvatarUrl: result.url });
|
||||
lastFlushedUserIdRef.current = sessionUser.id;
|
||||
} catch (err) {
|
||||
if (err instanceof CreateFlowUploadValidationError) {
|
||||
await clearPendingCommunityAvatarFile();
|
||||
}
|
||||
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
|
||||
return;
|
||||
}
|
||||
if (result.status !== "failed") return;
|
||||
if (attempt + 1 >= MAX_FLUSH_ATTEMPTS) return;
|
||||
const delay = Math.min(20_000, 5_000 * 2 ** attempt);
|
||||
timer = setTimeout(() => {
|
||||
if (!cancelled) setAttempt((current) => current + 1);
|
||||
}, delay);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
|
||||
}, [attempt, hasServerAvatar, sessionResolved, sessionUser, updateState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { readPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
function trimmedAvatarUrl(url: string | null): string | null {
|
||||
if (typeof url !== "string") return null;
|
||||
const trimmed = url.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo src for review cards. Uses the uploaded URL when the draft has one,
|
||||
* otherwise the guest file staged in IndexedDB.
|
||||
*
|
||||
* @param serverUrl `communityAvatarUrl` from create-flow state, if any.
|
||||
* @returns A URL the Rule card can render, or `null` when there is no logo.
|
||||
*
|
||||
* @example
|
||||
* const logoUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl ?? null);
|
||||
*/
|
||||
export function useCommunityAvatarDisplayUrl(
|
||||
serverUrl: string | null | undefined,
|
||||
): string | null {
|
||||
const resolvedServerUrl = trimmedAvatarUrl(serverUrl ?? null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedServerUrl) {
|
||||
setObjectUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let created: string | null = null;
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await readPendingCommunityAvatarFile();
|
||||
if (cancelled || !file) return;
|
||||
created = URL.createObjectURL(file);
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(created);
|
||||
created = null;
|
||||
return;
|
||||
}
|
||||
setObjectUrl((prev) => {
|
||||
if (prev && prev !== created) URL.revokeObjectURL(prev);
|
||||
return created;
|
||||
});
|
||||
} catch {
|
||||
// IndexedDB missing or blocked: the card keeps the placeholder.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [resolvedServerUrl]);
|
||||
|
||||
return resolvedServerUrl ?? objectUrl;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
|
||||
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
|
||||
@@ -47,12 +48,18 @@ export function useCreateFlowExit({
|
||||
}
|
||||
|
||||
if (saveDraft && isBackendSyncEnabled()) {
|
||||
const prepared = await stateWithPendingCommunityAvatar(state);
|
||||
if (prepared.ok === false) {
|
||||
setDraftSaveBannerMessage?.(messages.create.upload.errors.generic);
|
||||
return;
|
||||
}
|
||||
const draftState = prepared.state;
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
? state.editingPublishedRuleId.trim()
|
||||
typeof draftState.editingPublishedRuleId === "string"
|
||||
? draftState.editingPublishedRuleId.trim()
|
||||
: "";
|
||||
if (editingId.length > 0) {
|
||||
const payloadResult = buildPublishPayload(state);
|
||||
const payloadResult = buildPublishPayload(draftState);
|
||||
if (payloadResult.ok === false) {
|
||||
setDraftSaveBannerMessage?.(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
@@ -82,7 +89,7 @@ export function useCreateFlowExit({
|
||||
}
|
||||
} else {
|
||||
const payload: CreateFlowState = {
|
||||
...state,
|
||||
...draftState,
|
||||
...(currentStep ? { currentStep } : {}),
|
||||
};
|
||||
const result = await saveDraftToServer(payload);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
|
||||
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
|
||||
@@ -63,8 +64,19 @@ export function useCreateFlowFinalize({
|
||||
if (sessionUser === undefined) return;
|
||||
|
||||
setPublishBannerMessage(null);
|
||||
const payloadResult = buildPublishPayload(state);
|
||||
setIsPublishing(true);
|
||||
const prepared = await stateWithPendingCommunityAvatar(state);
|
||||
if (prepared.ok === false) {
|
||||
setIsPublishing(false);
|
||||
setPublishBannerMessage(messages.create.upload.errors.generic);
|
||||
return;
|
||||
}
|
||||
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
|
||||
updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
|
||||
}
|
||||
const payloadResult = buildPublishPayload(prepared.state);
|
||||
if (payloadResult.ok === false) {
|
||||
setIsPublishing(false);
|
||||
setPublishBannerMessage(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.reviewAndComplete.publish.missingCommunityName
|
||||
@@ -73,7 +85,6 @@ export function useCreateFlowFinalize({
|
||||
return;
|
||||
}
|
||||
const { title, summary, document: ruleDocument } = payloadResult;
|
||||
setIsPublishing(true);
|
||||
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
|
||||
@@ -8,6 +8,7 @@ import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
|
||||
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
@@ -148,11 +149,7 @@ export function CommunityReviewScreen() {
|
||||
? state.communityContext.trim()
|
||||
: undefined;
|
||||
|
||||
const avatarUrl =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: null;
|
||||
const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
|
||||
|
||||
const structure = messages.create.community.communityStructure;
|
||||
const sizes = messages.create.community.communitySize.communitySizes;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "../../components/FinalReviewChipEditModal";
|
||||
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
||||
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
|
||||
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
|
||||
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
|
||||
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
|
||||
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
|
||||
@@ -251,11 +252,7 @@ export function FinalReviewScreen({
|
||||
const descriptionEmptyHint =
|
||||
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
|
||||
|
||||
const avatarUrl =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: null;
|
||||
const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
|
||||
const storedFallback =
|
||||
typeof state.previewSurfaceColor === "string"
|
||||
? state.previewSurfaceColor
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CreateFlowState } from "../types";
|
||||
import { forgetFlushedCommunityAvatarUrl } from "../../../../lib/create/flushPendingCommunityAvatar";
|
||||
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||
import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
@@ -47,7 +48,10 @@ export function writeAnonymousCreateFlowState(value: CreateFlowState): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAnonymousCreateFlowStorage(): void {
|
||||
export function clearAnonymousCreateFlowStorage(options?: {
|
||||
/** Keep the staged logo so a failed upload can retry. */
|
||||
preservePendingAvatar?: boolean;
|
||||
}): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY);
|
||||
@@ -55,6 +59,8 @@ export function clearAnonymousCreateFlowStorage(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (options?.preservePendingAvatar) return;
|
||||
forgetFlushedCommunityAvatarUrl();
|
||||
void clearPendingCommunityAvatarFile();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user