feat(chat): rich shared-media view, status selector, drag-drop upload + fixes

Chat / shared media:
- Media/Docs/Links: clean underline tabs (green active), audio & video now
  classified as Media and rendered as tiles (download + headphone/play +
  duration) instead of broken-image glyphs; image thumbnails -> lightbox
- Drag-and-drop a file/video/image onto a conversation to send it
- Fix: removed #chatPanel{position:relative} override that collapsed the
  conversation pane (messages spilled into a clipped right-edge strip)
- "Media, links & docs" row cleaned up (no folder/placeholder icon); media
  popup keeps the back arrow, drops the redundant close button

Presence / status:
- Single current-status row with an arrow that expands Available/Away/On leave
- On leave = circle with minus, In a call = solid red indicators
- Fix: selected-status tick now follows the chosen option

Icons: added headphones + play; bumped icons.js cache-bust to v4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:01:15 +05:30
parent e9e5c7f406
commit 06f0b08a18
12 changed files with 589 additions and 116 deletions
+58 -5
View File
@@ -11,7 +11,7 @@ const PUSH = require('./push');
const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.reply_to || null, mentions: parseMentions(m.mentions), evt: m.msg_type || null, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead.
function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; }
@@ -265,7 +265,16 @@ route('GET', '/api/ice', async (req, res) => {
route('GET', '/api/me', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null });
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
});
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
route('POST', '/api/me/status', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { status } = await readBody(req);
if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
try { R.users.setStatus(u.id, status); } catch (_) {}
json(res, 200, { ok: true, status });
});
// --- Web Push: background/closed-tab notifications (no-op unless VAPID is configured) ---
@@ -619,7 +628,11 @@ route('GET', '/api/messages/conversations', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
const names = {};
const avatars = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; }
const statuses = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; statuses[x.id] = x.status || 'active'; }
const favs = new Set(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); } }
// DMs
const byOther = new Map();
for (const m of R.messages.recentFor(u.team_id, u.id)) {
@@ -632,7 +645,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const dc = dmCalls.get(CALLS.pairKey(u.id, c.other));
return {
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null,
callActive: !!dc, callRoom: dc ? dc.room : 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,
}; });
// Groups
@@ -640,7 +653,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const last = R.messages.lastInConversation(g.id);
const since = R.conversations.lastReadAt(g.id, u.id);
return {
kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
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 ? R.messages.unreadInConversation(g.id, u.id, since) : 0,
@@ -1188,6 +1201,46 @@ route('POST', '/api/messages', async (req, res) => {
json(res, 200, dto);
});
// 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);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id } = await readBody(req);
if (!id) return json(res, 400, { error: 'id required' });
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 delete your own messages' });
R.messages.markDeleted(id);
const evt = { type: 'chat-deleted', id, 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 });
});
// Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
route('POST', '/api/favorites', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { kind, id, on } = await readBody(req);
if (!kind || !id) return json(res, 400, { error: 'kind and id required' });
try { R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {}
json(res, 200, { ok: true, favorite: !!on });
});
// Shared media & files in a conversation (group) or DM — for the "Shared" Media/Files view.
route('GET', '/api/messages/media', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || '');
const group = q.get('group'); const other = q.get('with');
let rows = [], linkRows = [];
if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); rows = R.messages.attachmentsForConversation(u.team_id, group); linkRows = R.messages.linksForConversation(u.team_id, group); }
else if (other) { rows = R.messages.attachmentsForDm(u.team_id, u.id, other); linkRows = R.messages.linksForDm(u.team_id, u.id, other); }
else return json(res, 400, { error: 'group or with required' });
const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
const links = []; for (const m of linkRows) { const mm = (m.body || '').match(urlRe); if (mm) for (const url of mm) links.push({ url, at: m.created_at }); }
const att = rows.map((r) => ({ id: r.id, name: r.name, mime: r.mime, size: r.size, isImage: /^image\//.test(r.mime || ''), isAudio: /^audio\//.test(r.mime || ''), isVideo: /^video\//.test(r.mime || ''), at: r.created_at }));
const isMedia = (a) => a.isImage || a.isAudio || a.isVideo; // images, audio & video → "Media"; everything else → "Docs"
json(res, 200, { media: att.filter(isMedia), docs: att.filter((a) => !isMedia(a)), links });
});
route('POST', '/api/messages/read', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });