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:
+58
-5
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user