eleven / messenger Public

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>
Paul Campbell e838f78028a5bedb19faaf75cd94c85767fb565a parent 97a2d79
4 files changed, +112 −1
  • db.go +26 −0
  • db_test.go +22 −0
  • hub.go +16 −1
  • web/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;