From 1830b520a6cf0964168ae5d0b279d6b5fea4cdf2 Mon Sep 17 00:00:00 2001 From: limiteinductive Date: Sun, 29 Mar 2026 21:19:44 +0200 Subject: [PATCH] delete messages + fix layout overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- crates/colony/src/main.rs | 4 +++ crates/colony/src/routes.rs | 41 ++++++++++++++++++++++++ ui/colony/src/App.tsx | 19 ++++++++--- ui/colony/src/api.ts | 11 +++++++ ui/colony/src/components/MessageItem.tsx | 31 ++++++++++++------ 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/colony/src/main.rs b/crates/colony/src/main.rs index b9c692d..2588d15 100644 --- a/crates/colony/src/main.rs +++ b/crates/colony/src/main.rs @@ -49,6 +49,10 @@ async fn main() { "/api/channels/{channel_id}/messages", 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)) .fallback_service( ServeDir::new("static").fallback(ServeFile::new("static/index.html")), diff --git a/crates/colony/src/routes.rs b/crates/colony/src/routes.rs index b034d85..8649372 100644 --- a/crates/colony/src/routes.rs +++ b/crates/colony/src/routes.rs @@ -278,6 +278,47 @@ pub async fn post_message( Ok((StatusCode::CREATED, Json(message))) } +pub async fn delete_message( + State(state): State, + Path((channel_id, msg_id)): Path<(String, String)>, + Query(user_param): Query, +) -> Result { + 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 ── #[derive(Debug, sqlx::FromRow)] diff --git a/ui/colony/src/App.tsx b/ui/colony/src/App.tsx index e171469..b2b3214 100644 --- a/ui/colony/src/App.tsx +++ b/ui/colony/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Channel } from "@/types/Channel"; 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 { MessageItem } from "@/components/MessageItem"; import { ComposeBox } from "@/components/ComposeBox"; @@ -121,12 +121,12 @@ export default function App() { ); return ( -
-
+
+
{sidebar}
-
+
@@ -155,7 +155,7 @@ export default function App() { )}
- + {loading && messages.length === 0 ? (
{[1, 2, 3].map((i) => ( @@ -178,12 +178,21 @@ export default function App() { key={msg.id} message={msg} replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined} + currentUsername={getCurrentUsername()} onReply={(id) => { const target = messagesById.get(id); if (target) { setReplyTo({ id, username: target.user.display_name, content: target.content }); } }} + onDelete={async (chId, msgId) => { + try { + await deleteMessage(chId, msgId); + loadMessages(); + } catch { + // ignore + } + }} /> )) )} diff --git a/ui/colony/src/api.ts b/ui/colony/src/api.ts index cec358d..e59ef4f 100644 --- a/ui/colony/src/api.ts +++ b/ui/colony/src/api.ts @@ -74,3 +74,14 @@ export async function postMessage( }), ); } + +export async function deleteMessage( + channelId: string, + msgId: string, +): Promise { + const res = await fetch( + `${BASE}/channels/${channelId}/messages/${msgId}?${userQuery()}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); +} diff --git a/ui/colony/src/components/MessageItem.tsx b/ui/colony/src/components/MessageItem.tsx index 0438e45..c771b83 100644 --- a/ui/colony/src/components/MessageItem.tsx +++ b/ui/colony/src/components/MessageItem.tsx @@ -9,6 +9,8 @@ interface Props { message: Message; replyTarget?: Message; onReply: (id: string) => void; + onDelete: (channelId: string, msgId: string) => void; + currentUsername: string; } const TYPE_CONFIG: Record = { @@ -53,7 +55,7 @@ function userHue(username: string): number { 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 isAgent = message.user.role === "agent"; const isDeleted = !!message.deleted_at; @@ -147,14 +149,25 @@ export function MessageItem({ message, replyTarget, onReply }: Props) { - {/* Reply button */} - + {/* Actions */} +
+ + {!isDeleted && message.user.username === currentUsername && ( + + )} +
{/* Content */}