Private
Public Access
Add the ability to delete a message (#53)
Message.deleted_at has existed since the initial schema but was never wired up -- no WS envelope, no permission check, no frontend concept of it at all. Soft delete, author-only (mirrors the existing edit permission exactly): content and any attached image/file are cleared and the underlying MessageImage/MessageFile row and stored file are actually removed, not just detached, so the message becomes a "This message was deleted" tombstone with nothing left to recover through a stale attachment URL. A deleted message can no longer be edited or reacted to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -171,6 +171,21 @@ export function ChatPane({
|
||||
setLive((prev) =>
|
||||
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
|
||||
)
|
||||
} else if (envelope.type === 'message_deleted' && envelope.room_id === room.id) {
|
||||
// Mirrors what the server already did to the row (see
|
||||
// message_service.delete_message) -- content/image/file/preview
|
||||
// cleared, deleted_at set. `reactions` is left alone; MessageList
|
||||
// just doesn't render it once deleted_at is set, same as it
|
||||
// doesn't render anything else here.
|
||||
const tombstone = {
|
||||
content: null,
|
||||
image_id: null,
|
||||
file: null,
|
||||
link_preview: null,
|
||||
deleted_at: new Date().toISOString(),
|
||||
}
|
||||
setHistory((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
|
||||
setLive((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
|
||||
} else if (envelope.type === 'error') {
|
||||
setWsError(envelope.detail)
|
||||
}
|
||||
@@ -212,6 +227,10 @@ export function ChatPane({
|
||||
(messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji),
|
||||
[socket, room.id],
|
||||
)
|
||||
const sendDelete = useCallback(
|
||||
(messageId: string) => socket.sendDelete(room.id, messageId),
|
||||
[socket, room.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="chat-pane">
|
||||
@@ -264,6 +283,7 @@ export function ChatPane({
|
||||
myRooms={myRooms}
|
||||
onEdit={sendEdit}
|
||||
onReact={sendReaction}
|
||||
onDelete={sendDelete}
|
||||
/>
|
||||
<Composer
|
||||
roomId={room.id}
|
||||
|
||||
@@ -304,6 +304,25 @@
|
||||
border-color: var(--ds-accent);
|
||||
}
|
||||
|
||||
.message-delete-link {
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
color: var(--ds-muted);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.message-delete-link:hover {
|
||||
color: var(--ds-danger);
|
||||
border-color: var(--ds-danger);
|
||||
}
|
||||
|
||||
.message-deleted-text {
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.message-reaction-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -67,9 +67,18 @@ interface MessageListProps {
|
||||
myRooms: Map<string, string>
|
||||
onEdit: (messageId: string, content: string) => void
|
||||
onReact: (messageId: string, emoji: string) => void
|
||||
onDelete: (messageId: string) => void
|
||||
}
|
||||
|
||||
export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) {
|
||||
export function MessageList({
|
||||
roomId,
|
||||
messages,
|
||||
members,
|
||||
myRooms,
|
||||
onEdit,
|
||||
onReact,
|
||||
onDelete,
|
||||
}: MessageListProps) {
|
||||
const { user } = useAuth()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
@@ -133,6 +142,14 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
function handleDelete(messageId: string) {
|
||||
// Matches the confirm() pattern already used for other destructive
|
||||
// actions in this app (RoomInfoPanel's leave/delete-room,
|
||||
// ProfileModal's delete-theme) rather than a custom dialog.
|
||||
if (!confirm("Delete this message? This can't be undone.")) return
|
||||
onDelete(messageId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list" ref={containerRef}>
|
||||
{messages.map((msg, i) => {
|
||||
@@ -144,6 +161,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
// applies uniformly, including to your own messages.
|
||||
const isGroupStart = !prev || prev.user_id !== msg.user_id
|
||||
const editing = editingId === msg.id
|
||||
const deleted = !!msg.deleted_at
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
|
||||
@@ -166,7 +184,11 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
{deleted ? (
|
||||
<div className="message-text message-deleted-text">
|
||||
<em>This message was deleted</em>
|
||||
</div>
|
||||
) : editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={Math.min(10, draft.split('\n').length)}
|
||||
@@ -231,7 +253,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
{!editing && !deleted && (
|
||||
<div className="message-row-actions">
|
||||
<div className="message-reaction-wrap">
|
||||
<button
|
||||
@@ -277,6 +299,16 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{mine && (
|
||||
<button
|
||||
type="button"
|
||||
className="message-delete-link"
|
||||
onClick={() => handleDelete(msg.id)}
|
||||
aria-label="Delete message"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user