@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.
@@ -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._setupMutationObserver();
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
- // Check if this can group with the last message
108
- // Same author AND (no timestamp conflict)
109
- const canGroupWithLast = author === this._lastAuthor &&
110
- (!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);
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
- _setupMutationObserver() {
166
- let debounceTimer;
167
- this._mutationObserver = new MutationObserver(() => {
168
- clearTimeout(debounceTimer);
169
- 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);
170
209
  });
171
- 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, {
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 || !canGroup(msg, 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 || !canGroup(messages[i - 1], msg)) {
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 || !canGroup(messages[i - 1], msg);
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
- container.scrollTop = container.scrollHeight;
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
- container.addEventListener('scroll', checkScrollPosition, { passive: true });
402
+ this._addTrackedListener(container, 'scroll', checkScrollPosition);
363
403
  // Also check on resize
364
- window.addEventListener('resize', checkScrollPosition, { passive: true });
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.0",
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._setupMutationObserver();
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
- // Check if this can group with the last message
131
- // Same author AND (no timestamp conflict)
132
- const canGroupWithLast =
133
- author === this._lastAuthor &&
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
- private _setupMutationObserver() {
211
- let debounceTimer: ReturnType<typeof setTimeout>;
212
- this._mutationObserver = new MutationObserver(() => {
213
- clearTimeout(debounceTimer);
214
- 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);
215
253
  });
216
- 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, {
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 || !canGroup(msg, 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 || !canGroup(messages[i - 1], msg)) {
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 || !canGroup(messages[i - 1], msg);
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
- container.scrollTop = container.scrollHeight;
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
- container.addEventListener('scroll', checkScrollPosition, { passive: true });
494
+ this._addTrackedListener(container, 'scroll', checkScrollPosition);
454
495
 
455
496
  // Also check on resize
456
- window.addEventListener('resize', checkScrollPosition, { passive: true });
497
+ this._addTrackedListener(window, 'resize', checkScrollPosition);
457
498
  }
458
499
  }