restore/undo for deleted messages — backend + UI
- POST /api/channels/{id}/messages/{msg_id}/restore — undeletes a message
- Any user can restore (not just the deleter)
- Broadcasts restored message via WebSocket
- UI: "undo" button in floating pill on deleted messages
- API: restoreMessage() in api.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,10 @@ async fn main() {
|
|||||||
"/api/channels/{channel_id}/messages/{msg_id}",
|
"/api/channels/{channel_id}/messages/{msg_id}",
|
||||||
axum::routing::delete(routes::delete_message),
|
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))
|
.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")),
|
||||||
|
|||||||
@@ -319,6 +319,41 @@ pub async fn delete_message(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn restore_message(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((channel_id, msg_id)): Path<(String, String)>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
// 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 ──
|
// ── 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, deleteMessage } from "@/api";
|
import { getChannels, getMessages, getCurrentUsername, deleteMessage, restoreMessage } 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";
|
||||||
@@ -251,6 +251,14 @@ export default function App() {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onRestore={async (chId, msgId) => {
|
||||||
|
try {
|
||||||
|
await restoreMessage(chId, msgId);
|
||||||
|
loadMessages();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,3 +85,14 @@ export async function deleteMessage(
|
|||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreMessage(
|
||||||
|
channelId: string,
|
||||||
|
msgId: string,
|
||||||
|
): Promise<Message> {
|
||||||
|
return json(
|
||||||
|
await fetch(`${BASE}/channels/${channelId}/messages/${msgId}/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
replyTarget?: Message;
|
replyTarget?: Message;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onDelete: (channelId: string, msgId: string) => void;
|
onDelete: (channelId: string, msgId: string) => void;
|
||||||
|
onRestore: (channelId: string, msgId: string) => void;
|
||||||
currentUsername: string;
|
currentUsername: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ function userHue(username: string): number {
|
|||||||
return Math.abs(hash) % 360;
|
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 [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;
|
||||||
@@ -193,6 +194,16 @@ export function MessageItem({ message, compact, replyTarget, onSelect, onDelete,
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isDeleted && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRestore(message.channel_id, message.id); }}
|
||||||
|
className="px-2.5 py-1.5 text-[10px] font-mono text-muted-foreground hover:text-primary hover:bg-muted/50 transition-colors border-l-2 border-border"
|
||||||
|
title="Restore deleted message"
|
||||||
|
>
|
||||||
|
undo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user