@bbki.ng/bb-msg-history 0.13.1 → 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 -4
- package/dist/component.js +75 -61
- package/package.json +1 -1
- package/src/component.ts +78 -68
package/dist/component.d.ts
CHANGED
|
@@ -5,12 +5,11 @@ export declare class BBMsgHistory extends HTMLElement {
|
|
|
5
5
|
private _lastAuthor;
|
|
6
6
|
private _lastGroupTimestamp;
|
|
7
7
|
private _scrollButtonVisible;
|
|
8
|
-
private
|
|
9
|
-
private
|
|
10
|
-
private _lastScrollTop;
|
|
8
|
+
private _scrollListeners;
|
|
9
|
+
private _debounceTimer?;
|
|
11
10
|
static get observedAttributes(): string[];
|
|
12
11
|
constructor();
|
|
13
|
-
attributeChangedCallback(name: string): void;
|
|
12
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
14
13
|
/**
|
|
15
14
|
* Configure an author's avatar, side, and colors.
|
|
16
15
|
* Call before or after rendering — the component re-renders automatically.
|
|
@@ -48,9 +47,21 @@ export declare class BBMsgHistory extends HTMLElement {
|
|
|
48
47
|
* el.scrollToBottom(); // Scroll with smooth animation
|
|
49
48
|
*/
|
|
50
49
|
scrollToBottom(): this;
|
|
50
|
+
/**
|
|
51
|
+
* Check if two messages can be grouped (same author, no timestamp conflict)
|
|
52
|
+
*/
|
|
53
|
+
private _canGroupMessages;
|
|
51
54
|
private _appendSingleMessage;
|
|
52
55
|
connectedCallback(): void;
|
|
53
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;
|
|
54
65
|
private _setupMutationObserver;
|
|
55
66
|
private render;
|
|
56
67
|
private _renderFullStructure;
|
package/dist/component.js
CHANGED
|
@@ -13,12 +13,17 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
13
13
|
this._userAuthors = new Map();
|
|
14
14
|
this._lastAuthor = '';
|
|
15
15
|
this._scrollButtonVisible = false;
|
|
16
|
-
this.
|
|
17
|
-
this._isProgrammaticScroll = false;
|
|
18
|
-
this._lastScrollTop = 0;
|
|
16
|
+
this._scrollListeners = [];
|
|
19
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
|
+
});
|
|
20
23
|
}
|
|
21
|
-
attributeChangedCallback(name) {
|
|
24
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
25
|
+
if (oldValue === newValue)
|
|
26
|
+
return;
|
|
22
27
|
if (name === 'theme' || name === 'loading' || name === 'hide-scroll-bar' || name === 'infinite' || name === 'hide-scroll-button') {
|
|
23
28
|
this.render();
|
|
24
29
|
}
|
|
@@ -64,16 +69,21 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
64
69
|
* el.appendMessage({ author: 'bob', text: 'How are you?' });
|
|
65
70
|
*/
|
|
66
71
|
appendMessage(message) {
|
|
72
|
+
// Temporarily disconnect observer BEFORE updating textContent to prevent double render
|
|
73
|
+
this._mutationObserver?.disconnect();
|
|
74
|
+
clearTimeout(this._debounceTimer);
|
|
67
75
|
// Update textContent
|
|
68
76
|
const currentText = this.textContent || '';
|
|
69
77
|
const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
|
|
70
78
|
this.textContent = currentText + separator + `${message.author}: ${message.text}`;
|
|
71
|
-
// Temporarily disconnect observer to prevent recursive render
|
|
72
|
-
this._mutationObserver?.disconnect();
|
|
73
79
|
// Append single message without re-rendering entire list
|
|
74
80
|
this._appendSingleMessage(message);
|
|
75
81
|
// Reconnect observer
|
|
76
|
-
this.
|
|
82
|
+
this._mutationObserver?.observe(this, {
|
|
83
|
+
childList: true,
|
|
84
|
+
characterData: true,
|
|
85
|
+
subtree: true,
|
|
86
|
+
});
|
|
77
87
|
return this;
|
|
78
88
|
}
|
|
79
89
|
/**
|
|
@@ -96,6 +106,20 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
96
106
|
});
|
|
97
107
|
return this;
|
|
98
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
|
+
}
|
|
99
123
|
_appendSingleMessage(message) {
|
|
100
124
|
const container = this.shadowRoot.querySelector('.history');
|
|
101
125
|
// If empty state or no container, do full render first
|
|
@@ -107,14 +131,16 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
107
131
|
const text = message.text;
|
|
108
132
|
const timestamp = message.timestamp;
|
|
109
133
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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);
|
|
114
140
|
const isFirstFromAuthor = !canGroupWithLast;
|
|
115
141
|
this._lastAuthor = author;
|
|
116
142
|
const isSubsequent = !isFirstFromAuthor;
|
|
117
|
-
// Update group timestamp tracking
|
|
143
|
+
// Update group timestamp tracking (consistent with render())
|
|
118
144
|
if (isFirstFromAuthor) {
|
|
119
145
|
// Start new group
|
|
120
146
|
this._lastGroupTimestamp = timestamp;
|
|
@@ -138,16 +164,10 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
138
164
|
}
|
|
139
165
|
// Smooth scroll to bottom (skip in infinite mode)
|
|
140
166
|
if (!this.hasAttribute('infinite')) {
|
|
141
|
-
// Mark this as a programmatic scroll so the scroll handler ignores it
|
|
142
|
-
this._isProgrammaticScroll = true;
|
|
143
167
|
container.scrollTo({
|
|
144
168
|
top: container.scrollHeight,
|
|
145
169
|
behavior: 'smooth',
|
|
146
170
|
});
|
|
147
|
-
// Reset the flag after smooth scroll animation completes (~300ms)
|
|
148
|
-
setTimeout(() => {
|
|
149
|
-
this._isProgrammaticScroll = false;
|
|
150
|
-
}, 300);
|
|
151
171
|
// Hide scroll button since we're scrolling to bottom
|
|
152
172
|
if (this._scrollButtonVisible) {
|
|
153
173
|
this._scrollButtonVisible = false;
|
|
@@ -170,14 +190,28 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
170
190
|
}
|
|
171
191
|
disconnectedCallback() {
|
|
172
192
|
this._mutationObserver?.disconnect();
|
|
193
|
+
clearTimeout(this._debounceTimer);
|
|
194
|
+
this._cleanupListeners();
|
|
173
195
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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);
|
|
179
209
|
});
|
|
180
|
-
this.
|
|
210
|
+
this._scrollListeners = [];
|
|
211
|
+
}
|
|
212
|
+
_setupMutationObserver() {
|
|
213
|
+
// Observer was already created in constructor, just need to connect it
|
|
214
|
+
this._mutationObserver?.observe(this, {
|
|
181
215
|
childList: true,
|
|
182
216
|
characterData: true,
|
|
183
217
|
subtree: true,
|
|
@@ -191,20 +225,10 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
191
225
|
this._renderEmpty();
|
|
192
226
|
return;
|
|
193
227
|
}
|
|
194
|
-
// Helper: Check if two messages can be grouped (same author, no timestamp conflict)
|
|
195
|
-
const canGroup = (prev, curr) => {
|
|
196
|
-
if (prev.author !== curr.author)
|
|
197
|
-
return false;
|
|
198
|
-
// Different timestamps = break group
|
|
199
|
-
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
return true;
|
|
203
|
-
};
|
|
204
228
|
// First pass: determine which messages are last in their group
|
|
205
229
|
const lastInGroupFlags = messages.map((msg, i) => {
|
|
206
230
|
const next = messages[i + 1];
|
|
207
|
-
return !next || !
|
|
231
|
+
return !next || !this._canGroupMessages(msg, next);
|
|
208
232
|
});
|
|
209
233
|
// Second pass: collect the timestamp for each group
|
|
210
234
|
// Use the first non-empty timestamp in the group
|
|
@@ -212,7 +236,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
212
236
|
let currentGroupTimestamp;
|
|
213
237
|
messages.forEach((msg, i) => {
|
|
214
238
|
// Start of a new group
|
|
215
|
-
if (i === 0 || !
|
|
239
|
+
if (i === 0 || !this._canGroupMessages(messages[i - 1], msg)) {
|
|
216
240
|
currentGroupTimestamp = msg.timestamp;
|
|
217
241
|
}
|
|
218
242
|
else if (!currentGroupTimestamp && msg.timestamp) {
|
|
@@ -232,7 +256,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
232
256
|
const { author, text } = msg;
|
|
233
257
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
234
258
|
// Determine if this is a new author group (can't group with previous)
|
|
235
|
-
const isFirstFromAuthor = i === 0 || !
|
|
259
|
+
const isFirstFromAuthor = i === 0 || !this._canGroupMessages(messages[i - 1], msg);
|
|
236
260
|
lastAuthor = author;
|
|
237
261
|
const isSubsequent = !isFirstFromAuthor;
|
|
238
262
|
// Get timestamp if this is the last in group
|
|
@@ -256,6 +280,11 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
256
280
|
}
|
|
257
281
|
}
|
|
258
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
|
|
259
288
|
const loadingOverlay = this.hasAttribute('loading')
|
|
260
289
|
? `<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
261
290
|
<div class="loading-spinner"></div>
|
|
@@ -270,7 +299,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
270
299
|
${hideScrollButton ? '' : buildScrollButtonHtml()}
|
|
271
300
|
${loadingOverlay}
|
|
272
301
|
`;
|
|
273
|
-
this._setupAfterRender();
|
|
302
|
+
this._setupAfterRender(wasAtBottom);
|
|
274
303
|
}
|
|
275
304
|
_updateContent(historyContainer, messagesHtml) {
|
|
276
305
|
// Preserve scroll position before update
|
|
@@ -302,18 +331,15 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
302
331
|
existingOverlay.remove();
|
|
303
332
|
}
|
|
304
333
|
}
|
|
305
|
-
_setupAfterRender() {
|
|
334
|
+
_setupAfterRender(shouldScrollToBottom = true) {
|
|
306
335
|
requestAnimationFrame(() => {
|
|
307
336
|
const container = this.shadowRoot.querySelector('.history');
|
|
308
337
|
const scrollButton = this.shadowRoot.querySelector('.scroll-to-bottom');
|
|
309
338
|
const isInfinite = this.hasAttribute('infinite');
|
|
310
339
|
if (container && !isInfinite) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
requestAnimationFrame(() => {
|
|
315
|
-
this._isProgrammaticScroll = false;
|
|
316
|
-
});
|
|
340
|
+
if (shouldScrollToBottom) {
|
|
341
|
+
container.scrollTop = container.scrollHeight;
|
|
342
|
+
}
|
|
317
343
|
this._setupScrollTracking(container, scrollButton, { skipInitialCheck: true });
|
|
318
344
|
}
|
|
319
345
|
if (scrollButton && !isInfinite) {
|
|
@@ -349,21 +375,11 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
349
375
|
}
|
|
350
376
|
_setupScrollTracking(container, button, options) {
|
|
351
377
|
const checkScrollPosition = () => {
|
|
352
|
-
// Ignore programmatic scrolls - they don't indicate user intent
|
|
353
|
-
if (this._isProgrammaticScroll)
|
|
354
|
-
return;
|
|
355
|
-
// Mark that user has manually scrolled
|
|
356
|
-
if (!this._userHasScrolledManually) {
|
|
357
|
-
this._userHasScrolledManually = true;
|
|
358
|
-
}
|
|
359
|
-
const currentScrollTop = container.scrollTop;
|
|
360
|
-
const isScrollingUp = currentScrollTop < this._lastScrollTop;
|
|
361
|
-
this._lastScrollTop = currentScrollTop;
|
|
362
378
|
const threshold = 50; // pixels from bottom
|
|
363
379
|
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
364
380
|
const hasOverflow = container.scrollHeight > container.clientHeight;
|
|
365
|
-
//
|
|
366
|
-
const shouldShow = !isAtBottom && hasOverflow
|
|
381
|
+
// Show button when not at bottom and content has overflow
|
|
382
|
+
const shouldShow = !isAtBottom && hasOverflow;
|
|
367
383
|
if (shouldShow !== this._scrollButtonVisible) {
|
|
368
384
|
this._scrollButtonVisible = shouldShow;
|
|
369
385
|
// Only toggle button visibility if button exists
|
|
@@ -378,15 +394,13 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
378
394
|
}));
|
|
379
395
|
}
|
|
380
396
|
};
|
|
381
|
-
// Initialize last scroll position
|
|
382
|
-
this._lastScrollTop = container.scrollTop;
|
|
383
397
|
// Check initial state unless skipped
|
|
384
398
|
if (!options?.skipInitialCheck) {
|
|
385
399
|
checkScrollPosition();
|
|
386
400
|
}
|
|
387
401
|
// Listen for scroll events with passive listener for performance
|
|
388
|
-
|
|
402
|
+
this._addTrackedListener(container, 'scroll', checkScrollPosition);
|
|
389
403
|
// Also check on resize
|
|
390
|
-
|
|
404
|
+
this._addTrackedListener(window, 'resize', checkScrollPosition);
|
|
391
405
|
}
|
|
392
406
|
}
|
package/package.json
CHANGED
package/src/component.ts
CHANGED
|
@@ -12,9 +12,8 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
12
12
|
private _lastAuthor = '';
|
|
13
13
|
private _lastGroupTimestamp: string | undefined;
|
|
14
14
|
private _scrollButtonVisible = false;
|
|
15
|
-
private
|
|
16
|
-
private
|
|
17
|
-
private _lastScrollTop = 0;
|
|
15
|
+
private _scrollListeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
|
|
16
|
+
private _debounceTimer?: ReturnType<typeof setTimeout>;
|
|
18
17
|
|
|
19
18
|
static get observedAttributes() {
|
|
20
19
|
return ['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'];
|
|
@@ -23,9 +22,15 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
23
22
|
constructor() {
|
|
24
23
|
super();
|
|
25
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
|
+
});
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
attributeChangedCallback(name: string) {
|
|
32
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
|
33
|
+
if (oldValue === newValue) return;
|
|
29
34
|
if (name === 'theme' || name === 'loading' || name === 'hide-scroll-bar' || name === 'infinite' || name === 'hide-scroll-button') {
|
|
30
35
|
this.render();
|
|
31
36
|
}
|
|
@@ -75,19 +80,24 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
75
80
|
* el.appendMessage({ author: 'bob', text: 'How are you?' });
|
|
76
81
|
*/
|
|
77
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
|
+
|
|
78
87
|
// Update textContent
|
|
79
88
|
const currentText = this.textContent || '';
|
|
80
89
|
const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
|
|
81
90
|
this.textContent = currentText + separator + `${message.author}: ${message.text}`;
|
|
82
91
|
|
|
83
|
-
// Temporarily disconnect observer to prevent recursive render
|
|
84
|
-
this._mutationObserver?.disconnect();
|
|
85
|
-
|
|
86
92
|
// Append single message without re-rendering entire list
|
|
87
93
|
this._appendSingleMessage(message);
|
|
88
94
|
|
|
89
95
|
// Reconnect observer
|
|
90
|
-
this.
|
|
96
|
+
this._mutationObserver?.observe(this, {
|
|
97
|
+
childList: true,
|
|
98
|
+
characterData: true,
|
|
99
|
+
subtree: true,
|
|
100
|
+
});
|
|
91
101
|
|
|
92
102
|
return this;
|
|
93
103
|
}
|
|
@@ -116,6 +126,19 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
116
126
|
return this;
|
|
117
127
|
}
|
|
118
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
|
+
|
|
119
142
|
private _appendSingleMessage(message: Message): void {
|
|
120
143
|
const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
|
|
121
144
|
|
|
@@ -130,18 +153,19 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
130
153
|
const timestamp = message.timestamp;
|
|
131
154
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
132
155
|
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
(!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;
|
|
138
160
|
|
|
161
|
+
// Use unified grouping logic
|
|
162
|
+
const canGroupWithLast = this._canGroupMessages(prevMessage, message);
|
|
139
163
|
const isFirstFromAuthor = !canGroupWithLast;
|
|
140
164
|
this._lastAuthor = author;
|
|
141
165
|
|
|
142
166
|
const isSubsequent = !isFirstFromAuthor;
|
|
143
167
|
|
|
144
|
-
// Update group timestamp tracking
|
|
168
|
+
// Update group timestamp tracking (consistent with render())
|
|
145
169
|
if (isFirstFromAuthor) {
|
|
146
170
|
// Start new group
|
|
147
171
|
this._lastGroupTimestamp = timestamp;
|
|
@@ -176,19 +200,11 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
176
200
|
|
|
177
201
|
// Smooth scroll to bottom (skip in infinite mode)
|
|
178
202
|
if (!this.hasAttribute('infinite')) {
|
|
179
|
-
// Mark this as a programmatic scroll so the scroll handler ignores it
|
|
180
|
-
this._isProgrammaticScroll = true;
|
|
181
|
-
|
|
182
203
|
container.scrollTo({
|
|
183
204
|
top: container.scrollHeight,
|
|
184
205
|
behavior: 'smooth',
|
|
185
206
|
});
|
|
186
207
|
|
|
187
|
-
// Reset the flag after smooth scroll animation completes (~300ms)
|
|
188
|
-
setTimeout(() => {
|
|
189
|
-
this._isProgrammaticScroll = false;
|
|
190
|
-
}, 300);
|
|
191
|
-
|
|
192
208
|
// Hide scroll button since we're scrolling to bottom
|
|
193
209
|
if (this._scrollButtonVisible) {
|
|
194
210
|
this._scrollButtonVisible = false;
|
|
@@ -216,15 +232,31 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
216
232
|
|
|
217
233
|
disconnectedCallback() {
|
|
218
234
|
this._mutationObserver?.disconnect();
|
|
235
|
+
clearTimeout(this._debounceTimer);
|
|
236
|
+
this._cleanupListeners();
|
|
219
237
|
}
|
|
220
238
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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);
|
|
226
253
|
});
|
|
227
|
-
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, {
|
|
228
260
|
childList: true,
|
|
229
261
|
characterData: true,
|
|
230
262
|
subtree: true,
|
|
@@ -241,20 +273,10 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
241
273
|
return;
|
|
242
274
|
}
|
|
243
275
|
|
|
244
|
-
// Helper: Check if two messages can be grouped (same author, no timestamp conflict)
|
|
245
|
-
const canGroup = (prev: Message, curr: Message): boolean => {
|
|
246
|
-
if (prev.author !== curr.author) return false;
|
|
247
|
-
// Different timestamps = break group
|
|
248
|
-
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
249
|
-
return false;
|
|
250
|
-
}
|
|
251
|
-
return true;
|
|
252
|
-
};
|
|
253
|
-
|
|
254
276
|
// First pass: determine which messages are last in their group
|
|
255
277
|
const lastInGroupFlags: boolean[] = messages.map((msg, i) => {
|
|
256
278
|
const next = messages[i + 1];
|
|
257
|
-
return !next || !
|
|
279
|
+
return !next || !this._canGroupMessages(msg, next);
|
|
258
280
|
});
|
|
259
281
|
|
|
260
282
|
// Second pass: collect the timestamp for each group
|
|
@@ -264,7 +286,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
264
286
|
|
|
265
287
|
messages.forEach((msg, i) => {
|
|
266
288
|
// Start of a new group
|
|
267
|
-
if (i === 0 || !
|
|
289
|
+
if (i === 0 || !this._canGroupMessages(messages[i - 1], msg)) {
|
|
268
290
|
currentGroupTimestamp = msg.timestamp;
|
|
269
291
|
} else if (!currentGroupTimestamp && msg.timestamp) {
|
|
270
292
|
// If no timestamp yet and current msg has one, use it
|
|
@@ -286,7 +308,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
286
308
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
287
309
|
|
|
288
310
|
// Determine if this is a new author group (can't group with previous)
|
|
289
|
-
const isFirstFromAuthor = i === 0 || !
|
|
311
|
+
const isFirstFromAuthor = i === 0 || !this._canGroupMessages(messages[i - 1], msg);
|
|
290
312
|
lastAuthor = author;
|
|
291
313
|
const isSubsequent = !isFirstFromAuthor;
|
|
292
314
|
|
|
@@ -322,6 +344,12 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
322
344
|
}
|
|
323
345
|
|
|
324
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
|
+
|
|
325
353
|
const loadingOverlay = this.hasAttribute('loading')
|
|
326
354
|
? `<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
327
355
|
<div class="loading-spinner"></div>
|
|
@@ -339,7 +367,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
339
367
|
${loadingOverlay}
|
|
340
368
|
`;
|
|
341
369
|
|
|
342
|
-
this._setupAfterRender();
|
|
370
|
+
this._setupAfterRender(wasAtBottom);
|
|
343
371
|
}
|
|
344
372
|
|
|
345
373
|
private _updateContent(historyContainer: HTMLElement, messagesHtml: string): void {
|
|
@@ -379,19 +407,16 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
379
407
|
}
|
|
380
408
|
}
|
|
381
409
|
|
|
382
|
-
private _setupAfterRender(): void {
|
|
410
|
+
private _setupAfterRender(shouldScrollToBottom = true): void {
|
|
383
411
|
requestAnimationFrame(() => {
|
|
384
412
|
const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
|
|
385
413
|
const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
|
|
386
414
|
const isInfinite = this.hasAttribute('infinite');
|
|
387
415
|
|
|
388
416
|
if (container && !isInfinite) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
requestAnimationFrame(() => {
|
|
393
|
-
this._isProgrammaticScroll = false;
|
|
394
|
-
});
|
|
417
|
+
if (shouldScrollToBottom) {
|
|
418
|
+
container.scrollTop = container.scrollHeight;
|
|
419
|
+
}
|
|
395
420
|
this._setupScrollTracking(container, scrollButton, { skipInitialCheck: true });
|
|
396
421
|
}
|
|
397
422
|
|
|
@@ -435,24 +460,12 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
435
460
|
options?: { skipInitialCheck?: boolean }
|
|
436
461
|
): void {
|
|
437
462
|
const checkScrollPosition = () => {
|
|
438
|
-
// Ignore programmatic scrolls - they don't indicate user intent
|
|
439
|
-
if (this._isProgrammaticScroll) return;
|
|
440
|
-
|
|
441
|
-
// Mark that user has manually scrolled
|
|
442
|
-
if (!this._userHasScrolledManually) {
|
|
443
|
-
this._userHasScrolledManually = true;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const currentScrollTop = container.scrollTop;
|
|
447
|
-
const isScrollingUp = currentScrollTop < this._lastScrollTop;
|
|
448
|
-
this._lastScrollTop = currentScrollTop;
|
|
449
|
-
|
|
450
463
|
const threshold = 50; // pixels from bottom
|
|
451
464
|
const isAtBottom =
|
|
452
465
|
container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
453
466
|
const hasOverflow = container.scrollHeight > container.clientHeight;
|
|
454
|
-
//
|
|
455
|
-
const shouldShow = !isAtBottom && hasOverflow
|
|
467
|
+
// Show button when not at bottom and content has overflow
|
|
468
|
+
const shouldShow = !isAtBottom && hasOverflow;
|
|
456
469
|
|
|
457
470
|
if (shouldShow !== this._scrollButtonVisible) {
|
|
458
471
|
this._scrollButtonVisible = shouldShow;
|
|
@@ -472,18 +485,15 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
472
485
|
}
|
|
473
486
|
};
|
|
474
487
|
|
|
475
|
-
// Initialize last scroll position
|
|
476
|
-
this._lastScrollTop = container.scrollTop;
|
|
477
|
-
|
|
478
488
|
// Check initial state unless skipped
|
|
479
489
|
if (!options?.skipInitialCheck) {
|
|
480
490
|
checkScrollPosition();
|
|
481
491
|
}
|
|
482
492
|
|
|
483
493
|
// Listen for scroll events with passive listener for performance
|
|
484
|
-
|
|
494
|
+
this._addTrackedListener(container, 'scroll', checkScrollPosition);
|
|
485
495
|
|
|
486
496
|
// Also check on resize
|
|
487
|
-
|
|
497
|
+
this._addTrackedListener(window, 'resize', checkScrollPosition);
|
|
488
498
|
}
|
|
489
499
|
}
|