diff --git a/crates/colony/src/main.rs b/crates/colony/src/main.rs index 2588d15..fb540e4 100644 --- a/crates/colony/src/main.rs +++ b/crates/colony/src/main.rs @@ -53,6 +53,10 @@ async fn main() { "/api/channels/{channel_id}/messages/{msg_id}", axum::routing::delete(routes::delete_message), ) + .route( + "/api/channels/{channel_id}/messages/{msg_id}/restore", + axum::routing::post(routes::restore_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 8649372..675958e 100644 --- a/crates/colony/src/routes.rs +++ b/crates/colony/src/routes.rs @@ -319,6 +319,41 @@ pub async fn delete_message( Ok(StatusCode::NO_CONTENT) } +pub async fn restore_message( + State(state): State, + Path((channel_id, msg_id)): Path<(String, String)>, +) -> Result { + // Restore soft-deleted message (any user can restore) + let result = sqlx::query( + "UPDATE messages SET deleted_at = NULL WHERE id = ? AND channel_id = ? AND deleted_at IS NOT NULL", + ) + .bind(&msg_id) + .bind(&channel_id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Message not found or not deleted".into())); + } + + // Fetch restored message + 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 = ?", + ) + .bind(&msg_id) + .fetch_one(&state.db) + .await?; + + let message = row.to_api_message(); + + // Broadcast as new message (restored) + let tx = state.get_sender(&channel_id).await; + let _ = tx.send(WsEvent::Message(message.clone())); + + Ok(Json(message)) +} + // ── 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 8a1685b..731a4f3 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, deleteMessage } from "@/api"; +import { getChannels, getMessages, getCurrentUsername, deleteMessage, restoreMessage } from "@/api"; import { ChannelSidebar } from "@/components/ChannelSidebar"; import { MessageItem } from "@/components/MessageItem"; import { ComposeBox } from "@/components/ComposeBox"; @@ -251,6 +251,14 @@ export default function App() { // ignore } }} + onRestore={async (chId, msgId) => { + try { + await restoreMessage(chId, msgId); + loadMessages(); + } catch { + // ignore + } + }} /> ); diff --git a/ui/colony/src/api.ts b/ui/colony/src/api.ts index e59ef4f..fd94aa7 100644 --- a/ui/colony/src/api.ts +++ b/ui/colony/src/api.ts @@ -85,3 +85,14 @@ export async function deleteMessage( ); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); } + +export async function restoreMessage( + channelId: string, + msgId: string, +): Promise { + return json( + await fetch(`${BASE}/channels/${channelId}/messages/${msgId}/restore`, { + method: "POST", + }), + ); +} diff --git a/ui/colony/src/components/MessageItem.tsx b/ui/colony/src/components/MessageItem.tsx index f34e5b1..3468d97 100644 --- a/ui/colony/src/components/MessageItem.tsx +++ b/ui/colony/src/components/MessageItem.tsx @@ -11,6 +11,7 @@ interface Props { replyTarget?: Message; onSelect: (id: string) => void; onDelete: (channelId: string, msgId: string) => void; + onRestore: (channelId: string, msgId: string) => void; currentUsername: string; selected: boolean; } @@ -72,7 +73,7 @@ function userHue(username: string): number { return Math.abs(hash) % 360; } -export function MessageItem({ message, compact, replyTarget, onSelect, onDelete, currentUsername, selected }: Props) { +export function MessageItem({ message, compact, replyTarget, onSelect, onDelete, onRestore, currentUsername, selected }: Props) { const [metaOpen, setMetaOpen] = useState(false); const isAgent = message.user.role === "agent"; const isDeleted = !!message.deleted_at; @@ -193,6 +194,16 @@ export function MessageItem({ message, compact, replyTarget, onSelect, onDelete, × )} + {isDeleted && ( + + )} {/* Content */}