#10: deleting the last message showed "No messages yet" in the chat list. The sidebar sent an empty last_body for a deleted (content-cleared) row; now it sends a last_deleted flag and the row renders "This message was deleted" (or "You deleted this message"), matching the in-thread placeholder. #18: added "Delete for me" alongside "Delete for everyone". A new message_hidden table records a per-user hide; the thread + sidebar (last message, unread) filter out the requesting user's hidden messages, and the hide is echoed to their other devices (chat-hidden). "Delete for me" is offered on any message; "Delete for everyone" stays sender-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+23
-3
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user