Skip to content

Commit ef14a73

Browse files
fix: use sparse patches for thread root metadata updates (#8829)
* frontend/chat: use sparse patches for thread root metadata updates renameThread, setThreadPin, markThreadRead, and updateLastRead were calling syncdb.set() with the entire message object (via toJS()), round-tripping the history field through fromJS() on every update. Since updateLastRead fires on every scroll (100ms throttle), this continuously rewrote the thread root record — the most plausible cause of the intermittent "first message content disappears" bug. Refactor getThreadRootDoc → getThreadRootEntry to return only the primary key fields (date, sender_id, event) plus the Immutable message for reads. All callers now send sparse patches containing only the primary keys and the changed field(s). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * frontend/chat: use calendar-day boundaries for thread recency buckets Thread recency grouping ("Today", "Yesterday", etc.) used rolling 24h windows instead of calendar-day boundaries, so threads from last night could appear under "Today". Uses Date#setDate() for proper calendar arithmetic that also handles DST transitions correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d711902 commit ef14a73

File tree

2 files changed

+59
-43
lines changed

2 files changed

+59
-43
lines changed

src/packages/frontend/chat/actions.ts

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* This file is part of CoCalc: Copyright © 2020-2025 Sagemath, Inc.
2+
* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.
33
* License: MS-RSL – see LICENSE.md for details
44
*/
55

@@ -563,17 +563,15 @@ export class ChatActions extends Actions<ChatState> {
563563
if (this.syncdb == null) {
564564
return false;
565565
}
566-
const entry = this.getThreadRootDoc(threadKey);
566+
const entry = this.getThreadRootEntry(threadKey);
567567
if (entry == null) {
568568
return false;
569569
}
570570
const trimmed = name.trim();
571-
if (trimmed) {
572-
entry.doc.name = trimmed;
573-
} else {
574-
delete entry.doc.name;
575-
}
576-
this.syncdb.set(entry.doc);
571+
this.syncdb.set({
572+
...entry.key,
573+
name: trimmed || null,
574+
});
577575
this.syncdb.commit();
578576
return true;
579577
};
@@ -582,16 +580,14 @@ export class ChatActions extends Actions<ChatState> {
582580
if (this.syncdb == null) {
583581
return false;
584582
}
585-
const entry = this.getThreadRootDoc(threadKey);
583+
const entry = this.getThreadRootEntry(threadKey);
586584
if (entry == null) {
587585
return false;
588586
}
589-
if (pinned) {
590-
entry.doc.pin = true;
591-
} else {
592-
entry.doc.pin = false;
593-
}
594-
this.syncdb.set(entry.doc);
587+
this.syncdb.set({
588+
...entry.key,
589+
pin: pinned,
590+
});
595591
this.syncdb.commit();
596592
return true;
597593
};
@@ -608,12 +604,14 @@ export class ChatActions extends Actions<ChatState> {
608604
if (!account_id || !Number.isFinite(count)) {
609605
return false;
610606
}
611-
const entry = this.getThreadRootDoc(threadKey);
607+
const entry = this.getThreadRootEntry(threadKey);
612608
if (entry == null) {
613609
return false;
614610
}
615-
entry.doc[`read-${account_id}`] = count;
616-
this.syncdb.set(entry.doc);
611+
this.syncdb.set({
612+
...entry.key,
613+
[`read-${account_id}`]: count,
614+
});
617615
if (commit) {
618616
this.syncdb.commit();
619617
}
@@ -634,11 +632,11 @@ export class ChatActions extends Actions<ChatState> {
634632
if (!account_id || !Number.isFinite(dateMs)) {
635633
return false;
636634
}
637-
const entry = this.getThreadRootDoc(threadKey);
635+
const entry = this.getThreadRootEntry(threadKey);
638636
if (entry == null) {
639637
return false;
640638
}
641-
const rawValue = entry.doc[`lastread-${account_id}`];
639+
const rawValue = entry.message.get(`lastread-${account_id}`);
642640
const currentValue =
643641
typeof rawValue === "number"
644642
? rawValue
@@ -649,7 +647,10 @@ export class ChatActions extends Actions<ChatState> {
649647
// don't go backwards
650648
return false;
651649
}
652-
entry.doc[`lastread-${account_id}`] = dateMs;
650+
const update: Record<string, any> = {
651+
...entry.key,
652+
[`lastread-${account_id}`]: dateMs,
653+
};
653654
// also update the count-based read field for backward compatibility
654655
const messages = this.store?.get("messages");
655656
if (messages) {
@@ -667,9 +668,9 @@ export class ChatActions extends Actions<ChatState> {
667668
}
668669
}
669670
}
670-
entry.doc[`read-${account_id}`] = count;
671+
update[`read-${account_id}`] = count;
671672
}
672-
this.syncdb.set(entry.doc);
673+
this.syncdb.set(update);
673674
if (commit) {
674675
this.syncdb.commit();
675676
}
@@ -680,7 +681,7 @@ export class ChatActions extends Actions<ChatState> {
680681
getLastRead = (threadKey: string): number | undefined => {
681682
const account_id = this.redux.getStore("account").get_account_id();
682683
if (!account_id) return undefined;
683-
const entry = this.getThreadRootDoc(threadKey);
684+
const entry = this.getThreadRootEntry(threadKey);
684685
if (entry == null) return undefined;
685686
const val = entry.message.get(`lastread-${account_id}`);
686687
if (typeof val === "number" && val > 0) return val;
@@ -691,9 +692,12 @@ export class ChatActions extends Actions<ChatState> {
691692
return undefined;
692693
};
693694

694-
private getThreadRootDoc = (
695+
private getThreadRootEntry = (
695696
threadKey: string,
696-
): { doc: any; message: ChatMessageTyped } | null => {
697+
): {
698+
key: { date: string; sender_id: string; event: "chat" };
699+
message: ChatMessageTyped;
700+
} | null => {
697701
if (this.store == null) {
698702
return null;
699703
}
@@ -713,6 +717,10 @@ export class ChatActions extends Actions<ChatState> {
713717
if (message == null) {
714718
return null;
715719
}
720+
const sender_id = message.get("sender_id");
721+
if (typeof sender_id !== "string" || sender_id === "") {
722+
return null;
723+
}
716724
const dateField = message.get("date");
717725
const dateIso =
718726
dateField instanceof Date
@@ -723,8 +731,14 @@ export class ChatActions extends Actions<ChatState> {
723731
if (!dateIso) {
724732
return null;
725733
}
726-
const doc = { ...message.toJS(), date: dateIso };
727-
return { doc, message };
734+
return {
735+
key: {
736+
date: dateIso,
737+
sender_id,
738+
event: "chat",
739+
},
740+
message,
741+
};
728742
};
729743

730744
save_scroll_state = (position, height, offset): void => {
@@ -860,7 +874,7 @@ export class ChatActions extends Actions<ChatState> {
860874
}
861875
const rootMs =
862876
getThreadRootDate({ date: date.valueOf(), messages }) || date.valueOf();
863-
const entry = this.getThreadRootDoc(`${rootMs}`);
877+
const entry = this.getThreadRootEntry(`${rootMs}`);
864878
const rootMessage = entry?.message;
865879
if (rootMessage == null) {
866880
return false;
@@ -1098,7 +1112,11 @@ export class ChatActions extends Actions<ChatState> {
10981112
message: cur.toJS() as any as ChatMessage,
10991113
messages,
11001114
});
1101-
this.syncdb.delete({ event: "chat", date, sender_id: message.sender_id });
1115+
this.syncdb.delete({
1116+
event: "chat",
1117+
date,
1118+
sender_id: message.sender_id,
1119+
});
11021120
this.syncdb.set({
11031121
date,
11041122
history: cur?.get("history") ?? [],

src/packages/frontend/chat/threads.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,6 @@ export function deriveThreadLabel(
163163
return "Untitled Thread";
164164
}
165165

166-
const DAY_MS = 24 * 60 * 60 * 1000;
167166

168167
interface GroupOptions {
169168
now?: number;
@@ -178,16 +177,16 @@ const RECENCY_SECTIONS: { key: RecencyKey; title: string }[] = [
178177
{ key: "older", title: "Older" },
179178
];
180179

181-
function recencyKeyForDelta(delta: number): RecencyKey {
182-
if (delta < DAY_MS) {
183-
return "today";
184-
}
185-
if (delta < 2 * DAY_MS) {
186-
return "yesterday";
187-
}
188-
if (delta < 7 * DAY_MS) {
189-
return "last7days";
190-
}
180+
function recencyKeyForTime(threadTime: number, now: number): RecencyKey {
181+
const startOfToday = new Date(now);
182+
startOfToday.setHours(0, 0, 0, 0);
183+
if (threadTime >= startOfToday.getTime()) return "today";
184+
const startOfYesterday = new Date(startOfToday);
185+
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
186+
if (threadTime >= startOfYesterday.getTime()) return "yesterday";
187+
const startOf7DaysAgo = new Date(startOfToday);
188+
startOf7DaysAgo.setDate(startOf7DaysAgo.getDate() - 6);
189+
if (threadTime >= startOf7DaysAgo.getTime()) return "last7days";
191190
return "older";
192191
}
193192

@@ -211,8 +210,7 @@ export function groupThreadsByRecency<
211210
older: [],
212211
};
213212
for (const thread of remainder) {
214-
const delta = now - thread.newestTime;
215-
const key = recencyKeyForDelta(delta);
213+
const key = recencyKeyForTime(thread.newestTime, now);
216214
buckets[key].push(thread);
217215
}
218216
for (const def of RECENCY_SECTIONS) {

0 commit comments

Comments
 (0)