diff --git a/apps/web/src/app/publish/_api/use-publish.ts b/apps/web/src/app/publish/_api/use-publish.ts index b77260ff9d..2675d00999 100644 --- a/apps/web/src/app/publish/_api/use-publish.ts +++ b/apps/web/src/app/publish/_api/use-publish.ts @@ -1,36 +1,22 @@ import { validatePostCreating } from "@ecency/sdk"; -import { enforceThreeSpeakBeneficiary } from "@/api/threespeak-embed"; import { linkThreeSpeakEmbed } from "@/api/threespeak-embed/link-after-broadcast"; -import { - collectPresentMemeAttribution, - DECENTMEMES_FRONTEND, - enforceDecentMemesBeneficiary, - ensureDecentMemesTag -} from "@/api/decentmemes"; import { useCommentMutation, useReblogMutation } from "@/api/sdk-mutations"; import { EcencyEntriesCacheManagement } from "@/core/caches"; import { getQueryClient } from "@/core/react-query"; import { getAccountFullQueryOptions, getPostHeaderQueryOptions } from "@ecency/sdk"; -import { FullAccount, RewardType } from "@/entities"; -import { EntryBodyManagement, EntryMetadataManagement } from "@/features/entry-management"; +import { FullAccount } from "@/entities"; import { PollSnapshot } from "@/features/polls"; import { QueryKeys, type Poll } from "@ecency/sdk"; import { info, success } from "@/features/shared"; -import { - createPermlink, - isCommunity, - makeCommentOptions, - tempEntry -} from "@/utils"; +import { createPermlink, isCommunity, tempEntry } from "@/utils"; import { scheduleQuestsRefresh } from "@/utils/refresh-quests"; -import { postBodySummary } from "@ecency/render-helper"; import { EcencyAnalytics } from "@ecency/sdk"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import i18next from "i18next"; import { usePublishState } from "../_hooks"; import { sentry } from "@/core/sentry/lazy-sentry"; -import { SUBMIT_DESCRIPTION_MAX_LENGTH } from "@/app/submit/_consts"; import { useActiveAccount } from "@/core/hooks"; +import { buildPublishOperation } from "../_utils/build-publish-operation"; export function usePublishApi() { const queryClient = useQueryClient(); @@ -63,14 +49,6 @@ export function usePublishApi() { return useMutation({ mutationKey: ["publish-2.0"], mutationFn: async () => { - let cleanBody = EntryBodyManagement.EntryBodyManager.shared - .builder() - .buildClearBody(content!); - - cleanBody = EntryBodyManagement.EntryBodyManager.shared - .builder() - .withLocation(cleanBody, location); - // Ensure user is logged in and account data is available if (!username) { throw new Error("[Publish] No active user"); @@ -123,54 +101,31 @@ export function usePublishApi() { throw new Error("[Publish] Failed to generate unique permlink after multiple attempts"); } - const [parentPermlink] = tags!; - - // DecentMemes: reconcile tracked memes against the final body so a meme - // whose image was deleted no longer contributes a tag / metadata / beneficiary. - const memeAttribution = collectPresentMemeAttribution(decentMemes, cleanBody); - const hasMeme = memeAttribution.templateIds.length > 0; - const finalTags = hasMeme ? ensureDecentMemesTag(tags ?? []) : tags; - - const metaBuilder = await EntryMetadataManagement.EntryMetadataManager.shared - .builder() - .default() - .extractFromBody(content!) - // It should select filled description or if its empty or null/undefined then get auto summary - .withSummary( - metaDescription || postBodySummary(cleanBody, SUBMIT_DESCRIPTION_MAX_LENGTH) - ) - .withTags(finalTags) - .withPostLinks(postLinks) - .withLocation(location) - .withSelectedThumbnail(selectedThumbnail); - const jsonMeta = metaBuilder - .withPoll(poll) - .withDecentMemes( - hasMeme - ? { templateIds: memeAttribution.templateIds, frontend: DECENTMEMES_FRONTEND } - : undefined - ) - .withAiTools(aiTools) - .build(); + // Same assembly the RC pre-check prices, so the estimate the author saw + // describes the transaction actually broadcast here. + const { op, jsonMetadata, summary, options, beneficiariesDropped } = await buildPublishOperation({ + author, + permlink, + title: title!, + content: content!, + tags: tags!, + metaDescription, + selectedThumbnail, + reward, + beneficiaries, + poll, + postLinks, + location, + decentMemes, + aiTools + }); + const parentPermlink = op.parent_permlink; + const cleanBody = op.body; - let finalBeneficiaries = enforceThreeSpeakBeneficiary(beneficiaries, cleanBody); - if (hasMeme) { - // Merge the widget-supplied meme beneficiaries on our own terms: never - // trust its numbers - cap to Hive's 8-slot / 100% limits and keep the - // user's own beneficiaries intact. - const enforced = enforceDecentMemesBeneficiary( - finalBeneficiaries, - memeAttribution.beneficiaries, - author - ); - finalBeneficiaries = enforced.beneficiaries; - if (enforced.dropped) { - info(i18next.t("decentmemes.beneficiaries-trimmed")); - } + if (beneficiariesDropped) { + info(i18next.t("decentmemes.beneficiaries-trimmed")); } - const options = makeCommentOptions(author, permlink, reward as RewardType, finalBeneficiaries); - await commentMutation({ author, permlink, @@ -178,7 +133,7 @@ export function usePublishApi() { parentPermlink, title: title!, body: cleanBody, - jsonMetadata: jsonMeta, + jsonMetadata, ...(options ? { options: { @@ -204,9 +159,8 @@ export function usePublishApi() { body: content!, tags: tags!, - description: - metaDescription || postBodySummary(cleanBody, SUBMIT_DESCRIPTION_MAX_LENGTH), - jsonMeta + description: summary, + jsonMeta: jsonMetadata }), max_accepted_payout: options?.max_accepted_payout ?? "1000000.000 HBD", percent_hbd: options?.percent_hbd ?? 10000 diff --git a/apps/web/src/app/publish/_components/publish-validate-post.tsx b/apps/web/src/app/publish/_components/publish-validate-post.tsx index 634380fb68..20a436da6b 100644 --- a/apps/web/src/app/publish/_components/publish-validate-post.tsx +++ b/apps/web/src/app/publish/_components/publish-validate-post.tsx @@ -18,12 +18,15 @@ import { PublishActionBarCommunity } from "./publish-action-bar-community"; import { PublishScheduleDialog } from "./publish-schedule-dialog"; import { PublishValidatePostMeta } from "./publish-validate-post-meta"; import { PublishValidatePostThumbnailPicker } from "./publish-validate-post-thumbnail-picker"; -import { isCommunity } from "@/utils"; +import { createPermlink, isCommunity } from "@/utils"; +import { useQuery } from "@tanstack/react-query"; +import { buildPublishOperation } from "../_utils/build-publish-operation"; import { wordOverlapSimilarity } from "@/utils/text-similarity"; import { hasPublishContent } from "../_utils/content"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import { AvailableCredits } from "@/features/shared"; import { RcPrecheckBanner } from "@/features/shared/rc-precheck"; +import type { RcPrecheckPayload } from "@ecency/sdk"; import { canFitBeneficiary, isSupportEcencyRow, @@ -53,7 +56,14 @@ export function PublishValidatePost({ onClose, onSuccess }: Props) { beneficiaries, setBeneficiaries, title, - appliedTemplateBody + appliedTemplateBody, + reward, + selectedThumbnail, + poll, + postLinks, + location, + decentMemes, + aiTools } = usePublishState(); const { activeUser } = useActiveAccount(); @@ -110,6 +120,68 @@ export function PublishValidatePost({ onClose, onSuccess }: Props) { ); }, [activeUser?.username]); + // The operation this publish will actually broadcast, assembled by the same + // builder usePublishApi uses. Pricing the raw editor draft instead would + // understate a post carrying a summary, images, links, a poll or + // beneficiaries, since cost tracks serialized size, and understating is the + // one direction that lets the chain reject a post we called affordable. + const { data: publishOperation } = useQuery({ + queryKey: [ + "publish-rc-operation", + activeUser?.username, + title, + content, + tags, + metaDescription, + selectedThumbnail, + reward, + beneficiaries, + poll, + postLinks, + location, + decentMemes, + aiTools + ], + enabled: !!activeUser?.username && !!content, + // Pure function of the draft above, so a given key never needs recomputing. + staleTime: Infinity, + queryFn: () => + buildPublishOperation({ + author: activeUser!.username, + // The real permlink is settled at broadcast time after the collision + // check; only its length feeds the estimate, so use a same-shape one. + permlink: createPermlink(title ?? ""), + title: title ?? "", + content: content ?? "", + tags: tags ?? [], + metaDescription, + selectedThumbnail, + reward, + beneficiaries, + poll, + postLinks, + location, + decentMemes, + aiTools + }) + }); + + const rcPayload = useMemo( + () => + publishOperation + ? { + kind: "comment", + op: publishOperation.op, + options: publishOperation.options + ? { + beneficiaries: publishOperation.options.extensions?.[0]?.[1]?.beneficiaries + } + : undefined + } + : undefined, + [publishOperation] + ); + const submit = useCallback(async () => { if (!title?.trim()) { feedbackError(i18next.t("submit.empty-title-alert")); @@ -313,8 +385,12 @@ export function PublishValidatePost({ onClose, onSuccess }: Props) { {activeUser?.username && (
- - + +
)} diff --git a/apps/web/src/app/publish/_utils/build-publish-operation.ts b/apps/web/src/app/publish/_utils/build-publish-operation.ts new file mode 100644 index 0000000000..24e88fc585 --- /dev/null +++ b/apps/web/src/app/publish/_utils/build-publish-operation.ts @@ -0,0 +1,164 @@ +import { enforceThreeSpeakBeneficiary } from "@/api/threespeak-embed"; +import { + collectPresentMemeAttribution, + DECENTMEMES_FRONTEND, + enforceDecentMemesBeneficiary, + ensureDecentMemesTag +} from "@/api/decentmemes"; +import { SUBMIT_DESCRIPTION_MAX_LENGTH } from "@/app/submit/_consts"; +import { BeneficiaryRoute, Entry, RewardType } from "@/entities"; +import { EntryBodyManagement, EntryMetadataManagement } from "@/features/entry-management"; +import { PollSnapshot } from "@/features/polls"; +import { makeCommentOptions } from "@/utils"; +import { postBodySummary } from "@ecency/render-helper"; + +export interface PublishDraft { + author: string; + permlink: string; + title: string; + content: string; + tags: string[]; + metaDescription?: string | null; + selectedThumbnail?: string; + reward?: RewardType | string; + beneficiaries?: BeneficiaryRoute[]; + poll?: PollSnapshot; + postLinks?: Entry[]; + location?: any; + decentMemes?: any; + aiTools?: any; +} + +export interface PublishOperation { + /** The comment operation exactly as it will be broadcast. */ + op: { + author: string; + permlink: string; + parent_author: string; + parent_permlink: string; + title: string; + body: string; + json_metadata: string; + }; + /** The metadata object, before serialization, for the broadcast payload. */ + jsonMetadata: Record; + /** + * The description fed to the metadata builder, before it truncates further. + * Returned so the caller's cached entry keeps the length it always had. + */ + summary: string; + /** The comment_options operation, when this post needs one. */ + options: ReturnType; + /** Beneficiaries after the ThreeSpeak/DecentMemes rules were applied. */ + beneficiaries: BeneficiaryRoute[]; + /** Meme beneficiaries had to be trimmed to fit Hive's limits. */ + beneficiariesDropped: boolean; +} + +/** + * Assembles the operation a publish broadcasts: cleaned body, full metadata, + * enforced beneficiaries and comment options. + * + * Shared with the RC pre-check on purpose. Cost is driven by serialized size, + * so an estimate built from the raw editor draft, or from tags-only metadata, + * underprices a post carrying a summary, images, links, a poll or beneficiaries + * and can call a post affordable that the chain then rejects. The only way that + * stays true as publishing grows features is for both paths to build the + * operation here. + * + * Side effects belong to the caller: nothing here informs the user or touches + * the network, so the pre-check can run it while the author is still typing. + * The permlink is passed in because the real one is only settled at broadcast + * time, after the collision check. + */ +export async function buildPublishOperation({ + author, + permlink, + title, + content, + tags, + metaDescription, + selectedThumbnail, + reward, + beneficiaries, + poll, + postLinks, + location, + decentMemes, + aiTools +}: PublishDraft): Promise { + let cleanBody = EntryBodyManagement.EntryBodyManager.shared.builder().buildClearBody(content); + + cleanBody = EntryBodyManagement.EntryBodyManager.shared + .builder() + .withLocation(cleanBody, location); + + // DecentMemes: reconcile tracked memes against the final body so a meme + // whose image was deleted no longer contributes a tag / metadata / beneficiary. + const memeAttribution = collectPresentMemeAttribution(decentMemes, cleanBody); + const hasMeme = memeAttribution.templateIds.length > 0; + const finalTags = hasMeme ? ensureDecentMemesTag(tags ?? []) : tags; + + // It should select filled description or if its empty or null/undefined then get auto summary + const summary = metaDescription || postBodySummary(cleanBody, SUBMIT_DESCRIPTION_MAX_LENGTH); + + const metaBuilder = await EntryMetadataManagement.EntryMetadataManager.shared + .builder() + .default() + .extractFromBody(content) + .withSummary(summary) + .withTags(finalTags) + .withPostLinks(postLinks) + .withLocation(location) + .withSelectedThumbnail(selectedThumbnail); + const jsonMeta = metaBuilder + .withPoll(poll) + .withDecentMemes( + hasMeme + ? { templateIds: memeAttribution.templateIds, frontend: DECENTMEMES_FRONTEND } + : undefined + ) + .withAiTools(aiTools) + .build(); + + let finalBeneficiaries = enforceThreeSpeakBeneficiary(beneficiaries ?? [], cleanBody); + let beneficiariesDropped = false; + if (hasMeme) { + // Merge the widget-supplied meme beneficiaries on our own terms: never + // trust its numbers - cap to Hive's 8-slot / 100% limits and keep the + // user's own beneficiaries intact. + const enforced = enforceDecentMemesBeneficiary( + finalBeneficiaries, + memeAttribution.beneficiaries, + author + ); + finalBeneficiaries = enforced.beneficiaries; + beneficiariesDropped = enforced.dropped; + } + + const options = makeCommentOptions( + author, + permlink, + reward as RewardType, + finalBeneficiaries + ); + + return { + op: { + author, + permlink, + parent_author: "", + // The broadcast reads the first tag as posted, before the DecentMemes + // tag is folded in, so this must too. + parent_permlink: tags?.[0] ?? "", + title, + body: cleanBody, + json_metadata: JSON.stringify(jsonMeta) + }, + jsonMetadata: jsonMeta, + summary, + options, + beneficiaries: finalBeneficiaries, + beneficiariesDropped + }; +} diff --git a/apps/web/src/features/shared/available-credits/index.tsx b/apps/web/src/features/shared/available-credits/index.tsx index 721852f025..3aed76503e 100644 --- a/apps/web/src/features/shared/available-credits/index.tsx +++ b/apps/web/src/features/shared/available-credits/index.tsx @@ -1,9 +1,15 @@ "use client"; -import { calculateRCMana, powerRechargeTime, rcPower } from "@ecency/sdk"; +import { + calculateRCMana, + estimateRcPrecheck, + getRcResourceParamsQueryOptions, + powerRechargeTime, + rcPower, + type RcPrecheckPayload +} from "@ecency/sdk"; import { RcOperation } from "@/entities"; import { rcFormatter } from "@/utils"; -import { rcAffordableOperations } from "@/utils/rc-affordable-operations"; import { useMounted } from "@/utils/use-mounted"; import { getAccountRcQueryOptions, getRcStatsQueryOptions } from "@ecency/sdk"; import { flip, shift, useFloating } from "@floating-ui/react-dom"; @@ -20,10 +26,16 @@ import "./index.scss"; interface Props { username: string; operation: RcOperation; + /** + * What the user is about to broadcast, when the surface knows. Supplying it + * makes the "posts possible" count describe THIS post instead of an average + * one, and keeps it consistent with the pre-check warning beside it. + */ + payload?: RcPrecheckPayload; className?: string; } -export const AvailableCredits = ({ username, className }: Props) => { +export const AvailableCredits = ({ username, payload, className }: Props) => { const [rcpFixed, setRcpFixed] = useState(0); const [rcp, setRcp] = useState(0); const [rcpRechargeDate, setRcpRechargeDate] = useState(dayjs()); @@ -32,12 +44,14 @@ export const AvailableCredits = ({ username, className }: Props) => { const [commentAmount, setCommentAmount] = useState(0); const [voteAmount, setVoteAmount] = useState(0); const [transferAmount, setTransferAmount] = useState(0); + const [countsReady, setCountsReady] = useState(false); const [showPurchaseDialog, setShowPurchaseDialog] = useState(false); const [isShow, setIsShow] = useState(false); const { data: rcValues } = useQuery(getAccountRcQueryOptions(username)); const { data: rcStats } = useQuery(getRcStatsQueryOptions()); + const { data: rcParams } = useQuery(getRcResourceParamsQueryOptions()); const { refs, floatingStyles, update } = useFloating({ whileElementsMounted: safeAutoUpdate, @@ -68,17 +82,27 @@ export const AvailableCredits = ({ username, className }: Props) => { const formatIncoming = rcFormatter(inComing); setReceivedDelegation(formatIncoming); - const operationCosts = rcStats.ops; - const commentCost = operationCosts.comment_operation.avg_cost; - const transferCost = operationCosts.transfer_operation.avg_cost; - const voteCost = operationCosts.vote_operation.avg_cost; - - const availableResourceCredit: any = rcValues.map((a: any) => a.rc_manabar.current_mana); - const mana = Number(availableResourceCredit[0]); - setCommentAmount(rcAffordableOperations(mana, commentCost)); - setVoteAmount(rcAffordableOperations(mana, voteCost)); - setTransferAmount(rcAffordableOperations(mana, transferCost)); - }, [rcStats, rcValues]); + // Same estimator the pre-check warning uses, so the two cannot contradict + // each other. With a payload the post count describes THIS post; without + // one it falls back to the network average, which is the honest answer to + // "how many posts can I afford" when no post is in hand. + const counts = (["comment_operation", "vote_operation", "transfer_operation"] as const).map( + (op) => + estimateRcPrecheck({ + rcAccount: account, + rcStats, + rcParams, + operation: op, + payload: op === "comment_operation" ? payload : undefined, + fallback: "average" + }) + ); + const [comment, vote, transfer] = counts; + setCountsReady(comment.ready); + setCommentAmount(comment.ready ? comment.remaining : 0); + setVoteAmount(vote.ready ? vote.remaining : 0); + setTransferAmount(transfer.ready ? transfer.remaining : 0); + }, [rcStats, rcValues, rcParams, payload]); const show = () => setIsShow(true); const hide = () => setIsShow(false); @@ -116,7 +140,9 @@ export const AvailableCredits = ({ username, className }: Props) => { /> - {commentAmount <= 5 ? ( + {/* Only once the estimate is in: a count of 0 while the curve + parameters load is not a reason to tell someone to buy RC. */} + {countsReady && commentAmount <= 5 ? (