diff --git a/server/db.js b/server/db.js index 904ce49..f36e932 100644 --- a/server/db.js +++ b/server/db.js @@ -216,6 +216,9 @@ 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 */ } +// #18 "Delete for me": a per-user hide. The message row is untouched (others still see it); this +// table records that THIS user removed it from their own view (threads + sidebar filter it out). +try { db.exec('CREATE TABLE IF NOT EXISTS message_hidden (message_id TEXT NOT NULL, user_id TEXT NOT NULL, hidden_at INTEGER, PRIMARY KEY (message_id, user_id))'); } 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 */ } try { db.exec('ALTER TABLE messages ADD COLUMN fwd_from TEXT'); } catch (e) { /* exists — original sender name when a message was forwarded (#5) */ } diff --git a/server/public/home.html b/server/public/home.html index 3ee0507..a100402 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -210,6 +210,7 @@ .chat-row.unread .chat-prev{color:var(--ink);font-weight:500;} .prev-tick{display:inline-flex;vertical-align:middle;color:var(--muted);} .prev-tick.read{color:#2563eb;} .prev-tick svg{width:14px;height:14px;} .draft-lbl{color:#dc2626;font-weight:600;} + .prev-del{display:inline-flex;align-items:center;gap:.3rem;color:var(--muted);font-style:italic;opacity:.85;} .chat-row.unread .chat-name{font-weight:700;} .badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;} .no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;} @@ -1914,6 +1915,7 @@ function rowPreviewHTML(it){ const draft=getDraft(it.kind,it.id); if(draft && !active){ return 'Draft: '+pEsc(draft); } // unsent draft (#1) if(it.callActive){ return ''+ic('phone',12)+' Ongoing call'; } + if(it.last_deleted){ return ''+ic('trash',12)+' '+(it.last_from_me?'You deleted this message':'This message was deleted')+''; } // #10 const prevBody=isGifUrl(it.last_body)?'🎞️ GIF':it.last_body; // #5: don't show the raw GIF URL if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3) return ''+ic(it.last_status==='sent'?'check':'checkCheck',13)+' '+pEsc(prevBody); } @@ -2234,7 +2236,8 @@ function openMsgMore(msgId, anchor){ items.push({ic:'reply', label:'Reply', fn:()=>setReply(m)}); if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)}); if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)}); - if(mine) items.push({ic:'trash', label:'Delete', danger:true, fn:()=>deleteMessage(m.id)}); + items.push({ic:'trash', label:'Delete for me', danger:true, fn:()=>deleteForMe(m.id)}); // #18: hide from my view only + if(mine && !m.deleted) items.push({ic:'trash', label:'Delete for everyone', danger:true, fn:()=>deleteMessage(m.id)}); // #18: unsend for all const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu'; menu.innerHTML=items.map((it,i)=>'').join(''); document.body.appendChild(menu); @@ -2573,6 +2576,16 @@ function markMsgDeleted(id){ if(changed) renderThread(); } function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews +// #18 Delete-for-me: hide only from MY view (echoed to all my devices) — the message stays for everyone else. +async function deleteForMe(id){ if(!(await bzConfirm('This will remove the message from your chat on all your devices. Others will still see it.', {title:'Delete for me?', okText:'Delete', danger:true}))) return; try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } } +function removeMsgLocally(id){ + let changed=false; + const i=THREAD.findIndex(x=>x.id===id); if(i>=0){ THREAD.splice(i,1); changed=true; } + THREAD_CACHE.forEach(arr=>{ const j=arr.findIndex(x=>x.id===id); if(j>=0) arr.splice(j,1); }); + if(changed) renderThread(); + try{ loadSidebar(); }catch(_){} // last-message preview may roll back to the previous message +} +function onChatHidden(d){ if(!d||!d.id) return; removeMsgLocally(d.id); } // another of my devices hid it // DM delivered: recipient's client acked → second (grey) tick. function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } @@ -4291,7 +4304,7 @@ function connectChatWs(){ if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; } chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; }; - 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==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(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-hidden') onChatHidden(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==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(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=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer) }catch(_){} } diff --git a/server/repos.js b/server/repos.js index 319f31f..53b30f1 100644 --- a/server/repos.js +++ b/server/repos.js @@ -205,6 +205,12 @@ const messages = { 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), + // #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched). + hideForUser: (messageId, userId) => db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId, userId, now()), + hiddenForUser: async (userId) => (await db.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r) => r.message_id), + // Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message + // rolls the sidebar preview back to the previous one, instead of showing what they just removed). + lastInConversationForUser: (conversationId, userId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?) ORDER BY created_at DESC LIMIT 1').get(conversationId, userId), // Shared media/files in a conversation (group) or DM — newest first. attachmentsForConversation: (teamId, conversationId) => db.prepare(`SELECT a.id, a.name, a.mime, a.size, m.created_at FROM messages m JOIN attachments a ON a.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id=? AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, conversationId), attachmentsForDm: (teamId, a, b) => db.prepare(`SELECT at.id, at.name, at.mime, at.size, m.created_at FROM messages m JOIN attachments at ON at.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?)) AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, a, b, b, a), diff --git a/server/routes.js b/server/routes.js index 679117e..2796788 100644 --- a/server/routes.js +++ b/server/routes.js @@ -769,9 +769,11 @@ route('GET', '/api/messages/conversations', async (req, res) => { const favs = new Set(await R.favorites.forUser(u.id)); const inCall = new Set(); for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } } + const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: skip messages this user "deleted for me" // DMs const byOther = new Map(); for (const m of await R.messages.recentFor(u.team_id, u.id)) { + if (hidden.has(m.id)) continue; // #18 const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id; if (!raw) continue; // If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries @@ -786,11 +788,12 @@ route('GET', '/api/messages/conversations', async (req, res) => { kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, lastSeen: seen[c.other] || null, callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'), last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread, + last_deleted: !!c.last.deleted, // #10: a deleted last message must still read "message deleted", not "No messages yet" last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message }; }); // Groups const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => { - const last = await R.messages.lastInConversation(g.id); + const last = await R.messages.lastInConversationForUser(g.id, u.id); // #18: last message this user hasn't hidden const since = await R.conversations.lastReadAt(g.id, u.id); const members = await R.conversations.members(g.id); // Group read tick for MY last message: read = every other member has read it, delivered = some @@ -806,6 +809,7 @@ route('GET', '/api/messages/conversations', async (req, res) => { callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null, last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at, last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0, + last_deleted: !!(last && last.deleted), // #10: deleted last message still reads "message deleted" last_status: gStatus, }; })); @@ -821,9 +825,10 @@ route('GET', '/api/messages/thread', async (req, res) => { const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at const names = await namesFor(u.team_id); const group = q.get('group'); + const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: messages this user "deleted for me" if (group) { if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); - const rows = await R.messages.threadByConversation(group, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load + const rows = (await R.messages.threadByConversation(group, 40, before)).filter((m) => !hidden.has(m.id)); // #18 if (!peek && !before) { await R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; @@ -842,7 +847,7 @@ route('GET', '/api/messages/thread', async (req, res) => { const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread if (!other) return json(res, 400, { error: 'with or group required' }); if (!await R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); - const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load + const rows = (await R.messages.thread(u.team_id, u.id, other, 40, before)).filter((m) => !hidden.has(m.id)); // #18 if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13 const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names); return json(res, 200, await Promise.all(rows.map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; }))); @@ -1620,6 +1625,21 @@ 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 }); }); +// #18 Delete-for-me: hide a message from MY view only (any message I can see, mine or not). The row and +// everyone else are untouched. Echoed to my OTHER devices so it disappears there too. +route('POST', '/api/messages/hide', async (req, res) => { + const u = await currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { id } = await readBody(req); + if (!id) return json(res, 400, { error: 'id required' }); + const m = await R.messages.byId(id); + if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); + const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id); + if (!canSee) return json(res, 403, { error: 'not allowed' }); + await R.messages.hideForUser(id, u.id); + try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices + 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) => {