feat(chat/notif): single hover action-pill; sender DP in desktop+mobile notifications
- Message actions (reply/react/edit/delete) consolidated into ONE hover pill anchored to the
bubble's top-right, overlapping it so on short messages the icons no longer float off into
empty space and vanish before you can click.
- Desktop toast + mobile(FCM)/web-background push now show the sender's real DP:
* renderer passes the DP URL through; desktop shell downloads it for the toast icon
(canvas-drawing an external DP tainted it → initials). Desktop bumped to 0.1.4.
* DM push payload carries icon=sender avatar; sw.js already uses it (web background),
sendFcm sets notification.image (Android).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
-17
@@ -430,13 +430,19 @@
|
||||
.convo{position:relative;}
|
||||
.bubble{position:relative;}
|
||||
.bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;}
|
||||
.reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
|
||||
.bubble:hover .reply-btn,.bubble.show-actions .reply-btn{opacity:1;pointer-events:auto;}
|
||||
/* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It
|
||||
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
|
||||
off into empty space where the hover (and the buttons) would vanish before you can click. */
|
||||
.msg-actions{position:absolute;top:-14px;right:6px;z-index:6;display:flex;gap:1px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px;box-shadow:0 2px 8px rgba(20,30,60,.18);opacity:0;pointer-events:none;transition:opacity .12s;}
|
||||
.bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
|
||||
.msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;}
|
||||
.msg-actions button:hover{background:var(--blue-soft);}
|
||||
.msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;}
|
||||
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
|
||||
.reply-bar b{color:var(--ink);}
|
||||
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
|
||||
.reply-bar .rx:hover{color:var(--red);}
|
||||
.reply-btn,.react-btn{display:grid;place-items:center;}
|
||||
/* (message action button styling is under .msg-actions) */
|
||||
.emoji-pop{position:absolute;bottom:64px;left:12px;width:330px;height:300px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:50;display:flex;flex-direction:column;overflow:hidden;}
|
||||
.mention-pop{position:absolute;left:12px;right:12px;bottom:64px;max-height:240px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:60;padding:.3rem;}
|
||||
.mention-pop .mrow{display:flex;align-items:center;gap:.55rem;padding:.4rem .55rem;border-radius:8px;cursor:pointer;}
|
||||
@@ -467,12 +473,6 @@
|
||||
.emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;}
|
||||
.emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;}
|
||||
.emoji-grid button:hover{background:var(--blue-soft);}
|
||||
.react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
|
||||
.bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;}
|
||||
.del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;}
|
||||
.bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;}
|
||||
.edit-btn{position:absolute;top:-9px;right:84px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;}
|
||||
.bubble:hover .edit-btn,.bubble.show-actions .edit-btn{opacity:1;pointer-events:auto;}
|
||||
.bubble .t .edited{font-style:italic;opacity:.8;}
|
||||
.bubble.deleted{opacity:.85;}
|
||||
.bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;}
|
||||
@@ -767,7 +767,7 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||
<script>window.__BUILD='2026-07-02-batch37';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-02-batch38';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
@@ -1291,10 +1291,12 @@ function bubbleHTML(m){
|
||||
if(mine && convoIsGroup && m.id===_lastMineId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,3).join(', '), more=ns.length>3?(' +'+(ns.length-3)+' more'):''; seen='<button class="seenby" data-seen="'+pEsc(ns.join('|'))+'">'+ic('checkCheck',12)+' Seen by '+pEsc(head)+more+'</button>'; }
|
||||
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
|
||||
+ sender + quote + att + renderMsgBody(m) + pollHTML(m)
|
||||
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
|
||||
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
|
||||
+ ((mine && !m.deleted && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
|
||||
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
|
||||
+ (m.deleted?'':'<div class="msg-actions">'
|
||||
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
|
||||
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
|
||||
+ ((mine && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
|
||||
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
|
||||
+ '</div>')
|
||||
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+(m.edited_at?'<span class="edited"> · edited</span>':'')+rcpt+'</span>'
|
||||
+ reacts + seen + '</div>';
|
||||
}
|
||||
@@ -2046,10 +2048,11 @@ function notifAvatarDataUrl(kind,id,fallbackName){
|
||||
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
|
||||
function notify(title, body, kind, id){
|
||||
try{
|
||||
// Desktop app (Phase D): native Windows toast with an inline reply box + sender/group avatar.
|
||||
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
|
||||
// downloads it for the icon (drawing an external DP to a canvas here tainted it → initials).
|
||||
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
|
||||
notifAvatarDataUrl(kind, id, title)
|
||||
.then(avatar=>window.bizConnectNative.replyNotify({title, body, kind, id, avatar}))
|
||||
const _av=(rowFor(kind,id)||{}).avatar||null;
|
||||
Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av}))
|
||||
.then(r=>{ if(!r) return;
|
||||
if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast
|
||||
else if(r.open) openFromNotif(kind, id); // clicked the toast
|
||||
|
||||
+3
-1
@@ -53,7 +53,9 @@ async function fcmAccessToken() {
|
||||
}
|
||||
async function sendFcm(token, payload) {
|
||||
const at = await fcmAccessToken();
|
||||
const msg = { message: { token, notification: { title: payload.title || 'Biz Connect', body: payload.body || '' }, data: strData(payload.data || {}) } };
|
||||
const notif = { title: payload.title || 'Biz Connect', body: payload.body || '' };
|
||||
if (payload.icon) notif.image = payload.icon; // sender/group DP shown by Android
|
||||
const msg = { message: { token, notification: notif, data: strData(payload.data || {}) } };
|
||||
const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', {
|
||||
method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg),
|
||||
});
|
||||
|
||||
+1
-1
@@ -1263,7 +1263,7 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
try { CHAT.pushToUser(to, push); } catch (_) {}
|
||||
if (to !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
||||
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
|
||||
if (to !== u.id) PUSH.sendToUser(to, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id });
|
||||
if (to !== u.id) PUSH.sendToUser(to, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
||||
json(res, 200, dto);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user