Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@TWilson023
Copy link
Collaborator

@TWilson023 TWilson023 commented Nov 6, 2025

Summary by CodeRabbit

Release Notes

  • New Features

    • Rich text editor for bounty descriptions, campaign editor, and message composition with formatting toolbar
    • Markdown rendering in messages and email templates supporting bold, italic, links, and formatted lists
    • Input validation for messages preventing empty submissions and enforcing length limits
  • UI Improvements

    • Enhanced emoji picker component with custom rendering and composition support
    • Improved comment editing workflow with native autofocus behavior
    • Streamlined message input experience with better keyboard navigation

@vercel
Copy link
Contributor

vercel bot commented Nov 6, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Nov 7, 2025 10:57pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 6, 2025

Walkthrough

This pull request introduces a comprehensive rich-text editing infrastructure built on TipTap, including a new RichTextProvider context with configurable features, toolbar components, and markdown support. The infrastructure is integrated into message input, bounty editing, campaign editing, and email templates across the application.

Changes

Cohort / File(s) Summary
Rich Text Editor Infrastructure
packages/ui/src/rich-text-area/rich-text-provider.tsx, packages/ui/src/rich-text-area/rich-text-toolbar.tsx, packages/ui/src/rich-text-area/index.tsx
Introduces RichTextProvider context component with TipTap editor setup, configurable features (images, variables, markdown, headings, bold, italic, links), image upload handling, and variable mentions. RichTextToolbar refactored to consume context and conditionally render feature-based controls. RichTextArea simplified to a context-driven wrapper around EditorContent.
Dependencies
packages/ui/package.json
Updated TipTap packages from ^3.0.9 to ^3.10.2; added new dependency @tiptap/markdown (^3.10.2).
Message Input Refactoring
apps/web/ui/shared/message-input.tsx
Migrated from plain textarea to RichTextProvider-based rich editor with toolbar and emoji picker integration. Replaced onMount prop with RichText context ref. Added client-side validation (empty/max length checks), Cmd/Ctrl+Enter send trigger, and Cancel button support.
Text Rendering with Markdown
apps/web/ui/messages/messages-panel.tsx, apps/web/ui/partners/bounties/claim-bounty-modal.tsx
Replaced Linkify/plain text rendering with ReactMarkdown (messages-panel) and Markdown component (claim-bounty-modal) for rich text display with link styling and gfm plugin support.
Bounty and Campaign Editors
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx
Integrated RichTextProvider and RichTextArea with toolbar for bounty and campaign description/body editing. Wired markdown output via getMarkdown() and getJSON() with validation styling.
Component API Enhancements
apps/web/ui/shared/emoji-picker.tsx, apps/web/ui/partners/partner-comments.tsx
EmojiPicker extended with PropsWithChildren to support custom children; MessageInput autofocus logic simplified via native autoFocus prop replacing manual onMount focusing.
Email Templates
packages/email/src/templates/new-bounty-available.tsx, packages/email/src/templates/new-message-from-partner.tsx, packages/email/src/templates/new-message-from-program.tsx
Added Markdown component imports and replaced plain Text rendering with Markdown containers for message and bounty descriptions, enabling markdown emphasis and link formatting in emails.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant MessageInput
    participant RichTextProvider
    participant RichTextArea
    participant Editor as TipTap Editor
    participant onSend as onSend Callback

    User->>MessageInput: Focus & type message
    MessageInput->>RichTextProvider: Initialize with RichTextProvider
    RichTextProvider->>Editor: Create TipTap editor instance
    User->>RichTextArea: Type/format content via toolbar
    RichTextArea->>Editor: Update content (via EditorContent)
    User->>MessageInput: Press Cmd/Ctrl+Enter (or click Send)
    MessageInput->>Editor: Get markdown via editor context
    MessageInput->>MessageInput: Validate (non-empty & ≤ MAX_MESSAGE_LENGTH)
    alt Validation passes
        MessageInput->>onSend: Send message with markdown content
        onSend->>MessageInput: Success
        MessageInput->>RichTextProvider: Reset content
        RichTextProvider->>Editor: Clear editor
    else Validation fails
        MessageInput->>User: Disable send button or show error
    end
Loading
sequenceDiagram
    participant User
    participant BountyEditor as Bounty Editor Form
    participant RichTextProvider
    participant Controller as React Hook Form Controller
    participant onSubmit as Form Submit

    User->>BountyEditor: Open bounty description field
    BountyEditor->>RichTextProvider: Initialize with bounty description
    RichTextProvider->>RichTextProvider: Load initial markdown content
    User->>BountyEditor: Edit via toolbar (bold, italic, links)
    BountyEditor->>Controller: Update field via Controller onChange
    Controller->>Controller: Call getMarkdown() from RichTextArea
    User->>BountyEditor: Submit form
    BountyEditor->>onSubmit: Trigger with markdown description
    onSubmit->>onSubmit: Persist rich-text description
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • rich-text-provider.tsx: Dense logic with TipTap configuration, context setup, image upload handling, variable mentions, and multiple useEffect/useMemo hooks requiring careful validation of editor initialization and state management.
  • rich-text-toolbar.tsx: Context-driven feature-flag based rendering with complex conditional logic; need to verify feature flag alignment and button handler correctness.
  • message-input.tsx: Substantial refactoring replacing textarea with RichText; removal of onMount prop and changes to send validation/reset flow require verification of backward compatibility and event handling.
  • rich-text-area/index.tsx: Simplified but requires confirming that context delegation works correctly and no functionality was lost from the original implementation.
  • Email template changes: Multiple files with markdown rendering and styling; verify markdown parsing and styling consistency across templates.
  • Cross-file integration points (message-input, bounty editors, campaign editor) need verification that provider initialization, content reset, and validation wiring work correctly.

Possibly related PRs

  • PRs with code-level conflicts or direct modifications to same files:
    • Partner comment editing #2850: Directly modifies apps/web/ui/shared/message-input.tsx (added onMount/defaultValue for in-place comment editing); likely conflicts with this PR's removal of onMount and refactor to RichText.
    • Fix messages overflow/wrapping #2978: Modifies apps/web/ui/messages/messages-panel.tsx for message bubble rendering/styling; concurrent changes to whitespace and layout logic may require conflict resolution.
    • Add draft status for bounty submissions #2848: Modifies apps/web/ui/partners/bounties/claim-bounty-modal.tsx for draft-aware submission; concurrent changes to description rendering (plain text vs. Markdown) need alignment.

Poem

🐰 A rabbit's ode to rich text dreams,
With TipTap's threads and toolbar beams,
From plain text fields to markdown's glow,
Our editors flourish, watch them grow!
Bold, italic, links aligned—
A richer canvas, beautifully designed!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Rich text messages and bounties' accurately summarizes the main changes: adding rich text editing capabilities to message and bounty-related features across the codebase.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch rich-text-areas

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@steven-tey steven-tey marked this pull request as ready for review November 7, 2025 22:41
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
packages/ui/package.json (1)

75-82: Consider pinning TipTap dependencies more strictly for production stability.

Using caret ranges (^3.10.2) allows minor and patch updates to TipTap, which could introduce unexpected behavior in rich-text editing. For a foundational feature like this, consider narrowing to a more restrictive range (e.g., ~3.10.2 or exact pinning).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b6f6c2a and 64227bd.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (3 hunks)
  • apps/web/ui/messages/messages-panel.tsx (2 hunks)
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx (3 hunks)
  • apps/web/ui/partners/partner-comments.tsx (1 hunks)
  • apps/web/ui/shared/emoji-picker.tsx (2 hunks)
  • apps/web/ui/shared/message-input.tsx (2 hunks)
  • packages/email/src/templates/new-bounty-available.tsx (3 hunks)
  • packages/email/src/templates/new-message-from-partner.tsx (3 hunks)
  • packages/email/src/templates/new-message-from-program.tsx (3 hunks)
  • packages/ui/package.json (1 hunks)
  • packages/ui/src/rich-text-area/index.tsx (1 hunks)
  • packages/ui/src/rich-text-area/rich-text-provider.tsx (1 hunks)
  • packages/ui/src/rich-text-area/rich-text-toolbar.tsx (6 hunks)
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/ui/messages/messages-panel.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx
📚 Learning: 2025-09-12T17:31:10.548Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.548Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).

Applied to files:

  • packages/email/src/templates/new-bounty-available.tsx
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
Repo: dubinc/dub PR: 0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Do not use React components from hubspot/ui-extensions/crm in settings components

Applied to files:

  • packages/ui/package.json
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
Repo: dubinc/dub PR: 0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Only use components exported by hubspot/ui-extensions in settings components

Applied to files:

  • packages/ui/package.json
🧬 Code graph analysis (7)
apps/web/ui/shared/message-input.tsx (5)
apps/web/lib/zod/schemas/messages.ts (1)
  • MAX_MESSAGE_LENGTH (7-7)
packages/ui/src/rich-text-area/rich-text-provider.tsx (2)
  • RichTextProvider (62-232)
  • useRichTextContext (234-243)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (9-32)
packages/ui/src/rich-text-area/rich-text-toolbar.tsx (2)
  • RichTextToolbar (16-129)
  • RichTextToolbarButton (176-209)
apps/web/ui/shared/emoji-picker.tsx (1)
  • EmojiPicker (6-77)
packages/ui/src/rich-text-area/index.tsx (1)
packages/ui/src/rich-text-area/rich-text-provider.tsx (1)
  • useRichTextContext (234-243)
packages/email/src/templates/new-bounty-available.tsx (1)
packages/email/src/react-email.d.ts (1)
  • Text (15-15)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx (3)
packages/ui/src/rich-text-area/rich-text-provider.tsx (1)
  • RichTextProvider (62-232)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (9-32)
packages/ui/src/rich-text-area/rich-text-toolbar.tsx (1)
  • RichTextToolbar (16-129)
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (1)
apps/web/lib/zod/schemas/bounties.ts (1)
  • MAX_BOUNTY_SUBMISSION_DESCRIPTION_LENGTH (23-23)
packages/ui/src/rich-text-area/rich-text-toolbar.tsx (2)
packages/ui/src/rich-text-area/rich-text-provider.tsx (1)
  • useRichTextContext (234-243)
packages/ui/src/icons/index.tsx (1)
  • Icon (80-80)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (3)
packages/ui/src/rich-text-area/rich-text-provider.tsx (1)
  • RichTextProvider (62-232)
packages/ui/src/rich-text-area/rich-text-toolbar.tsx (1)
  • RichTextToolbar (16-129)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (9-32)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Socket Security: Pull Request Alerts
  • GitHub Check: build
🔇 Additional comments (7)
packages/ui/package.json (1)

75-82: TipTap 3.10.2 is compatible with React 19.1.1 and Next.js 15.5.4, but requires careful SSR setup and extension validation.

TipTap core (@tiptap/react v3.10.2) works with React 19 and Next.js 15, and the addition of @tiptap/markdown aligns well. However, there are known edge cases in some extensions (notably some Pro extensions or third‑party deps like tippyjs-react). Before merging:

  • Verify that the editor is initialized client-side with SSR guards (e.g., immediatelyRender: false)
  • Test any TipTap Pro extensions or those using tippyjs-react to ensure they work correctly
  • Validate @tiptap/markdown integration in your actual editor implementation
packages/email/src/templates/new-message-from-program.tsx (1)

126-138: LGTM! Clean markdown implementation.

The markdown rendering with custom container and link styles provides good visual separation for message content. The implementation cleanly replaces the previous plain text rendering.

packages/email/src/templates/new-message-from-partner.tsx (1)

88-100: LGTM! Consistent with the other message template.

The markdown implementation matches the styling and structure used in new-message-from-program.tsx, which promotes consistency across message email templates.

apps/web/ui/messages/messages-panel.tsx (4)

13-14: LGTM!

The markdown rendering imports are appropriate for the feature.


186-212: Good security practices applied.

The markdown implementation follows security best practices:

  • Element whitelist via allowedElements prevents XSS
  • Links configured with target="_blank" and rel="noopener noreferrer" prevents tabnabbing attacks

186-216: The review comment is incorrect—these markdown renderings have different, intentional requirements.

The inline ReactMarkdown (lines 186-216) and MessageMarkdown component serve different purposes:

Inline version restrictions:

  • allowedElements restricts rendering to: p, a, code, strong, em, ul, ol, li (no headings, images, blockquotes)
  • remarkPlugins={[remarkGfm]} enables GitHub Flavored Markdown features
  • Uses semantic color tokens: text-content-inverted, text-content-default, text-content-emphasis
  • No image or special paragraph handling

MessageMarkdown differences:

  • Allows all markdown elements (headings, blockquotes, hr, images, code blocks)
  • No remarkPlugins configuration (no GFM support)
  • Uses neutral color scheme (prose-neutral with conditional grays)
  • Custom img component wraps images with zoom functionality
  • Special paragraph handling to avoid invalid <p><div></div></p> nesting

Consolidating these would require breaking changes to either component. The variation is intentional for different message rendering contexts.

Likely an incorrect or invalid review comment.


178-184: Verify whitespace handling implementation and migration completeness.

The review comment cannot be fully verified because the diff does not show what was removed—only the final state is provided. However, legitimate concerns exist about the message rendering changes:

  1. Unverified assumption: The claim that whitespace-pre-wrap was removed cannot be confirmed; this class is not visible in the current code or git HEAD.

  2. Migration scope gap: The migration migrate-campaign-message-to-markdown.ts only converts messages with type: "campaign" to markdown format. Verify whether other message types exist and whether they also need migration or markdown conversion.

  3. Inconsistent styling: Two rendering paths exist with different styling:

    • Lines 186–216 (ReactMarkdown): includes break-words class
    • Lines 479–481 (MessageMarkdown): lacks break-words class

    Ensure both paths handle word wrapping and whitespace consistently.

Before merging, confirm:

  • What code was actually changed in this PR (the review shows final state only)
  • Whether all message types are properly handled by migrations
  • That both message rendering paths have consistent styling

/>
),
}}
remarkPlugins={[remarkGfm] as any}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix the type assertion.

The as any assertion bypasses TypeScript's type safety. The type mismatch between remarkGfm and the expected plugin type should be resolved properly.

This typically happens when the plugin types don't match the version of react-markdown. Consider:

remarkPlugins={[remarkGfm]}

If the type error persists, verify that the versions of react-markdown, remark-gfm, and their type definitions are compatible, or use a more specific type assertion like:

remarkPlugins={[remarkGfm] as PluggableList}
🤖 Prompt for AI Agents
In apps/web/ui/messages/messages-panel.tsx around line 213, the code uses a
broad `as any` for remarkPlugins which bypasses TypeScript safety; replace the
unsafe assertion with the correct plugin list type (e.g. use the PluggableList
type from the unified/react-markdown types) and import that type, i.e. cast the
array to the proper PluggableList instead of any; if the type error persists,
ensure react-markdown and remark-gfm versions (and their type defs) are
compatible and update/install matching types so the plugin type matches without
using `any`.

Comment on lines +417 to 419
<p className="text-content-subtle font-medium">
<Markdown className="p-0">{bounty.description}</Markdown>
</p>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove the <p> wrapper around Markdown output

Markdown can emit block elements (<p>, <ul>, etc.). Nesting those inside this <p> gives invalid markup and can break spacing/styling in browsers. Drop the wrapper and move the styling onto Markdown itself.

-                    <p className="text-content-subtle font-medium">
-                      <Markdown className="p-0">{bounty.description}</Markdown>
-                    </p>
+                    <Markdown className="text-content-subtle font-medium p-0">
+                      {bounty.description}
+                    </Markdown>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<p className="text-content-subtle font-medium">
<Markdown className="p-0">{bounty.description}</Markdown>
</p>
<Markdown className="text-content-subtle font-medium p-0">
{bounty.description}
</Markdown>
🤖 Prompt for AI Agents
In apps/web/ui/partners/bounties/claim-bounty-modal.tsx around lines 417 to 419,
remove the outer <p> that wraps the <Markdown> output because Markdown can emit
block-level elements and nesting them inside a <p> produces invalid markup;
instead apply the current className and font styling directly to the <Markdown>
component (e.g., move "text-content-subtle font-medium p-0" onto <Markdown>) so
the same styling is preserved without illegal HTML nesting.

Comment on lines 36 to +103
const sendMessage = () => {
const message = typedMessage.trim();
if (!message) return;
if (!message || message.length >= MAX_MESSAGE_LENGTH) return;

if (onSendMessage(message) !== false) setTypedMessage("");
if (onSendMessage(message) !== false) {
setTypedMessage("");
richTextRef.current?.setContent("");
}
};

useEffect(() => onMount?.({ textarea: textAreaRef.current }), [onMount]);

return (
<div
className={cn(
"border-border-subtle overflow-hidden rounded-xl border has-[textarea:focus]:border-neutral-500 has-[textarea:focus]:ring-1 has-[textarea:focus]:ring-neutral-500",
"border-border-subtle overflow-hidden rounded-xl border focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500",
className,
)}
>
<ReactTextareaAutosize
ref={textAreaRef}
<RichTextProvider
ref={richTextRef}
features={["bold", "italic", "links"]}
markdown
autoFocus={autoFocus}
className="placeholder:text-content-subtle block max-h-24 w-full resize-none border-none p-3 text-base focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={typedMessage}
maxLength={MAX_MESSAGE_LENGTH}
onChange={(e) => setTypedMessage(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
sendMessage();
}
editorClassName="block max-h-24 w-full resize-none border-none p-3 text-base sm:text-sm"
onChange={(editor) => setTypedMessage((editor as any).getMarkdown())}
editorProps={{
handleDOMEvents: {
keydown: (_, e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
sendMessage();
return false;
}
},
},
}}
onBlur={(e) => (selectionStartRef.current = e.target.selectionStart)}
/>
>
<RichTextArea />

<div className="flex items-center justify-between gap-4 px-3 pb-3">
<div className="flex items-center gap-2">
<EmojiPicker
onSelect={(emoji) => {
const pos = selectionStartRef.current;
setTypedMessage((prev) =>
pos !== null
? prev.slice(0, pos) + emoji + prev.slice(pos)
: prev + emoji,
);
textAreaRef.current?.focus();
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
{onCancel && (
<div className="flex items-center justify-between gap-4 px-3 pb-3">
<MessageInputToolbar />
<div className="flex items-center justify-between gap-2">
{onCancel && (
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
</span>
</span>
</span>
</span>
}
onClick={sendMessage}
className="h-8 w-fit rounded-lg px-4"
/>
}
disabled={typedMessage.trim().length >= MAX_MESSAGE_LENGTH}
onClick={sendMessage}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Allow messages up to MAX_MESSAGE_LENGTH.

Using >= makes both the guard and the button disable trip at exactly 2000 characters, even though the constant represents the allowed maximum. That regresses the limit by one character.

Apply this diff:

-    if (!message || message.length >= MAX_MESSAGE_LENGTH) return;
+    if (!message || message.length > MAX_MESSAGE_LENGTH) return;
@@
-              disabled={typedMessage.trim().length >= MAX_MESSAGE_LENGTH}
+              disabled={typedMessage.trim().length > MAX_MESSAGE_LENGTH}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sendMessage = () => {
const message = typedMessage.trim();
if (!message) return;
if (!message || message.length >= MAX_MESSAGE_LENGTH) return;
if (onSendMessage(message) !== false) setTypedMessage("");
if (onSendMessage(message) !== false) {
setTypedMessage("");
richTextRef.current?.setContent("");
}
};
useEffect(() => onMount?.({ textarea: textAreaRef.current }), [onMount]);
return (
<div
className={cn(
"border-border-subtle overflow-hidden rounded-xl border has-[textarea:focus]:border-neutral-500 has-[textarea:focus]:ring-1 has-[textarea:focus]:ring-neutral-500",
"border-border-subtle overflow-hidden rounded-xl border focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500",
className,
)}
>
<ReactTextareaAutosize
ref={textAreaRef}
<RichTextProvider
ref={richTextRef}
features={["bold", "italic", "links"]}
markdown
autoFocus={autoFocus}
className="placeholder:text-content-subtle block max-h-24 w-full resize-none border-none p-3 text-base focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={typedMessage}
maxLength={MAX_MESSAGE_LENGTH}
onChange={(e) => setTypedMessage(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
sendMessage();
}
editorClassName="block max-h-24 w-full resize-none border-none p-3 text-base sm:text-sm"
onChange={(editor) => setTypedMessage((editor as any).getMarkdown())}
editorProps={{
handleDOMEvents: {
keydown: (_, e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
sendMessage();
return false;
}
},
},
}}
onBlur={(e) => (selectionStartRef.current = e.target.selectionStart)}
/>
>
<RichTextArea />
<div className="flex items-center justify-between gap-4 px-3 pb-3">
<div className="flex items-center gap-2">
<EmojiPicker
onSelect={(emoji) => {
const pos = selectionStartRef.current;
setTypedMessage((prev) =>
pos !== null
? prev.slice(0, pos) + emoji + prev.slice(pos)
: prev + emoji,
);
textAreaRef.current?.focus();
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
{onCancel && (
<div className="flex items-center justify-between gap-4 px-3 pb-3">
<MessageInputToolbar />
<div className="flex items-center justify-between gap-2">
{onCancel && (
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
</span>
</span>
</span>
</span>
}
onClick={sendMessage}
className="h-8 w-fit rounded-lg px-4"
/>
}
disabled={typedMessage.trim().length >= MAX_MESSAGE_LENGTH}
onClick={sendMessage}
const sendMessage = () => {
const message = typedMessage.trim();
if (!message || message.length > MAX_MESSAGE_LENGTH) return;
if (onSendMessage(message) !== false) {
setTypedMessage("");
richTextRef.current?.setContent("");
}
};
return (
<div
className={cn(
"border-border-subtle overflow-hidden rounded-xl border focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500",
className,
)}
>
<RichTextProvider
ref={richTextRef}
features={["bold", "italic", "links"]}
markdown
autoFocus={autoFocus}
placeholder={placeholder}
editorClassName="block max-h-24 w-full resize-none border-none p-3 text-base sm:text-sm"
onChange={(editor) => setTypedMessage((editor as any).getMarkdown())}
editorProps={{
handleDOMEvents: {
keydown: (_, e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
sendMessage();
return false;
}
},
},
}}
>
<RichTextArea />
<div className="flex items-center justify-between gap-4 px-3 pb-3">
<MessageInputToolbar />
<div className="flex items-center justify-between gap-2">
{onCancel && (
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
</span>
</span>
</span>
}
disabled={typedMessage.trim().length > MAX_MESSAGE_LENGTH}
onClick={sendMessage}
🤖 Prompt for AI Agents
In apps/web/ui/shared/message-input.tsx around lines 36 to 103, the length
checks use ">=" which incorrectly prevents sending or enables the disabled state
at exactly MAX_MESSAGE_LENGTH; change both comparisons to ">" so messages of
length exactly MAX_MESSAGE_LENGTH are allowed (i.e., replace message.length >=
MAX_MESSAGE_LENGTH with message.length > MAX_MESSAGE_LENGTH in the sendMessage
guard, and replace typedMessage.trim().length >= MAX_MESSAGE_LENGTH with
typedMessage.trim().length > MAX_MESSAGE_LENGTH on the Button disabled prop).

Comment on lines +53 to +104
<RichTextProvider
ref={richTextRef}
features={["bold", "italic", "links"]}
markdown
autoFocus={autoFocus}
className="placeholder:text-content-subtle block max-h-24 w-full resize-none border-none p-3 text-base focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={typedMessage}
maxLength={MAX_MESSAGE_LENGTH}
onChange={(e) => setTypedMessage(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
sendMessage();
}
editorClassName="block max-h-24 w-full resize-none border-none p-3 text-base sm:text-sm"
onChange={(editor) => setTypedMessage((editor as any).getMarkdown())}
editorProps={{
handleDOMEvents: {
keydown: (_, e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
sendMessage();
return false;
}
},
},
}}
onBlur={(e) => (selectionStartRef.current = e.target.selectionStart)}
/>
>
<RichTextArea />

<div className="flex items-center justify-between gap-4 px-3 pb-3">
<div className="flex items-center gap-2">
<EmojiPicker
onSelect={(emoji) => {
const pos = selectionStartRef.current;
setTypedMessage((prev) =>
pos !== null
? prev.slice(0, pos) + emoji + prev.slice(pos)
: prev + emoji,
);
textAreaRef.current?.focus();
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
{onCancel && (
<div className="flex items-center justify-between gap-4 px-3 pb-3">
<MessageInputToolbar />
<div className="flex items-center justify-between gap-2">
{onCancel && (
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="secondary"
text="Cancel"
onClick={onCancel}
className="h-8 w-fit rounded-lg px-4"
/>
)}
<Button
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
variant="primary"
text={
<span className="flex items-center gap-2">
{sendButtonText}
<span className="hidden items-center gap-1 sm:flex">
<span className="flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]">
{navigator.platform.startsWith("Mac") ? "⌘" : "^"}
</span>
<span className="flex size-4 items-center justify-center rounded border border-neutral-700">
<ArrowTurnLeft className="text-content-inverted size-2.5" />
</span>
</span>
</span>
</span>
}
onClick={sendMessage}
className="h-8 w-fit rounded-lg px-4"
/>
}
disabled={typedMessage.trim().length >= MAX_MESSAGE_LENGTH}
onClick={sendMessage}
className="h-8 w-fit rounded-lg px-4"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Ensure the editor reflects defaultValue.

Switching to RichTextProvider dropped the propagation of defaultValue, so edit flows now render an empty editor while typedMessage still holds the old value. Users see a blank message and risk sending stale content. Please feed the provider with the initial value and keep state/editor in sync when defaultValue changes.

Apply this diff:

-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
@@
-  const richTextRef = useRef<{ setContent: (content: any) => void }>(null);
+  const richTextRef = useRef<{ setContent: (content: any) => void }>(null);
   const [typedMessage, setTypedMessage] = useState(defaultValue);
+
+  useEffect(() => {
+    setTypedMessage(defaultValue);
+    richTextRef.current?.setContent(defaultValue);
+  }, [defaultValue]);
@@
       <RichTextProvider
         ref={richTextRef}
         features={["bold", "italic", "links"]}
         markdown
+        initialValue={defaultValue}
         autoFocus={autoFocus}

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +81 to 87
<Text className="m-0 mt-2 p-0 text-sm font-medium text-neutral-500">
<Markdown
markdownCustomStyles={{ link: { color: "black" } }}
>
{bounty.description}
</Markdown>
</Text>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refactor to avoid invalid HTML nesting.

Wrapping the Markdown component inside a Text component can create invalid HTML since Text typically renders as a <p> tag and Markdown will render its own block-level elements (paragraphs, headings, etc.). This nesting can cause rendering issues in email clients.

Additionally, the styling approach differs from the message templates (new-message-from-program.tsx and new-message-from-partner.tsx), which use markdownContainerStyles for consistent visual presentation.

Apply this diff to match the pattern used in the other email templates:

-                  <Text className="m-0 mt-2 p-0 text-sm font-medium text-neutral-500">
-                    <Markdown
-                      markdownCustomStyles={{ link: { color: "black" } }}
-                    >
-                      {bounty.description}
-                    </Markdown>
-                  </Text>
+                  <Markdown
+                    markdownCustomStyles={{ link: { color: "black" } }}
+                    markdownContainerStyles={{
+                      margin: 0,
+                      marginTop: 8,
+                      padding: 0,
+                      fontSize: 14,
+                      fontWeight: 500,
+                      color: "#737373",
+                    }}
+                  >
+                    {bounty.description}
+                  </Markdown>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Text className="m-0 mt-2 p-0 text-sm font-medium text-neutral-500">
<Markdown
markdownCustomStyles={{ link: { color: "black" } }}
>
{bounty.description}
</Markdown>
</Text>
<Markdown
markdownCustomStyles={{ link: { color: "black" } }}
markdownContainerStyles={{
margin: 0,
marginTop: 8,
padding: 0,
fontSize: 14,
fontWeight: 500,
color: "#737373",
}}
>
{bounty.description}
</Markdown>
🤖 Prompt for AI Agents
In packages/email/src/templates/new-bounty-available.tsx around lines 81 to 87,
the code wraps a Markdown component inside a Text (likely a <p>) which can
produce invalid HTML nesting because Markdown emits block-level elements; update
this to match the other templates by removing the surrounding Text and rendering
Markdown directly with the same styling prop (e.g., pass markdownContainerStyles
or equivalent container class/styles used in new-message-from-program.tsx and
new-message-from-partner.tsx) so the block-level HTML from Markdown is not
nested inside a paragraph and visual styling remains consistent.

Comment on lines +87 to +110
uploadImage
? async (file: File, currentEditor: Editor, pos: number) => {
setIsUploading(true);

const src = await uploadImage?.(file);
if (!src) {
setIsUploading(false);
return;
}

currentEditor
.chain()
.insertContentAt(pos, {
type: "image",
attrs: {
src,
},
})
.focus()
.run();

setIsUploading(false);
}
: null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Release the upload lock on failures.

If uploadImage throws or rejects, setIsUploading(false) never runs, so the editor stays dimmed/disabled until reload. Wrap the upload flow in a try/finally so the flag resets regardless of outcome.

Apply this diff:

-        uploadImage
-          ? async (file: File, currentEditor: Editor, pos: number) => {
-              setIsUploading(true);
-
-              const src = await uploadImage?.(file);
-              if (!src) {
-                setIsUploading(false);
-                return;
-              }
-
-              currentEditor
-                .chain()
-                .insertContentAt(pos, {
-                  type: "image",
-                  attrs: {
-                    src,
-                  },
-                })
-                .focus()
-                .run();
-
-              setIsUploading(false);
-            }
+        uploadImage
+          ? async (file: File, currentEditor: Editor, pos: number) => {
+              setIsUploading(true);
+              try {
+                const src = await uploadImage(file);
+                if (!src) {
+                  return;
+                }
+
+                currentEditor
+                  .chain()
+                  .insertContentAt(pos, {
+                    type: "image",
+                    attrs: {
+                      src,
+                    },
+                  })
+                  .focus()
+                  .run();
+              } finally {
+                setIsUploading(false);
+              }
+            }
🤖 Prompt for AI Agents
In packages/ui/src/rich-text-area/rich-text-provider.tsx around lines 87 to 110,
the uploadImage branch sets isUploading to true but never guarantees
setIsUploading(false) if uploadImage throws or rejects; wrap the async upload
flow in try/finally so setIsUploading(false) is always called, calling
setIsUploading(true) before the try, performing the await uploadImage(...) and
editor insert inside the try, and in the finally block call
setIsUploading(false); optionally catch the error inside the try to log or
rethrow if needed.

@steven-tey steven-tey merged commit 51b5916 into main Nov 7, 2025
8 of 9 checks passed
@steven-tey steven-tey deleted the rich-text-areas branch November 7, 2025 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants