feat(chat): edit message (#4)

Sender can edit their own text messages: a pencil action on the bubble loads the text into the
composer in an 'Editing' mode; saving updates the body, marks it 'edited', and pushes the change
live to the other side/tabs (chat-edited, mirroring delete). Adds messages.edited_at + editBody().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:45:20 +05:30
parent 9bc109528e
commit 51e206279b
4 changed files with 66 additions and 6 deletions
+2
View File
@@ -216,6 +216,8 @@ try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /*
// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment // Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment
// are cleared and clients render a "This message was deleted" placeholder. // are cleared and clients render a "This message was deleted" placeholder.
try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// A message can be edited by its sender; edited_at marks it (shows an "edited" label).
try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ }
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.) // User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ } try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or // BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
+43 -6
View File
@@ -471,6 +471,9 @@
.bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;} .bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;}
.del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;} .del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;}
.bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;} .bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;}
.edit-btn{position:absolute;top:-9px;right:84px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;}
.bubble:hover .edit-btn,.bubble.show-actions .edit-btn{opacity:1;pointer-events:auto;}
.bubble .t .edited{font-style:italic;opacity:.8;}
.bubble.deleted{opacity:.85;} .bubble.deleted{opacity:.85;}
.bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;} .bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;}
.bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);} .bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);}
@@ -764,7 +767,7 @@
<body> <body>
<script src="/icons.js?v=4"></script> <script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-02-batch36';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-02-batch37';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load // Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -1290,8 +1293,9 @@ function bubbleHTML(m){
+ sender + quote + att + renderMsgBody(m) + pollHTML(m) + sender + quote + att + renderMsgBody(m) + pollHTML(m)
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>' + '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>' + '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
+ ((mine && !m.deleted && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'') + (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+rcpt+'</span>' + '<span class="t">'+pEsc(fmtClock(m.created_at))+(m.edited_at?'<span class="edited"> · edited</span>':'')+rcpt+'</span>'
+ reacts + seen + '</div>'; + reacts + seen + '</div>';
} }
// reply + emoji state/helpers // reply + emoji state/helpers
@@ -1320,6 +1324,36 @@ function setReply(m){
const inp=document.getElementById('msgInput'); if(inp) inp.focus(); const inp=document.getElementById('msgInput'); if(inp) inp.focus();
} }
function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } } function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } }
// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar;
// the next send saves the edit instead of posting a new message.
let editTarget=null;
function startEdit(m){
if(!m) return; clearReply(); editTarget=m;
const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; }
const inp=document.getElementById('msgInput'); if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); }
}
function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } }
async function saveEdit(text){
const m=editTarget; if(!m) return;
if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action)
if(text===(m.body||'')){ cancelEdit(); return; } // no change
editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; }
const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); }
try{
await postJSON('/api/messages/edit',{ id:m.id, body:text });
const t=THREAD.find(x=>x.id===m.id); if(t){ t.body=text; t.edited_at=Date.now(); updateBubble(t); }
THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===m.id){ x.body=text; x.edited_at=Date.now(); } }));
// keep the chat-list preview in sync if this was the last message
const it=rowFor(selected.kind, selected.id); if(it && it.last_from_me){ const last=THREAD[THREAD.length-1]; if(last && last.id===m.id){ it.last_body=text; renderChats(searchVal()); } }
}catch(e){ toast(e.message||'Could not edit'); }
}
// A message I can see was edited by its sender → update it in place (thread + cache + list preview).
function onChatEdited(d){
if(!d||!d.id) return;
const t=THREAD.find(x=>x.id===d.id); if(t){ t.body=d.body; t.edited_at=d.edited_at||Date.now(); updateBubble(t); }
THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===d.id){ x.body=d.body; x.edited_at=d.edited_at||Date.now(); } }));
try{ loadSidebar(); }catch(_){} // refresh last-message previews
}
// attachments // attachments
let pendingAttach=null; let pendingAttach=null;
function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; } function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
@@ -1635,7 +1669,7 @@ async function openConvo(kind,id){
el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } }; el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } };
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome; const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); }); const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
clearReply(); pendingAttach=null; hideAttach(); clearReply(); cancelEdit(); pendingAttach=null; hideAttach();
const ab2=document.getElementById('attachBtn'); if(ab2) ab2.onclick=()=>{ const fi=document.getElementById('fileInput'); if(fi) fi.click(); }; const ab2=document.getElementById('attachBtn'); if(ab2) ab2.onclick=()=>{ const fi=document.getElementById('fileInput'); if(fi) fi.click(); };
const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ if(fi.files&&fi.files[0]) uploadFile(fi.files[0]); }; const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ if(fi.files&&fi.files[0]) uploadFile(fi.files[0]); };
const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); }; const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); };
@@ -1664,6 +1698,7 @@ async function openConvo(kind,id){
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; } const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; } const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; } const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
const ed=e.target.closest('.edit-btn'); if(ed){ const mm=THREAD.find(x=>x.id===ed.dataset.edit); if(mm) startEdit(mm); return; }
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; } const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; } const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; } const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
@@ -1864,7 +1899,9 @@ async function selectChat(kind,id){
} }
async function sendMessage(){ async function sendMessage(){
const inp=document.getElementById('msgInput'); if(!inp) return; const inp=document.getElementById('msgInput'); if(!inp) return;
const text=inp.value.trim(); if((!text&&!pendingAttach)||!selected) return; const text=inp.value.trim();
if(editTarget){ saveEdit(text); return; } // in edit mode → save the edit instead of sending new
if((!text&&!pendingAttach)||!selected) return;
inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft
const replyTo=replyTarget?replyTarget.id:null; const replyTo=replyTarget?replyTarget.id:null;
const attachmentId=pendingAttach?pendingAttach.id:null; const attachmentId=pendingAttach?pendingAttach.id:null;
@@ -1875,7 +1912,7 @@ async function sendMessage(){
// Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo // Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo
// can arrive BEFORE this POST resolves, so onChatMessage may have already appended it. // can arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); } if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
clearReply(); pendingAttach=null; hideAttach(); clearReply(); cancelEdit(); pendingAttach=null; hideAttach();
{ const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } } { const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
const it=rowFor(selected.kind,selected.id); const it=rowFor(selected.kind,selected.id);
if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; } if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; }
@@ -2113,7 +2150,7 @@ function connectChatWs(){
try{ try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} }; chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){} }catch(_){}
} }
+1
View File
@@ -188,6 +188,7 @@ const messages = {
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId), byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId), setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id), markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
editBody: (id, body) => db.prepare('UPDATE messages SET body=?, edited_at=? WHERE id=?').run(body, now(), id),
// Delete-for-everyone: clear the content but keep the row (renders as a placeholder). // Delete-for-everyone: clear the content but keep the row (renders as a placeholder).
markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id), markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id),
// Shared media/files in a conversation (group) or DM — newest first. // Shared media/files in a conversation (group) or DM — newest first.
+20
View File
@@ -1282,6 +1282,26 @@ route('POST', '/api/messages/delete', async (req, res) => {
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
json(res, 200, { ok: true }); json(res, 200, { ok: true });
}); });
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
// change live to the other side / other tabs (mirrors the delete broadcast).
route('POST', '/api/messages/edit', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id, body } = await readBody(req);
if (!id || typeof body !== 'string') return json(res, 400, { error: 'id and body required' });
const text = body.trim();
if (!text) return json(res, 400, { error: 'message cannot be empty' });
const m = R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only edit your own messages' });
if (m.deleted) return json(res, 400, { error: 'cannot edit a deleted message' });
R.messages.editBody(id, text);
const edited = R.messages.byId(id);
const evt = { type: 'chat-edited', id, body: text, edited_at: edited.edited_at, conversation_id: m.conversation_id || null };
if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
json(res, 200, { ok: true, edited_at: edited.edited_at });
});
// Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'. // Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
route('POST', '/api/favorites', async (req, res) => { route('POST', '/api/favorites', async (req, res) => {
const u = currentUser(req); const u = currentUser(req);