Skip to content

Dev#15

Merged
BuckyMcYolo merged 5 commits intomainfrom
dev
Mar 9, 2026
Merged

Dev#15
BuckyMcYolo merged 5 commits intomainfrom
dev

Conversation

@BuckyMcYolo
Copy link
Copy Markdown
Owner

@BuckyMcYolo BuckyMcYolo commented Mar 6, 2026

Summary

This PR implements message replies and file attachments end-to-end (types, API, realtime, frontend, and UI), adds presigned S3 uploads, and updates related tooling/config.

Key Changes

  • Cross-layer referenced-message support:

    • Added referencedMessage/referencedMessageId across realtime types, API OpenAPI schemas, and frontend models.
    • messageWithAuthorSchema and related OpenAPI schemas updated to include referencedMessage.
  • API & storage:

    • New uploads presign endpoint (POST /v1/uploads/presign): request/response schemas, route, handler with auth/authorization checks, filename sanitization, size/type validation, presigned S3 PutObject creation, and returned uploadUrl/fileUrl/key.
    • New s3 client module using S3_* env vars; added S3 env entries to server env schema and .env.example.
    • Added payloadTooLargeSchema to OpenAPI helpers.
    • apps/api package.json: added @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner.
  • Realtime service:

    • sendMessage payload schema expanded to accept referencedMessageId and attachments (max 10, with refine requiring content or attachments).
    • Insertion flow marks messages as type "reply" when referencing another message; validates referenced message is in the same channel and fetches referenced message details to attach to emitted RealtimeMessage.
    • Realtime message type extended with attachments and referencedMessage fields.
  • Queries / performance:

    • messages query (fetchMessagePage) batch-loads referenced messages to avoid N+1 queries and maps referenced message author details into responses.
  • Frontend (web):

    • Reply UX: new useReplyState hook; replying flow wired into Channel/DM routes; MessageInput accepts replyingTo/onCancelReply; MessageItem and MessageList accept onReply and render ReplyPreview; added scrollToMessage highlight behavior.
    • Attachments UX: new useFileUpload hook, PendingAttachment type, AttachmentPreview, AttachmentGrid/ImageGrid with lightbox, DropZoneOverlay, and drag-and-drop integration via react-dropzone.
    • MessageInput API extended: onSend now accepts referencedMessage and attachments; editor behavior adjusted for reply and attachment lifecycle; optimistic message creation/adapter updated to include referencedMessage and attachments.
    • Added Toaster integration (sonner) and a theme-aware wrapper component; UI packages updated accordingly.
  • Packages & config:

    • Added react-dropzone and sonner to apps/web; next-themes and sonner to packages/ui.
    • Added new exports and upload-related constants/types to packages/realtime-types and packages/realtime-types/src/uploads.ts.
    • Minor UI and component adjustments (icons, date divider optimization, message action bar icon change).

Quality Observations

Strengths

  • End-to-end coverage: types, API, realtime, and frontend updated consistently.
  • Backend handles performance concern by batch-loading referenced messages.
  • Frontend provides cohesive reply and attachment UX with optimistic updates and upload lifecycle handling.

Risks / Issues

  • No tests added for reply validation, cross-channel access control, or presign/upload flows — coverage gap.
  • Presign/upload relies on correct S3 env and bucket policies/CORS; deployment configuration must be verified.
  • Several public component/signature changes (MessageInput, MessageList, realtime adapter, createOptimisticMessage) require updating callers across the app and possibly third-party integrations.
  • Some operations perform eager resource usage (e.g., creating S3 client eagerly) and rely on env presence; consider lazy init or clearer startup validation.

Recommended follow-ups

  • Add unit/integration tests for reply creation, cross-channel rejection, and presign/upload error cases.
  • Document required S3 environment variables and expected bucket policies/CORS in deployment docs.
  • Sweep codebase (and external consumers) to update call sites to new MessageInput/MessageList/realtime adapter signatures.
  • Consider additional input validation tests and edge cases for attachment limits and MIME types.

Confidence Score: 4/5

Well-structured and comprehensive cross-layer changes with attention to types and performance. Main concerns are missing tests and verifying deployment S3 configuration; otherwise ready with minor follow-ups.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 6, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7237975b-bfcf-4e7c-90e5-8c17f0beca06

📥 Commits

Reviewing files that changed from the base of the PR and between ca3de8e and 1f3d2c5.

📒 Files selected for processing (7)
  • apps/api/src/routes/v1/uploads/schema.ts
  • apps/web/src/components/chat/date-divider.tsx
  • apps/web/src/hooks/use-file-upload.ts
  • apps/web/src/routes/_authenticated/dms/$dmId.tsx
  • packages/realtime-types/package.json
  • packages/realtime-types/src/events.ts
  • packages/realtime-types/src/uploads.ts

📝 Walkthrough

Walkthrough

Adds reply and attachment support end-to-end: new schemas/types (referencedMessage, attachments), DB/realtime wiring for referencedMessageId, S3 presign upload API and client, client file-upload hook and UI, composer reply UI and optimistic send flow, and integration across routes and adapters.

Changes

Cohort / File(s) Summary
Schemas & Realtime types
apps/api/src/lib/helpers/openapi/message-schemas.ts, packages/realtime-types/src/events.ts, packages/realtime-types/src/uploads.ts
Added referencedMessageSchema / RealtimeReferencedMessage and RealtimeAttachment; extended message schemas/types to include referencedMessage and attachments; added allowed MIME types and MAX_ATTACHMENTS constant.
Server message handling
apps/api/src/lib/queries/messages.ts, apps/realtime/src/services/messages.ts, apps/realtime/src/index.ts
Batch-fetch referenced messages in queries; validate/persist referencedMessageId on create and set message type to "reply" when present; make URL extraction null-safe.
Uploads API, S3 client & env
apps/api/src/routes/v1/uploads/*, apps/api/src/lib/s3.ts, apps/api/src/app.ts, packages/env/src/server.ts, .env.example, apps/api/package.json
New POST /v1/uploads/presign route with auth/size/type checks; S3 client module; added S3 env vars and AWS SDK deps; mounted uploads router; OpenAPI presign schemas and payload-too-large response.
Client composer, sending & realtime adapter
apps/web/src/components/chat/composer/message-input.tsx, apps/web/src/hooks/use-message-sending.ts, apps/web/src/lib/realtime-adapter.ts
Composer and onSend expanded to accept referencedMessage and attachments; optimistic messages include referencedMessage and attachments; outbound payload uses referencedMessageId and attachments.
File upload hook & UI
apps/web/src/hooks/use-file-upload.ts, apps/web/src/components/chat/composer/attachment-preview.tsx, apps/web/src/components/chat/drop-zone-overlay.tsx
Added useFileUpload with PendingAttachment lifecycle, presign+upload flow, preview generation and helpers; AttachmentPreview and DropZoneOverlay UI components.
Message rendering & UX plumbing
apps/web/src/components/chat/message-item.tsx, apps/web/src/components/chat/message-list.tsx, apps/web/src/components/chat/message-action-bar.tsx, apps/web/src/components/chat/message-attachment.tsx, apps/web/src/components/chat/date-divider.tsx
Added reply preview UI, onReply plumbing (list→item→composer), scroll-to-highlight for referenced messages, attachment grid/lightbox and media renderers, small icon and date-divider tweaks, and sticky date indicator.
Routes integration & app UI
apps/web/src/routes/_authenticated/.../$channelId.tsx, apps/web/src/routes/_authenticated/dms/$dmId.tsx, apps/web/src/main.tsx
Integrated useReplyState and useFileUpload into channel/DM routes; wired dropzone, reply state, and file-upload props to MessageList and MessageInput; added Toaster usage.
UI library & packages
packages/ui/src/components/sonner.tsx, apps/web/package.json, packages/ui/package.json
Added Sonner/toaster wrapper and next-themes wiring; added dependencies (react-dropzone, sonner, next-themes) to relevant package.json files.
OpenAPI helpers
apps/api/src/lib/helpers/openapi/schemas.ts
Added payloadTooLargeSchema OpenAPI response schema for file-too-large cases.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant MessageList
    participant MessageItem
    participant useReplyState
    participant MessageInput
    participant useFileUpload
    participant useMessageSending
    participant RealtimeService
    participant API
    participant S3

    User->>MessageItem: click "Reply"
    MessageItem->>MessageList: onReply(message)
    MessageList->>useReplyState: setReplyingTo(message)
    useReplyState->>MessageInput: replyingTo prop set
    MessageInput->>User: show reply banner

    User->>MessageInput: add text (+files via useFileUpload) -> Send
    MessageInput->>useFileUpload: request presign for files
    useFileUpload->>API: POST /v1/uploads/presign
    API->>useFileUpload: returns uploadUrl & fileUrl
    useFileUpload->>S3: upload file to uploadUrl
    useFileUpload->>MessageInput: returns attachment metadata

    MessageInput->>useMessageSending: handleSend(content, {referencedMessage, attachments})
    useMessageSending->>MessageInput: create optimistic message
    useMessageSending->>RealtimeService: emit message:send (referencedMessageId + attachments)
    RealtimeService->>API: persist message
    API->>RealtimeService: stored message with referencedMessage populated
    RealtimeService->>useMessageSending: broadcast stored message
    useMessageSending->>MessageList: replace optimistic with real message
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • Dev #15: Appears to add the same referencedMessage/attachments schema and wiring across backend, realtime, and frontend — strong overlap.
  • Dev #12: Touches MessageInput internals and send behavior; related to composer/onSend signature and command helpers.
  • feat: Created packages/realtime-types/** (@repo/realtime-types): #9: Adds/updates realtime types and exports for uploads and message attachments used by this change.

Poem

🐇 I nibbled bytes and sewed a thread,

Replies now hop where old ones led,
Files tucked in a shiny crate,
Optimistic tails await,
Chat burrows hum, good things ahead.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Dev' is vague and non-descriptive, failing to convey any meaningful information about the changeset's primary purpose. Replace with a specific, descriptive title that captures the main feature or change. Examples: 'Add message replies and file attachments support' or 'Implement reply threads and S3 file uploads'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

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.

Copy link
Copy Markdown

@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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/components/chat/date-divider.tsx (1)

9-14: 🧹 Nitpick | 🔵 Trivial

Consider caching the formatted date.

formatDateDivider(date) is called twice—once for the data-date-divider attribute and once for the label text. While the overhead is likely minimal, you could assign it to a variable for clarity.

♻️ Optional refactor
 export function DateDivider({ date }: DateDividerProps) {
+  const formattedDate = formatDateDivider(date)
   return (
-    <div className="py-3" data-date-divider={formatDateDivider(date)}>
+    <div className="py-3" data-date-divider={formattedDate}>
       <div className="relative flex items-center">
         <div className="h-px w-full bg-border/70" />
         <span className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-xs font-medium text-muted-foreground">
-          {formatDateDivider(date)}
+          {formattedDate}
         </span>
       </div>
     </div>
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/date-divider.tsx` around lines 9 - 14, The
component calls formatDateDivider(date) twice; cache the formatted value in a
local variable (e.g., const formatted = formatDateDivider(date)) inside the
date-divider component and replace both uses (data-date-divider and the span
content) with that variable to avoid duplicate calls and improve clarity (refer
to formatDateDivider and the JSX surrounding data-date-divider and the span).
apps/web/src/components/chat/message-item.tsx (1)

130-149: ⚠️ Potential issue | 🟠 Major

Disable reply actions for optimistic rows.

handleReply can capture a message whose id is still just the client nonce. createOptimisticMessage uses that nonce as a temporary id until the ACK arrives in apps/web/src/lib/realtime-adapter.ts, Lines 136-160, so a quick reply ends up sending a referencedMessageId the server can't resolve. Even if the row is later replaced in the list, the stored draft still carries the old nonce. Gate onReply behind a persisted/pending check before wiring it into MessageActionBar.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/message-item.tsx` around lines 130 - 149, The
reply action must be disabled for optimistic (non-persisted) messages that still
carry the client nonce id: add a persisted/pending guard (matching the logic
from createOptimisticMessage in realtime-adapter.ts) before wiring onReply into
MessageActionBar and also short-circuit handleReply; e.g., compute a boolean
like canReply = message.persisted || !message.pending (or the equivalent
persisted check used elsewhere), pass onReply={canReply ? handleReply :
undefined} into <MessageActionBar>, and make handleReply return early if the
message is optimistic so no draft is created referencing the temporary nonce.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/chat/composer/message-input.tsx`:
- Around line 100-106: The onSend prop should signal actual send success so
reply mode is only cleared after the message is persisted: change the onSend
signature used in message-input.tsx to return a Promise<boolean> (or some
success indicator) instead of being fire-and-forget, update callers (notably
useMessageSending) to resolve true only on ACK/persist and false on early-return
or failure, and then in the component call onCancelReply (or clear replyingTo)
only when the returned promise indicates success; reference the onSend prop in
message-input.tsx, the useMessageSending hook (the send logic/ACK handling), and
the replyingTo/onCancelReply flow so the optimistic row rollback path does not
clear reply mode prematurely.

In `@apps/web/src/lib/realtime-adapter.ts`:
- Around line 16-17: Add a new field referencedMessageId: string | null to the
RealtimeMessage type and ensure the object emitted in the "message:created"
event populates that field from the source message's referenced ID (use the
stored referencedMessageId or rm.referencedMessage?.id as fallback), so reply
identity is preserved even when referencedMessage is deleted; update the
RealtimeMessage type definition and the place constructing the emitted message
(the message creation / emission code in realtime-adapter.ts) to include
referencedMessageId.

In `@apps/web/src/routes/_authenticated/dms/`$dmId.tsx:
- Around line 74-75: The reply state (replyingTo) is not cleared when the route
param dmId changes, so a selected reply can leak into a different DM; add an
effect that watches dmId and calls clearReply (or setReplyingTo(null)) whenever
dmId changes (and optionally on unmount) to reset the local reply state; locate
the useReplyState destructure (replyingTo, setReplyingTo, clearReply) in the
component and add a useEffect dependent on dmId that calls clearReply to ensure
referencedMessageId is never emitted for the wrong conversation.

In `@packages/realtime-types/src/events.ts`:
- Around line 80-90: The author object is duplicated across
RealtimeReferencedMessage, RealtimeMessage.author, and RealtimeMessageMention;
extract a shared type (e.g., RealtimeAuthor) that defines { id, name, username,
displayUsername, image } and replace the inline author shapes with this new
RealtimeAuthor type in RealtimeReferencedMessage, RealtimeMessage (author
property), and RealtimeMessageMention to remove duplication and keep types
consistent.

---

Outside diff comments:
In `@apps/web/src/components/chat/date-divider.tsx`:
- Around line 9-14: The component calls formatDateDivider(date) twice; cache the
formatted value in a local variable (e.g., const formatted =
formatDateDivider(date)) inside the date-divider component and replace both uses
(data-date-divider and the span content) with that variable to avoid duplicate
calls and improve clarity (refer to formatDateDivider and the JSX surrounding
data-date-divider and the span).

In `@apps/web/src/components/chat/message-item.tsx`:
- Around line 130-149: The reply action must be disabled for optimistic
(non-persisted) messages that still carry the client nonce id: add a
persisted/pending guard (matching the logic from createOptimisticMessage in
realtime-adapter.ts) before wiring onReply into MessageActionBar and also
short-circuit handleReply; e.g., compute a boolean like canReply =
message.persisted || !message.pending (or the equivalent persisted check used
elsewhere), pass onReply={canReply ? handleReply : undefined} into
<MessageActionBar>, and make handleReply return early if the message is
optimistic so no draft is created referencing the temporary nonce.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2b6f18ad-2ade-47af-8330-3d3a6df66e2f

📥 Commits

Reviewing files that changed from the base of the PR and between 349b43f and e832394.

📒 Files selected for processing (14)
  • apps/api/src/lib/helpers/openapi/message-schemas.ts
  • apps/api/src/lib/queries/messages.ts
  • apps/realtime/src/services/messages.ts
  • apps/web/src/components/chat/composer/message-input.tsx
  • apps/web/src/components/chat/date-divider.tsx
  • apps/web/src/components/chat/message-action-bar.tsx
  • apps/web/src/components/chat/message-item.tsx
  • apps/web/src/components/chat/message-list.tsx
  • apps/web/src/hooks/use-message-sending.ts
  • apps/web/src/hooks/use-reply-state.ts
  • apps/web/src/lib/realtime-adapter.ts
  • apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
  • apps/web/src/routes/_authenticated/dms/$dmId.tsx
  • packages/realtime-types/src/events.ts

Comment on lines +100 to +106
onSend: (
content: string,
options?: {
mentions: Message["mentions"]
referencedMessage?: Message["referencedMessage"]
}
) => void
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only clear reply mode after a successful send.

onSend is still fire-and-forget, but useMessageSending can return early when the socket or user is unavailable and can roll the optimistic row back on ACK failure in apps/web/src/hooks/use-message-sending.ts, Lines 114-150. Line 580 clears replyingTo anyway, so a failed send silently exits reply mode and the retry becomes a plain message. Have onSend resolve a success signal and only call onCancelReply once the send actually sticks.

🛠️ Proposed fix
 interface MessageInputProps {
   context: ChatContext
   onSend: (
     content: string,
     options?: {
       mentions: Message["mentions"]
       referencedMessage?: Message["referencedMessage"]
     }
-  ) => void
+  ) => Promise<boolean> | boolean
   isSending?: boolean
   currentUserId?: string
   mentionCandidates?: MentionCandidate[]
   replyingTo?: Message | null
   onCancelReply?: () => void
 }
@@
-  const handleSend = useCallback(() => {
+  const handleSend = useCallback(async () => {
     if (!editor) return
@@
-    onSend(trimmed, {
+    const didSend = await onSend(trimmed, {
       mentions,
       referencedMessage: replyingTo
         ? {
             id: replyingTo.id,
             content: replyingTo.content,
             author: replyingTo.author,
           }
         : undefined,
     })
+    if (!didSend) return
     editor.commands.clearContent(true)
     editor.commands.focus("end")
     setPlainText("")
     onCancelReply?.()
   }, [

Also applies to: 567-580

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/composer/message-input.tsx` around lines 100 -
106, The onSend prop should signal actual send success so reply mode is only
cleared after the message is persisted: change the onSend signature used in
message-input.tsx to return a Promise<boolean> (or some success indicator)
instead of being fire-and-forget, update callers (notably useMessageSending) to
resolve true only on ACK/persist and false on early-return or failure, and then
in the component call onCancelReply (or clear replyingTo) only when the returned
promise indicates success; reference the onSend prop in message-input.tsx, the
useMessageSending hook (the send logic/ACK handling), and the
replyingTo/onCancelReply flow so the optimistic row rollback path does not clear
reply mode prematurely.

Comment thread apps/web/src/lib/realtime-adapter.ts
Comment thread apps/web/src/routes/_authenticated/dms/$dmId.tsx
Comment thread packages/realtime-types/src/events.ts
Copy link
Copy Markdown

@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: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/main.tsx (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Remove unused import.

The ReactQueryDevtools import is no longer used since the component is commented out on line 44. This creates dead code and may trigger linting warnings.

🧹 Proposed fix
-import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/main.tsx` at line 3, Remove the unused ReactQueryDevtools
import: delete the import declaration for ReactQueryDevtools and ensure no
remaining references to that symbol (the devtools component is currently
commented out in main.tsx), so the code no longer contains the unused import
that triggers lint warnings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Around line 10-15: Reorder the S3-related environment variable entries in
.env.example into alphabetical order by variable name (referencing
S3_ACCESS_KEY_ID, S3_BUCKET_NAME, S3_ENDPOINT, S3_PUBLIC_URL, S3_REGION,
S3_SECRET_ACCESS_KEY) so they appear sorted and easier to locate; update the
block to list those variables in ascending alphabetical order while preserving
values/placeholders and any comments.

In `@apps/api/src/routes/v1/uploads/handlers.ts`:
- Around line 19-24: Replace the incorrect 403 response for oversized uploads
with the proper 413 status: locate the branch that checks "if (size >
env.MAX_FILE_UPLOAD_SIZE)" and change the c.json response that currently uses
HttpStatusCodes.FORBIDDEN to use HttpStatusCodes.PAYLOAD_TOO_LARGE (and adjust
the message to indicate the payload is too large) so clients receive the correct
HTTP 413 semantics for payload size violations.
- Around line 41-83: The current logic can skip both checks when ch.guildId is
falsy and ch.type is not in DM_CHANNEL_TYPES, allowing authorization bypass;
change the branch so the DM membership check is run only for non-guild channels
and unknown channel types are rejected: replace the second independent if
(DM_CHANNEL_TYPES.includes(...)) block with an else if (ch.guildId == null &&
DM_CHANNEL_TYPES.includes(ch.type as ...)) that queries channelMember, and add a
final else that returns the same forbidden response (using the existing json +
HttpStatusCodes.FORBIDDEN). Keep the same db.select(...) calls and the
guildMember/channelMember identifiers but ensure the fallback else handles
unknown/non-guild non-DM channel types.

In `@apps/api/src/routes/v1/uploads/schema.ts`:
- Around line 3-18: Extract the ALLOWED_MIME_TYPES array into a shared package
(e.g., export const ALLOWED_MIME_TYPES from `@repo/shared`) and replace the
duplicated local definitions in both locations: remove the local
ALLOWED_MIME_TYPES in the uploads schema and import the shared constant instead,
keeping it as a readonly array (as const) so consumers can adapt it; update the
client hook (use-file-upload) to construct a Set from the shared array (new
Set(ALLOWED_MIME_TYPES)) where it previously used its own Set, and ensure all
imports and type usages reference the shared symbol ALLOWED_MIME_TYPES.
- Line 31: The size field in the upload schema (size: z.number().int().min(1))
should also enforce the maximum file size used by the handler: add
.max(MAX_FILE_UPLOAD_SIZE) to the zod chain so the schema validates the same
limit as the handler (reference MAX_FILE_UPLOAD_SIZE used in the upload
handler). If importing env/MAX_FILE_UPLOAD_SIZE at schema-definition time causes
module init issues, document that and keep the handler-level check as the source
of truth; otherwise reference the env constant when building the schema so
OpenAPI and early validation reflect the actual max.

In `@apps/realtime/src/services/messages.ts`:
- Around line 39-50: The code currently uses input.payload.referencedMessageId
(hasReply) and writes the row as a reply before verifying the referenced message
belongs to the same channel; fix this by checking inside the db.transaction
(using tx and schema.message) that a message with id =
input.payload.referencedMessageId AND channelId = input.payload.channelId
exists, and only set type = "reply" and referencedMessageId when that validation
succeeds; apply the same validation and conditional-persist logic to the second
code path referenced (the block around the other insert at lines ~92-107) so no
cross-channel referencedMessage leaks are written or emitted.

In `@apps/web/src/components/chat/composer/attachment-preview.tsx`:
- Around line 29-35: The remove button in attachment-preview.tsx is an icon-only
control so screen readers can't identify which attachment it targets; update the
button in the component that calls onRemove(attachment.id) to provide an
accessible name (e.g., add aria-label={`Remove attachment ${attachment.name ||
attachment.id}`} or include visually-hidden text describing the attachment) so
each X button is uniquely announced and remains clickable while preserving the
existing onClick/onRemove behavior.

In `@apps/web/src/components/chat/message-attachment.tsx`:
- Around line 92-102: The current useEffect only registers global keys when
hasMultiple is true so Escape won't reliably close single-image lightboxes;
modify the effect (useEffect) and its local handler (handleKeyDown) to always
attach when open is true, have handleKeyDown call goNext() / goPrev() only for
ArrowRight/Left when hasMultiple is true, and call the dialog close callback
(onClose) when e.key === "Escape"; ensure you still remove the same handler in
the cleanup and keep the dependency list ([open, hasMultiple, goNext, goPrev,
onClose]).

In `@apps/web/src/components/chat/message-item.tsx`:
- Around line 112-113: The current isReply boolean should not depend on
referencedMessageId or showHeader; change isReply to be message.type === "reply"
only, then in the reply render path use message.referencedMessageId to decide
whether to render the referenced message content or the "Original message was
deleted" fallback (render the fallback when referencedMessageId is null), and
remove the extra showHeader guard that prevents rendering reply context for
grouped replies—adjust the rendering logic around the reply block (the isReply
usage and the render at the earlier reply block and the follow-up render at the
block currently gated by showHeader) so reply context always displays for
message.type === "reply" while still showing the deletion fallback when
referencedMessageId is null.

In `@apps/web/src/hooks/use-file-upload.ts`:
- Around line 85-118: The current addFiles function slices the incoming files
before validation and uses render-time attachments.length, causing invalid files
to consume slots and race conditions when multiple addFiles run; instead, first
validate/filter the incoming files by size and MIME (using ALLOWED_MIME_TYPES
and env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE) to produce validatedCandidates, then
inside setAttachments(prev => { const remaining = MAX_ATTACHMENTS - prev.length;
const accepted = validatedCandidates.slice(0, remaining); return [...prev,
...acceptedMappedToPendingAttachments] }) compute the remaining slots from prev,
map only the accepted candidates to PendingAttachment objects (using
getImageDimensions, URL.createObjectURL, crypto.randomUUID, etc.), and append
them, ensuring the cap is enforced atomically and avoids burning slots for
rejected files.

In `@apps/web/src/main.tsx`:
- Line 44: The commented-out ReactQueryDevtools line in main.tsx should be
either removed or made environment-aware; either delete the commented line
entirely, or restore it and wrap the ReactQueryDevtools component (the
ReactQueryDevtools symbol) with a runtime condition that only renders when
process.env.NODE_ENV === 'development' (or your app's equivalent env flag) so
devtools appear in dev builds and are excluded in production. Ensure the
conditional is applied where the app tree is rendered in main.tsx so the
devtools mount is automatic for development and omitted for prod.

In `@apps/web/src/routes/_authenticated/`$guildSlug/$channelId.tsx:
- Around line 104-105: The component's reply state from useReplyState
(replyingTo, setReplyingTo, clearReply) is not cleared when channelId changes,
causing stale referencedMessageId to leak across channels; add an effect that
watches channelId and calls clearReply (or setReplyingTo(null)) whenever
channelId changes to reset the local reply state and prevent cross-channel
leakage.

In `@packages/realtime-types/src/events.ts`:
- Around line 14-30: The attachmentPayloadSchema used by
sendMessagePayloadSchema is too permissive (accepts any valid URL and
contentType) allowing callers to bypass the uploads presign checks; update
validation to enforce the same allowlist/host and path constraints used by the
upload API (or invoke the shared upload URL validator) for
attachmentPayloadSchema (and/or revalidate attachments inside createMessage) so
only approved hosts/paths (e.g., your /uploads/presign origin and allowed
hostnames) or signed URLs are accepted before persisting.

In `@ROADMAP.md`:
- Line 24: Update the ROADMAP entry for "File/image attachment uploads (R2)" so
the roadmap isn't stale after this PR: either move that checklist item from
Phase 1 pending to the Completed/Done section (mark it shipped) or replace the
text with a new, specific follow-up task describing the remaining work that
truly belongs to R2 (e.g., "Finalize R2: server-side thumbnailing" or similar).
Edit the "File/image attachment uploads (R2)" line in ROADMAP.md accordingly and
ensure the checklist/section formatting remains consistent with the surrounding
items.

---

Outside diff comments:
In `@apps/web/src/main.tsx`:
- Line 3: Remove the unused ReactQueryDevtools import: delete the import
declaration for ReactQueryDevtools and ensure no remaining references to that
symbol (the devtools component is currently commented out in main.tsx), so the
code no longer contains the unused import that triggers lint warnings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c2d83d3c-42c2-4ae0-b2df-34a3261acc8e

📥 Commits

Reviewing files that changed from the base of the PR and between e832394 and ec6fa79.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (23)
  • .env.example
  • ROADMAP.md
  • apps/api/package.json
  • apps/api/src/app.ts
  • apps/api/src/lib/s3.ts
  • apps/api/src/routes/v1/uploads/handlers.ts
  • apps/api/src/routes/v1/uploads/index.ts
  • apps/api/src/routes/v1/uploads/routes.ts
  • apps/api/src/routes/v1/uploads/schema.ts
  • apps/realtime/src/index.ts
  • apps/realtime/src/services/messages.ts
  • apps/web/src/components/chat/composer/attachment-preview.tsx
  • apps/web/src/components/chat/composer/message-input.tsx
  • apps/web/src/components/chat/message-attachment.tsx
  • apps/web/src/components/chat/message-item.tsx
  • apps/web/src/hooks/use-file-upload.ts
  • apps/web/src/hooks/use-message-sending.ts
  • apps/web/src/lib/realtime-adapter.ts
  • apps/web/src/main.tsx
  • apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
  • apps/web/src/routes/_authenticated/dms/$dmId.tsx
  • packages/env/src/server.ts
  • packages/realtime-types/src/events.ts

Comment thread .env.example
Comment on lines +19 to +24
if (size > env.MAX_FILE_UPLOAD_SIZE) {
return c.json(
{ success: false, message: "File too large" },
HttpStatusCodes.FORBIDDEN
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider using HTTP 413 for payload size violations.

Returning FORBIDDEN (403) for oversized files conflates authorization failures with payload issues. HTTP 413 PAYLOAD_TOO_LARGE is semantically correct here and helps clients distinguish between "you're not allowed" vs "your file is too big."

Suggested fix
   if (size > env.MAX_FILE_UPLOAD_SIZE) {
     return c.json(
       { success: false, message: "File too large" },
-      HttpStatusCodes.FORBIDDEN
+      HttpStatusCodes.CONTENT_TOO_LARGE
     )
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/uploads/handlers.ts` around lines 19 - 24, Replace the
incorrect 403 response for oversized uploads with the proper 413 status: locate
the branch that checks "if (size > env.MAX_FILE_UPLOAD_SIZE)" and change the
c.json response that currently uses HttpStatusCodes.FORBIDDEN to use
HttpStatusCodes.PAYLOAD_TOO_LARGE (and adjust the message to indicate the
payload is too large) so clients receive the correct HTTP 413 semantics for
payload size violations.

Comment thread apps/api/src/routes/v1/uploads/handlers.ts
Comment thread apps/api/src/routes/v1/uploads/schema.ts Outdated
.refine((ct) => (ALLOWED_MIME_TYPES as readonly string[]).includes(ct), {
message: "Unsupported file type",
}),
size: z.number().int().min(1),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding a maximum size constraint to the schema.

The schema only validates min(1) for size, while the handler separately enforces MAX_FILE_UPLOAD_SIZE. Adding a max constraint to the schema would provide earlier validation and better OpenAPI documentation.

♻️ Suggested change to include max size validation
+import { env } from "@repo/env/server"
+
 export const presignRequestSchema = z.object({
   channelId: z.string().uuid(),
   filename: z.string().min(1).max(256),
   contentType: z
     .string()
     .refine((ct) => (ALLOWED_MIME_TYPES as readonly string[]).includes(ct), {
       message: "Unsupported file type",
     }),
-  size: z.number().int().min(1),
+  size: z.number().int().min(1).max(env.MAX_FILE_UPLOAD_SIZE),
 })

Note: If env cannot be imported at schema definition time due to module initialization order, the current handler-level enforcement is acceptable.

📝 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
size: z.number().int().min(1),
import { env } from "@repo/env/server"
export const presignRequestSchema = z.object({
channelId: z.string().uuid(),
filename: z.string().min(1).max(256),
contentType: z
.string()
.refine((ct) => (ALLOWED_MIME_TYPES as readonly string[]).includes(ct), {
message: "Unsupported file type",
}),
size: z.number().int().min(1).max(env.MAX_FILE_UPLOAD_SIZE),
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/uploads/schema.ts` at line 31, The size field in the
upload schema (size: z.number().int().min(1)) should also enforce the maximum
file size used by the handler: add .max(MAX_FILE_UPLOAD_SIZE) to the zod chain
so the schema validates the same limit as the handler (reference
MAX_FILE_UPLOAD_SIZE used in the upload handler). If importing
env/MAX_FILE_UPLOAD_SIZE at schema-definition time causes module init issues,
document that and keep the handler-level check as the source of truth; otherwise
reference the env constant when building the schema so OpenAPI and early
validation reflect the actual max.

Comment thread apps/web/src/hooks/use-file-upload.ts Outdated
Comment thread apps/web/src/main.tsx
</TooltipProvider>
</ThemeProvider>
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-right" />
{/*<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-right" />*/}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider environment-based conditional rendering or complete removal.

Commented-out code can clutter the codebase. Consider either:

  1. Removing the devtools entirely if no longer needed, or
  2. Conditionally rendering based on environment for automatic dev/prod handling
♻️ Alternative approach: Environment-based conditional rendering
-      {/*<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-right" />*/}
+      {import.meta.env.DEV && (
+        <ReactQueryDevtools initialIsOpen={false} buttonPosition="top-right" />
+      )}

This approach automatically enables devtools in development and disables them in production builds.

📝 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
{/*<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-right" />*/}
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-right" />
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/main.tsx` at line 44, The commented-out ReactQueryDevtools line
in main.tsx should be either removed or made environment-aware; either delete
the commented line entirely, or restore it and wrap the ReactQueryDevtools
component (the ReactQueryDevtools symbol) with a runtime condition that only
renders when process.env.NODE_ENV === 'development' (or your app's equivalent
env flag) so devtools appear in dev builds and are excluded in production.
Ensure the conditional is applied where the app tree is rendered in main.tsx so
the devtools mount is automatic for development and omitted for prod.

Comment thread apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
Comment on lines +14 to +30
export const attachmentPayloadSchema = z.object({
url: z.string().url(),
filename: z.string().min(1).max(256),
size: z.number().int().min(1),
contentType: z.string().min(1),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
})

export const sendMessagePayloadSchema = z
.object({
channelId: z.string().uuid(),
content: z.string().trim().max(2000).optional(),
nonce: z.string().max(100).optional(),
referencedMessageId: z.string().uuid().optional(),
attachments: z.array(attachmentPayloadSchema).max(10).optional(),
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The attachment payload is too permissive for a server-trusted path.

This only requires a syntactically valid URL and a non-empty contentType. A direct message:send caller can bypass the /uploads/presign checks and persist arbitrary external attachments, and createMessage stores them verbatim. Reuse the same allowlist/host validation as the upload API, or validate attachments again on the server before persisting them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/realtime-types/src/events.ts` around lines 14 - 30, The
attachmentPayloadSchema used by sendMessagePayloadSchema is too permissive
(accepts any valid URL and contentType) allowing callers to bypass the uploads
presign checks; update validation to enforce the same allowlist/host and path
constraints used by the upload API (or invoke the shared upload URL validator)
for attachmentPayloadSchema (and/or revalidate attachments inside createMessage)
so only approved hosts/paths (e.g., your /uploads/presign origin and allowed
hostnames) or signed URLs are accepted before persisting.

Comment thread ROADMAP.md Outdated

## Phase 1 — Core UX Gaps

- [ ] File/image attachment uploads (R2)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Mark the attachment upload item as shipped or split out the remaining follow-up.

This PR adds attachment support, but Line 24 still tracks File/image attachment uploads (R2) as pending in Phase 1. That leaves the roadmap stale immediately after merge. Either move this item to Completed or rename it to the specific remaining follow-up if “R2” means a narrower next step.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ROADMAP.md` at line 24, Update the ROADMAP entry for "File/image attachment
uploads (R2)" so the roadmap isn't stale after this PR: either move that
checklist item from Phase 1 pending to the Completed/Done section (mark it
shipped) or replace the text with a new, specific follow-up task describing the
remaining work that truly belongs to R2 (e.g., "Finalize R2: server-side
thumbnailing" or similar). Edit the "File/image attachment uploads (R2)" line in
ROADMAP.md accordingly and ensure the checklist/section formatting remains
consistent with the surrounding items.

- Add Sonner toasts for oversized/unsupported file upload errors
- Fix upload handler auth bypass for non-guild non-DM channels
- Use 413 instead of 403 for oversized uploads
- Validate referenced message belongs to same channel
- Fix race condition in useFileUpload slot checking
- Fix lightbox Escape key and reply display for grouped messages
- Clear reply state on channel change
Copy link
Copy Markdown

@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: 5

♻️ Duplicate comments (3)
apps/realtime/src/services/messages.ts (1)

113-144: ⚠️ Potential issue | 🟡 Minor

Hydrate the reply preview inside the transaction.

Lines 115-129 re-read the referenced row after commit with a separate db query. That means the reply can be persisted successfully, but the emitted realtime payload can still go out with referencedMessage: null or different data if the target changes between the validation/insert and this fetch. Reuse the row you already validated inside the transaction and build referencedMessage from that result instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/realtime/src/services/messages.ts` around lines 113 - 144, The code
re-queries the referenced message after commit causing potential stale/missing
reply previews; instead, when hasReply and input.payload.referencedMessageId are
validated inside the transaction, reuse the previously fetched row (the
refMsg/validated result obtained in-transaction) to populate referencedMessage
rather than issuing a new db.select after commit; construct referencedMessage
from that refMsg (assign id, content and author fields) within the transaction
scope so the emitted realtime payload uses the same validated data.
apps/web/src/routes/_authenticated/dms/$dmId.tsx (1)

77-80: ⚠️ Potential issue | 🟠 Major

Reset reply state when dmId changes.

This still carries replyingTo across DM navigation, so a reply selected in one conversation can be sent in another. Mirror the clearReply() effect from apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/_authenticated/dms/`$dmId.tsx around lines 77 - 80, The
reply selection persists across DM navigation because we never clear reply state
when dmId changes; add an effect that calls clearReply whenever dmId changes.
Specifically, in the component that uses useReplyState (replyingTo,
setReplyingTo, clearReply) add a useEffect watching dmId (and clearReply) and
invoke clearReply() inside it—mirror the clearReply effect used in the
$guildSlug/$channelId.tsx file so replies from one conversation are reset when
switching DMs.
apps/web/src/components/chat/composer/message-input.tsx (1)

95-102: ⚠️ Potential issue | 🟠 Major

Only clear the composer after the send actually succeeds.

useMessageSending can return early when the socket or user is unavailable and can roll the optimistic row back on ACK failure, but this component clears the editor, reply target, and attachments immediately after calling onSend. Make onSend resolve a success signal and gate the cleanup on that result.

Also applies to: 581-596

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/composer/message-input.tsx` around lines 95 -
102, The component currently calls the onSend callback and immediately clears
the editor, reply target, and attachments; change it to await a success signal
from onSend and only perform cleanup when it resolves successfully. Update the
onSend prop usage in message-input.tsx to expect a Promise<boolean> (or similar
success result) from onSend, call await onSend(content, options), and only if
the result indicates success then clear the editor state, reply target, and
attachments (the existing clearEditor/clearAttachments/setReplyTargetCleared
code paths); if onSend rejects or returns failure, keep the composer state
intact so optimistic rollbacks or retries work correctly. Ensure callers of
useMessageSending/onSend are updated to return the success boolean/promise so
the component can gate cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/chat/composer/attachment-preview.tsx`:
- Around line 25-27: The div rendering each attachment (the element with
key={attachment.id} in the attachment preview JSX) uses the deprecated Tailwind
v3 utility "flex-shrink-0"; update the className to use the Tailwind v4
equivalent "shrink-0" (replace "flex-shrink-0" with "shrink-0" in the className
string) so the preview cards no longer shrink.

In `@apps/web/src/components/chat/composer/message-input.tsx`:
- Around line 553-558: The send button currently bases canSend on
pendingAttachments.length while handleSend uses getUploadedAttachments(), so
failed uploads leave the button enabled but clicking does nothing; change
canSend (and the analogous check near the other block mentioned) to derive from
getUploadedAttachments().length and/or uploadedAttachments (the result of
getUploadedAttachments()) and also ensure isSending/content length checks
remain; update any early-return logic in handleSend/send-related handlers to use
uploadedAttachments (not pendingAttachments) so only actually uploaded
attachments enable sending.

In `@apps/web/src/components/chat/message-attachment.tsx`:
- Around line 17-26: The <track kind="captions" /> element is an empty
placeholder and provides no accessibility value; remove this empty <track> node
from the video element (the JSX that uses attachment.url) so you don't expose
meaningless caption tracks, or if you intentionally left it as a placeholder add
a clear inline comment explaining that captions are unavailable and why the
empty track remains; update the video JSX accordingly (the element that renders
the video with src={attachment.url}) to reflect either removal or the
explanatory comment.

In `@apps/web/src/hooks/use-file-upload.ts`:
- Around line 127-140: The current pattern declares and assigns the local
variable `accepted` inside the `setAttachments` updater which is unconventional
and confusing; move the calculation of accepted files out of the updater
(compute `accepted = prepared.slice(0, MAX_ATTACHMENTS - prev.length)` or
compute `remaining` and `accepted` before calling `setAttachments`) so you call
`setAttachments(prev => accepted.length === 0 ? prev : [...prev, ...accepted])`
with `accepted` already determined, and keep the existing `toast.error` logic
when `prepared.length > remaining`; alternatively rename to `acceptedRef` or add
a clear comment if you must capture it for use after the setter — update
references to `accepted`, `setAttachments`, `MAX_ATTACHMENTS`, `prepared`, and
the `toast.error` branch accordingly.

In `@ROADMAP.md`:
- Around line 22-25: Update the Phase 1 — Core UX Gaps list in ROADMAP.md to
reflect that message replies were shipped: either add a checked item like "- [x]
Message replies" under the same section or merge it into the existing "- [x]
File/image attachment uploads (R2)" entry so the roadmap matches the delivered
scope; ensure the checkbox is checked and the wording matches other items for
consistency (e.g., "Message replies" or "Replies and attachments").

---

Duplicate comments:
In `@apps/realtime/src/services/messages.ts`:
- Around line 113-144: The code re-queries the referenced message after commit
causing potential stale/missing reply previews; instead, when hasReply and
input.payload.referencedMessageId are validated inside the transaction, reuse
the previously fetched row (the refMsg/validated result obtained in-transaction)
to populate referencedMessage rather than issuing a new db.select after commit;
construct referencedMessage from that refMsg (assign id, content and author
fields) within the transaction scope so the emitted realtime payload uses the
same validated data.

In `@apps/web/src/components/chat/composer/message-input.tsx`:
- Around line 95-102: The component currently calls the onSend callback and
immediately clears the editor, reply target, and attachments; change it to await
a success signal from onSend and only perform cleanup when it resolves
successfully. Update the onSend prop usage in message-input.tsx to expect a
Promise<boolean> (or similar success result) from onSend, call await
onSend(content, options), and only if the result indicates success then clear
the editor state, reply target, and attachments (the existing
clearEditor/clearAttachments/setReplyTargetCleared code paths); if onSend
rejects or returns failure, keep the composer state intact so optimistic
rollbacks or retries work correctly. Ensure callers of useMessageSending/onSend
are updated to return the success boolean/promise so the component can gate
cleanup.

In `@apps/web/src/routes/_authenticated/dms/`$dmId.tsx:
- Around line 77-80: The reply selection persists across DM navigation because
we never clear reply state when dmId changes; add an effect that calls
clearReply whenever dmId changes. Specifically, in the component that uses
useReplyState (replyingTo, setReplyingTo, clearReply) add a useEffect watching
dmId (and clearReply) and invoke clearReply() inside it—mirror the clearReply
effect used in the $guildSlug/$channelId.tsx file so replies from one
conversation are reset when switching DMs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9b593806-15b3-4964-bf04-b159651c7215

📥 Commits

Reviewing files that changed from the base of the PR and between ec6fa79 and ca3de8e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (18)
  • ROADMAP.md
  • apps/api/src/lib/helpers/openapi/schemas.ts
  • apps/api/src/routes/v1/uploads/handlers.ts
  • apps/api/src/routes/v1/uploads/routes.ts
  • apps/api/src/routes/v1/uploads/schema.ts
  • apps/realtime/src/services/messages.ts
  • apps/web/package.json
  • apps/web/src/components/chat/composer/attachment-preview.tsx
  • apps/web/src/components/chat/composer/message-input.tsx
  • apps/web/src/components/chat/drop-zone-overlay.tsx
  • apps/web/src/components/chat/message-attachment.tsx
  • apps/web/src/components/chat/message-item.tsx
  • apps/web/src/hooks/use-file-upload.ts
  • apps/web/src/main.tsx
  • apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
  • apps/web/src/routes/_authenticated/dms/$dmId.tsx
  • packages/ui/package.json
  • packages/ui/src/components/sonner.tsx

Comment on lines +25 to +27
<div
key={attachment.id}
className="relative flex-shrink-0 rounded-md border border-border bg-background"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check Tailwind version in package.json
find . -name "package.json" -type f | head -20 | xargs grep -l "tailwind" 2>/dev/null

Repository: BuckyMcYolo/townhall

Length of output: 138


🏁 Script executed:

# Look for tailwind config
find . -name "tailwind.config.*" -type f

Repository: BuckyMcYolo/townhall

Length of output: 46


🏁 Script executed:

# Check the actual file content at the specified location
cat -n apps/web/src/components/chat/composer/attachment-preview.tsx | sed -n '20,35p'

Repository: BuckyMcYolo/townhall

Length of output: 846


🌐 Web query:

Tailwind CSS v4 flex-shrink utility naming changes

💡 Result:

In Tailwind CSS v4, the old flex-shrink-* utilities were removed (they were already deprecated in v3) and replaced with the shorter shrink-* naming scheme. [1]

Mapping:

  • flex-shrinkshrink [2]
  • flex-shrink-0shrink-0 [2]
  • flex-shrink-{n}shrink-{n} (e.g. flex-shrink-2shrink-2) [1]

Tailwind v4 also supports:

  • shrink-[<value>] (arbitrary values)
  • shrink-(<custom-property>) (CSS variable shorthand) [2]

Sources:
[1] Tailwind CSS Upgrade Guide (v3 → v4) — deprecated utilities removed/replaced
[2] Tailwind CSS docs: flex-shrink (v4) — shows shrink, shrink-0, and related syntaxes


🏁 Script executed:

# Check Tailwind version in the web app package.json
cat apps/web/package.json | jq '.devDependencies.tailwindcss, .dependencies.tailwindcss'

Repository: BuckyMcYolo/townhall

Length of output: 78


Replace deprecated Tailwind v3 utility with v4 equivalent.

The project uses Tailwind v4 (^4.1.18), which removed flex-shrink-0 and replaced it with shrink-0. Using the old class name will not prevent the preview cards from shrinking. Update to the new utility:

Suggested fix
-          className="relative flex-shrink-0 rounded-md border border-border bg-background"
+          className="relative shrink-0 rounded-md border border-border bg-background"
📝 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
<div
key={attachment.id}
className="relative flex-shrink-0 rounded-md border border-border bg-background"
<div
key={attachment.id}
className="relative shrink-0 rounded-md border border-border bg-background"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/composer/attachment-preview.tsx` around lines 25
- 27, The div rendering each attachment (the element with key={attachment.id} in
the attachment preview JSX) uses the deprecated Tailwind v3 utility
"flex-shrink-0"; update the className to use the Tailwind v4 equivalent
"shrink-0" (replace "flex-shrink-0" with "shrink-0" in the className string) so
the preview cards no longer shrink.

Comment on lines +553 to +558
const uploadedAttachments = getUploadedAttachments()
const hasAttachments = uploadedAttachments.length > 0
const hasContent = trimmed.length > 0

if ((!hasContent && !hasAttachments) || isSending) return
if (hasContent && trimmed.length > MAX_MESSAGE_LENGTH) return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Don't enable send for attachments that aren't actually sendable.

canSend uses pendingAttachments.length, but handleSend only submits getUploadedAttachments(). After an upload error, the send button stays enabled even though clicking it becomes a no-op. Derive canSend from successfully uploaded attachments instead.

Also applies to: 645-649

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/composer/message-input.tsx` around lines 553 -
558, The send button currently bases canSend on pendingAttachments.length while
handleSend uses getUploadedAttachments(), so failed uploads leave the button
enabled but clicking does nothing; change canSend (and the analogous check near
the other block mentioned) to derive from getUploadedAttachments().length and/or
uploadedAttachments (the result of getUploadedAttachments()) and also ensure
isSending/content length checks remain; update any early-return logic in
handleSend/send-related handlers to use uploadedAttachments (not
pendingAttachments) so only actually uploaded attachments enable sending.

Comment on lines +17 to +26
<video
src={attachment.url}
controls
preload="metadata"
className="max-h-[300px] max-w-[400px] rounded-md"
>
<track kind="captions" />
</video>
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Empty caption tracks provide no accessibility value.

The <track kind="captions" /> elements are placeholders that satisfy linting but don't provide actual captions. For proper accessibility, consider either removing them (if captions aren't available) or adding a comment explaining the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/message-attachment.tsx` around lines 17 - 26,
The <track kind="captions" /> element is an empty placeholder and provides no
accessibility value; remove this empty <track> node from the video element (the
JSX that uses attachment.url) so you don't expose meaningless caption tracks, or
if you intentionally left it as a placeholder add a clear inline comment
explaining that captions are unavailable and why the empty track remains; update
the video JSX accordingly (the element that renders the video with
src={attachment.url}) to reflect either removal or the explanatory comment.

Comment on lines +127 to +140
let accepted: PendingAttachment[] = []
setAttachments((prev) => {
const remaining = MAX_ATTACHMENTS - prev.length
accepted = prepared.slice(0, remaining)
if (prepared.length > remaining) {
toast.error(
`You can only attach up to ${MAX_ATTACHMENTS} files per message`
)
}
if (accepted.length === 0) return prev
return [...prev, ...accepted]
})

if (accepted.length === 0) return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider extracting accepted files calculation for clarity.

The pattern of declaring accepted outside setAttachments and assigning it inside works correctly because React's state setter executes synchronously. However, this pattern is unconventional and could confuse future maintainers.

♻️ Alternative approach for clarity
-      // Atomically check remaining slots and append
-      let accepted: PendingAttachment[] = []
-      setAttachments((prev) => {
-        const remaining = MAX_ATTACHMENTS - prev.length
-        accepted = prepared.slice(0, remaining)
-        if (prepared.length > remaining) {
-          toast.error(
-            `You can only attach up to ${MAX_ATTACHMENTS} files per message`
-          )
-        }
-        if (accepted.length === 0) return prev
-        return [...prev, ...accepted]
-      })
-
-      if (accepted.length === 0) return
+      // Atomically check remaining slots and append
+      let acceptedRef: PendingAttachment[] = []
+      setAttachments((prev) => {
+        const remaining = MAX_ATTACHMENTS - prev.length
+        const toAccept = prepared.slice(0, remaining)
+        if (prepared.length > remaining) {
+          toast.error(
+            `You can only attach up to ${MAX_ATTACHMENTS} files per message`
+          )
+        }
+        if (toAccept.length === 0) return prev
+        acceptedRef = toAccept // Capture for upload phase
+        return [...prev, ...toAccept]
+      })
+
+      if (acceptedRef.length === 0) return

Adding a comment or renaming to acceptedRef makes the intent clearer that this variable captures state for use after the setter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/use-file-upload.ts` around lines 127 - 140, The current
pattern declares and assigns the local variable `accepted` inside the
`setAttachments` updater which is unconventional and confusing; move the
calculation of accepted files out of the updater (compute `accepted =
prepared.slice(0, MAX_ATTACHMENTS - prev.length)` or compute `remaining` and
`accepted` before calling `setAttachments`) so you call `setAttachments(prev =>
accepted.length === 0 ? prev : [...prev, ...accepted])` with `accepted` already
determined, and keep the existing `toast.error` logic when `prepared.length >
remaining`; alternatively rename to `acceptedRef` or add a clear comment if you
must capture it for use after the setter — update references to `accepted`,
`setAttachments`, `MAX_ATTACHMENTS`, `prepared`, and the `toast.error` branch
accordingly.

Comment thread ROADMAP.md
Comment on lines +22 to +25
## Phase 1 — Core UX Gaps

- [x] File/image attachment uploads (R2)
- [ ] Message deletion
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add reply support to the shipped roadmap items.

This PR ships message replies as well as attachments, but the roadmap update only reflects the attachment half. That leaves ROADMAP.md stale immediately after merge. Please add a completed item for replies here or fold it into the completed entry so the roadmap matches the delivered scope.

📝 Suggested update
-## Phase 1 — Core UX Gaps
-
-- [x] File/image attachment uploads (R2)
+## Phase 1 — Core UX Gaps
+
+- [x] Message replies
+- [x] File/image attachment uploads
📝 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
## Phase 1 — Core UX Gaps
- [x] File/image attachment uploads (R2)
- [ ] Message deletion
## Phase 1 — Core UX Gaps
- [x] Message replies
- [x] File/image attachment uploads
- [ ] Message deletion
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ROADMAP.md` around lines 22 - 25, Update the Phase 1 — Core UX Gaps list in
ROADMAP.md to reflect that message replies were shipped: either add a checked
item like "- [x] Message replies" under the same section or merge it into the
existing "- [x] File/image attachment uploads (R2)" entry so the roadmap matches
the delivered scope; ensure the checkbox is checked and the wording matches
other items for consistency (e.g., "Message replies" or "Replies and
attachments").

@BuckyMcYolo BuckyMcYolo merged commit 2bc78b7 into main Mar 9, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 9, 2026
Merged
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.

1 participant