delete messages + fix layout overflow
- DELETE /api/channels/{id}/messages/{msg_id} — soft delete, own only
- Broadcasts WsEvent::Delete to subscribers
- UI: "Del" button on hover for own messages (turns red)
- Fix layout: h-full + overflow-hidden on flex containers
- ScrollArea gets min-h-0 to properly constrain in flex
- Messages no longer push compose past viewport
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,10 @@ async fn main() {
|
|||||||
"/api/channels/{channel_id}/messages",
|
"/api/channels/{channel_id}/messages",
|
||||||
get(routes::list_messages).post(routes::post_message),
|
get(routes::list_messages).post(routes::post_message),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/channels/{channel_id}/messages/{msg_id}",
|
||||||
|
axum::routing::delete(routes::delete_message),
|
||||||
|
)
|
||||||
.route("/ws/{channel_id}", get(ws::ws_handler))
|
.route("/ws/{channel_id}", get(ws::ws_handler))
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new("static").fallback(ServeFile::new("static/index.html")),
|
ServeDir::new("static").fallback(ServeFile::new("static/index.html")),
|
||||||
|
|||||||
@@ -278,6 +278,47 @@ pub async fn post_message(
|
|||||||
Ok((StatusCode::CREATED, Json(message)))
|
Ok((StatusCode::CREATED, Json(message)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_message(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((channel_id, msg_id)): Path<(String, String)>,
|
||||||
|
Query(user_param): Query<UserParam>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let user_id = resolve_user(&state.db, &user_param).await?;
|
||||||
|
|
||||||
|
// Verify message exists and belongs to this user
|
||||||
|
let row = sqlx::query_as::<_, MessageWithUserRow>(
|
||||||
|
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
|
||||||
|
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ? AND m.channel_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&msg_id)
|
||||||
|
.bind(&channel_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = match row {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Err(AppError::NotFound("Message not found".into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if row.user_id != user_id {
|
||||||
|
return Err(AppError::BadRequest("Can only delete your own messages".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
sqlx::query("UPDATE messages SET deleted_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
|
||||||
|
.bind(&msg_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Broadcast delete event
|
||||||
|
let tx = state.get_sender(&channel_id).await;
|
||||||
|
let _ = tx.send(WsEvent::Delete {
|
||||||
|
id: uuid::Uuid::parse_str(&msg_id).unwrap(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Joined row type for message + user ──
|
// ── Joined row type for message + user ──
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Channel } from "@/types/Channel";
|
import type { Channel } from "@/types/Channel";
|
||||||
import type { Message } from "@/types/Message";
|
import type { Message } from "@/types/Message";
|
||||||
import { getChannels, getMessages, getCurrentUsername } from "@/api";
|
import { getChannels, getMessages, getCurrentUsername, deleteMessage } from "@/api";
|
||||||
import { ChannelSidebar } from "@/components/ChannelSidebar";
|
import { ChannelSidebar } from "@/components/ChannelSidebar";
|
||||||
import { MessageItem } from "@/components/MessageItem";
|
import { MessageItem } from "@/components/MessageItem";
|
||||||
import { ComposeBox } from "@/components/ComposeBox";
|
import { ComposeBox } from "@/components/ComposeBox";
|
||||||
@@ -121,12 +121,12 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full overflow-hidden">
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:flex md:flex-col md:h-full">
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
|
||||||
<div className="px-4 py-3 md:px-6 md:py-4 border-b-2 border-border flex items-center gap-3">
|
<div className="px-4 py-3 md:px-6 md:py-4 border-b-2 border-border flex items-center gap-3">
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetTrigger className="md:hidden p-1 h-8 w-8 text-muted-foreground hover:text-primary font-mono font-bold text-lg">
|
<SheetTrigger className="md:hidden p-1 h-8 w-8 text-muted-foreground hover:text-primary font-mono font-bold text-lg">
|
||||||
@@ -155,7 +155,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea ref={scrollRef} className="flex-1">
|
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
|
||||||
{loading && messages.length === 0 ? (
|
{loading && messages.length === 0 ? (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
@@ -178,12 +178,21 @@ export default function App() {
|
|||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg}
|
message={msg}
|
||||||
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
|
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
|
||||||
|
currentUsername={getCurrentUsername()}
|
||||||
onReply={(id) => {
|
onReply={(id) => {
|
||||||
const target = messagesById.get(id);
|
const target = messagesById.get(id);
|
||||||
if (target) {
|
if (target) {
|
||||||
setReplyTo({ id, username: target.user.display_name, content: target.content });
|
setReplyTo({ id, username: target.user.display_name, content: target.content });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDelete={async (chId, msgId) => {
|
||||||
|
try {
|
||||||
|
await deleteMessage(chId, msgId);
|
||||||
|
loadMessages();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -74,3 +74,14 @@ export async function postMessage(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteMessage(
|
||||||
|
channelId: string,
|
||||||
|
msgId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE}/channels/${channelId}/messages/${msgId}?${userQuery()}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Props {
|
|||||||
message: Message;
|
message: Message;
|
||||||
replyTarget?: Message;
|
replyTarget?: Message;
|
||||||
onReply: (id: string) => void;
|
onReply: (id: string) => void;
|
||||||
|
onDelete: (channelId: string, msgId: string) => void;
|
||||||
|
currentUsername: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_CONFIG: Record<string, { border: string; label: string; labelBg: string }> = {
|
const TYPE_CONFIG: Record<string, { border: string; label: string; labelBg: string }> = {
|
||||||
@@ -53,7 +55,7 @@ function userHue(username: string): number {
|
|||||||
return Math.abs(hash) % 360;
|
return Math.abs(hash) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageItem({ message, replyTarget, onReply }: Props) {
|
export function MessageItem({ message, replyTarget, onReply, onDelete, currentUsername }: Props) {
|
||||||
const [metaOpen, setMetaOpen] = useState(false);
|
const [metaOpen, setMetaOpen] = useState(false);
|
||||||
const isAgent = message.user.role === "agent";
|
const isAgent = message.user.role === "agent";
|
||||||
const isDeleted = !!message.deleted_at;
|
const isDeleted = !!message.deleted_at;
|
||||||
@@ -147,14 +149,25 @@ export function MessageItem({ message, replyTarget, onReply }: Props) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Reply button */}
|
{/* Actions */}
|
||||||
|
<div className="ml-auto flex items-center gap-1 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onReply(message.id)}
|
onClick={() => onReply(message.id)}
|
||||||
className="font-sans text-[10px] font-bold uppercase tracking-wider text-muted-foreground md:opacity-0 md:group-hover:opacity-60 hover:!text-primary transition-all ml-auto min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-end"
|
className="font-sans text-[10px] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
|
{!isDeleted && message.user.username === currentUsername && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(message.channel_id, message.id)}
|
||||||
|
className="font-sans text-[10px] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user