aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js54
-rw-r--r--src/style.css4
2 files changed, 49 insertions, 9 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 2782b4a..ae8cb53 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -83,6 +83,10 @@ export function messagesFeature(ctx) {
const dmRead = (pk) => 'dm:' + pk;
const chRead = (id) => 'ch:' + id;
+ // The list screen has a watermark of its own: standing on it answers the
+ // header dot ("someone's waiting" — you've now been shown who), while each
+ // row keeps its own dot until that conversation is actually opened.
+ const HOME_READ = 'home';
const newestFrom = (entries, theirs) => {
let ts = 0;
@@ -97,17 +101,31 @@ export function messagesFeature(ctx) {
save(s);
}
+ // Per-conversation dots for the list screen: newer than when we last had
+ // that thread (or any channel of that room) open.
+ const dmUnread = (pk, msgs) =>
+ newestFrom(msgs.values(), (m) => !m.mine) > (st().read[dmRead(pk)] || 0);
+ function roomUnread(room) {
+ const s = st();
+ const my = myPubkeys();
+ for (const [id, msgs] of room.byChannel)
+ if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true;
+ return false;
+ }
+
// How many conversations are waiting on us — the header only draws a dot, but
- // a count keeps the door open for a number later.
+ // a count keeps the door open for a number later. Floored by the list-screen
+ // watermark: once you've seen the list, the header stops repeating it.
function unreadCount() {
const s = st();
const my = myPubkeys();
+ const seen = s.read[HOME_READ] || 0;
let n = 0;
for (const [pk, msgs] of threads)
- if (newestFrom(msgs.values(), (m) => !m.mine) > (s.read[dmRead(pk)] || 0)) n++;
+ if (newestFrom(msgs.values(), (m) => !m.mine) > Math.max(seen, 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++;
+ if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > Math.max(seen, s.read[chRead(id)] || 0)) n++;
return n;
}
const communityById = (cid) => communities().find((c) => c.community_id === cid);
@@ -1280,11 +1298,24 @@ export function messagesFeature(ctx) {
const dmRows = [...threads.entries()]
.map(([peer, m]) => {
const last = [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at).at(-1);
- return { peer, last };
+ return { peer, last, unread: dmUnread(peer, m) };
})
.filter((x) => x.last)
.sort((a, b) => b.last.rumor.created_at - a.last.rumor.created_at);
+ // Being here answers the header dot — including anything that lands while
+ // the list is open, since arrivals repaint it and the rows below say who.
+ {
+ const my = myPubkeys();
+ let newest = 0;
+ for (const msgs of threads.values())
+ newest = Math.max(newest, newestFrom(msgs.values(), (m) => !m.mine));
+ for (const room of rooms.values())
+ for (const msgs of room.byChannel.values())
+ newest = Math.max(newest, newestFrom(msgs.values(), (m) => !my.includes(m.author)));
+ markRead(HOME_READ, newest);
+ }
+
const kids = [];
// Chat takes the whole screen, so home carries the way back to the wallet.
@@ -1351,16 +1382,18 @@ export function messagesFeature(ctx) {
const shownDms = (ui.msgAllDms || dmRows.length <= DM_PREVIEW + 2) ? dmRows : dmRows.slice(0, DM_PREVIEW);
kids.push(
dmRows.length
- ? h('div', { class: 'list' }, shownDms.map(({ peer, last }) =>
+ ? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) =>
h('div', {
- class: 'item chat-thread-row',
+ class: 'item chat-thread-row' + (unread ? ' unread' : ''),
onClick: () => { ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); },
},
avatar(peer),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
h('div', { class: 'row between' },
h('span', { class: 'chat-name' }, displayName(peer)),
- h('span', { class: 'chat-time' }, timeLabel(last.rumor.created_at * 1000))),
+ h('span', { class: 'chat-time thread-when' },
+ timeLabel(last.rumor.created_at * 1000),
+ unread ? h('i', { class: 'thread-dot' }) : null)),
h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + last.rumor.content)))))
: h('div', { class: 'muted small' }, t('msgNoDms')));
if (shownDms.length < dmRows.length)
@@ -1394,13 +1427,16 @@ export function messagesFeature(ctx) {
const room = rooms.get(jm.community_id);
const name = room?.folded?.metadata?.name || jm.name;
const memberCount = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0;
+ const unread = room ? roomUnread(room) : false;
return h('div', {
- class: 'item chat-thread-row',
+ class: 'item chat-thread-row' + (unread ? ' unread' : ''),
onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); },
},
h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('div', { class: 'chat-name' }, name),
+ h('div', { class: 'row between' },
+ h('span', { class: 'chat-name' }, name),
+ unread ? h('i', { class: 'thread-dot' }) : null),
h('div', { class: 'muted small' },
memberCount ? t('msgMembers', { n: memberCount }) : t('msgEncrypted'))));
})));
diff --git a/src/style.css b/src/style.css
index ff652a5..759b15d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -412,6 +412,10 @@ hr.divider { margin: 16px 0; }
.chat-back { font-size: 22px; line-height: 1; padding: 2px 10px 4px; flex-shrink: 0; }
.chat-thread-row { display: flex; gap: 10px; align-items: center; cursor: pointer; }
.chat-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.thread-when { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
+.thread-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); flex-shrink: 0; }
+.chat-thread-row.unread .chat-name { font-weight: 700; }
+.chat-thread-row.unread .chat-preview { color: var(--ink); }
.chat-invite { padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--surface2); flex: 0 0 auto; max-height: 40vh; overflow-y: auto; }
.chat-invite input { font: inherit; color: var(--ink); background: var(--paper); border: 1px solid var(--line-strong); border-radius: 9px; padding: 7px 10px; min-width: 0; }
.chat-row.dm { justify-content: flex-start; }