@bbki.ng/bb-msg-history 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/component.d.ts +15 -1
- package/dist/component.js +73 -33
- package/package.json +15 -14
- package/src/component.ts +76 -35
package/dist/component.d.ts
CHANGED
|
@@ -5,9 +5,11 @@ export declare class BBMsgHistory extends HTMLElement {
|
|
|
5
5
|
private _lastAuthor;
|
|
6
6
|
private _lastGroupTimestamp;
|
|
7
7
|
private _scrollButtonVisible;
|
|
8
|
+
private _scrollListeners;
|
|
9
|
+
private _debounceTimer?;
|
|
8
10
|
static get observedAttributes(): string[];
|
|
9
11
|
constructor();
|
|
10
|
-
attributeChangedCallback(name: string): void;
|
|
12
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
11
13
|
/**
|
|
12
14
|
* Configure an author's avatar, side, and colors.
|
|
13
15
|
* Call before or after rendering — the component re-renders automatically.
|
|
@@ -45,9 +47,21 @@ export declare class BBMsgHistory extends HTMLElement {
|
|
|
45
47
|
* el.scrollToBottom(); // Scroll with smooth animation
|
|
46
48
|
*/
|
|
47
49
|
scrollToBottom(): this;
|
|
50
|
+
/**
|
|
51
|
+
* Check if two messages can be grouped (same author, no timestamp conflict)
|
|
52
|
+
*/
|
|
53
|
+
private _canGroupMessages;
|
|
48
54
|
private _appendSingleMessage;
|
|
49
55
|
connectedCallback(): void;
|
|
50
56
|
disconnectedCallback(): void;
|
|
57
|
+
/**
|
|
58
|
+
* Track an event listener for cleanup on disconnect
|
|
59
|
+
*/
|
|
60
|
+
private _addTrackedListener;
|
|
61
|
+
/**
|
|
62
|
+
* Remove all tracked event listeners
|
|
63
|
+
*/
|
|
64
|
+
private _cleanupListeners;
|
|
51
65
|
private _setupMutationObserver;
|
|
52
66
|
private render;
|
|
53
67
|
private _renderFullStructure;
|
package/dist/component.js
CHANGED
|
@@ -13,9 +13,17 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
13
13
|
this._userAuthors = new Map();
|
|
14
14
|
this._lastAuthor = '';
|
|
15
15
|
this._scrollButtonVisible = false;
|
|
16
|
+
this._scrollListeners = [];
|
|
16
17
|
this.attachShadow({ mode: 'open' });
|
|
18
|
+
// Create MutationObserver once - will be connected in connectedCallback
|
|
19
|
+
this._mutationObserver = new MutationObserver(() => {
|
|
20
|
+
clearTimeout(this._debounceTimer);
|
|
21
|
+
this._debounceTimer = setTimeout(() => this.render(), 50);
|
|
22
|
+
});
|
|
17
23
|
}
|
|
18
|
-
attributeChangedCallback(name) {
|
|
24
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
25
|
+
if (oldValue === newValue)
|
|
26
|
+
return;
|
|
19
27
|
if (name === 'theme' || name === 'loading' || name === 'hide-scroll-bar' || name === 'infinite' || name === 'hide-scroll-button') {
|
|
20
28
|
this.render();
|
|
21
29
|
}
|
|
@@ -61,16 +69,21 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
61
69
|
* el.appendMessage({ author: 'bob', text: 'How are you?' });
|
|
62
70
|
*/
|
|
63
71
|
appendMessage(message) {
|
|
72
|
+
// Temporarily disconnect observer BEFORE updating textContent to prevent double render
|
|
73
|
+
this._mutationObserver?.disconnect();
|
|
74
|
+
clearTimeout(this._debounceTimer);
|
|
64
75
|
// Update textContent
|
|
65
76
|
const currentText = this.textContent || '';
|
|
66
77
|
const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
|
|
67
78
|
this.textContent = currentText + separator + `${message.author}: ${message.text}`;
|
|
68
|
-
// Temporarily disconnect observer to prevent recursive render
|
|
69
|
-
this._mutationObserver?.disconnect();
|
|
70
79
|
// Append single message without re-rendering entire list
|
|
71
80
|
this._appendSingleMessage(message);
|
|
72
81
|
// Reconnect observer
|
|
73
|
-
this.
|
|
82
|
+
this._mutationObserver?.observe(this, {
|
|
83
|
+
childList: true,
|
|
84
|
+
characterData: true,
|
|
85
|
+
subtree: true,
|
|
86
|
+
});
|
|
74
87
|
return this;
|
|
75
88
|
}
|
|
76
89
|
/**
|
|
@@ -93,6 +106,20 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
93
106
|
});
|
|
94
107
|
return this;
|
|
95
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Check if two messages can be grouped (same author, no timestamp conflict)
|
|
111
|
+
*/
|
|
112
|
+
_canGroupMessages(prev, curr) {
|
|
113
|
+
if (!prev)
|
|
114
|
+
return false;
|
|
115
|
+
if (prev.author !== curr.author)
|
|
116
|
+
return false;
|
|
117
|
+
// Different timestamps = break group
|
|
118
|
+
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
96
123
|
_appendSingleMessage(message) {
|
|
97
124
|
const container = this.shadowRoot.querySelector('.history');
|
|
98
125
|
// If empty state or no container, do full render first
|
|
@@ -104,14 +131,16 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
104
131
|
const text = message.text;
|
|
105
132
|
const timestamp = message.timestamp;
|
|
106
133
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
134
|
+
// Build previous message object for grouping check
|
|
135
|
+
const prevMessage = this._lastAuthor
|
|
136
|
+
? { author: this._lastAuthor, text: '', timestamp: this._lastGroupTimestamp }
|
|
137
|
+
: null;
|
|
138
|
+
// Use unified grouping logic
|
|
139
|
+
const canGroupWithLast = this._canGroupMessages(prevMessage, message);
|
|
111
140
|
const isFirstFromAuthor = !canGroupWithLast;
|
|
112
141
|
this._lastAuthor = author;
|
|
113
142
|
const isSubsequent = !isFirstFromAuthor;
|
|
114
|
-
// Update group timestamp tracking
|
|
143
|
+
// Update group timestamp tracking (consistent with render())
|
|
115
144
|
if (isFirstFromAuthor) {
|
|
116
145
|
// Start new group
|
|
117
146
|
this._lastGroupTimestamp = timestamp;
|
|
@@ -161,14 +190,28 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
161
190
|
}
|
|
162
191
|
disconnectedCallback() {
|
|
163
192
|
this._mutationObserver?.disconnect();
|
|
193
|
+
clearTimeout(this._debounceTimer);
|
|
194
|
+
this._cleanupListeners();
|
|
164
195
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Track an event listener for cleanup on disconnect
|
|
198
|
+
*/
|
|
199
|
+
_addTrackedListener(el, type, fn) {
|
|
200
|
+
el.addEventListener(type, fn);
|
|
201
|
+
this._scrollListeners.push({ el, type, fn });
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Remove all tracked event listeners
|
|
205
|
+
*/
|
|
206
|
+
_cleanupListeners() {
|
|
207
|
+
this._scrollListeners.forEach(({ el, type, fn }) => {
|
|
208
|
+
el.removeEventListener(type, fn);
|
|
170
209
|
});
|
|
171
|
-
this.
|
|
210
|
+
this._scrollListeners = [];
|
|
211
|
+
}
|
|
212
|
+
_setupMutationObserver() {
|
|
213
|
+
// Observer was already created in constructor, just need to connect it
|
|
214
|
+
this._mutationObserver?.observe(this, {
|
|
172
215
|
childList: true,
|
|
173
216
|
characterData: true,
|
|
174
217
|
subtree: true,
|
|
@@ -182,20 +225,10 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
182
225
|
this._renderEmpty();
|
|
183
226
|
return;
|
|
184
227
|
}
|
|
185
|
-
// Helper: Check if two messages can be grouped (same author, no timestamp conflict)
|
|
186
|
-
const canGroup = (prev, curr) => {
|
|
187
|
-
if (prev.author !== curr.author)
|
|
188
|
-
return false;
|
|
189
|
-
// Different timestamps = break group
|
|
190
|
-
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
};
|
|
195
228
|
// First pass: determine which messages are last in their group
|
|
196
229
|
const lastInGroupFlags = messages.map((msg, i) => {
|
|
197
230
|
const next = messages[i + 1];
|
|
198
|
-
return !next || !
|
|
231
|
+
return !next || !this._canGroupMessages(msg, next);
|
|
199
232
|
});
|
|
200
233
|
// Second pass: collect the timestamp for each group
|
|
201
234
|
// Use the first non-empty timestamp in the group
|
|
@@ -203,7 +236,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
203
236
|
let currentGroupTimestamp;
|
|
204
237
|
messages.forEach((msg, i) => {
|
|
205
238
|
// Start of a new group
|
|
206
|
-
if (i === 0 || !
|
|
239
|
+
if (i === 0 || !this._canGroupMessages(messages[i - 1], msg)) {
|
|
207
240
|
currentGroupTimestamp = msg.timestamp;
|
|
208
241
|
}
|
|
209
242
|
else if (!currentGroupTimestamp && msg.timestamp) {
|
|
@@ -223,7 +256,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
223
256
|
const { author, text } = msg;
|
|
224
257
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
225
258
|
// Determine if this is a new author group (can't group with previous)
|
|
226
|
-
const isFirstFromAuthor = i === 0 || !
|
|
259
|
+
const isFirstFromAuthor = i === 0 || !this._canGroupMessages(messages[i - 1], msg);
|
|
227
260
|
lastAuthor = author;
|
|
228
261
|
const isSubsequent = !isFirstFromAuthor;
|
|
229
262
|
// Get timestamp if this is the last in group
|
|
@@ -247,6 +280,11 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
247
280
|
}
|
|
248
281
|
}
|
|
249
282
|
_renderFullStructure(messagesHtml) {
|
|
283
|
+
// Check if we should preserve scroll position before re-rendering
|
|
284
|
+
const existingContainer = this.shadowRoot.querySelector('.history');
|
|
285
|
+
const wasAtBottom = existingContainer
|
|
286
|
+
? existingContainer.scrollHeight - existingContainer.scrollTop - existingContainer.clientHeight < 50
|
|
287
|
+
: true; // Default to true for initial render
|
|
250
288
|
const loadingOverlay = this.hasAttribute('loading')
|
|
251
289
|
? `<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
252
290
|
<div class="loading-spinner"></div>
|
|
@@ -261,7 +299,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
261
299
|
${hideScrollButton ? '' : buildScrollButtonHtml()}
|
|
262
300
|
${loadingOverlay}
|
|
263
301
|
`;
|
|
264
|
-
this._setupAfterRender();
|
|
302
|
+
this._setupAfterRender(wasAtBottom);
|
|
265
303
|
}
|
|
266
304
|
_updateContent(historyContainer, messagesHtml) {
|
|
267
305
|
// Preserve scroll position before update
|
|
@@ -293,13 +331,15 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
293
331
|
existingOverlay.remove();
|
|
294
332
|
}
|
|
295
333
|
}
|
|
296
|
-
_setupAfterRender() {
|
|
334
|
+
_setupAfterRender(shouldScrollToBottom = true) {
|
|
297
335
|
requestAnimationFrame(() => {
|
|
298
336
|
const container = this.shadowRoot.querySelector('.history');
|
|
299
337
|
const scrollButton = this.shadowRoot.querySelector('.scroll-to-bottom');
|
|
300
338
|
const isInfinite = this.hasAttribute('infinite');
|
|
301
339
|
if (container && !isInfinite) {
|
|
302
|
-
|
|
340
|
+
if (shouldScrollToBottom) {
|
|
341
|
+
container.scrollTop = container.scrollHeight;
|
|
342
|
+
}
|
|
303
343
|
this._setupScrollTracking(container, scrollButton, { skipInitialCheck: true });
|
|
304
344
|
}
|
|
305
345
|
if (scrollButton && !isInfinite) {
|
|
@@ -359,8 +399,8 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
359
399
|
checkScrollPosition();
|
|
360
400
|
}
|
|
361
401
|
// Listen for scroll events with passive listener for performance
|
|
362
|
-
|
|
402
|
+
this._addTrackedListener(container, 'scroll', checkScrollPosition);
|
|
363
403
|
// Also check on resize
|
|
364
|
-
|
|
404
|
+
this._addTrackedListener(window, 'resize', checkScrollPosition);
|
|
365
405
|
}
|
|
366
406
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bbki.ng/bb-msg-history",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "A chat-style message history web component",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,19 @@
|
|
|
23
23
|
"dist",
|
|
24
24
|
"src"
|
|
25
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "tsc -w",
|
|
28
|
+
"preview": "python3 -m http.server 8000",
|
|
29
|
+
"prepare": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
|
|
30
|
+
"build": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
|
|
31
|
+
"lint": "eslint src/**/*.ts",
|
|
32
|
+
"lint:fix": "eslint src/**/*.ts --fix",
|
|
33
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
34
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"release": "release-it"
|
|
38
|
+
},
|
|
26
39
|
"lint-staged": {
|
|
27
40
|
"*.ts": [
|
|
28
41
|
"eslint --fix",
|
|
@@ -47,17 +60,5 @@
|
|
|
47
60
|
"terser": "^5.46.0",
|
|
48
61
|
"typescript": "^5.9.3",
|
|
49
62
|
"vitest": "^3.2.4"
|
|
50
|
-
},
|
|
51
|
-
"scripts": {
|
|
52
|
-
"start": "tsc -w",
|
|
53
|
-
"preview": "python3 -m http.server 8000",
|
|
54
|
-
"build": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
|
|
55
|
-
"lint": "eslint src/**/*.ts",
|
|
56
|
-
"lint:fix": "eslint src/**/*.ts --fix",
|
|
57
|
-
"format": "prettier --write \"src/**/*.ts\"",
|
|
58
|
-
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
59
|
-
"test": "vitest run",
|
|
60
|
-
"test:watch": "vitest",
|
|
61
|
-
"release": "release-it"
|
|
62
63
|
}
|
|
63
|
-
}
|
|
64
|
+
}
|
package/src/component.ts
CHANGED
|
@@ -12,6 +12,8 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
12
12
|
private _lastAuthor = '';
|
|
13
13
|
private _lastGroupTimestamp: string | undefined;
|
|
14
14
|
private _scrollButtonVisible = false;
|
|
15
|
+
private _scrollListeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
|
|
16
|
+
private _debounceTimer?: ReturnType<typeof setTimeout>;
|
|
15
17
|
|
|
16
18
|
static get observedAttributes() {
|
|
17
19
|
return ['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'];
|
|
@@ -20,9 +22,15 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
20
22
|
constructor() {
|
|
21
23
|
super();
|
|
22
24
|
this.attachShadow({ mode: 'open' });
|
|
25
|
+
// Create MutationObserver once - will be connected in connectedCallback
|
|
26
|
+
this._mutationObserver = new MutationObserver(() => {
|
|
27
|
+
clearTimeout(this._debounceTimer);
|
|
28
|
+
this._debounceTimer = setTimeout(() => this.render(), 50);
|
|
29
|
+
});
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
attributeChangedCallback(name: string) {
|
|
32
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
|
33
|
+
if (oldValue === newValue) return;
|
|
26
34
|
if (name === 'theme' || name === 'loading' || name === 'hide-scroll-bar' || name === 'infinite' || name === 'hide-scroll-button') {
|
|
27
35
|
this.render();
|
|
28
36
|
}
|
|
@@ -72,19 +80,24 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
72
80
|
* el.appendMessage({ author: 'bob', text: 'How are you?' });
|
|
73
81
|
*/
|
|
74
82
|
appendMessage(message: Message): this {
|
|
83
|
+
// Temporarily disconnect observer BEFORE updating textContent to prevent double render
|
|
84
|
+
this._mutationObserver?.disconnect();
|
|
85
|
+
clearTimeout(this._debounceTimer);
|
|
86
|
+
|
|
75
87
|
// Update textContent
|
|
76
88
|
const currentText = this.textContent || '';
|
|
77
89
|
const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
|
|
78
90
|
this.textContent = currentText + separator + `${message.author}: ${message.text}`;
|
|
79
91
|
|
|
80
|
-
// Temporarily disconnect observer to prevent recursive render
|
|
81
|
-
this._mutationObserver?.disconnect();
|
|
82
|
-
|
|
83
92
|
// Append single message without re-rendering entire list
|
|
84
93
|
this._appendSingleMessage(message);
|
|
85
94
|
|
|
86
95
|
// Reconnect observer
|
|
87
|
-
this.
|
|
96
|
+
this._mutationObserver?.observe(this, {
|
|
97
|
+
childList: true,
|
|
98
|
+
characterData: true,
|
|
99
|
+
subtree: true,
|
|
100
|
+
});
|
|
88
101
|
|
|
89
102
|
return this;
|
|
90
103
|
}
|
|
@@ -113,6 +126,19 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
113
126
|
return this;
|
|
114
127
|
}
|
|
115
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Check if two messages can be grouped (same author, no timestamp conflict)
|
|
131
|
+
*/
|
|
132
|
+
private _canGroupMessages(prev: Message | null, curr: Message): boolean {
|
|
133
|
+
if (!prev) return false;
|
|
134
|
+
if (prev.author !== curr.author) return false;
|
|
135
|
+
// Different timestamps = break group
|
|
136
|
+
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
116
142
|
private _appendSingleMessage(message: Message): void {
|
|
117
143
|
const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
|
|
118
144
|
|
|
@@ -127,18 +153,19 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
127
153
|
const timestamp = message.timestamp;
|
|
128
154
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
129
155
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
(!this._lastGroupTimestamp || !timestamp || this._lastGroupTimestamp === timestamp);
|
|
156
|
+
// Build previous message object for grouping check
|
|
157
|
+
const prevMessage: Message | null = this._lastAuthor
|
|
158
|
+
? { author: this._lastAuthor, text: '', timestamp: this._lastGroupTimestamp }
|
|
159
|
+
: null;
|
|
135
160
|
|
|
161
|
+
// Use unified grouping logic
|
|
162
|
+
const canGroupWithLast = this._canGroupMessages(prevMessage, message);
|
|
136
163
|
const isFirstFromAuthor = !canGroupWithLast;
|
|
137
164
|
this._lastAuthor = author;
|
|
138
165
|
|
|
139
166
|
const isSubsequent = !isFirstFromAuthor;
|
|
140
167
|
|
|
141
|
-
// Update group timestamp tracking
|
|
168
|
+
// Update group timestamp tracking (consistent with render())
|
|
142
169
|
if (isFirstFromAuthor) {
|
|
143
170
|
// Start new group
|
|
144
171
|
this._lastGroupTimestamp = timestamp;
|
|
@@ -205,15 +232,31 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
205
232
|
|
|
206
233
|
disconnectedCallback() {
|
|
207
234
|
this._mutationObserver?.disconnect();
|
|
235
|
+
clearTimeout(this._debounceTimer);
|
|
236
|
+
this._cleanupListeners();
|
|
208
237
|
}
|
|
209
238
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Track an event listener for cleanup on disconnect
|
|
241
|
+
*/
|
|
242
|
+
private _addTrackedListener(el: EventTarget, type: string, fn: EventListener): void {
|
|
243
|
+
el.addEventListener(type, fn);
|
|
244
|
+
this._scrollListeners.push({ el, type, fn });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Remove all tracked event listeners
|
|
249
|
+
*/
|
|
250
|
+
private _cleanupListeners(): void {
|
|
251
|
+
this._scrollListeners.forEach(({ el, type, fn }) => {
|
|
252
|
+
el.removeEventListener(type, fn);
|
|
215
253
|
});
|
|
216
|
-
this.
|
|
254
|
+
this._scrollListeners = [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private _setupMutationObserver() {
|
|
258
|
+
// Observer was already created in constructor, just need to connect it
|
|
259
|
+
this._mutationObserver?.observe(this, {
|
|
217
260
|
childList: true,
|
|
218
261
|
characterData: true,
|
|
219
262
|
subtree: true,
|
|
@@ -230,20 +273,10 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
230
273
|
return;
|
|
231
274
|
}
|
|
232
275
|
|
|
233
|
-
// Helper: Check if two messages can be grouped (same author, no timestamp conflict)
|
|
234
|
-
const canGroup = (prev: Message, curr: Message): boolean => {
|
|
235
|
-
if (prev.author !== curr.author) return false;
|
|
236
|
-
// Different timestamps = break group
|
|
237
|
-
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
return true;
|
|
241
|
-
};
|
|
242
|
-
|
|
243
276
|
// First pass: determine which messages are last in their group
|
|
244
277
|
const lastInGroupFlags: boolean[] = messages.map((msg, i) => {
|
|
245
278
|
const next = messages[i + 1];
|
|
246
|
-
return !next || !
|
|
279
|
+
return !next || !this._canGroupMessages(msg, next);
|
|
247
280
|
});
|
|
248
281
|
|
|
249
282
|
// Second pass: collect the timestamp for each group
|
|
@@ -253,7 +286,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
253
286
|
|
|
254
287
|
messages.forEach((msg, i) => {
|
|
255
288
|
// Start of a new group
|
|
256
|
-
if (i === 0 || !
|
|
289
|
+
if (i === 0 || !this._canGroupMessages(messages[i - 1], msg)) {
|
|
257
290
|
currentGroupTimestamp = msg.timestamp;
|
|
258
291
|
} else if (!currentGroupTimestamp && msg.timestamp) {
|
|
259
292
|
// If no timestamp yet and current msg has one, use it
|
|
@@ -275,7 +308,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
275
308
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
276
309
|
|
|
277
310
|
// Determine if this is a new author group (can't group with previous)
|
|
278
|
-
const isFirstFromAuthor = i === 0 || !
|
|
311
|
+
const isFirstFromAuthor = i === 0 || !this._canGroupMessages(messages[i - 1], msg);
|
|
279
312
|
lastAuthor = author;
|
|
280
313
|
const isSubsequent = !isFirstFromAuthor;
|
|
281
314
|
|
|
@@ -311,6 +344,12 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
311
344
|
}
|
|
312
345
|
|
|
313
346
|
private _renderFullStructure(messagesHtml: string): void {
|
|
347
|
+
// Check if we should preserve scroll position before re-rendering
|
|
348
|
+
const existingContainer = this.shadowRoot!.querySelector('.history') as HTMLElement | null;
|
|
349
|
+
const wasAtBottom = existingContainer
|
|
350
|
+
? existingContainer.scrollHeight - existingContainer.scrollTop - existingContainer.clientHeight < 50
|
|
351
|
+
: true; // Default to true for initial render
|
|
352
|
+
|
|
314
353
|
const loadingOverlay = this.hasAttribute('loading')
|
|
315
354
|
? `<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
316
355
|
<div class="loading-spinner"></div>
|
|
@@ -328,7 +367,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
328
367
|
${loadingOverlay}
|
|
329
368
|
`;
|
|
330
369
|
|
|
331
|
-
this._setupAfterRender();
|
|
370
|
+
this._setupAfterRender(wasAtBottom);
|
|
332
371
|
}
|
|
333
372
|
|
|
334
373
|
private _updateContent(historyContainer: HTMLElement, messagesHtml: string): void {
|
|
@@ -368,14 +407,16 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
368
407
|
}
|
|
369
408
|
}
|
|
370
409
|
|
|
371
|
-
private _setupAfterRender(): void {
|
|
410
|
+
private _setupAfterRender(shouldScrollToBottom = true): void {
|
|
372
411
|
requestAnimationFrame(() => {
|
|
373
412
|
const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
|
|
374
413
|
const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
|
|
375
414
|
const isInfinite = this.hasAttribute('infinite');
|
|
376
415
|
|
|
377
416
|
if (container && !isInfinite) {
|
|
378
|
-
|
|
417
|
+
if (shouldScrollToBottom) {
|
|
418
|
+
container.scrollTop = container.scrollHeight;
|
|
419
|
+
}
|
|
379
420
|
this._setupScrollTracking(container, scrollButton, { skipInitialCheck: true });
|
|
380
421
|
}
|
|
381
422
|
|
|
@@ -450,9 +491,9 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
450
491
|
}
|
|
451
492
|
|
|
452
493
|
// Listen for scroll events with passive listener for performance
|
|
453
|
-
|
|
494
|
+
this._addTrackedListener(container, 'scroll', checkScrollPosition);
|
|
454
495
|
|
|
455
496
|
// Also check on resize
|
|
456
|
-
|
|
497
|
+
this._addTrackedListener(window, 'resize', checkScrollPosition);
|
|
457
498
|
}
|
|
458
499
|
}
|