feat(chat): forward messages with multi-select (#1)

- Message action pill gains a Forward button; tapping it enters selection mode
  (tap bubbles to multi-select, footer bar shows count + Forward/Cancel, Esc exits).
- Forward picker lists EXISTING conversations only (DMs + groups from the sidebar),
  searchable, multi-target. POST /api/messages/forward copies body+attachment into
  each target (authorized as participant/member), live-pushed like a normal send.
- /files auth now accepts ANY message carrying an attachment (allByAttachment), so
  forwarded images stay viewable for the new recipients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:09:02 +05:30
parent 3a976d58ab
commit a9b3533f7a
4 changed files with 118 additions and 7 deletions
+37
View File
@@ -1341,6 +1341,43 @@ route('POST', '/api/messages', async (req, res) => {
json(res, 200, dto);
});
// Forward one or more of my visible messages to existing conversations (DMs I'm in / groups I'm a
// member of). Copies body + attachment (attachment stays viewable via the any-carrier /files auth).
route('POST', '/api/messages/forward', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { messageIds, targets } = await readBody(req);
if (!Array.isArray(messageIds) || !messageIds.length || !Array.isArray(targets) || !targets.length) return json(res, 400, { error: 'messageIds and targets required' });
// Gather source messages the user is allowed to see, oldest-first (preserve order).
const srcs = [];
for (const mid of messageIds.slice(0, 30)) {
const m = R.messages.byId(mid);
if (!m || m.team_id !== u.team_id || m.deleted || m.poll_id) continue;
const ok = m.conversation_id ? R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
if (ok && (m.body || m.attachment_id)) srcs.push(m);
}
if (!srcs.length) return json(res, 400, { error: 'nothing to forward' });
srcs.sort((a, b) => a.created_at - b.created_at);
const names = namesFor(u.team_id);
let sent = 0;
for (const t of (targets || []).slice(0, 20)) {
let isGroup = t.kind === 'group', tid = t.id;
if (isGroup) { if (!R.conversations.isMember(tid, u.id)) continue; }
else { tid = R.users.resolve(tid); if (!tid || !R.users.inTenant(tid, u.team_id)) continue; }
for (const m of srcs) {
const nid = A.token(16);
if (isGroup) R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid });
else R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id });
const dto = buildMsgDTO(R.messages.byId(nid), names, u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
if (isGroup) { for (const mid of R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }
else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} }
sent++;
}
}
json(res, 200, { ok: true, sent });
});
// Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder).
route('POST', '/api/messages/delete', async (req, res) => {
const u = currentUser(req);