Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -3312,6 +3312,7 @@
"all-done-subtitle": "You're all set. Keep the waves coming!"
},
"sources": "Sources",
"short-content-hint": "Too short to earn points ({{n}} characters or fewer, links excluded)",
"reply-form-title": "Replying to",
"hide-replies": "Hide replies",
"no-replies": "There aren't replies yet. Be first!",
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/features/shared/comment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ import { useActiveAccount } from "@/core/hooks/use-active-account";
import { useIsMobile } from "@/features/ui/util/use-is-mobile";
import { useQuery } from "@tanstack/react-query";
import {
earnsQuestContentCredit,
getCommunityContextQueryOptions,
getCommunityPermissions,
getCommunityType,
QUEST_MIN_CONTENT_LENGTH
} from "@ecency/sdk";
import { isCommunity } from "@/utils";
import { shouldShowShortContentHint } from "@/utils/short-content-hint";
import { EntryPageContext } from "@/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/context";
import { RcPrecheckBanner } from "@/features/shared/rc-precheck";

Expand Down Expand Up @@ -262,8 +262,11 @@ export function Comment({
// The backend drops a reply this short without telling anyone, so the user posts,
// watches the quest counter stay put and reports it as a bug. An edit never earns
// either (the original already claimed the reward), so there is nothing to say there.
const showShortReplyHint =
!!activeUser && !isEdit && !!text?.trim() && !earnsQuestContentCredit(text);
const showShortReplyHint = shouldShowShortContentHint({
username: activeUser?.username,
isEditing: isEdit,
text
});

return (
<>
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/features/waves/components/wave-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { useIsMobile } from "@/features/ui/util/use-is-mobile";
import { WaveFormToolbar } from "@/features/waves/components/wave-form/wave-form-toolbar";
import { useWaveSubmit } from "@/features/waves";
import axios from "axios";
import { uploadImage } from "@ecency/sdk";
import { QUEST_MIN_CONTENT_LENGTH, uploadImage } from "@ecency/sdk";
import { shouldShowShortContentHint } from "@/utils/short-content-hint";
import { ensureValidToken } from "@/utils";
import { error } from "@/features/shared";
import { useRouter, useSearchParams } from "next/navigation";
Expand Down Expand Up @@ -84,6 +85,16 @@ const WaveFormComponent = ({
const textLength = text?.length ?? 0;
const exceedsCharacterLimit = textLength > characterLimit;

// A wave is a comment on the chain, so the points backend applies the same minimum
// length to it as to a reply, silently. Waves are short by design, which is exactly why
// it bites here. Not gated on `isReply`: a reply to a wave is a comment too and earns
// on the same rule.
const showShortContentHint = shouldShowShortContentHint({
username: activeUsername,
isEditing: !!entry,
text
});

const poll = useEntryPollExtractor(entry);
useEffect(() => {
if (poll) {
Expand Down Expand Up @@ -341,6 +352,12 @@ const WaveFormComponent = ({

<QuestStreakChip className="mb-1.5" />

{showShortContentHint && (
<div className="mb-1.5 text-xs opacity-60" role="status">
{i18next.t("waves.short-content-hint", { n: QUEST_MIN_CONTENT_LENGTH })}
</div>
)}

<WaveFormToolbar
isEdit={!!entry}
disabled={formInteractivityDisabled}
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/specs/utils/short-content-hint.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { shouldShowShortContentHint } from "@/utils/short-content-hint";

const draft = (overrides: Record<string, unknown> = {}) => ({
username: "alice",
isEditing: false,
text: "Thank you",
...overrides
});

describe("shouldShowShortContentHint", () => {
it("warns on the short content the backend refuses", () => {
expect(shouldShowShortContentHint(draft({ text: "Thank you" }))).toBe(true);
expect(shouldShowShortContentHint(draft({ text: "Lol 😂" }))).toBe(true);
expect(shouldShowShortContentHint(draft({ text: "❤️" }))).toBe(true);
});

it("counts a link-only body as too short, matching the backend rule", () => {
expect(
shouldShowShortContentHint(draft({ text: "https://i.example.com/a-very-long-url.gif" }))
).toBe(true);
});

it("counts emoji as code points, as the backend does", () => {
// 13 astral emoji are 13 code points to Python's len() but 26 UTF-16 units. Getting
// this wrong would stay silent and promise points the backend then refuses.
expect(shouldShowShortContentHint(draft({ text: "😂".repeat(13) }))).toBe(true);
});

it("stays quiet once the body is long enough to earn", () => {
expect(
shouldShowShortContentHint(
draft({ text: "Thank you, this is a genuinely useful reply with something to say" })
)
).toBe(false);
});

it("stays quiet on an untouched composer", () => {
expect(shouldShowShortContentHint(draft({ text: "" }))).toBe(false);
expect(shouldShowShortContentHint(draft({ text: " " }))).toBe(false);
expect(shouldShowShortContentHint(draft({ text: undefined }))).toBe(false);
});

it("stays quiet when there is nothing to earn", () => {
// Logged out earns nothing, and an edit never earns again: the original already
// claimed the reward.
expect(shouldShowShortContentHint(draft({ username: undefined }))).toBe(false);
expect(shouldShowShortContentHint(draft({ username: "" }))).toBe(false);
expect(shouldShowShortContentHint(draft({ isEditing: true }))).toBe(false);
});
});
41 changes: 41 additions & 0 deletions apps/web/src/utils/short-content-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { earnsQuestContentCredit } from "@ecency/sdk";

interface ShortContentHintInput {
/** Signed-in account name, if any. */
username?: string | null;
/** Editing existing content rather than creating new content. */
isEditing?: boolean;
/** Current composer body. */
text?: string | null;
}

/**
* Whether to warn that this content is too short to earn points or quest credit.
*
* The points backend drops a comment whose body is at or under a minimum length once
* URLs are stripped, so a one-word reply, an emoji, or an image-only post earns nothing
* and never reaches the daily comment quest. The rule is deliberate but invisible, and
* it is the single biggest source of "quests do not show my action" reports.
*
* Shared by the reply composer and the wave composer, because a wave is a comment on the
* chain and goes through exactly the same rule. Kept in one place so the two cannot
* drift apart, and so the rule is testable without standing up either composer.
*
* Quiet for logged-out users (nothing to earn), while editing (the original already
* claimed the reward), and on an untouched composer (nothing to nag about yet).
*/
export function shouldShowShortContentHint({
username,
isEditing,
text
}: ShortContentHintInput): boolean {
if (!username || isEditing) {
return false;
}

if (!text?.trim()) {
return false;
}

return !earnsQuestContentCredit(text);
}
Loading