feat: delete (unsend) your own messages
A 'Delete' action in the message picker (own messages, any time) sends a WS 'delete' frame; the server hard-deletes the message + its reactions if you're the author (deleteMessage, author-gated + thread-bound) and broadcasts it; clients drop the bubble and re-flow the separator/grouping around it. Unsend-style — the ciphertext is actually removed, not hidden. Added TestDeleteMessage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 files changed,
+112
−1
db.go+26 −0db_test.go+22 −0hub.go+16 −1web/static/chat.js+48 −0
diff --git a/db.go b/db.go| index e9c8bae..bdb9844 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -898,6 +898,32 @@ func (db *DB) editMessage(id int64, blob, authorHash string, window time.Duratio |
| return Message{ID: id, Blob: blob, EditedAt: now.Unix()}, true, nil |
| } |
| +// deleteMessage hard-deletes a message (and its reactions) if authorHash is the |
| +// author — unsend-style, so the ciphertext is actually gone, not just hidden. |
| +// Returns whether a row was removed (false = not the author / unknown id). |
| +func (db *DB) deleteMessage(id int64, authorHash string) (bool, error) { |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return false, err |
| + } |
| + defer tx.Rollback() |
| + res, err := tx.Exec("DELETE FROM messages WHERE id = ? AND author_hash = ?", id, authorHash) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return false, err |
| + } |
| + if n == 0 { |
| + return false, tx.Commit() |
| + } |
| + if _, err := tx.Exec("DELETE FROM reactions WHERE message_id = ?", id); err != nil { |
| + return false, err |
| + } |
| + return true, tx.Commit() |
| +} |
| + |
| // messagesBefore returns the `limit` messages immediately older than `before` |
| // (id < before), ascending — used to paginate history upward. |
| func (db *DB) messagesBefore(threadID string, before int64, limit int) ([]Message, error) { |
diff --git a/db_test.go b/db_test.go| index e13912d..cbd8f8e 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -78,6 +78,28 @@ func TestMessages(t *testing.T) { |
| } |
| } |
| +func TestDeleteMessage(t *testing.T) { |
| + db := newTestDB(t) |
| + m1, _ := db.insertMessage("t1", "blob1", "author-a") |
| + db.insertMessage("t1", "blob2", "author-b") |
| + |
| + // A non-author can't delete it. |
| + if ok, err := db.deleteMessage(m1.ID, "author-b"); err != nil || ok { |
| + t.Fatalf("non-author delete: ok=%v err=%v (want ok=false)", ok, err) |
| + } |
| + if all, _ := db.messagesAfter("t1", 0, 100); len(all) != 2 { |
| + t.Fatalf("message wrongly deleted by a non-author") |
| + } |
| + // The author can delete it; the other message survives. |
| + if ok, err := db.deleteMessage(m1.ID, "author-a"); err != nil || !ok { |
| + t.Fatalf("author delete: ok=%v err=%v (want ok=true)", ok, err) |
| + } |
| + all, _ := db.messagesAfter("t1", 0, 100) |
| + if len(all) != 1 || all[0].Blob != "blob2" { |
| + t.Fatalf("after delete: %+v (want only blob2)", all) |
| + } |
| +} |
| + |
| func TestMessagesPagination(t *testing.T) { |
| db := newTestDB(t) |
| ids := make([]int64, 0, 5) |
diff --git a/hub.go b/hub.go| index 158f7cc..72a37f2 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -202,7 +202,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| s.markActive(token) // this device is open + focused; mute its siblings' push |
| continue |
| } |
| - if in.Type != "msg" && in.Type != "edit" && in.Type != "react" && in.Type != "unreact" && in.Type != "typing" { |
| + if in.Type != "msg" && in.Type != "edit" && in.Type != "delete" && in.Type != "react" && in.Type != "unreact" && in.Type != "typing" { |
| continue |
| } |
| // Re-check the credential so a revoked invite / removed device can't keep |
| @@ -263,6 +263,21 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| out, _ := json.Marshal(wsOut{Type: "edit", ThreadID: in.ThreadID, ID: m.ID, Blob: m.Blob, EditedAt: m.EditedAt}) |
| s.broadcastToThread(in.ThreadID, out) |
| + case "delete": |
| + if in.MessageID <= 0 || !s.db.messageInThread(in.MessageID, in.ThreadID) { |
| + continue |
| + } |
| + ok, err := s.db.deleteMessage(in.MessageID, userID) |
| + if err != nil { |
| + log.Println("delete message:", err) |
| + continue |
| + } |
| + if !ok { |
| + continue // not the author, or unknown id — silently ignore |
| + } |
| + out, _ := json.Marshal(wsOut{Type: "delete", ThreadID: in.ThreadID, ID: in.MessageID}) |
| + s.broadcastToThread(in.ThreadID, out) |
| + |
| case "react", "unreact": |
| if in.MessageID <= 0 || !allowedReactions[in.Emoji] || |
| !s.db.messageInThread(in.MessageID, in.ThreadID) { |
diff --git a/web/static/chat.js b/web/static/chat.js| index 1be7a74..2315ada 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -89,6 +89,12 @@ const ICON_REACT = |
| '<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>' + |
| '<line x1="9" y1="9" x2="9.01" y2="9"></line>' + |
| '<line x1="15" y1="9" x2="15.01" y2="9"></line></svg>'; |
| +// Trash, for the "Delete" action in the message picker. |
| +const ICON_TRASH = |
| + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + |
| + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| + '<polyline points="3 6 5 6 21 6"></polyline>' + |
| + '<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>'; |
| let msgEls = {}; // message id -> its .msg element (for live reaction/edit updates) |
| let msgData = {}; // message id -> { mine, createdAt, data, replyMeta } (edit/reply) |
| @@ -628,6 +634,13 @@ function prevMsgOf(el) { |
| return p; |
| } |
| +// Nearest .msg sibling below `el`, skipping any .time-sep between messages. |
| +function nextMsgOf(el) { |
| + let n = el && el.nextElementSibling; |
| + while (n && !n.classList.contains("msg")) n = n.nextElementSibling; |
| + return n; |
| +} |
| + |
| // Ensure the right separator (or none) sits before `el`, based on the gap to the |
| // previous message. `el.dataset.t` is the message time (ms). The feed only grows |
| // at the bottom here (no history paging), so refreshing the appended message is |
| @@ -996,6 +1009,19 @@ function openReactionPicker(msgId, anchor) { |
| }; |
| pop.appendChild(edit); |
| } |
| + // Delete (unsend), for your own messages — any time. |
| + if (d && d.mine) { |
| + const del = document.createElement("button"); |
| + del.className = "picker-action"; |
| + del.innerHTML = ICON_TRASH; // static in-code SVG (safe) |
| + del.setAttribute("aria-label", "Delete"); |
| + del.title = "Delete"; |
| + del.onclick = () => { |
| + pop.remove(); |
| + if (confirm("Delete this message for everyone? This can't be undone.")) deleteMessage(msgId); |
| + }; |
| + pop.appendChild(del); |
| + } |
| document.body.appendChild(pop); |
| const r = anchor.getBoundingClientRect(); |
| pop.style.top = `${Math.max(8, r.top - pop.offsetHeight - 8)}px`; |
| @@ -1312,6 +1338,13 @@ function cancelEdit() { |
| } |
| } |
| +// Unsend a message (server deletes it for everyone if we're the author). |
| +function deleteMessage(msgId) { |
| + if (!ws || ws.readyState !== WebSocket.OPEN || !activeId) return; |
| + if (editing === msgId) cancelEdit(); // don't keep editing a message we're deleting |
| + ws.send(JSON.stringify({ type: "delete", thread_id: activeId, message_id: msgId })); |
| +} |
| + |
| // ---- attachments (images) ----------------------------------------------- |
| // Downscale + re-encode an image in-browser (caps the long edge, strips EXIF/GPS). |
| @@ -1471,6 +1504,21 @@ function connectWS() { |
| } |
| return; |
| } |
| + if (msg.type === "delete") { |
| + // Hard-deleted upstream: drop the bubble + its tracked state. (A separator |
| + // above it may briefly linger; harmless and gone on next thread open.) |
| + if (msg.thread_id === activeId && msgEls[msg.id]) { |
| + const next = nextMsgOf(msgEls[msg.id]); |
| + msgEls[msg.id].remove(); |
| + delete msgEls[msg.id]; |
| + delete msgData[msg.id]; |
| + if (next) { |
| + refreshSeparator(next); |
| + refreshSenderName(next); |
| + } |
| + } |
| + return; |
| + } |
| if (msg.type === "typing") { |
| onTyping(msg); |
| return; |