-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Rich text messages and bounties #3067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughThis 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsxapps/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.tsxapps/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
allowedElementsprevents XSS- Links configured with
target="_blank"andrel="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) andMessageMarkdowncomponent serve different purposes:Inline version restrictions:
allowedElementsrestricts 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
remarkPluginsconfiguration (no GFM support)- Uses neutral color scheme (
prose-neutralwith conditional grays)- Custom
imgcomponent wraps images with zoom functionality- Special paragraph handling to avoid invalid
<p><div></div></p>nestingConsolidating 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:
Unverified assumption: The claim that
whitespace-pre-wrapwas removed cannot be confirmed; this class is not visible in the current code or git HEAD.Migration scope gap: The migration
migrate-campaign-message-to-markdown.tsonly converts messages withtype: "campaign"to markdown format. Verify whether other message types exist and whether they also need migration or markdown conversion.Inconsistent styling: Two rendering paths exist with different styling:
- Lines 186–216 (ReactMarkdown): includes
break-wordsclass- Lines 479–481 (MessageMarkdown): lacks
break-wordsclassEnsure 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} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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`.
| <p className="text-content-subtle font-medium"> | ||
| <Markdown className="p-0">{bounty.description}</Markdown> | ||
| </p> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| <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.
| 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} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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).
| <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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| <Text className="m-0 mt-2 p-0 text-sm font-medium text-neutral-500"> | ||
| <Markdown | ||
| markdownCustomStyles={{ link: { color: "black" } }} | ||
| > | ||
| {bounty.description} | ||
| </Markdown> | ||
| </Text> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| <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.
| 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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Summary by CodeRabbit
Release Notes
New Features
UI Improvements