From 51e206279b5af9cdd1f417f50693ddeab40c40b5 Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 2 Jul 2026 17:45:20 +0530 Subject: [PATCH] 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 --- server/db.js | 2 ++ server/public/home.html | 49 ++++++++++++++++++++++++++++++++++++----- server/repos.js | 1 + server/routes.js | 20 +++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/server/db.js b/server/db.js index a267a97..9957a55 100644 --- a/server/db.js +++ b/server/db.js @@ -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 // 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 */ } +// 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.) 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 diff --git a/server/public/home.html b/server/public/home.html index 5cece71..d065eee 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -471,6 +471,9 @@ .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;} .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 .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);} @@ -764,7 +767,7 @@ - @@ -1290,8 +1293,9 @@ function bubbleHTML(m){ + sender + quote + att + renderMsgBody(m) + pollHTML(m) + '' + '' + + ((mine && !m.deleted && m.body && !m.poll)?'':'') + (mine?'':'') - + ''+pEsc(fmtClock(m.created_at))+rcpt+'' + + ''+pEsc(fmtClock(m.created_at))+(m.edited_at?' · edited':'')+rcpt+'' + reacts + seen + ''; } // reply + emoji state/helpers @@ -1320,6 +1324,36 @@ function setReply(m){ 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=''; } } +// 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=''+ic('edit',13)+' Editing message'+ic('x',15)+''; 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 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'; } @@ -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); } }; const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome; 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 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); }; @@ -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 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 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 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; } @@ -1864,7 +1899,9 @@ async function selectChat(kind,id){ } async function sendMessage(){ 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 const replyTo=replyTarget?replyTarget.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 // 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); } - 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 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; } @@ -2113,7 +2150,7 @@ function connectChatWs(){ try{ chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); 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 }catch(_){} } diff --git a/server/repos.js b/server/repos.js index 2fc92dc..e97f44d 100644 --- a/server/repos.js +++ b/server/repos.js @@ -188,6 +188,7 @@ const messages = { 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), 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). 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. diff --git a/server/routes.js b/server/routes.js index 0466ea6..133536b 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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 (_) {} } 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:' or 'group:'. route('POST', '/api/favorites', async (req, res) => { const u = currentUser(req);