Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/chat/threads.ts
2441 views
1
/*
2
Utility helpers for deriving thread metadata from the chat message list.
3
*/
4
5
import { React } from "@cocalc/frontend/app-framework";
6
7
import type { ChatMessageTyped, ChatMessages } from "./types";
8
import { newest_content } from "./utils";
9
10
export const ALL_THREADS_KEY = "__ALL_THREADS__";
11
12
export interface ThreadListItem {
13
key: string;
14
label: string;
15
newestTime: number;
16
messageCount: number;
17
rootMessage?: ChatMessageTyped;
18
}
19
20
export type ThreadSectionKey =
21
| "pinned"
22
| "today"
23
| "yesterday"
24
| "last7days"
25
| "older";
26
27
export interface ThreadSection<T extends ThreadListItem = ThreadListItem> {
28
key: ThreadSectionKey;
29
title: string;
30
threads: T[];
31
}
32
33
export function useThreadList(messages?: ChatMessages): ThreadListItem[] {
34
return React.useMemo(() => {
35
if (messages == null || messages.size === 0) {
36
return [];
37
}
38
39
const threads = new Map<
40
string,
41
{
42
key: string;
43
newestTime: number;
44
messageCount: number;
45
rootMessage?: ChatMessageTyped;
46
}
47
>();
48
49
for (const [timeRaw, message] of messages) {
50
if (message == null) continue;
51
const timeString =
52
typeof timeRaw === "string" ? timeRaw : `${timeRaw ?? ""}`;
53
const replyTo = message.get("reply_to");
54
const rootKey = replyTo ? `${new Date(replyTo).valueOf()}` : timeString;
55
let thread = threads.get(rootKey);
56
if (thread == null) {
57
thread = {
58
key: rootKey,
59
newestTime: 0,
60
messageCount: 0,
61
};
62
threads.set(rootKey, thread);
63
}
64
thread.messageCount += 1;
65
const dateValue = message.get("date")?.valueOf();
66
if (dateValue != null && dateValue > thread.newestTime) {
67
thread.newestTime = dateValue;
68
}
69
if (!replyTo) {
70
thread.rootMessage = message;
71
}
72
}
73
74
const items: ThreadListItem[] = [];
75
for (const entry of threads.values()) {
76
if (entry.rootMessage == null) {
77
const maybeRoot = messages.get(entry.key);
78
if (maybeRoot) {
79
entry.rootMessage = maybeRoot;
80
}
81
}
82
items.push({
83
key: entry.key,
84
label: deriveThreadLabel(entry.rootMessage, entry.key),
85
newestTime: entry.newestTime,
86
messageCount: entry.messageCount,
87
rootMessage: entry.rootMessage,
88
});
89
}
90
91
items.sort((a, b) => b.newestTime - a.newestTime);
92
return items;
93
}, [messages]);
94
}
95
96
export function deriveThreadLabel(
97
rootMessage: ChatMessageTyped | undefined,
98
fallbackKey: string,
99
): string {
100
const explicitName = rootMessage?.get("name") as string | undefined;
101
if (typeof explicitName === "string") {
102
const trimmed = explicitName.trim();
103
if (trimmed) {
104
return trimmed;
105
}
106
}
107
const content = rootMessage ? newest_content(rootMessage) : "";
108
const normalized = content.replace(/\s+/g, " ").trim();
109
if (normalized) {
110
const words = normalized.split(" ");
111
const short = words.slice(0, 8).join(" ");
112
return words.length > 8 ? `${short}…` : short;
113
}
114
const timestamp = parseInt(fallbackKey);
115
if (!isNaN(timestamp)) {
116
return new Date(timestamp).toLocaleString();
117
}
118
return "Untitled Thread";
119
}
120
121
const DAY_MS = 24 * 60 * 60 * 1000;
122
123
interface GroupOptions {
124
now?: number;
125
}
126
127
type RecencyKey = Exclude<ThreadSectionKey, "pinned">;
128
129
const RECENCY_SECTIONS: { key: RecencyKey; title: string }[] = [
130
{ key: "today", title: "Today" },
131
{ key: "yesterday", title: "Yesterday" },
132
{ key: "last7days", title: "Last 7 Days" },
133
{ key: "older", title: "Older" },
134
];
135
136
function recencyKeyForDelta(delta: number): RecencyKey {
137
if (delta < DAY_MS) {
138
return "today";
139
}
140
if (delta < 2 * DAY_MS) {
141
return "yesterday";
142
}
143
if (delta < 7 * DAY_MS) {
144
return "last7days";
145
}
146
return "older";
147
}
148
149
export function groupThreadsByRecency<T extends ThreadListItem & { isPinned?: boolean }>(
150
threads: T[],
151
options: GroupOptions = {},
152
): ThreadSection<T>[] {
153
if (!threads || threads.length === 0) {
154
return [];
155
}
156
const now = options.now ?? Date.now();
157
const sections: ThreadSection<T>[] = [];
158
const pinned = threads.filter((thread) => !!thread.isPinned);
159
const remainder = threads.filter((thread) => !thread.isPinned);
160
if (pinned.length > 0) {
161
sections.push({ key: "pinned", title: "Pinned", threads: pinned });
162
}
163
const buckets: Record<RecencyKey, T[]> = {
164
today: [],
165
yesterday: [],
166
last7days: [],
167
older: [],
168
};
169
for (const thread of remainder) {
170
const delta = now - thread.newestTime;
171
const key = recencyKeyForDelta(delta);
172
buckets[key].push(thread);
173
}
174
for (const def of RECENCY_SECTIONS) {
175
const list = buckets[def.key];
176
if (list.length > 0) {
177
sections.push({ key: def.key, title: def.title, threads: list });
178
}
179
}
180
return sections;
181
}
182
183