From dc8f4b841ff57f4ef74597c2f80b6f90b352db13 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 21:04:09 -0700 Subject: Messages in the header, and a room you can tell is alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header carries a message button again, left of the wallet selector, with a red dot when a conversation is waiting. Unread is a per-thread watermark on the newest message from someone else, kept in the rumor's own created_at so it means the same thing on every device it syncs to. Rooms subscribe at startup now, or the dot could not speak for a community nobody was listening to. Communities no longer buzz a phone by default: push registration only carries the stream keys of communities you have opted in, per community, from a bell in the room header. DMs and payments are unchanged. Presence and typing ride the channel stream as 21059 wraps, so who is around stays inside the community's encryption rather than becoming a public status. Measured against relay.coinos.io and nos.lol, neither actually drops NIP-01 ephemeral kinds, so beats carry a NIP-40 expiration and relay.coinos.io sweeps kind 21059 hourly — and presence is dated by the beat, never by arrival, or a replayed beat would light up the whole member list. The member list follows Telegram: online, then last seen in minutes and hours, then a vague 'recently' rather than advertising how long someone has been gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/app.js | 18 ++++ src/concord.js | 6 +- src/features/messages.js | 246 +++++++++++++++++++++++++++++++++++++++++++++-- src/i18n.js | 12 +++ src/style.css | 26 +++++ 5 files changed, 300 insertions(+), 8 deletions(-) diff --git a/src/app.js b/src/app.js index fdbe8f4..f2f9ee3 100644 --- a/src/app.js +++ b/src/app.js @@ -1448,6 +1448,23 @@ function avatarMenu() { : null; })()); } +// Messages sit one tap away, left of the wallet selector. The dot is presence, +// not a count — it says "someone's waiting", and the chat list says who. +function messagesBtn() { + const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); + if (!me) return null; + const unread = featureHook('unreadMessages') || 0; + return h('button', { + class: 'header-msgs' + (unread ? ' unread' : ''), + title: t('msgDmsTitle'), + 'aria-label': t('msgDmsTitle'), + onClick: () => { ui.profilePk = null; ui.chatOpen = true; ui.msgView = 'home'; render(); }, + }, h('span', { + class: 'hm-ico', + html: '', + })); +} + function brandHeader(withLock) { const acc = activeAccount(); return h( @@ -1460,6 +1477,7 @@ function brandHeader(withLock) { ), withLock ? h('div', { class: 'row gap6', style: 'align-items:center' }, + messagesBtn(), h('button', { class: 'btn-sm', onClick: () => { ui.screen = 'accounts'; render(); } }, acc ? acc.label : t('accounts')), avatarMenu()) diff --git a/src/concord.js b/src/concord.js index 095f6d8..09e4f72 100644 --- a/src/concord.js +++ b/src/concord.js @@ -95,13 +95,17 @@ export const makeSeal = async (rumor, signer, streamConvKey, { plaintext = false return signer instanceof Uint8Array ? finalizeEvent(seal, signer) : signer.signEvent(seal); }; -export const makeWrap = (seal, stream, { ephemeral = false } = {}) => { +// `expiration` is NIP-40, in seconds: for wraps whose whole value is being +// current (presence, typing), it tells a relay it may drop this rather than +// keep it forever. It has to be in the event before signing, so it lives here. +export const makeWrap = (seal, stream, { ephemeral = false, expiration = 0 } = {}) => { let wrap = { kind: ephemeral ? 21059 : 1059, content: nip44.encrypt(JSON.stringify(seal), stream.convKey), tags: [["p", getPublicKey(generateSecretKey())]], created_at: seal.created_at, }; + if (expiration) wrap.tags.push(["expiration", String(expiration)]); return finalizeEvent(wrap, stream.sk); }; diff --git a/src/features/messages.js b/src/features/messages.js index d93a863..70886d3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -61,6 +61,8 @@ export function messagesFeature(ctx) { s.dms ||= {}; // { [peerPk]: [{ id, from, text, t }] } s.declined ||= {}; // direct-invite rumor ids dismissed s.tombstones ||= {}; // { [cid]: removed_at ms } — left communities (CORD-02 §8) + s.read ||= {}; // { ['dm:'+pk | 'ch:'+id]: created_at } — last message we've seen + s.notify ||= {}; // { [cid]: true } — communities that may buzz your phone (opt-in) for (const c of s.communities) c.added_at ||= Date.now(); // pre-multi-community shape: joined was { [pubkey]: true } for coinos for (const k of Object.keys(s.joined)) @@ -70,6 +72,43 @@ export function messagesFeature(ctx) { const save = (s) => wallet.saveFeatureState('messages', s); const communities = () => [COMMUNITY, ...st().communities]; + + // ---- unread ------------------------------------------------------------- + // A conversation is unread when its newest message from someone else is + // newer than the last one we looked at. The watermark is the rumor's own + // created_at, not a local clock, so it means the same thing on every device + // the state syncs to. Our own messages never count — sending isn't reading, + // but you've obviously seen what you wrote. + + const dmRead = (pk) => 'dm:' + pk; + const chRead = (id) => 'ch:' + id; + + const newestFrom = (entries, theirs) => { + let ts = 0; + for (const m of entries) if (theirs(m)) ts = Math.max(ts, m.rumor.created_at || 0); + return ts; + }; + + function markRead(key, ts) { + const s = st(); + if (!ts || (s.read[key] || 0) >= ts) return; + s.read[key] = ts; + save(s); + } + + // How many conversations are waiting on us — the header only draws a dot, but + // a count keeps the door open for a number later. + function unreadCount() { + const s = st(); + const my = myPubkeys(); + let n = 0; + for (const [pk, msgs] of threads) + if (newestFrom(msgs.values(), (m) => !m.mine) > (s.read[dmRead(pk)] || 0)) n++; + for (const room of rooms.values()) + for (const [id, msgs] of room.byChannel) + if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) n++; + return n; + } const communityById = (cid) => communities().find((c) => c.community_id === cid); // ---- identity ----------------------------------------------------------- @@ -170,6 +209,8 @@ export function messagesFeature(ctx) { edits: new Map(), deletes: new Set(), reactions: new Map(), + presence: new Map(), // pubkey -> ms of their last beat (this session only) + typing: new Map(), // pubkey -> { ch, t } subbed: new Set(), relays: jm.relays && jm.relays.length ? jm.relays : DM_RELAYS, }; @@ -219,7 +260,7 @@ export function messagesFeature(ctx) { function subChannel(room, id) { if (room.subbed.has(id)) return; room.subbed.add(id); - allUnsubs.push(subscribeOn(room.relays, { kinds: [1059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => { + allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); const opened = openWrap(wrap, room.chStream(id)); @@ -233,9 +274,24 @@ export function messagesFeature(ctx) { // CORD-03 §3: the rumor must commit to the channel/epoch that decrypted it if (tag('channel')?.[1] !== channelId || tag('epoch')?.[1] !== String(EPOCH)) return; if (room.folded && room.folded.banned.has(author)) return; + if (rumor.kind === PRESENCE || rumor.kind === TYPING) { + // Dated by the beat itself, never by arrival. Relays hand back stored + // beats when we subscribe, and treating those as "just now" would light + // up the whole member list as online. Clamped to now so a fast clock + // can't hold someone online forever. + const ts = Math.min(Date.now(), eventMs(rumor) || rumor.created_at * 1000); + if (ts > (room.presence.get(author) || 0)) room.presence.set(author, ts); + if (rumor.kind === TYPING && Date.now() - ts < TYPING_MS) { + room.typing.set(author, { ch: channelId, t: ts }); + setTimeout(scheduleRepaint, TYPING_MS + 100); // clear itself when it lapses + } + scheduleRepaint(); + return; + } if (rumor.kind === 9) { const msgs = room.byChannel.get(channelId) || room.byChannel.set(channelId, new Map()).get(channelId); msgs.set(rumor.id, { rumor, author }); + room.typing.delete(author); // the message itself ends the "typing…" } else if (rumor.kind === 5) { for (const e of rumor.tags.filter((x) => x[0] === 'e')) { const m = room.byChannel.get(channelId)?.get(e[1]); @@ -519,15 +575,38 @@ export function messagesFeature(ctx) { return Uint8Array.from(raw, (c) => c.charCodeAt(0)); }; + // What the notifier is allowed to wake us for. Communities are opt-in and + // off by default: a busy room would otherwise buzz a phone all day for + // conversations that aren't addressed to anyone in particular. function pushWatch() { + const on = st().notify; const authors = []; for (const jm of communities()) { + if (!on[jm.community_id]) continue; const root = hexToBytes(jm.community_root); for (const c of (jm.channels || []).slice(0, 8)) authors.push(channelKey(root, c.id, EPOCH).pk); } return { ptags: myPubkeys(), authors }; } + const roomNotify = (cid) => !!st().notify[cid]; + async function toggleRoomNotify(cid) { + const s = st(); + if (s.notify[cid]) delete s.notify[cid]; else s.notify[cid] = true; + save(s); + render(); + // The server keeps the watch list, so the change only lands once we + // re-register. Asking for permission is fair here — they just opted in. + const ok = await registerPush({ interactive: !!s.notify[cid] }); + if (!ok && s.notify[cid]) { + const s2 = st(); + delete s2.notify[cid]; + save(s2); + toast(t('msgPushFailed')); + render(); + } + } + async function registerPush({ interactive = false } = {}) { try { if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false; @@ -996,6 +1075,100 @@ export function messagesFeature(ctx) { const backBtn = (onClick) => h('button', { class: 'iconbtn chat-back', onClick }, '‹'); + // ---- presence & typing -------------------------------------------------- + // Both ride the channel stream as 21059 wraps, so they stay inside the + // community's encryption — who's around is members-only, not a public + // status broadcast. 21059 sits in NIP-01's ephemeral range, but measured + // against the relays we actually use (relay.coinos.io, nos.lol) neither + // drops them, so they carry a NIP-40 expiration and relay.coinos.io sweeps + // the kind on a timer; nothing here may assume the relay forgets. + // Presence only exists while someone is watching a room. A member nobody + // saw beat is dated by their last message instead, the way an offline + // contact still shows a last-seen. + + const PRESENCE = 20100; + const TYPING = 20101; + const BEAT_MS = 60_000; // how often we announce ourselves + const ONLINE_MS = 100_000; // a beat older than this is no longer "online" + const TYPING_MS = 7_000; // how long a typing ping stands + const TYPE_THROTTLE = 5_000; // and how rarely we send one + + const lastPing = new Map(); // channelId + kind -> ms + + async function ping(room, chId, kind) { + const key = chId + ':' + kind; + const gap = kind === PRESENCE ? BEAT_MS : TYPE_THROTTLE; + if (Date.now() - (lastPing.get(key) || 0) < gap) return; + lastPing.set(key, Date.now()); + try { + const id = await identity(); + if (!id) return; + const { created_at, ms } = msTags(Date.now()); + const rumor = { + kind, pubkey: id.pubkey, content: '', + tags: [['channel', chId], ['epoch', String(EPOCH)], ms], created_at, + }; + // NIP-40: a beat is worthless once it's stale, so relays that honour + // expiration may drop it instead of keeping it forever. + const wrap = await wrapRumor(rumor, id.signer, room.chStream(chId), + { ephemeral: true, expiration: created_at + 300 }); + await publishOn(room.relays, wrap); + } catch { lastPing.delete(key); } + } + + // Keep beating while a room is on screen — otherwise everyone would look + // online for exactly one render and then go dark. + let beatTimer = null; + function keepBeating() { + if (beatTimer) return; + beatTimer = setInterval(() => { + if (!ui.chatOpen || ui.msgView !== 'room') return; + const jm = communityById(ui.msgCommunity) || COMMUNITY; + const room = rooms.get(jm.community_id); + if (!room) return; + const chans = roomChannels(room); + const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0]; + if (ch) ping(room, ch.id, PRESENCE); + scheduleRepaint(); // let "online" lapse into "last seen" on its own + }, 30_000); + } + + // When we last had any sign of someone: a beat, or failing that the newest + // thing they said anywhere in the community. + function lastSeenMs(room, pk) { + let ts = room.presence.get(pk) || 0; + for (const msgs of room.byChannel.values()) + for (const m of msgs.values()) + if (m.author === pk) ts = Math.max(ts, eventMs(m.rumor) || 0); + return ts; + } + + const isOnline = (room, pk) => Date.now() - (room.presence.get(pk) || 0) < ONLINE_MS; + + function typingNow(room, chId) { + const my = myPubkeys(); + const out = []; + for (const [pk, ts] of room.typing) + if (ts.ch === chId && Date.now() - ts.t < TYPING_MS && !my.includes(pk)) out.push(pk); + return out; + } + + // Telegram's wording, and its kindness: exact minutes while it's fresh, + // then a vague "recently" rather than advertising how long someone's been away. + function seenLabel(room, pk) { + if (isOnline(room, pk)) return t('msgOnline'); + const ts = lastSeenMs(room, pk); + if (!ts) return t('msgSeenRecently'); + const mins = Math.floor((Date.now() - ts) / 60_000); + if (mins < 1) return t('msgSeenJustNow'); + if (mins < 60) return t('msgSeenMins', { n: mins }); + const hrs = Math.floor(mins / 60); + if (hrs < 24) return t('msgSeenHours', { n: hrs }); + const days = Math.floor(hrs / 24); + if (days <= 7) return t('msgSeenDays', { n: days }); + return t('msgSeenRecently'); + } + // Recipient search for New message: debounced, sequenced, cached upstream. const dmSearch = { rows: null, busy: false }; const dmSearcher = makeSearcher((q, rows) => { @@ -1017,12 +1190,12 @@ export function messagesFeature(ctx) { }); }; - const composer = (placeholder, onSend) => + const composer = (placeholder, onSend, onType) => h('div', { class: 'chat-compose' }, h('input', { class: 'grow', type: 'text', id: 'msg-draft', placeholder, value: ui.msgDraft || '', maxlength: '2000', - onInput: (e) => { ui.msgDraft = e.target.value; }, + onInput: (e) => { ui.msgDraft = e.target.value; if (onType && e.target.value) onType(); }, onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } }, }), h('button', { class: 'btn-primary btn-sm', disabled: ui.msgSending, onClick: onSend }, @@ -1191,6 +1364,7 @@ export function messagesFeature(ctx) { .filter((m) => !room.deletes.has(m.rumor.id)) .filter((m) => !(room.folded && room.folded.banned.has(m.author))) .sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor)); + markRead(chRead(chId), newestFrom(msgs, (m) => !my.includes(m.author))); if (!msgs.length) return [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgEmpty'))]; let lastAuthor = null, lastT = 0; @@ -1237,17 +1411,25 @@ export function messagesFeature(ctx) { const chans = roomChannels(room); const ch = chans.find((c) => c.id === ui.msgChannel) || chans[0]; const name = room.folded?.metadata?.name || jm.name; - const memberCount = [...room.members.values()].filter((m) => m.state === 'join').length; + const members = [...room.members.entries()].filter(([, m]) => m.state === 'join'); + const memberCount = members.length; + const onlineCount = members.filter(([pk]) => isOnline(room, pk)).length; stickToBottom(); + keepBeating(); + if (ch) ping(room, ch.id, PRESENCE); return h('div', { class: 'card col chat-card' }, h('div', { class: 'row between chat-head' }, h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, backBtn(() => { ui.msgView = 'home'; render(); }), - h('div', { class: 'col', style: 'gap:2px;min-width:0' }, + h('div', { + class: 'col clickable', style: 'gap:2px;min-width:0', + onClick: () => { ui.msgMembers = !ui.msgMembers; render(); }, + }, h('div', { class: 'chat-title' }, name), h('div', { class: 'muted small' }, - memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted')))), + memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'), + onlineCount ? h('span', { class: 'online-count' }, ' · ', t('msgNOnline', { n: onlineCount })) : null))), h('div', { class: 'row gap6', style: 'align-items:center' }, chans.length > 1 ? h('div', { class: 'seg' }, chans.map((c) => @@ -1256,11 +1438,20 @@ export function messagesFeature(ctx) { onClick: () => { ui.msgChannel = c.id; ui.msgStick = true; subChannel(room, c.id); render(); }, }, '#' + c.name))) : h('div', { class: 'tag' }, '#' + (ch ? ch.name : '')), + h('button', { + class: 'iconbtn bell' + (roomNotify(jm.community_id) ? ' on' : ''), + title: roomNotify(jm.community_id) ? t('msgNotifyOn') : t('msgNotifyOff'), + onClick: () => toggleRoomNotify(jm.community_id), + html: roomNotify(jm.community_id) + ? '' + : '', + }), h('button', { class: 'iconbtn', title: t('msgInviteTitle'), onClick: () => { ui.msgInvitePanel = !ui.msgInvitePanel; render(); }, }, '+'))), ui.msgInvitePanel ? invitePanel(room) : null, + ui.msgMembers ? memberPanel(room, members) : null, h('div', { class: 'chat-log', onScroll: (e) => { @@ -1268,7 +1459,40 @@ export function messagesFeature(ctx) { ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60; }, }, ...(ch ? messageRows(room, ch.id) : [])), - composer(t('msgPlaceholder', { channel: ch ? ch.name : '' }), () => ch && sendMessage(room, ch.id))); + ch ? typingLine(room, ch.id) : null, + composer( + t('msgPlaceholder', { channel: ch ? ch.name : '' }), + () => ch && sendMessage(room, ch.id), + () => ch && ping(room, ch.id, TYPING))); + } + + // "Alice is typing…" — named up to two, counted beyond that. + function typingLine(room, chId) { + const who = typingNow(room, chId); + if (!who.length) return null; + const label = who.length === 1 ? t('msgTyping', { name: displayName(who[0]) }) + : who.length === 2 ? t('msgTyping2', { a: displayName(who[0]), b: displayName(who[1]) }) + : t('msgTypingMany', { n: who.length }); + return h('div', { class: 'chat-typing' }, + h('span', { class: 'typing-dots' }, h('i'), h('i'), h('i')), + h('span', { class: 'small muted' }, label)); + } + + function memberPanel(room, members) { + const rows = members + .map(([pk]) => ({ pk, online: isOnline(room, pk), seen: lastSeenMs(room, pk) })) + .sort((a, b) => (b.online - a.online) || (b.seen - a.seen)); + return h('div', { class: 'col chat-members' }, + ...rows.map((r) => h('div', { + class: 'item chat-thread-row', + onClick: () => openProfile(r.pk), + }, + h('span', { class: 'ava-wrap' + (r.online ? ' online' : '') }, avatar(r.pk)), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + h('span', { class: 'chat-name' }, displayName(r.pk)), + r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), + h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk)))))); } function invitePanel(room) { @@ -1320,6 +1544,9 @@ export function messagesFeature(ctx) { const peer = ui.msgPeer; if (!peer) { ui.msgView = 'home'; return homeView(); } const msgs = [...(threads.get(peer)?.values() || [])].sort((a, b) => a.rumor.created_at - b.rumor.created_at); + // Looking at the thread is reading it — including anything that lands while + // it's still open, since every arrival repaints us. + markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine)); stickToBottom(); return h('div', { class: 'card col chat-card' }, h('div', { class: 'row chat-head gap6', style: 'align-items:center' }, @@ -1359,6 +1586,8 @@ export function messagesFeature(ctx) { // no balance card, no tabs; each view carries its own way back. // The bare avatar node for the app header's identity menu. headerAvatar(pk) { return avatar(pk, 'chat-avatar header-ava', false); }, + // Conversations waiting on us, for the header's message button. + unreadMessages() { return unreadCount(); }, screenView() { if (ui.screen !== 'wallet') return null; if (ui.profilePk) return profileScreen(); @@ -1406,6 +1635,9 @@ export function messagesFeature(ctx) { setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } startDMs(); + // Communities subscribe up front too, not just when chat opens — the + // header's unread dot can't report a room nobody is listening to. + for (const jm of communities()) { try { ensureRoom(jm); } catch {} } syncLists().catch(() => {}); registerPush().catch(() => {}); // silent refresh when permission already granted }, diff --git a/src/i18n.js b/src/i18n.js index c23d950..177de06 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -174,6 +174,18 @@ const STR = { msgPushOffer: 'Get notified when you receive payments and messages, even with coinos closed.', msgPushEnable: 'Enable notifications', msgPushOn: 'Notifications on', + msgNotifyOn: 'Notifications on for this community — tap to mute', + msgNotifyOff: 'Muted — tap to be notified about this community', + msgOnline: 'online', + msgNOnline: '{n} online', + msgSeenJustNow: 'last seen just now', + msgSeenMins: 'last seen {n} min ago', + msgSeenHours: 'last seen {n}h ago', + msgSeenDays: 'last seen {n}d ago', + msgSeenRecently: 'last seen recently', + msgTyping: '{name} is typing…', + msgTyping2: '{a} and {b} are typing…', + msgTypingMany: '{n} people are typing…', msgPushFailed: 'Could not enable notifications', msgChannelPlaceholder: 'New channel name', msgOwnerOnly: 'Only the community owner can do that', diff --git a/src/style.css b/src/style.css index 7c3113e..b90be07 100644 --- a/src/style.css +++ b/src/style.css @@ -395,6 +395,32 @@ hr.divider { margin: 16px 0; } .chat-avatar.fallback { position: relative; overflow: hidden; } .chat-avatar.fallback .punk { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .header-avatar { padding: 0; border: none; background: none; cursor: pointer; display: flex; } +.chat-typing { display: flex; align-items: center; gap: 7px; padding: 2px 4px 6px; min-height: 20px; } +.typing-dots { display: inline-flex; gap: 3px; align-items: center; } +.typing-dots i { width: 4px; height: 4px; border-radius: 50%; background: var(--muted); animation: typing-bounce 1.2s infinite ease-in-out; } +.typing-dots i:nth-child(2) { animation-delay: .15s; } +.typing-dots i:nth-child(3) { animation-delay: .3s; } +@keyframes typing-bounce { 0%, 60%, 100% { opacity: .3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } } +.chat-members { max-height: 260px; overflow-y: auto; border-bottom: 1px solid var(--line); } +.ava-wrap { position: relative; display: inline-flex; flex-shrink: 0; } +.ava-wrap.online::after { + content: ''; position: absolute; right: -1px; bottom: -1px; width: 10px; height: 10px; + border-radius: 50%; background: #3fbf5f; box-shadow: 0 0 0 2px var(--paper); +} +.small.is-online { color: #3fbf5f; } +.online-count { color: #3fbf5f; } +.iconbtn.bell.on { color: var(--ink); } +.header-msgs { position: relative; padding: 4px; border: none; background: none; cursor: pointer; display: flex; color: var(--muted); } +.header-msgs:hover { color: var(--ink); } +.header-msgs .hm-ico { display: flex; transition: transform .15s ease; } +.header-msgs:hover .hm-ico { transform: scale(1.08); } +.header-msgs.unread { color: var(--ink); } +.header-msgs.unread::after { + content: ''; position: absolute; top: 2px; right: 2px; width: 9px; height: 9px; + border-radius: 50%; background: var(--red); + box-shadow: 0 0 0 2px var(--bg); animation: dot-pop .3s ease-out; +} +@keyframes dot-pop { from { transform: scale(0); } to { transform: scale(1); } } .chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; } .npub-box { display: flex; align-items: center; gap: 8px; } .copy-ico { flex-shrink: 0; background: none; border: none; padding: 2px; color: var(--muted); cursor: pointer; display: flex; } -- cgit v1.2.3