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
+6 -6
View File
@@ -156,15 +156,15 @@ function handleGet(req, res) {
const id = path.basename(decodeURIComponent(pathOnly));
const a = R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
// Authorize: the uploader, a participant of the message carrying this attachment,
// or a member of the group that uses this attachment as its image.
const msg = R.messages.byAttachment(id);
// Authorize: the uploader, a member of the group using it as an avatar, or a participant of ANY
// message carrying it (the "any" covers forwarded attachments, which reuse the same id).
const avatarGroup = R.conversations.byAvatar(id);
const carriers = R.messages.allByAttachment(id);
const allowed = a.uploader_id === u.id
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|| (msg && (
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id)));
|| carriers.some((msg) => msg.conversation_id
? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id));
if (!allowed) return json(res, 403, { error: 'forbidden' });
const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });