eleven / messenger Public

Edit your own messages (15-minute window) (#2)

* server: allow editing a message for 15 minutes after sending

Adds an "edit" WebSocket frame that replaces a message's ciphertext in place.
The server enforces both the time window and authorship without seeing any
plaintext: messages now store author_hash (hashToken of the sender's invite
token) and an edited_at timestamp, and editMessage updates only when the
author matches and created_at is within editWindow (15m) — both checks live in
the UPDATE's WHERE clause, so they can't be bypassed by client claims.

Column additions are idempotent ALTERs (older DBs predate them). Edits are not
pushed. messagesAfter/Message now carry edited_at so history shows the marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: edit your own messages (15-minute window)

Adds an Edit action to the message menu and a desktop hover pencil beside the
bubble (mirroring the hover reply), shown only on your own messages and only
while still in the edit window — the affordance drops the moment it lapses.
Editing loads the text into the composer with an "Editing message" banner;
sending re-encrypts the original payload with the new text and emits an "edit"
frame. The change applies from the broadcast echo (same flow as sending) and is
marked "Edited · <time>". send() re-checks the window before emitting and toasts
if it lapsed mid-edit, so a rejected edit is never silently dropped.

Factors message-content building into fillContent() so edits rebuild text in
place, preserving reactions and reply links. Reply snippets stay frozen at
reply time (an edited parent doesn't rewrite existing quotes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
James Adam ed38c73c5b3e358d7b1057a4223839e96114a20c parent ef7d6b0
7 files changed, +360 −75
  • db.go +49 −6
  • db_test.go +42 −4
  • handlers_test.go +1 −1
  • hub.go +23 −3
  • web/pages/index.html +1 −0
  • web/static/chat.js +223 −50
  • web/static/style.css +21 −11
diff --git a/db.go b/db.go
index 1535268..9d931ff 100644
--- a/db.go
+++ b/db.go
@@ -105,7 +105,22 @@ CREATE TABLE IF NOT EXISTS link_previews (
ok INTEGER NOT NULL DEFAULT 0,
fetched_at INTEGER NOT NULL
);`)
- return err
+ if err != nil {
+ return err
+ }
+ // Additive column migrations (older DBs created `messages` without these).
+ // author_hash is hashToken(senderToken) — lets the server enforce that only
+ // the original author may edit, without ever seeing the (encrypted) author
+ // name. edited_at is set when a message is edited in place.
+ for _, c := range []string{
+ "ALTER TABLE messages ADD COLUMN author_hash TEXT",
+ "ALTER TABLE messages ADD COLUMN edited_at INTEGER",
+ } {
+ if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return err
+ }
+ }
+ return nil
}
// ---- link previews (cache) ----------------------------------------------
@@ -307,12 +322,15 @@ type Message struct {
ID int64 `json:"id"`
CreatedAt int64 `json:"created_at"`
Blob string `json:"blob"`
+ EditedAt int64 `json:"edited_at,omitempty"`
Reactions []ReactionView `json:"reactions,omitempty"`
}
-func (db *DB) insertMessage(blob string) (Message, error) {
+func (db *DB) insertMessage(blob, authorHash string) (Message, error) {
now := time.Now().Unix()
- res, err := db.sql.Exec("INSERT INTO messages(created_at, blob) VALUES(?, ?)", now, blob)
+ res, err := db.sql.Exec(
+ "INSERT INTO messages(created_at, blob, author_hash) VALUES(?, ?, ?)", now, blob, authorHash,
+ )
if err != nil {
return Message{}, err
}
@@ -323,6 +341,31 @@ func (db *DB) insertMessage(blob string) (Message, error) {
return Message{ID: id, CreatedAt: now, Blob: blob}, nil
}
+// editMessage replaces a message's ciphertext in place, but only if authorHash
+// matches the original sender and the message is still within the edit window.
+// Both checks live in the WHERE clause so they're enforced atomically by the DB,
+// never by client-supplied claims. Returns ok=false (no error) when the edit is
+// not permitted — wrong author, too old, or unknown id.
+func (db *DB) editMessage(id int64, blob, authorHash string, window time.Duration) (Message, bool, error) {
+ now := time.Now()
+ cutoff := now.Add(-window).Unix()
+ res, err := db.sql.Exec(
+ "UPDATE messages SET blob = ?, edited_at = ? WHERE id = ? AND author_hash = ? AND created_at >= ?",
+ blob, now.Unix(), id, authorHash, cutoff,
+ )
+ if err != nil {
+ return Message{}, false, err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return Message{}, false, err
+ }
+ if n == 0 {
+ return Message{}, false, nil
+ }
+ return Message{ID: id, Blob: blob, EditedAt: now.Unix()}, true, nil
+}
+
// messagesAfter returns messages with id > after (ascending). When after is 0,
// it returns the most recent `limit` messages instead of the entire history.
func (db *DB) messagesAfter(after int64, limit int) ([]Message, error) {
@@ -330,12 +373,12 @@ func (db *DB) messagesAfter(after int64, limit int) ([]Message, error) {
var err error
if after == 0 {
rows, err = db.sql.Query(
- "SELECT id, created_at, blob FROM (SELECT id, created_at, blob FROM messages ORDER BY id DESC LIMIT ?) ORDER BY id ASC",
+ "SELECT id, created_at, blob, COALESCE(edited_at, 0) FROM (SELECT id, created_at, blob, edited_at FROM messages ORDER BY id DESC LIMIT ?) ORDER BY id ASC",
limit,
)
} else {
rows, err = db.sql.Query(
- "SELECT id, created_at, blob FROM messages WHERE id > ? ORDER BY id ASC LIMIT ?",
+ "SELECT id, created_at, blob, COALESCE(edited_at, 0) FROM messages WHERE id > ? ORDER BY id ASC LIMIT ?",
after, limit,
)
}
@@ -346,7 +389,7 @@ func (db *DB) messagesAfter(after int64, limit int) ([]Message, error) {
out := []Message{}
for rows.Next() {
var m Message
- if err := rows.Scan(&m.ID, &m.CreatedAt, &m.Blob); err != nil {
+ if err := rows.Scan(&m.ID, &m.CreatedAt, &m.Blob, &m.EditedAt); err != nil {
return nil, err
}
out = append(out, m)
diff --git a/db_test.go b/db_test.go
index baf5087..a16b754 100644
--- a/db_test.go
+++ b/db_test.go
@@ -1,6 +1,9 @@
package main
-import "testing"
+import (
+ "testing"
+ "time"
+)
func newTestDB(t *testing.T) *DB {
t.Helper()
@@ -59,11 +62,11 @@ func TestAdminToken(t *testing.T) {
func TestMessages(t *testing.T) {
db := newTestDB(t)
- m1, err := db.insertMessage("blob1")
+ m1, err := db.insertMessage("blob1", "author-a")
if err != nil || m1.ID == 0 {
t.Fatalf("insertMessage: %v id=%d", err, m1.ID)
}
- db.insertMessage("blob2")
+ db.insertMessage("blob2", "author-a")
all, err := db.messagesAfter(0, 100)
if err != nil || len(all) != 2 {
@@ -75,6 +78,41 @@ func TestMessages(t *testing.T) {
}
}
+func TestEditMessage(t *testing.T) {
+ db := newTestDB(t)
+ m, err := db.insertMessage("orig", "author-a")
+ if err != nil {
+ t.Fatalf("insertMessage: %v", err)
+ }
+
+ // Wrong author can't edit, even within the window.
+ if _, ok, err := db.editMessage(m.ID, "hacked", "author-b", time.Hour); err != nil || ok {
+ t.Fatalf("edit by wrong author should fail: ok=%v err=%v", ok, err)
+ }
+
+ // Right author within the window succeeds and stamps edited_at.
+ edited, ok, err := db.editMessage(m.ID, "fixed", "author-a", time.Hour)
+ if err != nil || !ok || edited.Blob != "fixed" || edited.EditedAt == 0 {
+ t.Fatalf("edit by author should succeed: ok=%v err=%v m=%+v", ok, err, edited)
+ }
+ got, _ := db.messagesAfter(0, 100)
+ if len(got) != 1 || got[0].Blob != "fixed" || got[0].EditedAt == 0 {
+ t.Fatalf("edit not persisted: %+v", got)
+ }
+
+ // Past the window the author is still refused. Backdate created_at so the
+ // real window logic is exercised (a fresh insert is always "now").
+ old, _ := db.insertMessage("old", "author-a")
+ if _, err := db.sql.Exec(
+ "UPDATE messages SET created_at = ? WHERE id = ?", time.Now().Add(-time.Hour).Unix(), old.ID,
+ ); err != nil {
+ t.Fatalf("backdate: %v", err)
+ }
+ if _, ok, err := db.editMessage(old.ID, "toolate", "author-a", 15*time.Minute); err != nil || ok {
+ t.Fatalf("edit past window should fail: ok=%v err=%v", ok, err)
+ }
+}
+
func TestPushSubscriptions(t *testing.T) {
db := newTestDB(t)
inv, _ := db.createInvite("x")
@@ -103,7 +141,7 @@ func TestPushSubscriptions(t *testing.T) {
func TestReactions(t *testing.T) {
db := newTestDB(t)
- m, _ := db.insertMessage("blob")
+ m, _ := db.insertMessage("blob", "author-a")
// Two people add 👍; one adds ❤️.
db.addReaction(m.ID, "tokA", "👍")
diff --git a/handlers_test.go b/handlers_test.go
index 0b2cdb0..4fff0dd 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -105,7 +105,7 @@ func TestMessagesIncludeReactions(t *testing.T) {
json.NewDecoder(resp.Body).Decode(&inv)
// Seed a message + reactions directly (they normally arrive over the WS).
- m, _ := srv.db.insertMessage("ciphertext-blob")
+ m, _ := srv.db.insertMessage("ciphertext-blob", "author-x")
srv.db.addReaction(m.ID, "someoneElse", "👍")
srv.db.addReaction(m.ID, inv.Token, "❤️")
diff --git a/hub.go b/hub.go
index 9f4f5fa..1383e39 100644
--- a/hub.go
+++ b/hub.go
@@ -72,10 +72,15 @@ type wsIn struct {
type wsOut struct {
Type string `json:"type"`
ID int64 `json:"id"`
- CreatedAt int64 `json:"created_at"`
+ CreatedAt int64 `json:"created_at,omitempty"`
Blob string `json:"blob"`
+ EditedAt int64 `json:"edited_at,omitempty"`
}
+// editWindow is how long after sending a message its author may still edit it.
+// Enforced server-side against the message's created_at (see editMessage).
+const editWindow = 15 * time.Minute
+
type wsReactionOut struct {
Type string `json:"type"`
MessageID int64 `json:"message_id"`
@@ -134,7 +139,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if json.Unmarshal(data, &in) != nil {
continue
}
- if in.Type != "msg" && in.Type != "react" && in.Type != "unreact" {
+ if in.Type != "msg" && in.Type != "edit" && in.Type != "react" && in.Type != "unreact" {
continue
}
// Re-check revocation so a revoked client can't keep posting or reacting.
@@ -147,7 +152,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if in.Blob == "" || len(in.Blob) > maxBlobBytes {
continue
}
- m, err := s.db.insertMessage(in.Blob)
+ m, err := s.db.insertMessage(in.Blob, hashToken(token))
if err != nil {
log.Println("insert message:", err)
continue
@@ -157,6 +162,21 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
// Notify others (not the sender), best-effort, async.
go s.pushNewMessage(m.Blob, token)
+ case "edit":
+ if in.MessageID <= 0 || in.Blob == "" || len(in.Blob) > maxBlobBytes {
+ continue
+ }
+ m, ok, err := s.db.editMessage(in.MessageID, in.Blob, hashToken(token), editWindow)
+ if err != nil {
+ log.Println("edit message:", err)
+ continue
+ }
+ if !ok {
+ continue // wrong author, too old, or unknown id — silently ignore
+ }
+ out, _ := json.Marshal(wsOut{Type: "edit", ID: m.ID, Blob: m.Blob, EditedAt: m.EditedAt})
+ s.hub.broadcast <- out
+
case "react", "unreact":
if in.MessageID <= 0 || !allowedReactions[in.Emoji] {
continue
diff --git a/web/pages/index.html b/web/pages/index.html
index 362272c..313e67a 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -33,6 +33,7 @@
</div>
<div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
<div class="composer-reply hidden" id="composer-reply"></div>
+ <div class="composer-edit hidden" id="composer-edit"></div>
<div class="composer-preview hidden" id="composer-preview"></div>
<form class="composer" id="composer" autocomplete="off">
<button type="button" class="attach-btn" id="attach-btn" aria-label="Attach a photo or file">+</button>
diff --git a/web/static/chat.js b/web/static/chat.js
index 25c3c5e..c71c5af 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -22,6 +22,10 @@ const SESSION_KEY = "lchat_session";
// The fixed reaction set, matching the server allowlist (hub.go).
const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
+// How long after sending you may still edit a message. Mirrors editWindow on the
+// server (hub.go), which is the real enforcement — this only gates the UI.
+const EDIT_WINDOW_MS = 15 * 60 * 1000;
+
// A crisp, optically-centered close icon (the "×" text glyph sits high in its box).
const ICON_X =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ' +
@@ -36,6 +40,13 @@ const ICON_REPLY =
'<polyline points="9 17 4 12 9 7"></polyline>' +
'<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>';
+// Pencil, used for the Edit action in the message menu and the composer banner.
+const ICON_EDIT =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M12 20h9"></path>' +
+ '<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>';
+
const app = document.getElementById("app");
const session = loadSession();
@@ -399,6 +410,12 @@ async function start(session) {
const myReactions = new Map();
const reactionEls = new Map();
+ // Editing state, keyed by message id:
+ // msgPayloads: the decrypted payload (so editing can preserve name/ts/etc.)
+ // msgCreatedAt: server created_at (seconds) for the client-side edit-window check
+ const msgPayloads = new Map();
+ const msgCreatedAt = new Map();
+
// Reply threading, keyed by the parent's message id:
// replyCounts: how many replies a message has (drives the "N Replies" tag)
// replyCountEls: the per-message tag element to repaint
@@ -447,6 +464,35 @@ async function start(session) {
return q;
}
+ // (Re)build a message's content column from its decrypted payload: reply quote,
+ // text bubble, attachments, link preview. Shared by initial render and by edits
+ // (which replace the text in place), so it always reflects the current payload.
+ function fillContent(content, payload) {
+ content.innerHTML = "";
+ if (payload.replyTo) content.appendChild(buildReplyQuote(payload.replyTo));
+ if (payload.text) {
+ const bubble = document.createElement("div");
+ bubble.className = "bubble";
+ appendText(bubble, payload.text);
+ content.appendChild(bubble);
+ }
+ if (Array.isArray(payload.attachments)) {
+ for (const att of payload.attachments) content.appendChild(renderAttachment(att));
+ }
+ if (payload.text) addLinkPreview(content, payload.text);
+ }
+
+ // The time/"Edited" line under a message.
+ function stampTime(el, payload, createdAt, edited) {
+ let t = el.querySelector(":scope > .time");
+ if (!t) {
+ t = document.createElement("div");
+ t.className = "time";
+ el.appendChild(t);
+ }
+ t.textContent = (edited ? "Edited · " : "") + formatTime(payload.ts || createdAt * 1000);
+ }
+
// Desktop-only: a reply icon that reveals beside the bubble on hover, so reply
// is discoverable without the press-and-hold. (Touch uses double-tap instead.)
function buildHoverReply(meta) {
@@ -462,6 +508,21 @@ async function start(session) {
return b;
}
+ // Desktop-only edit icon, mirroring buildHoverReply. Only built for your own
+ // messages that are still in the edit window (see render).
+ function buildHoverEdit(id) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "edit-hover";
+ b.innerHTML = ICON_EDIT;
+ b.setAttribute("aria-label", "Edit");
+ b.onclick = (e) => {
+ e.stopPropagation();
+ startEdit(id);
+ };
+ return b;
+ }
+
async function render(msg) {
if (seen.has(msg.id)) return;
seen.add(msg.id);
@@ -500,28 +561,32 @@ async function start(session) {
const content = document.createElement("div");
content.className = "content";
- if (payload.replyTo) content.appendChild(buildReplyQuote(payload.replyTo));
- if (payload.text) {
- const bubble = document.createElement("div");
- bubble.className = "bubble";
- appendText(bubble, payload.text);
- content.appendChild(bubble);
- }
- if (Array.isArray(payload.attachments)) {
- for (const att of payload.attachments) content.appendChild(renderAttachment(att));
- }
- if (payload.text) addLinkPreview(content, payload.text);
+ fillContent(content, payload);
const replyMeta = { id: msg.id, name: payload.name, snippet: replySnippet(payload) };
- // Desktop: a reply icon that reveals beside the bubble on hover. It's absolutely
- // positioned inside .content, so revealing it never shifts the bubble (touch uses
- // double-tap instead).
- if (!isCoarse) content.appendChild(buildHoverReply(replyMeta));
+ // Desktop: hover-revealed icons inside .content — absolutely positioned, so
+ // revealing them never shifts the bubble (touch uses the long-press menu).
+ // Reply on every message; Edit only on your own, and only while still in the
+ // window — drop the affordance the moment it lapses.
+ if (!isCoarse) {
+ content.appendChild(buildHoverReply(replyMeta));
+ const remaining = msg.created_at * 1000 + EDIT_WINDOW_MS - Date.now();
+ if (mine && remaining > 0) {
+ const editBtn = buildHoverEdit(msg.id);
+ content.appendChild(editBtn);
+ setTimeout(() => editBtn.remove(), remaining);
+ }
+ }
bubbleRow.appendChild(content);
el.appendChild(bubbleRow);
- // React or reply: double-tap on touch, press-and-hold on desktop.
- attachReact(el, msg.id, replyMeta);
+ // Keep the payload + send time so the menu can offer Edit (own, in-window
+ // messages) and an edit can re-encrypt preserving everything but the text.
+ msgPayloads.set(msg.id, payload);
+ msgCreatedAt.set(msg.id, msg.created_at);
+
+ // React, reply, or edit: double-tap on touch, press-and-hold on desktop.
+ attachReact(el, msg.id, { replyMeta, mine, createdAt: msg.created_at });
const reactions = document.createElement("div");
reactions.className = "reactions";
@@ -540,10 +605,7 @@ async function start(session) {
replyCountEls.set(msg.id, replyCount);
paintReplyCount(msg.id); // reflect replies that may already have arrived
- const t = document.createElement("div");
- t.className = "time";
- t.textContent = formatTime(payload.ts || msg.created_at * 1000);
- el.appendChild(t);
+ stampTime(el, payload, msg.created_at, msg.edited_at);
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
feed.appendChild(el);
@@ -551,6 +613,25 @@ async function start(session) {
if (atBottom || mine) feed.scrollTop = feed.scrollHeight;
}
+ // Apply an authoritative edit broadcast: re-decrypt and rebuild that message's
+ // text in place, and mark it "Edited". The editor sees their own change this
+ // way too (same echo-driven flow as sending). No-op if the message isn't in the
+ // loaded window.
+ async function applyEdit(msg) {
+ let payload;
+ try {
+ payload = await decrypt(key, msg.blob);
+ } catch {
+ return;
+ }
+ msgPayloads.set(msg.id, payload);
+ const el = feed.querySelector('[data-id="' + msg.id + '"]');
+ if (!el) return;
+ const content = el.querySelector(".content");
+ if (content) fillContent(content, payload);
+ stampTime(el, payload, msgCreatedAt.get(msg.id) || 0, msg.edited_at || 1);
+ }
+
// Repaint the reaction chips for one message from current state.
function renderReactionsFor(msgId) {
const container = reactionEls.get(msgId);
@@ -611,7 +692,7 @@ async function start(session) {
pickerCloser = null;
}
}
- function openReactionPicker(x, y, onPick, onReply) {
+ function openReactionPicker(x, y, onPick, onReply, onEdit) {
closeReactionPicker();
pickerEl = document.createElement("div");
pickerEl.className = "emoji-picker";
@@ -627,19 +708,21 @@ async function start(session) {
};
pickerEl.appendChild(b);
}
- if (onReply) {
- const r = document.createElement("button");
- r.type = "button";
- r.className = "picker-action";
- r.innerHTML = ICON_REPLY;
- r.setAttribute("aria-label", "Reply");
- r.onclick = (ev) => {
+ const addAction = (icon, label, fn) => {
+ const a = document.createElement("button");
+ a.type = "button";
+ a.className = "picker-action";
+ a.innerHTML = icon;
+ a.setAttribute("aria-label", label);
+ a.onclick = (ev) => {
ev.stopPropagation();
- onReply();
+ fn();
closeReactionPicker();
};
- pickerEl.appendChild(r);
- }
+ pickerEl.appendChild(a);
+ };
+ if (onReply) addAction(ICON_REPLY, "Reply", onReply);
+ if (onEdit) addAction(ICON_EDIT, "Edit", onEdit);
document.body.appendChild(pickerEl);
// Centre the picker above the press point, clamped to the viewport.
const pw = pickerEl.offsetWidth;
@@ -660,13 +743,28 @@ async function start(session) {
// Touch reacts with a double-tap (leaving long-press free for text selection);
// desktop reacts with a press-and-hold (which doesn't clash with drag-select).
- function attachReact(el, msgId, replyMeta) {
- if (isCoarse) attachDoubleTap(el, msgId, replyMeta);
- else attachLongPress(el, msgId, replyMeta);
+ function attachReact(el, msgId, ctx) {
+ if (isCoarse) attachDoubleTap(el, msgId, ctx);
+ else attachLongPress(el, msgId, ctx);
+ }
+
+ // Open the message menu at (x, y). Edit only appears on your own messages that
+ // are still within the edit window — checked here (at open time), so it falls
+ // away as the window lapses.
+ function openMessageMenu(x, y, msgId, ctx) {
+ const canEdit =
+ ctx.mine && Date.now() - ctx.createdAt * 1000 < EDIT_WINDOW_MS;
+ openReactionPicker(
+ x,
+ y,
+ (emoji) => toggleReaction(msgId, emoji),
+ () => startReply(ctx.replyMeta),
+ canEdit ? () => startEdit(msgId) : null
+ );
}
// Double-tap to react (touch). Two taps within 320ms near the same spot.
- function attachDoubleTap(el, msgId, replyMeta) {
+ function attachDoubleTap(el, msgId, ctx) {
let lastT = 0;
let lx = 0;
let ly = 0;
@@ -676,12 +774,7 @@ async function start(session) {
if (t - lastT < 320 && Math.abs(e.clientX - lx) < 30 && Math.abs(e.clientY - ly) < 30) {
e.preventDefault();
lastT = 0;
- openReactionPicker(
- e.clientX,
- e.clientY,
- (emoji) => toggleReaction(msgId, emoji),
- () => startReply(replyMeta)
- );
+ openMessageMenu(e.clientX, e.clientY, msgId, ctx);
} else {
lastT = t;
lx = e.clientX;
@@ -691,7 +784,7 @@ async function start(session) {
}
// Press-and-hold (mouse) on a message opens the reaction picker.
- function attachLongPress(el, msgId, replyMeta) {
+ function attachLongPress(el, msgId, ctx) {
let timer = null;
let sx = 0;
let sy = 0;
@@ -711,12 +804,7 @@ async function start(session) {
timer = setTimeout(() => {
timer = null;
fired = true;
- openReactionPicker(
- e.clientX,
- e.clientY,
- (emoji) => toggleReaction(msgId, emoji),
- () => startReply(replyMeta)
- );
+ openMessageMenu(e.clientX, e.clientY, msgId, ctx);
}, 450);
});
el.addEventListener("pointermove", (e) => {
@@ -927,6 +1015,8 @@ async function start(session) {
const msg = JSON.parse(ev.data);
if (msg.type === "msg") {
render(msg);
+ } else if (msg.type === "edit") {
+ applyEdit(msg);
} else if (msg.type === "reaction") {
reactionState.set(msg.message_id, msg.reactions || []);
renderReactionsFor(msg.message_id);
@@ -954,11 +1044,15 @@ async function start(session) {
const fileInput = document.getElementById("file-input");
const previewBox = document.getElementById("composer-preview");
const replyBox = document.getElementById("composer-reply");
+ const editBox = document.getElementById("composer-edit");
// The attachment staged for the next send (uploaded already), or null.
let pendingAttachment = null;
// The message this composer is replying to ({id, name, snippet}), or null.
let replyingTo = null;
+ // The id of the message being edited, or null. Editing and replying are
+ // mutually exclusive — starting one cancels the other.
+ let editingId = null;
// Begin replying to a message: stage the parent and show a cancelable banner
// above the composer. Independent of any staged attachment.
@@ -1005,6 +1099,56 @@ async function start(session) {
replyBox.innerHTML = "";
}
+ // Begin editing your own (in-window) message: load its text into the composer
+ // and show a cancelable banner. Sending replaces the message in place.
+ function startEdit(id) {
+ const payload = msgPayloads.get(id);
+ if (!payload) return;
+ cancelReply();
+ editingId = id;
+ input.value = payload.text || "";
+ autosize();
+ syncSendBtn();
+ editBox.classList.remove("hidden");
+ editBox.innerHTML = "";
+ const bar = document.createElement("div");
+ bar.className = "reply-bar";
+ const icon = document.createElement("span");
+ icon.className = "reply-bar-icon";
+ icon.innerHTML = ICON_EDIT;
+ const body = document.createElement("div");
+ body.className = "reply-bar-body";
+ const who = document.createElement("div");
+ who.className = "reply-bar-name";
+ who.textContent = "Editing message";
+ const txt = document.createElement("div");
+ txt.className = "reply-bar-text";
+ txt.textContent = replySnippet(payload);
+ body.appendChild(who);
+ body.appendChild(txt);
+ const x = document.createElement("button");
+ x.type = "button";
+ x.className = "reply-bar-cancel";
+ x.innerHTML = ICON_X;
+ x.setAttribute("aria-label", "Cancel edit");
+ x.onclick = cancelEdit;
+ bar.appendChild(icon);
+ bar.appendChild(body);
+ bar.appendChild(x);
+ editBox.appendChild(bar);
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+ }
+
+ function cancelEdit() {
+ editingId = null;
+ editBox.classList.add("hidden");
+ editBox.innerHTML = "";
+ input.value = "";
+ autosize();
+ syncSendBtn();
+ }
+
const syncSendBtn = () => {
sendBtn.hidden = input.value.trim() === "" && !pendingAttachment;
};
@@ -1201,7 +1345,26 @@ async function start(session) {
const send = async () => {
const text = input.value.trim();
- if ((!text && !pendingAttachment) || !ws || ws.readyState !== WebSocket.OPEN) return;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+
+ // Edit path: replace an existing message's text in place. Re-check the window
+ // here (it may have lapsed while typing) so we don't silently drop the edit —
+ // the server would reject it and no echo would come back.
+ if (editingId != null) {
+ const orig = msgPayloads.get(editingId);
+ if (!text || !orig) return; // editing can't blank a message (text-only v1)
+ if (Date.now() - (msgCreatedAt.get(editingId) || 0) * 1000 >= EDIT_WINDOW_MS) {
+ toast("Too late to edit this message");
+ return; // keep the composer populated so nothing is lost
+ }
+ const id = editingId;
+ const blob = await encrypt(key, { ...orig, text });
+ cancelEdit();
+ ws.send(JSON.stringify({ type: "edit", message_id: id, blob }));
+ return;
+ }
+
+ if (!text && !pendingAttachment) return;
const payload = { name: session.name, text, ts: Date.now() };
if (pendingAttachment) payload.attachments = [pendingAttachment];
// Carry the reply link inside the encrypted payload (the server never learns
@@ -1269,3 +1432,13 @@ function showBanner(text, warn) {
b.classList.toggle("hidden", !text);
b.classList.toggle("warn", !!warn);
}
+
+let toastTimer;
+function toast(msg) {
+ const t = document.getElementById("toast");
+ if (!t) return;
+ t.textContent = msg;
+ t.classList.add("show");
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.classList.remove("show"), 1800);
+}
diff --git a/web/static/style.css b/web/static/style.css
index f58ca11..74a45c1 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -181,13 +181,13 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.msg .bubble a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.msg .time { font-size: 11px; color: var(--muted); margin: 3px 6px 0; }
-/* desktop hover reply icon — absolutely positioned just outside the bubble so it
- reveals on hover without shifting any content. It abuts the bubble (no gap) so
- the pointer can travel from bubble to icon without losing the hover. */
-.reply-hover {
+/* desktop hover reply/edit icons — absolutely positioned just outside the bubble
+ so they reveal on hover without shifting any content. They abut the bubble (no
+ gap) so the pointer can travel from bubble to icon without losing the hover. */
+.reply-hover,
+.edit-hover {
position: absolute;
top: 50%;
- right: -30px;
transform: translateY(-50%);
background: transparent;
color: var(--muted);
@@ -200,11 +200,18 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
pointer-events: none;
transition: opacity 0.12s;
}
+/* them: icon to the right of the bubble; me: to the left. Edit only appears on
+ your own messages, sitting just beyond the reply icon. */
+.reply-hover { right: -30px; }
.msg.me .reply-hover { right: auto; left: -30px; }
-.reply-hover svg { width: 18px; height: 18px; }
-.reply-hover:hover { background: var(--bubble-them); color: var(--accent); }
+.msg.me .edit-hover { right: auto; left: -58px; }
+.reply-hover svg,
+.edit-hover svg { width: 18px; height: 18px; }
+.reply-hover:hover,
+.edit-hover:hover { background: var(--bubble-them); color: var(--accent); }
@media (hover: hover) {
- .msg:hover .reply-hover { opacity: 1; pointer-events: auto; }
+ .msg:hover .reply-hover,
+ .msg:hover .edit-hover { opacity: 1; pointer-events: auto; }
}
/* message content (text bubble + any attachments) */
@@ -455,13 +462,16 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
25% { background: rgba(91, 108, 255, 0.14); }
}
-/* composer "Replying to …" banner, above the composer (and any attachment preview) */
-.composer-reply {
+/* composer "Replying to …" / "Editing message" banners, above the composer
+ (and any attachment preview). They share the .reply-bar inner markup. */
+.composer-reply,
+.composer-edit {
padding: 8px 14px;
border-top: 1px solid var(--border);
background: var(--panel);
}
-.composer-reply.hidden { display: none; }
+.composer-reply.hidden,
+.composer-edit.hidden { display: none; }
.reply-bar { display: flex; align-items: center; gap: 10px; }
.reply-bar-icon { flex: none; color: var(--accent); display: inline-flex; }
.reply-bar-icon svg { width: 18px; height: 18px; }