fix: auto-apply new web builds; DP missing in DM but shown in group (batch79)
Auto-update (no manual step, no version confusion): - New code now applies ITSELF. The client polls /api/build and, as soon as it's SAFE, silently hard-reloads onto the new build. Safe = not in a call, no live screen session, no dialog open, nothing half-typed; if the user is busy we wait and apply the moment they're free. A brief "Updated to the latest version" toast confirms it. - Removed the "web build" row from Settings: users must never have to reason about an app version vs a web build. The only version surfaced is the desktop app's (auto-updater). DP bug: a contact showed their photo in a GROUP but fell back to initials in the 1:1. Two causes, both handled: - Duplicate rows for one person (signed in by email once and by mobile another time before the bizgaze_user_id merge landed) — only one row carries the DP, and the group happened to reference the row WITH the photo. avatarsFor() now keys rows by stable person identity (bizgaze person id → email → name) so a photo-less row borrows its twin's photo. Applied to contacts, conversations, group members and group info. - A DM whose counterparty was merged away is now keyed by the SURVIVING account, so the row carries that account's name/photo/presence (and split threads collapse into one). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+31
-7
@@ -13,6 +13,23 @@ const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s);
|
||||
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.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, fwd_from: m.deleted ? null : (m.fwd_from || 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; }
|
||||
// id -> profile photo, with a fallback across DUPLICATE rows for the same person.
|
||||
//
|
||||
// A person can end up with more than one row (signed in by email once and by mobile another time, before
|
||||
// the bizgaze_user_id merge landed). Only one of those rows carries the DP. Groups happened to reference
|
||||
// the row WITH the photo while a DM referenced the one without — so the same contact showed their picture
|
||||
// in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze
|
||||
// person id, else email, else name) and let a photo-less row borrow its twin's photo.
|
||||
function avatarsFor(teamId) {
|
||||
const users = R.users.listByTenant(teamId);
|
||||
const personKey = (x) => (x.bizgaze_user_id ? 'bz:' + x.bizgaze_user_id
|
||||
: (x.email ? 'em:' + String(x.email).toLowerCase() : 'nm:' + String(x.name || '').trim().toLowerCase()));
|
||||
const byPerson = {};
|
||||
for (const x of users) { if (x.avatar_url) { const k = personKey(x); if (!byPerson[k]) byPerson[k] = x.avatar_url; } }
|
||||
const out = {};
|
||||
for (const x of users) out[x.id] = x.avatar_url || byPerson[personKey(x)] || null;
|
||||
return out;
|
||||
}
|
||||
// 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; }
|
||||
const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
@@ -685,7 +702,8 @@ route('GET', '/api/messages/contacts', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
|
||||
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: x.avatar_url || null })));
|
||||
const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback
|
||||
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null })));
|
||||
});
|
||||
|
||||
// Cross-tenant people search via the BizGaze directory (token stays server-side). Results are
|
||||
@@ -714,17 +732,21 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
const names = {};
|
||||
const avatars = {};
|
||||
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'; }
|
||||
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; }
|
||||
Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
|
||||
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)) {
|
||||
const other = m.sender_id === u.id ? m.recipient_id : m.sender_id;
|
||||
if (!other) continue;
|
||||
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
|
||||
// that account's name/photo/presence (and two half-threads for one person collapse into one row).
|
||||
const other = (() => { try { return R.users.resolve(raw) || raw; } catch (_) { return raw; } })();
|
||||
if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 });
|
||||
if (m.recipient_id === u.id && m.sender_id === other && !m.read_at) byOther.get(other).unread++;
|
||||
if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++;
|
||||
}
|
||||
const dmItems = [...byOther.values()].map((c) => {
|
||||
const dc = dmCalls.get(CALLS.pairKey(u.id, c.other));
|
||||
@@ -839,7 +861,8 @@ route('GET', '/api/groups/members', async (req, res) => {
|
||||
const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group');
|
||||
if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
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; }
|
||||
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; }
|
||||
Object.assign(avatars, avatarsFor(u.team_id));
|
||||
const adminSet = new Set(R.conversations.admins(gid));
|
||||
json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
|
||||
});
|
||||
@@ -853,7 +876,8 @@ route('GET', '/api/groups/info', async (req, res) => {
|
||||
const g = R.conversations.byId(gid);
|
||||
const tenantUsers = R.users.listByTenant(u.team_id);
|
||||
const names = {}; const avatars = {};
|
||||
for (const x of tenantUsers) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; }
|
||||
for (const x of tenantUsers) { names[x.id] = x.name || x.email; }
|
||||
Object.assign(avatars, avatarsFor(u.team_id));
|
||||
const adminSet = new Set(R.conversations.admins(gid));
|
||||
json(res, 200, {
|
||||
id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id,
|
||||
|
||||
Reference in New Issue
Block a user