aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js18
-rw-r--r--src/concord.js6
-rw-r--r--src/features/messages.js246
-rw-r--r--src/i18n.js12
-rw-r--r--src/style.css26
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: '<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>',
+ }));
+}
+
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) =>
@@ -1257,10 +1439,19 @@ export function messagesFeature(ctx) {
}, '#' + 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)
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0" fill="none"/></svg>'
+ : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.7 21a2 2 0 0 1-3.4 0"/><path d="M18 8a6 6 0 0 0-9.3-5"/><path d="M6.3 6.3A6 6 0 0 0 6 8c0 7-3 9-3 9h15"/><path d="m2 2 20 20"/></svg>',
+ }),
+ 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; }