@bbki.ng/bb-msg-history 0.14.0 → 1.0.0

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.
@@ -1,13 +1,17 @@
1
1
  import type { AuthorOptions, Message } from './types/index.js';
2
2
  export declare class BBMsgHistory extends HTMLElement {
3
3
  private _mutationObserver?;
4
+ private _debounceTimer?;
5
+ private _eventTracker;
6
+ private _messageProcessor;
7
+ private _scrollManager;
8
+ private _renderer;
4
9
  private _userAuthors;
5
10
  private _lastAuthor;
6
11
  private _lastGroupTimestamp;
7
- private _scrollButtonVisible;
8
12
  static get observedAttributes(): string[];
9
13
  constructor();
10
- attributeChangedCallback(name: string): void;
14
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
11
15
  /**
12
16
  * Configure an author's avatar, side, and colors.
13
17
  * Call before or after rendering — the component re-renders automatically.
@@ -45,15 +49,13 @@ export declare class BBMsgHistory extends HTMLElement {
45
49
  * el.scrollToBottom(); // Scroll with smooth animation
46
50
  */
47
51
  scrollToBottom(): this;
52
+ /**
53
+ * Internal: Append a single message with incremental DOM update
54
+ */
48
55
  private _appendSingleMessage;
49
56
  connectedCallback(): void;
50
57
  disconnectedCallback(): void;
51
58
  private _setupMutationObserver;
52
59
  private render;
53
- private _renderFullStructure;
54
- private _updateContent;
55
- private _updateLoadingOverlay;
56
60
  private _setupAfterRender;
57
- private _renderEmpty;
58
- private _setupScrollTracking;
59
61
  }
package/dist/component.js CHANGED
@@ -1,22 +1,37 @@
1
- import { EMPTY_STYLES, LOADING_STYLES, MAIN_STYLES } from './const/styles.js';
2
1
  import { parseMessages } from './utils/message-parser.js';
3
- import { resolveAuthorConfig } from './utils/author-resolver.js';
4
- import { setupTooltips } from './utils/tooltip.js';
5
- import { buildMessageRowHtml, setupTooltipForElement } from './utils/message-builder.js';
6
- import { buildScrollButtonHtml } from './utils/scroll-button.js';
2
+ import { EventTracker } from './utils/event-tracker.js';
3
+ import { MessageProcessor } from './core/message-processor.js';
4
+ import { ScrollManager } from './core/scroll-manager.js';
5
+ import { Renderer } from './core/renderer.js';
7
6
  export class BBMsgHistory extends HTMLElement {
8
7
  static get observedAttributes() {
9
8
  return ['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'];
10
9
  }
11
10
  constructor() {
12
11
  super();
12
+ // Core modules
13
+ this._eventTracker = new EventTracker();
14
+ this._messageProcessor = new MessageProcessor();
15
+ // State
13
16
  this._userAuthors = new Map();
14
17
  this._lastAuthor = '';
15
- this._scrollButtonVisible = false;
16
18
  this.attachShadow({ mode: 'open' });
19
+ // Initialize renderer with shadow root
20
+ this._renderer = new Renderer(this.shadowRoot);
21
+ // Initialize scroll manager with callback
22
+ this._scrollManager = new ScrollManager(this, this.shadowRoot, this._eventTracker, _ => {
23
+ // Callback for visibility changes (state tracking if needed)
24
+ });
25
+ // Create MutationObserver for reactive rendering
26
+ this._mutationObserver = new MutationObserver(() => {
27
+ clearTimeout(this._debounceTimer);
28
+ this._debounceTimer = setTimeout(() => this.render(), 50);
29
+ });
17
30
  }
18
- attributeChangedCallback(name) {
19
- if (name === 'theme' || name === 'loading' || name === 'hide-scroll-bar' || name === 'infinite' || name === 'hide-scroll-button') {
31
+ attributeChangedCallback(name, oldValue, newValue) {
32
+ if (oldValue === newValue)
33
+ return;
34
+ if (['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'].includes(name)) {
20
35
  this.render();
21
36
  }
22
37
  }
@@ -61,16 +76,21 @@ export class BBMsgHistory extends HTMLElement {
61
76
  * el.appendMessage({ author: 'bob', text: 'How are you?' });
62
77
  */
63
78
  appendMessage(message) {
79
+ // Temporarily disconnect observer BEFORE updating textContent to prevent double render
80
+ this._mutationObserver?.disconnect();
81
+ clearTimeout(this._debounceTimer);
64
82
  // Update textContent
65
83
  const currentText = this.textContent || '';
66
84
  const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
67
85
  this.textContent = currentText + separator + `${message.author}: ${message.text}`;
68
- // Temporarily disconnect observer to prevent recursive render
69
- this._mutationObserver?.disconnect();
70
86
  // Append single message without re-rendering entire list
71
87
  this._appendSingleMessage(message);
72
88
  // Reconnect observer
73
- this._setupMutationObserver();
89
+ this._mutationObserver?.observe(this, {
90
+ childList: true,
91
+ characterData: true,
92
+ subtree: true,
93
+ });
74
94
  return this;
75
95
  }
76
96
  /**
@@ -80,79 +100,28 @@ export class BBMsgHistory extends HTMLElement {
80
100
  * el.scrollToBottom(); // Scroll with smooth animation
81
101
  */
82
102
  scrollToBottom() {
83
- if (this.hasAttribute('infinite')) {
84
- return this;
85
- }
86
- const container = this.shadowRoot?.querySelector('.history');
87
- if (!container) {
88
- return this;
89
- }
90
- container.scrollTo({
91
- top: container.scrollHeight,
92
- behavior: 'smooth',
93
- });
103
+ this._scrollManager.scrollToBottom();
94
104
  return this;
95
105
  }
106
+ /**
107
+ * Internal: Append a single message with incremental DOM update
108
+ */
96
109
  _appendSingleMessage(message) {
97
- const container = this.shadowRoot.querySelector('.history');
98
- // If empty state or no container, do full render first
99
- if (!container) {
110
+ const result = this._renderer.appendSingleMessage(message, this._userAuthors, {
111
+ author: this._lastAuthor,
112
+ groupTimestamp: this._lastGroupTimestamp,
113
+ });
114
+ if (!result.success) {
115
+ // Container not ready, do full render
100
116
  this.render();
101
117
  return;
102
118
  }
103
- const author = message.author;
104
- const text = message.text;
105
- const timestamp = message.timestamp;
106
- 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);
111
- const isFirstFromAuthor = !canGroupWithLast;
112
- this._lastAuthor = author;
113
- const isSubsequent = !isFirstFromAuthor;
114
- // Update group timestamp tracking
115
- if (isFirstFromAuthor) {
116
- // Start new group
117
- this._lastGroupTimestamp = timestamp;
118
- }
119
- else if (!this._lastGroupTimestamp && timestamp) {
120
- // If no timestamp in group yet and current has one, use it
121
- this._lastGroupTimestamp = timestamp;
122
- }
123
- // When appending, we assume this IS the last in group (for now)
124
- // If another message from same author comes, we'll re-render
125
- const isLastInGroup = true;
126
- const groupTimestamp = this._lastGroupTimestamp;
127
- // Use utility function to build message HTML
128
- const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent, groupTimestamp, isLastInGroup);
129
- // Append to container
130
- container.insertAdjacentHTML('beforeend', msgHtml);
131
- // Setup tooltip for new element using utility function
132
- const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
133
- if (newWrapper) {
134
- setupTooltipForElement(newWrapper);
135
- }
136
- // Smooth scroll to bottom (skip in infinite mode)
119
+ // Update state
120
+ this._lastAuthor = result.lastAuthor;
121
+ this._lastGroupTimestamp = result.lastGroupTimestamp;
122
+ // Scroll to bottom (skip in infinite mode)
137
123
  if (!this.hasAttribute('infinite')) {
138
- container.scrollTo({
139
- top: container.scrollHeight,
140
- behavior: 'smooth',
141
- });
142
- // Hide scroll button since we're scrolling to bottom
143
- if (this._scrollButtonVisible) {
144
- this._scrollButtonVisible = false;
145
- const scrollButton = this.shadowRoot.querySelector('.scroll-to-bottom');
146
- if (scrollButton) {
147
- scrollButton.classList.remove('visible');
148
- }
149
- // Dispatch hide event (always, regardless of button visibility)
150
- this.dispatchEvent(new CustomEvent('bb-scrollbuttonhide', {
151
- bubbles: true,
152
- composed: true,
153
- detail: { visible: false }
154
- }));
155
- }
124
+ this._scrollManager.scrollToBottom();
156
125
  }
157
126
  }
158
127
  connectedCallback() {
@@ -161,14 +130,11 @@ export class BBMsgHistory extends HTMLElement {
161
130
  }
162
131
  disconnectedCallback() {
163
132
  this._mutationObserver?.disconnect();
133
+ clearTimeout(this._debounceTimer);
134
+ this._eventTracker.cleanup();
164
135
  }
165
136
  _setupMutationObserver() {
166
- let debounceTimer;
167
- this._mutationObserver = new MutationObserver(() => {
168
- clearTimeout(debounceTimer);
169
- debounceTimer = setTimeout(() => this.render(), 50);
170
- });
171
- this._mutationObserver.observe(this, {
137
+ this._mutationObserver?.observe(this, {
172
138
  childList: true,
173
139
  characterData: true,
174
140
  subtree: true,
@@ -179,188 +145,30 @@ export class BBMsgHistory extends HTMLElement {
179
145
  if (messages.length === 0) {
180
146
  this._lastAuthor = '';
181
147
  this._lastGroupTimestamp = undefined;
182
- this._renderEmpty();
148
+ this._renderer.renderEmpty(this.hasAttribute('loading'));
183
149
  return;
184
150
  }
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
- // First pass: determine which messages are last in their group
196
- const lastInGroupFlags = messages.map((msg, i) => {
197
- const next = messages[i + 1];
198
- return !next || !canGroup(msg, next);
199
- });
200
- // Second pass: collect the timestamp for each group
201
- // Use the first non-empty timestamp in the group
202
- const groupTimestamps = new Map();
203
- let currentGroupTimestamp;
204
- messages.forEach((msg, i) => {
205
- // Start of a new group
206
- if (i === 0 || !canGroup(messages[i - 1], msg)) {
207
- currentGroupTimestamp = msg.timestamp;
208
- }
209
- else if (!currentGroupTimestamp && msg.timestamp) {
210
- // If no timestamp yet and current msg has one, use it
211
- currentGroupTimestamp = msg.timestamp;
212
- }
213
- // If this is the last message in the group, save the timestamp
214
- if (lastInGroupFlags[i]) {
215
- groupTimestamps.set(i, currentGroupTimestamp);
216
- currentGroupTimestamp = undefined;
217
- }
218
- });
219
- // Third pass: build HTML
220
- let lastAuthor = '';
221
- const messagesHtml = messages
222
- .map((msg, i) => {
223
- const { author, text } = msg;
224
- const config = resolveAuthorConfig(author, this._userAuthors);
225
- // Determine if this is a new author group (can't group with previous)
226
- const isFirstFromAuthor = i === 0 || !canGroup(messages[i - 1], msg);
227
- lastAuthor = author;
228
- const isSubsequent = !isFirstFromAuthor;
229
- // Get timestamp if this is the last in group
230
- const isLastInGroup = lastInGroupFlags[i];
231
- const groupTimestamp = groupTimestamps.get(i);
232
- // Use utility function to build message HTML
233
- return buildMessageRowHtml(author, text, config, isSubsequent, groupTimestamp, isLastInGroup);
234
- })
235
- .join('');
151
+ // Process messages (single-pass algorithm)
152
+ const { processed, lastAuthor, lastGroupTimestamp } = this._messageProcessor.process(messages);
153
+ // Update state
236
154
  this._lastAuthor = lastAuthor;
237
- // Check if we need to create or update the structure
238
- const historyContainer = this.shadowRoot.querySelector('.history');
239
- const needsFullSetup = !historyContainer;
240
- if (needsFullSetup) {
241
- // First render - create full structure
242
- this._renderFullStructure(messagesHtml);
243
- }
244
- else {
245
- // Update only - preserve DOM structure, just update content
246
- this._updateContent(historyContainer, messagesHtml);
247
- }
248
- }
249
- _renderFullStructure(messagesHtml) {
250
- const loadingOverlay = this.hasAttribute('loading')
251
- ? `<div class="loading-overlay" role="status" aria-label="Loading messages">
252
- <div class="loading-spinner"></div>
253
- </div>`
254
- : '';
155
+ this._lastGroupTimestamp = lastGroupTimestamp;
156
+ // Render messages
157
+ const isLoading = this.hasAttribute('loading');
255
158
  const hideScrollButton = this.hasAttribute('hide-scroll-button');
256
- this.shadowRoot.innerHTML = `
257
- <style>${MAIN_STYLES}${LOADING_STYLES}</style>
258
- <div class="history" role="log" aria-live="polite" aria-label="Message history">
259
- ${messagesHtml}
260
- </div>
261
- ${hideScrollButton ? '' : buildScrollButtonHtml()}
262
- ${loadingOverlay}
263
- `;
264
- this._setupAfterRender();
265
- }
266
- _updateContent(historyContainer, messagesHtml) {
267
- // Preserve scroll position before update
268
- const scrollContainer = historyContainer;
269
- const wasAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
270
- // Update messages content only
271
- historyContainer.innerHTML = messagesHtml;
272
- // Update loading overlay
273
- this._updateLoadingOverlay();
274
- // Restore scroll position or scroll to bottom if we were there
275
- if (wasAtBottom) {
276
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
277
- }
278
- // Re-setup tooltips for new content
279
- setupTooltips(this.shadowRoot);
159
+ const { wasAtBottom } = this._renderer.render(processed, this._userAuthors, isLoading, hideScrollButton);
160
+ // Setup scroll tracking and other post-render tasks
161
+ this._setupAfterRender(wasAtBottom);
280
162
  }
281
- _updateLoadingOverlay() {
282
- const existingOverlay = this.shadowRoot.querySelector('.loading-overlay');
283
- const shouldShow = this.hasAttribute('loading');
284
- if (shouldShow && !existingOverlay) {
285
- const overlay = document.createElement('div');
286
- overlay.className = 'loading-overlay';
287
- overlay.setAttribute('role', 'status');
288
- overlay.setAttribute('aria-label', 'Loading messages');
289
- overlay.innerHTML = '<div class="loading-spinner"></div>';
290
- this.shadowRoot.appendChild(overlay);
291
- }
292
- else if (!shouldShow && existingOverlay) {
293
- existingOverlay.remove();
294
- }
295
- }
296
- _setupAfterRender() {
163
+ _setupAfterRender(shouldScrollToBottom = true) {
297
164
  requestAnimationFrame(() => {
298
- const container = this.shadowRoot.querySelector('.history');
299
- const scrollButton = this.shadowRoot.querySelector('.scroll-to-bottom');
165
+ const container = this._renderer.getHistoryContainer();
166
+ const scrollButton = this._renderer.getScrollButton();
300
167
  const isInfinite = this.hasAttribute('infinite');
301
168
  if (container && !isInfinite) {
302
- container.scrollTop = container.scrollHeight;
303
- this._setupScrollTracking(container, scrollButton, { skipInitialCheck: true });
169
+ // Initialize scroll manager
170
+ this._scrollManager.init(container, scrollButton, shouldScrollToBottom);
304
171
  }
305
- if (scrollButton && !isInfinite) {
306
- scrollButton.addEventListener('click', () => {
307
- container?.scrollTo({
308
- top: container.scrollHeight,
309
- behavior: 'smooth',
310
- });
311
- });
312
- }
313
- setupTooltips(this.shadowRoot);
314
172
  });
315
173
  }
316
- _renderEmpty() {
317
- const isLoading = this.hasAttribute('loading');
318
- if (isLoading) {
319
- // Show loading overlay with minimum height for better appearance
320
- this.shadowRoot.innerHTML = `
321
- <style>${EMPTY_STYLES}${LOADING_STYLES}</style>
322
- <div style="position: relative; min-height: 120px;">
323
- <div class="loading-overlay" role="status" aria-label="Loading messages">
324
- <div class="loading-spinner"></div>
325
- </div>
326
- </div>
327
- `;
328
- }
329
- else {
330
- this.shadowRoot.innerHTML = `
331
- <style>${EMPTY_STYLES}</style>
332
- <div class="empty-state">No messages</div>
333
- `;
334
- }
335
- }
336
- _setupScrollTracking(container, button, options) {
337
- const checkScrollPosition = () => {
338
- const threshold = 50; // pixels from bottom
339
- const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
340
- const hasOverflow = container.scrollHeight > container.clientHeight;
341
- // Show button when not at bottom and content has overflow
342
- const shouldShow = !isAtBottom && hasOverflow;
343
- if (shouldShow !== this._scrollButtonVisible) {
344
- this._scrollButtonVisible = shouldShow;
345
- // Only toggle button visibility if button exists
346
- if (button) {
347
- button.classList.toggle('visible', shouldShow);
348
- }
349
- // Dispatch custom event (always, regardless of button visibility)
350
- this.dispatchEvent(new CustomEvent(shouldShow ? 'bb-scrollbuttonshow' : 'bb-scrollbuttonhide', {
351
- bubbles: true,
352
- composed: true,
353
- detail: { visible: shouldShow }
354
- }));
355
- }
356
- };
357
- // Check initial state unless skipped
358
- if (!options?.skipInitialCheck) {
359
- checkScrollPosition();
360
- }
361
- // Listen for scroll events with passive listener for performance
362
- container.addEventListener('scroll', checkScrollPosition, { passive: true });
363
- // Also check on resize
364
- window.addEventListener('resize', checkScrollPosition, { passive: true });
365
- }
366
174
  }
@@ -22,7 +22,7 @@ export const AUTHOR_CONFIG = {
22
22
  textColor: THEME.gray[900],
23
23
  side: 'left',
24
24
  },
25
- 'GitHub': {
25
+ GitHub: {
26
26
  avatar: '<div style="width: 60%; height: 60%; margin: auto"><svg data-testid="geist-icon" height="16" stroke-linejoin="round" viewBox="0 0 16 16" width="16" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1.46252C4.40875 1.46252 1.5 4.37029 1.5 7.96032C1.5 10.8356 3.36062 13.2642 5.94438 14.1251C6.26937 14.182 6.39125 13.987 6.39125 13.8165C6.39125 13.6621 6.38313 13.1504 6.38313 12.6063C4.75 12.9068 4.3275 12.2083 4.1975 11.8428C4.12437 11.6559 3.8075 11.0793 3.53125 10.9249C3.30375 10.8031 2.97875 10.5026 3.52312 10.4945C4.035 10.4863 4.40062 10.9656 4.5225 11.1605C5.1075 12.1433 6.04188 11.8671 6.41563 11.6966C6.4725 11.2742 6.64313 10.9899 6.83 10.8275C5.38375 10.665 3.8725 10.1046 3.8725 7.61919C3.8725 6.91255 4.12438 6.32775 4.53875 5.87291C4.47375 5.71046 4.24625 5.04444 4.60375 4.15099C4.60375 4.15099 5.14812 3.98042 6.39125 4.81701C6.91125 4.67081 7.46375 4.59771 8.01625 4.59771C8.56875 4.59771 9.12125 4.67081 9.64125 4.81701C10.8844 3.9723 11.4288 4.15099 11.4288 4.15099C11.7863 5.04444 11.5588 5.71046 11.4938 5.87291C11.9081 6.32775 12.16 6.90443 12.16 7.61919C12.16 10.1127 10.6406 10.665 9.19438 10.8275C9.43 11.0305 9.63313 11.4204 9.63313 12.0296C9.63313 12.8987 9.625 13.5972 9.625 13.8165C9.625 13.987 9.74687 14.1901 10.0719 14.1251C11.3622 13.6896 12.4835 12.8606 13.2779 11.7547C14.0722 10.6488 14.4997 9.32178 14.5 7.96032C14.5 4.37029 11.5913 1.46252 8 1.46252Z" fill="currentColor"></path></svg></div>',
27
27
  side: 'left',
28
28
  bubbleColor: '#ecf4ec',
@@ -0,0 +1,56 @@
1
+ import type { Message } from '../types/index.js';
2
+ /**
3
+ * Extended message with grouping metadata
4
+ */
5
+ export interface ProcessedMessage extends Message {
6
+ /** Whether this is the first message from this author in the current group */
7
+ isFirstFromAuthor: boolean;
8
+ /** Whether this is the last message in the current group */
9
+ isLastInGroup: boolean;
10
+ /** The timestamp to display for this group (if any) */
11
+ groupTimestamp?: string;
12
+ }
13
+ /**
14
+ * Result of processing messages, including state for incremental updates
15
+ */
16
+ export interface ProcessResult {
17
+ /** Processed messages with grouping metadata */
18
+ processed: ProcessedMessage[];
19
+ /** The author of the last message */
20
+ lastAuthor: string;
21
+ /** The timestamp of the current group */
22
+ lastGroupTimestamp?: string;
23
+ }
24
+ /**
25
+ * MessageProcessor - Handles message grouping and metadata calculation
26
+ *
27
+ * Encapsulates the grouping algorithm to determine:
28
+ * - Which messages are first from an author (show avatar)
29
+ * - Which messages are last in a group (show timestamp)
30
+ * - Group timestamps for display
31
+ *
32
+ * Optimized to process messages in a single pass instead of multiple traversals.
33
+ */
34
+ export declare class MessageProcessor {
35
+ /**
36
+ * Check if two messages can be grouped together
37
+ * Messages can be grouped if they have the same author and compatible timestamps
38
+ *
39
+ * @param prev - Previous message (null if this is the first)
40
+ * @param curr - Current message
41
+ * @returns true if messages can be grouped
42
+ */
43
+ private canGroup;
44
+ /**
45
+ * Process messages to add grouping metadata
46
+ *
47
+ * This method performs a single-pass algorithm that:
48
+ * 1. Determines first/last status for each message in its group
49
+ * 2. Assigns group timestamps consistently
50
+ * 3. Tracks state for incremental updates
51
+ *
52
+ * @param messages - Raw messages from parser
53
+ * @returns Processed messages with grouping metadata and state
54
+ */
55
+ process(messages: Message[]): ProcessResult;
56
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * MessageProcessor - Handles message grouping and metadata calculation
3
+ *
4
+ * Encapsulates the grouping algorithm to determine:
5
+ * - Which messages are first from an author (show avatar)
6
+ * - Which messages are last in a group (show timestamp)
7
+ * - Group timestamps for display
8
+ *
9
+ * Optimized to process messages in a single pass instead of multiple traversals.
10
+ */
11
+ export class MessageProcessor {
12
+ /**
13
+ * Check if two messages can be grouped together
14
+ * Messages can be grouped if they have the same author and compatible timestamps
15
+ *
16
+ * @param prev - Previous message (null if this is the first)
17
+ * @param curr - Current message
18
+ * @returns true if messages can be grouped
19
+ */
20
+ canGroup(prev, curr) {
21
+ if (!prev)
22
+ return false;
23
+ if (prev.author !== curr.author)
24
+ return false;
25
+ // Different timestamps = break group
26
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
27
+ return false;
28
+ }
29
+ return true;
30
+ }
31
+ /**
32
+ * Process messages to add grouping metadata
33
+ *
34
+ * This method performs a single-pass algorithm that:
35
+ * 1. Determines first/last status for each message in its group
36
+ * 2. Assigns group timestamps consistently
37
+ * 3. Tracks state for incremental updates
38
+ *
39
+ * @param messages - Raw messages from parser
40
+ * @returns Processed messages with grouping metadata and state
41
+ */
42
+ process(messages) {
43
+ if (messages.length === 0) {
44
+ return { processed: [], lastAuthor: '' };
45
+ }
46
+ const processed = [];
47
+ let lastAuthor = '';
48
+ let lastGroupTimestamp;
49
+ // Track group state
50
+ let currentGroupTimestamp;
51
+ for (let i = 0; i < messages.length; i++) {
52
+ const msg = messages[i];
53
+ const prev = i > 0 ? messages[i - 1] : null;
54
+ const next = i < messages.length - 1 ? messages[i + 1] : null;
55
+ // Determine if this is first from author
56
+ const isFirstFromAuthor = !this.canGroup(prev, msg);
57
+ // Start of new group - initialize group timestamp
58
+ if (isFirstFromAuthor) {
59
+ currentGroupTimestamp = msg.timestamp;
60
+ }
61
+ else if (!currentGroupTimestamp && msg.timestamp) {
62
+ // If no timestamp yet and current msg has one, use it
63
+ currentGroupTimestamp = msg.timestamp;
64
+ }
65
+ // Determine if this is last in group
66
+ const isLastInGroup = !next || !this.canGroup(msg, next);
67
+ // Create processed message with metadata
68
+ const processedMsg = {
69
+ ...msg,
70
+ isFirstFromAuthor,
71
+ isLastInGroup,
72
+ groupTimestamp: isLastInGroup ? currentGroupTimestamp : undefined,
73
+ };
74
+ processed.push(processedMsg);
75
+ // Update state tracking
76
+ lastAuthor = msg.author;
77
+ // If this is the last in group, reset group timestamp
78
+ if (isLastInGroup) {
79
+ lastGroupTimestamp = currentGroupTimestamp;
80
+ currentGroupTimestamp = undefined;
81
+ }
82
+ }
83
+ return { processed, lastAuthor, lastGroupTimestamp };
84
+ }
85
+ }
@@ -0,0 +1,87 @@
1
+ import type { AuthorOptions, Message } from '../types/index.js';
2
+ import type { ProcessedMessage } from './message-processor.js';
3
+ /**
4
+ * State for incremental message appending
5
+ */
6
+ export interface LastState {
7
+ author: string;
8
+ groupTimestamp?: string;
9
+ }
10
+ /**
11
+ * Result of a full render operation
12
+ */
13
+ export interface RenderResult {
14
+ wasAtBottom: boolean;
15
+ }
16
+ /**
17
+ * Result of an incremental append operation
18
+ */
19
+ export interface AppendResult {
20
+ success: boolean;
21
+ lastAuthor: string;
22
+ lastGroupTimestamp?: string;
23
+ }
24
+ /**
25
+ * Renderer - Manages all DOM rendering operations
26
+ *
27
+ * Centralizes DOM manipulation logic:
28
+ * - Full render of all messages
29
+ * - Incremental update when appending single messages
30
+ * - Empty state rendering
31
+ * - Loading overlay management
32
+ */
33
+ export declare class Renderer {
34
+ private shadowRoot;
35
+ constructor(shadowRoot: ShadowRoot);
36
+ /**
37
+ * Render the complete message history
38
+ *
39
+ * @param messages - Processed messages with grouping metadata
40
+ * @param authors - User-defined author configurations
41
+ * @param isLoading - Whether to show loading overlay
42
+ * @param hideScrollButton - Whether to hide the scroll-to-bottom button
43
+ * @returns Result indicating if we were at bottom before render
44
+ */
45
+ render(messages: ProcessedMessage[], authors: Map<string, AuthorOptions>, isLoading: boolean, hideScrollButton: boolean): RenderResult;
46
+ /**
47
+ * Build HTML string for all messages
48
+ */
49
+ private buildMessagesHtml;
50
+ /**
51
+ * Render full structure including styles, container, and scroll button
52
+ */
53
+ private renderFullStructure;
54
+ /**
55
+ * Update content while preserving DOM structure
56
+ */
57
+ private updateContent;
58
+ /**
59
+ * Append a single message without full re-render
60
+ *
61
+ * @param message - The message to append
62
+ * @param authors - User-defined author configurations
63
+ * @param lastState - Previous state for grouping logic
64
+ * @returns Result indicating success and updated state
65
+ */
66
+ appendSingleMessage(message: Message, authors: Map<string, AuthorOptions>, lastState: LastState): AppendResult;
67
+ /**
68
+ * Check if two messages can be grouped
69
+ */
70
+ private canGroupMessages;
71
+ /**
72
+ * Render empty state (no messages)
73
+ */
74
+ renderEmpty(isLoading: boolean): void;
75
+ /**
76
+ * Update loading overlay visibility
77
+ */
78
+ updateLoadingOverlay(shouldShow: boolean): void;
79
+ /**
80
+ * Get the history container element
81
+ */
82
+ getHistoryContainer(): HTMLElement | null;
83
+ /**
84
+ * Get the scroll-to-bottom button element
85
+ */
86
+ getScrollButton(): HTMLButtonElement | null;
87
+ }