@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.
@@ -5,12 +5,11 @@ export declare class BBMsgHistory extends HTMLElement {
5
5
  private _lastAuthor;
6
6
  private _lastGroupTimestamp;
7
7
  private _scrollButtonVisible;
8
- private _userHasScrolledManually;
9
- private _isProgrammaticScroll;
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._userHasScrolledManually = false;
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._setupMutationObserver();
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
- // Check if this can group with the last message
111
- // Same author AND (no timestamp conflict)
112
- const canGroupWithLast = author === this._lastAuthor &&
113
- (!this._lastGroupTimestamp || !timestamp || this._lastGroupTimestamp === timestamp);
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
- _setupMutationObserver() {
175
- let debounceTimer;
176
- this._mutationObserver = new MutationObserver(() => {
177
- clearTimeout(debounceTimer);
178
- debounceTimer = setTimeout(() => this.render(), 50);
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._mutationObserver.observe(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 || !canGroup(msg, 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 || !canGroup(messages[i - 1], msg)) {
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 || !canGroup(messages[i - 1], msg);
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
- // Mark as programmatic scroll to prevent triggering user scroll detection
312
- this._isProgrammaticScroll = true;
313
- container.scrollTop = container.scrollHeight;
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
- // Only show button when: user has scrolled, is not at bottom, is scrolling up
366
- const shouldShow = !isAtBottom && hasOverflow && this._userHasScrolledManually && isScrollingUp;
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
- container.addEventListener('scroll', checkScrollPosition, { passive: true });
402
+ this._addTrackedListener(container, 'scroll', checkScrollPosition);
389
403
  // Also check on resize
390
- window.addEventListener('resize', checkScrollPosition, { passive: true });
404
+ this._addTrackedListener(window, 'resize', checkScrollPosition);
391
405
  }
392
406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/bb-msg-history",
3
- "version": "0.13.1",
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",
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 _userHasScrolledManually = false;
16
- private _isProgrammaticScroll = false;
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._setupMutationObserver();
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
- // Check if this can group with the last message
134
- // Same author AND (no timestamp conflict)
135
- const canGroupWithLast =
136
- author === this._lastAuthor &&
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
- private _setupMutationObserver() {
222
- let debounceTimer: ReturnType<typeof setTimeout>;
223
- this._mutationObserver = new MutationObserver(() => {
224
- clearTimeout(debounceTimer);
225
- debounceTimer = setTimeout(() => this.render(), 50);
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._mutationObserver.observe(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 || !canGroup(msg, 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 || !canGroup(messages[i - 1], msg)) {
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 || !canGroup(messages[i - 1], msg);
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
- // Mark as programmatic scroll to prevent triggering user scroll detection
390
- this._isProgrammaticScroll = true;
391
- container.scrollTop = container.scrollHeight;
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
- // Only show button when: user has scrolled, is not at bottom, is scrolling up
455
- const shouldShow = !isAtBottom && hasOverflow && this._userHasScrolledManually && isScrollingUp;
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
- container.addEventListener('scroll', checkScrollPosition, { passive: true });
494
+ this._addTrackedListener(container, 'scroll', checkScrollPosition);
485
495
 
486
496
  // Also check on resize
487
- window.addEventListener('resize', checkScrollPosition, { passive: true });
497
+ this._addTrackedListener(window, 'resize', checkScrollPosition);
488
498
  }
489
499
  }