Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@

- [x] File/image attachment uploads (R2)
- [x] Message deletion
- [ ] Message editing UI
- [ ] User profiles (bio, custom status, avatar upload)
- [x] Message editing UI
- [x] User profiles (bio, custom status, avatar upload)
- [ ] Channel edit/delete
- [ ] Settings pages
- [x] Settings pages

## Phase 2 — Permissions & Moderation

Expand Down
34 changes: 32 additions & 2 deletions apps/api/src/routes/v1/uploads/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { and, eq } from "drizzle-orm"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import { s3Client } from "@/lib/s3"
import type { AppRouteHandler } from "@/lib/types/app-types"
import type { PresignRoute } from "./routes"
import { PRESIGNED_URL_EXPIRY_SECONDS } from "./schema"
import type { AvatarPresignRoute, PresignRoute } from "./routes"
import { MAX_AVATAR_SIZE, PRESIGNED_URL_EXPIRY_SECONDS } from "./schema"
Comment on lines +10 to +11
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 9, 2026

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Switch these local imports to @/ aliases.

These new relative specifiers add another inconsistency inside apps/api/src. Keep them on the alias path so the source tree follows the same tsc-alias rewrite contract everywhere.

Suggested change
-import type { AvatarPresignRoute, PresignRoute } from "./routes"
-import { MAX_AVATAR_SIZE, PRESIGNED_URL_EXPIRY_SECONDS } from "./schema"
+import type { AvatarPresignRoute, PresignRoute } from "@/routes/v1/uploads/routes"
+import { MAX_AVATAR_SIZE, PRESIGNED_URL_EXPIRY_SECONDS } from "@/routes/v1/uploads/schema"

As per coding guidelines, "Use @/* path aliases for imports in compiled packages like apps/api, with tsc-alias rewriting them to relative .js paths in the dist directory`."

🤖 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 10 - 11, Replace the
local relative imports in apps/api/src/routes/v1/uploads/handlers.ts with the
project path aliases: change the import of AvatarPresignRoute and PresignRoute
from "./routes" to "@/routes/v1/uploads/routes" (or the appropriate alias path
to that module) and change the import of MAX_AVATAR_SIZE and
PRESIGNED_URL_EXPIRY_SECONDS from "./schema" to "@/routes/v1/uploads/schema" (or
the correct alias path); ensure the symbols AvatarPresignRoute, PresignRoute,
MAX_AVATAR_SIZE, and PRESIGNED_URL_EXPIRY_SECONDS are referenced from the alias
imports so tsc-alias rewrites them correctly during build.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

why is this flagged as major?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@BuckyMcYolo, fair pushback — "Major" was too aggressive here. This is really a Minor style/consistency issue. Same-directory relative imports (./routes, ./schema) resolve correctly at build time without any help from tsc-alias, so there's no functional or runtime risk. The flag is purely about keeping the file internally consistent — the rest of handlers.ts already uses @/ aliases for all other imports, and these two new ones break that pattern.

In short: it won't cause a bug, it's just an inconsistency. I'll keep that in mind for future severity calibration.


🧠 Learnings used
Learnt from: CR
Repo: BuckyMcYolo/townhall PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-20T18:54:27.248Z
Learning: Applies to apps/api/src/**/*.ts : Use `@/*` path aliases for imports in compiled packages like `apps/api`, with `tsc-alias` rewriting them to relative `.js` paths in the dist directory

Learnt from: CR
Repo: BuckyMcYolo/townhall PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-20T18:54:27.248Z
Learning: Applies to packages/{db,env,auth,ui,typescript-config}/src/**/*.ts : Use relative imports (not path aliases) in uncompiled packages like `packages/db`, `packages/env`, `packages/auth` that are resolved at runtime by Node


const DM_CHANNEL_TYPES = ["dm", "group_dm"] as const

Expand Down Expand Up @@ -107,3 +107,33 @@ export const presign: AppRouteHandler<PresignRoute> = async (c) => {

return c.json({ uploadUrl, fileUrl, key }, HttpStatusCodes.OK)
}

export const avatarPresign: AppRouteHandler<AvatarPresignRoute> = async (c) => {
const user = c.var.user
const { filename, contentType, size } = c.req.valid("json")

if (size > MAX_AVATAR_SIZE) {
return c.json(
{ success: false, message: "File too large" },
HttpStatusCodes.REQUEST_TOO_LONG
)
}

const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_")
const key = `avatars/${user.id}/${crypto.randomUUID()}/${sanitizedFilename}`

const command = new PutObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: key,
ContentType: contentType,
ContentLength: size,
})

const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
})

const fileUrl = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/${key}`

return c.json({ uploadUrl, fileUrl }, HttpStatusCodes.OK)
}
4 changes: 3 additions & 1 deletion apps/api/src/routes/v1/uploads/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createRouter } from "@/lib/helpers/app/create-app"
import * as handlers from "./handlers"
import * as routes from "./routes"

const uploadsRouter = createRouter().openapi(routes.presign, handlers.presign)
const uploadsRouter = createRouter()
.openapi(routes.presign, handlers.presign)
.openapi(routes.avatarPresign, handlers.avatarPresign)

export default uploadsRouter
34 changes: 33 additions & 1 deletion apps/api/src/routes/v1/uploads/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
unauthorizedSchema,
} from "@/lib/helpers/openapi/schemas"
import { sessionAuthMiddleware } from "@/middleware/session-auth"
import { presignRequestSchema, presignResponseSchema } from "./schema"
import {
avatarPresignRequestSchema,
avatarPresignResponseSchema,
presignRequestSchema,
presignResponseSchema,
} from "./schema"
Comment on lines +11 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Use @/ imports in apps/api sources.

This new relative import block introduces an exception inside apps/api/src, even though this package is supposed to rely on @/* specifiers and let tsc-alias rewrite them during build.

Suggested change
 import {
   avatarPresignRequestSchema,
   avatarPresignResponseSchema,
   presignRequestSchema,
   presignResponseSchema,
-} from "./schema"
+} from "@/routes/v1/uploads/schema"

As per coding guidelines, "Use @/* path aliases for imports in compiled packages like apps/api, with tsc-alias rewriting them to relative .js paths in the dist directory`."

📝 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
import {
avatarPresignRequestSchema,
avatarPresignResponseSchema,
presignRequestSchema,
presignResponseSchema,
} from "./schema"
import {
avatarPresignRequestSchema,
avatarPresignResponseSchema,
presignRequestSchema,
presignResponseSchema,
} from "@/routes/v1/uploads/schema"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/uploads/routes.ts` around lines 11 - 16, The import
block in routes.ts uses a relative path ("./schema") which violates the package
rule to use path aliases; update the import to use the `@/` alias (e.g. import {
avatarPresignRequestSchema, avatarPresignResponseSchema, presignRequestSchema,
presignResponseSchema } from "@/routes/v1/uploads/schema") so tsc-alias can
rewrite it during build and keep the same named symbols unchanged.


export const presign = createRoute({
path: "/uploads/presign",
Expand Down Expand Up @@ -37,3 +42,30 @@ export const presign = createRoute({
})

export type PresignRoute = typeof presign

export const avatarPresign = createRoute({
path: "/uploads/avatar/presign",
method: "post",
summary: "Request a presigned URL for avatar upload",
description:
"Returns a presigned URL for uploading a user avatar to S3-compatible storage.",
tags: ["Uploads"],
middleware: [sessionAuthMiddleware] as const,
request: {
body: jsonContent({
schema: avatarPresignRequestSchema,
description: "Avatar file metadata",
}),
},
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: avatarPresignResponseSchema,
description: "Presigned URL for avatar upload",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.REQUEST_TOO_LONG]: payloadTooLargeSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})

export type AvatarPresignRoute = typeof avatarPresign
24 changes: 24 additions & 0 deletions apps/api/src/routes/v1/uploads/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,27 @@ export const presignResponseSchema = z.object({
fileUrl: z.string().url(),
key: z.string(),
})

const AVATAR_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
] as const

export const MAX_AVATAR_SIZE = 2 * 1024 * 1024 // 2 MB

export const avatarPresignRequestSchema = z.object({
filename: z.string().min(1).max(256),
contentType: z
.string()
.refine((ct) => (AVATAR_MIME_TYPES as readonly string[]).includes(ct), {
message: "Unsupported image type",
}),
size: z.number().int().min(1).max(MAX_AVATAR_SIZE),
})

export const avatarPresignResponseSchema = z.object({
uploadUrl: z.string().url(),
fileUrl: z.string().url(),
})
27 changes: 24 additions & 3 deletions apps/web/src/components/chat/message-action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
TooltipTrigger,
} from "@repo/ui/components/tooltip"
import { MoreHorizontal, Reply } from "lucide-react"
import { useEffect, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { EmojiReactionPicker } from "./emoji-reaction-picker"

interface MessageActionBarProps {
Expand All @@ -36,11 +36,32 @@ export function MessageActionBar({
}: MessageActionBarProps) {
const [isEmojiOpen, setIsEmojiOpen] = useState(false)
const [isMoreOpen, setIsMoreOpen] = useState(false)
const [suppressTooltip, setSuppressTooltip] = useState(false)
const suppressTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)

useEffect(() => {
onOverlayOpenChange?.(isEmojiOpen || isMoreOpen)
}, [isEmojiOpen, isMoreOpen, onOverlayOpenChange])

useEffect(() => {
return () => {
if (suppressTimeoutRef.current) clearTimeout(suppressTimeoutRef.current)
}
}, [])

const handleMoreOpenChange = useCallback((open: boolean) => {
setIsMoreOpen(open)
if (!open) {
// Suppress tooltip briefly after dropdown closes to prevent it from sticking
setSuppressTooltip(true)
if (suppressTimeoutRef.current) clearTimeout(suppressTimeoutRef.current)
suppressTimeoutRef.current = setTimeout(
() => setSuppressTooltip(false),
150
)
}
}, [])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="flex items-center gap-0.5 rounded-md border border-border bg-background p-0.5 shadow-sm">
<EmojiReactionPicker onSelect={onReact} onOpenChange={setIsEmojiOpen} />
Expand All @@ -61,8 +82,8 @@ export function MessageActionBar({
<TooltipContent side="top">Reply</TooltipContent>
</Tooltip>

<DropdownMenu open={isMoreOpen} onOpenChange={setIsMoreOpen}>
<Tooltip>
<DropdownMenu open={isMoreOpen} onOpenChange={handleMoreOpenChange}>
<Tooltip open={isMoreOpen || suppressTooltip ? false : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/message-edit-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export function MessageEditInput({
[]
)

// Force focus after mount — the Radix dropdown close animation steals focus back
useEffect(() => {
if (!editor) return
const timeout = setTimeout(() => {
editor.commands.focus("end")
}, 200)
return () => clearTimeout(timeout)
}, [editor])

const handleSave = useCallback(() => {
if (!editor) return

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function ReplyPreview({
<button
type="button"
onClick={() => scrollToMessage(referencedMessage.id)}
className="mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-sm text-xs hover:opacity-80"
className="mb-0.5 flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-xs hover:opacity-80"
>
<div className="mb-1 ml-4 h-3 w-8 rounded-tl-md border-t-2 border-l-2 border-muted-foreground/40" />
<Avatar size="sm" className="size-4">
Expand All @@ -102,7 +102,7 @@ function ReplyPreview({
{nameInitial(author.displayUsername ?? author.name)}
</AvatarFallback>
</Avatar>
<span className="font-semibold text-foreground/80">
<span className="shrink-0 font-semibold text-foreground/80">
{author.displayUsername ?? author.name}
</span>
{truncatedContent && (
Expand Down
Loading