Skip to content

Commit eca7feb

Browse files
JSKittyclaude
andcommitted
fix: system events pile at top + load before their window
System events (wallpaper/membership changes) live outside the message pagination and were loaded in full on open, so every historical event stacked above the loaded window and stayed pinned during scroll. Window them like messages: buffer the full set, reveal only those inside the loaded message range, and reveal more as older messages page in. Also fix the insertion logic to anchor on any rendered message element (not just .dmsg) so prepended messages can't slip beneath a stranded system event, and let date dividers header system events too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f7bd124 commit eca7feb

3 files changed

Lines changed: 88 additions & 31 deletions

File tree

src/js/chat-scroll.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,19 @@ async function loadMoreMessages() {
108108
return;
109109
}
110110

111+
// Reveal any buffered system events now inside the expanded window so
112+
// they prepend in chronological order alongside the older messages.
113+
const revealedSys = revealSystemEventsInWindow(strOpenChat);
114+
111115
// Update the chat object's messages array for compatibility
112116
chat.messages = eventCache.getEvents(strOpenChat) || [];
113117

114118
// Get profile for rendering
115119
const isGroup = chat?.chat_type === 'MlsGroup';
116120
const profile = !isGroup ? getProfile(chat.id) : null;
117121

118-
// Render the older events (prepend)
119-
await updateChat(chat, olderMessages, profile, false);
122+
// Render the older events + newly-revealed system events (prepend)
123+
await updateChat(chat, [...olderMessages, ...revealedSys], profile, false);
120124

121125
// Update rendered count
122126
proceduralScrollState.renderedMessageCount += olderMessages.length;

src/js/render/chat/message-row.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ function renderMessage(msg, sender, editID = '', contextElement = null) {
103103
// dedup guard skips re-rendering it on the openChat pre-paint pass.
104104
// Without this, system events rendered twice on every chat reopen.
105105
el.id = msg.id;
106+
// Carry `at` so the date-divider rebuild treats a system event as
107+
// day content (a divider should head it, not float below it).
108+
el.dataset.at = msg.at;
106109
return el;
107110
}
108111

src/main.js

Lines changed: 79 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5510,11 +5510,15 @@ async function updateChat(chat, arrMessages = [], profile = null, fClicked = fal
55105510
continue;
55115511
}
55125512

5513-
// Get the oldest message in the DOM
5513+
// Get the oldest message in the DOM. Match any rendered element
5514+
// that maps to a message — including system events (which render
5515+
// as .msg-inline-timestamp, not .dmsg). Anchoring only to .dmsg
5516+
// would let older prepended messages slip BELOW a system event
5517+
// stranded at the top, pinning it there out of chronological order.
55145518
let oldestMsgElement = null;
55155519
for (let i = 0; i < domChatMessages.children.length; i++) {
55165520
const child = domChatMessages.children[i];
5517-
if (child.classList && child.classList.contains('dmsg')) {
5521+
if (child.id && chat.messages.some(m => m.id === child.id)) {
55185522
oldestMsgElement = child;
55195523
break;
55205524
}
@@ -5553,11 +5557,14 @@ async function updateChat(chat, arrMessages = [], profile = null, fClicked = fal
55535557
// This is a less common case, so we'll do a linear scan
55545558
let inserted = false;
55555559

5556-
// Get the message elements sorted by time (oldest to newest)
5560+
// Get the message elements sorted by time (oldest to newest).
5561+
// Include system events (.msg-inline-timestamp with an id) so a
5562+
// mid-list insert lands in true chronological order relative to
5563+
// them, not just relative to .dmsg rows.
55575564
let messageNodes = [];
55585565
for (let i = 0; i < domChatMessages.children.length; i++) {
55595566
const child = domChatMessages.children[i];
5560-
if (child.id && child.classList && child.classList.contains('dmsg')) {
5567+
if (child.id) {
55615568
const childMsg = chat.messages.find(m => m.id === child.id);
55625569
if (childMsg) {
55635570
messageNodes.push({ element: child, message: childMsg });
@@ -5710,12 +5717,14 @@ function _dedupeAdjacentDaySeparators() {
57105717
}
57115718
for (const sep of stale) sep.remove();
57125719

5713-
// Re-insert one date divider above each `.dmsg` that starts a new day.
5720+
// Re-insert one date divider above the first day-content element (a
5721+
// `.dmsg` row OR a system event — both carry `dataset.at`) that starts a
5722+
// new day. The "New" divider and stale separators have no `at`, so the
5723+
// Number.isFinite guard skips them.
57145724
let prevAt = null;
57155725
const inserts = [];
57165726
for (const child of domChatMessages.children) {
5717-
if (!child.classList?.contains('dmsg')) continue;
5718-
const at = parseInt(child.dataset.at, 10);
5727+
const at = parseInt(child.dataset?.at, 10);
57195728
if (!Number.isFinite(at)) continue;
57205729
if (prevAt === null || _dmsgIsDifferentDay(prevAt, at)) {
57215730
inserts.push({ before: child, at });
@@ -6740,6 +6749,46 @@ function hideEditHistory() {
67406749
* Open a chat with a particular contact
67416750
* @param {string} contact
67426751
*/
6752+
// System events (wallpaper/membership changes) are synthesized app-data
6753+
// events stored apart from the message-views pagination (kind 30078,
6754+
// distinguished by a `d` tag, so the kind-filtered message window skips them).
6755+
// To give them the SAME on-demand windowing as messages, the full set is
6756+
// fetched once into this side buffer, then revealed into the message cache
6757+
// only as far back as the loaded message window reaches — and progressively
6758+
// as the user scrolls older messages into view.
6759+
const _systemEventBuffer = new Map(); // chatId -> sorted array of system-event msg objects
6760+
6761+
/** Reveal buffered system events that fall within the currently-loaded
6762+
* message window into the event cache. Returns the newly-revealed ones so
6763+
* the caller can hand them to updateChat. Dedup-safe via cache.addEvent. */
6764+
function revealSystemEventsInWindow(chatId) {
6765+
const buffer = _systemEventBuffer.get(chatId);
6766+
if (!buffer || !buffer.length) return [];
6767+
6768+
// Lower bound of the loaded window = oldest real (non-system) message in
6769+
// the cache. Below that, messages haven't been paged in yet, so their
6770+
// system events stay hidden. Once every message is loaded, the bound drops
6771+
// away and the remaining (oldest) system events reveal too.
6772+
const stats = eventCache.getStats(chatId);
6773+
let bound = -Infinity;
6774+
if (!stats?.isFullyLoaded) {
6775+
const loaded = eventCache.getEvents(chatId) || [];
6776+
let oldestReal = Infinity;
6777+
for (const m of loaded) {
6778+
if (!m.system_event && m.at < oldestReal) oldestReal = m.at;
6779+
}
6780+
if (oldestReal !== Infinity) bound = oldestReal;
6781+
}
6782+
6783+
const revealed = [];
6784+
for (const sm of buffer) {
6785+
if (sm.at >= bound && eventCache.addEvent(chatId, sm)) {
6786+
revealed.push(sm);
6787+
}
6788+
}
6789+
return revealed;
6790+
}
6791+
67436792
async function openChat(contact) {
67446793
pushBack('chat', closeChat);
67456794
// Abandon a wallpaper preview staged in a different chat so its edit
@@ -6854,30 +6903,31 @@ async function openChat(contact) {
68546903
// Merge any historical PIVX payments — helper in pivx.js
68556904
await mergePivxPaymentsIntoChat(contact, initialMessages);
68566905

6857-
// Load system events (member joined/left, etc.) for this chat and merge them
6906+
// Load system events (wallpaper/membership changes). They're fetched in
6907+
// full but buffered — only the ones inside the initially-loaded message
6908+
// window are revealed now; the rest surface as the user scrolls older
6909+
// messages into view (same on-demand windowing as messages).
68586910
try {
68596911
const systemEvents = await invoke('get_system_events', { conversationId: contact });
6860-
if (systemEvents && systemEvents.length > 0) {
6861-
for (const event of systemEvents) {
6862-
// Check if this event already exists in messages
6863-
const existing = initialMessages.find(m => m.id === event.id);
6864-
if (!existing) {
6865-
const systemMsg = {
6866-
id: event.id,
6867-
at: event.at,
6868-
content: event.content,
6869-
mine: false,
6870-
attachments: [],
6871-
system_event: {
6872-
event_type: event.event_type,
6873-
member_npub: event.member_npub,
6874-
}
6875-
};
6876-
// Add to cache (which also adds to initialMessages since they share the same array reference)
6877-
eventCache.addEvent(contact, systemMsg);
6878-
}
6879-
}
6880-
// Re-sort by timestamp after adding system events
6912+
const buffer = (systemEvents || [])
6913+
.filter(event => !initialMessages.find(m => m.id === event.id))
6914+
.map(event => ({
6915+
id: event.id,
6916+
at: event.at,
6917+
content: event.content,
6918+
mine: false,
6919+
attachments: [],
6920+
system_event: {
6921+
event_type: event.event_type,
6922+
member_npub: event.member_npub,
6923+
},
6924+
}))
6925+
.sort((a, b) => a.at - b.at);
6926+
_systemEventBuffer.set(contact, buffer);
6927+
// revealSystemEventsInWindow adds into the cache array, which is
6928+
// aliased to initialMessages — re-sort so the pre-paint renders them
6929+
// in chronological order.
6930+
if (revealSystemEventsInWindow(contact).length > 0) {
68816931
initialMessages.sort((a, b) => a.at - b.at);
68826932
}
68836933
} catch (e) {

0 commit comments

Comments
 (0)