@bbki.ng/bb-msg-history 0.14.1 → 2.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.
Files changed (54) hide show
  1. package/dist/component.d.ts +6 -18
  2. package/dist/component.js +43 -275
  3. package/dist/components/bb-custom-avatar.d.ts +20 -0
  4. package/dist/components/bb-custom-avatar.js +145 -0
  5. package/dist/components/bb-letter-avatar.d.ts +14 -0
  6. package/dist/components/bb-letter-avatar.js +61 -0
  7. package/dist/components/bb-loading-overlay.d.ts +14 -0
  8. package/dist/components/bb-loading-overlay.js +89 -0
  9. package/dist/components/bb-message-bubble.d.ts +19 -0
  10. package/dist/components/bb-message-bubble.js +116 -0
  11. package/dist/components/bb-message.d.ts +27 -0
  12. package/dist/components/bb-message.js +174 -0
  13. package/dist/components/bb-msg-history.d.ts +111 -0
  14. package/dist/components/bb-msg-history.js +473 -0
  15. package/dist/components/bb-scroll-button.d.ts +16 -0
  16. package/dist/components/bb-scroll-button.js +161 -0
  17. package/dist/components/bb-timestamp.d.ts +15 -0
  18. package/dist/components/bb-timestamp.js +59 -0
  19. package/dist/components/index.d.ts +7 -0
  20. package/dist/components/index.js +7 -0
  21. package/dist/const/authors.js +1 -1
  22. package/dist/const/styles.js +0 -33
  23. package/dist/contexts/author-context.d.ts +8 -0
  24. package/dist/contexts/author-context.js +6 -0
  25. package/dist/controllers/scroll-controller.d.ts +52 -0
  26. package/dist/controllers/scroll-controller.js +138 -0
  27. package/dist/core/message-processor.d.ts +56 -0
  28. package/dist/core/message-processor.js +85 -0
  29. package/dist/core/renderer.d.ts +87 -0
  30. package/dist/core/renderer.js +196 -0
  31. package/dist/core/scroll-manager.d.ts +54 -0
  32. package/dist/core/scroll-manager.js +119 -0
  33. package/dist/parsers/base.d.ts +21 -0
  34. package/dist/parsers/base.js +1 -0
  35. package/dist/parsers/default-parser.d.ts +10 -0
  36. package/dist/parsers/default-parser.js +40 -0
  37. package/dist/parsers/index.d.ts +2 -0
  38. package/dist/parsers/index.js +1 -0
  39. package/dist/utils/event-tracker.d.ts +23 -0
  40. package/dist/utils/event-tracker.js +33 -0
  41. package/dist/utils/message-builder.d.ts +0 -4
  42. package/dist/utils/message-builder.js +0 -15
  43. package/dist/utils/tooltip.d.ts +11 -2
  44. package/dist/utils/tooltip.js +56 -13
  45. package/package.json +1 -1
  46. package/src/component.ts +56 -338
  47. package/src/const/authors.ts +3 -2
  48. package/src/const/styles.ts +0 -33
  49. package/src/core/message-processor.ts +120 -0
  50. package/src/core/renderer.ts +276 -0
  51. package/src/core/scroll-manager.ts +148 -0
  52. package/src/utils/event-tracker.ts +38 -0
  53. package/src/utils/message-builder.ts +0 -15
  54. package/src/utils/tooltip.ts +0 -16
package/src/component.ts CHANGED
@@ -1,19 +1,24 @@
1
1
  import type { AuthorOptions, Message } from './types/index.js';
2
- import { EMPTY_STYLES, LOADING_STYLES, MAIN_STYLES } from './const/styles.js';
3
2
  import { parseMessages } from './utils/message-parser.js';
4
- import { resolveAuthorConfig } from './utils/author-resolver.js';
5
- import { setupTooltips } from './utils/tooltip.js';
6
- import { buildMessageRowHtml, setupTooltipForElement } from './utils/message-builder.js';
7
- import { buildScrollButtonHtml } from './utils/scroll-button.js';
3
+ import { EventTracker } from './utils/event-tracker.js';
4
+ import { MessageProcessor } from './core/message-processor.js';
5
+ import { ScrollManager } from './core/scroll-manager.js';
6
+ import { Renderer } from './core/renderer.js';
8
7
 
9
8
  export class BBMsgHistory extends HTMLElement {
10
9
  private _mutationObserver?: MutationObserver;
10
+ private _debounceTimer?: ReturnType<typeof setTimeout>;
11
+
12
+ // Core modules
13
+ private _eventTracker = new EventTracker();
14
+ private _messageProcessor = new MessageProcessor();
15
+ private _scrollManager: ScrollManager;
16
+ private _renderer: Renderer;
17
+
18
+ // State
11
19
  private _userAuthors = new Map<string, AuthorOptions>();
12
20
  private _lastAuthor = '';
13
21
  private _lastGroupTimestamp: string | undefined;
14
- private _scrollButtonVisible = false;
15
- private _scrollListeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
16
- private _debounceTimer?: ReturnType<typeof setTimeout>;
17
22
 
18
23
  static get observedAttributes() {
19
24
  return ['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'];
@@ -22,7 +27,16 @@ export class BBMsgHistory extends HTMLElement {
22
27
  constructor() {
23
28
  super();
24
29
  this.attachShadow({ mode: 'open' });
25
- // Create MutationObserver once - will be connected in connectedCallback
30
+
31
+ // Initialize renderer with shadow root
32
+ this._renderer = new Renderer(this.shadowRoot!);
33
+
34
+ // Initialize scroll manager with callback
35
+ this._scrollManager = new ScrollManager(this, this.shadowRoot!, this._eventTracker, _ => {
36
+ // Callback for visibility changes (state tracking if needed)
37
+ });
38
+
39
+ // Create MutationObserver for reactive rendering
26
40
  this._mutationObserver = new MutationObserver(() => {
27
41
  clearTimeout(this._debounceTimer);
28
42
  this._debounceTimer = setTimeout(() => this.render(), 50);
@@ -31,7 +45,7 @@ export class BBMsgHistory extends HTMLElement {
31
45
 
32
46
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
33
47
  if (oldValue === newValue) return;
34
- if (name === 'theme' || name === 'loading' || name === 'hide-scroll-bar' || name === 'infinite' || name === 'hide-scroll-button') {
48
+ if (['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'].includes(name)) {
35
49
  this.render();
36
50
  }
37
51
  }
@@ -109,119 +123,32 @@ export class BBMsgHistory extends HTMLElement {
109
123
  * el.scrollToBottom(); // Scroll with smooth animation
110
124
  */
111
125
  scrollToBottom(): this {
112
- if (this.hasAttribute('infinite')) {
113
- return this;
114
- }
115
-
116
- const container = this.shadowRoot?.querySelector('.history') as HTMLElement | null;
117
- if (!container) {
118
- return this;
119
- }
120
-
121
- container.scrollTo({
122
- top: container.scrollHeight,
123
- behavior: 'smooth',
124
- });
125
-
126
+ this._scrollManager.scrollToBottom();
126
127
  return this;
127
128
  }
128
129
 
129
130
  /**
130
- * Check if two messages can be grouped (same author, no timestamp conflict)
131
+ * Internal: Append a single message with incremental DOM update
131
132
  */
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
-
142
133
  private _appendSingleMessage(message: Message): void {
143
- const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
134
+ const result = this._renderer.appendSingleMessage(message, this._userAuthors, {
135
+ author: this._lastAuthor,
136
+ groupTimestamp: this._lastGroupTimestamp,
137
+ });
144
138
 
145
- // If empty state or no container, do full render first
146
- if (!container) {
139
+ if (!result.success) {
140
+ // Container not ready, do full render
147
141
  this.render();
148
142
  return;
149
143
  }
150
144
 
151
- const author = message.author;
152
- const text = message.text;
153
- const timestamp = message.timestamp;
154
- const config = resolveAuthorConfig(author, this._userAuthors);
155
-
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;
160
-
161
- // Use unified grouping logic
162
- const canGroupWithLast = this._canGroupMessages(prevMessage, message);
163
- const isFirstFromAuthor = !canGroupWithLast;
164
- this._lastAuthor = author;
165
-
166
- const isSubsequent = !isFirstFromAuthor;
167
-
168
- // Update group timestamp tracking (consistent with render())
169
- if (isFirstFromAuthor) {
170
- // Start new group
171
- this._lastGroupTimestamp = timestamp;
172
- } else if (!this._lastGroupTimestamp && timestamp) {
173
- // If no timestamp in group yet and current has one, use it
174
- this._lastGroupTimestamp = timestamp;
175
- }
176
-
177
- // When appending, we assume this IS the last in group (for now)
178
- // If another message from same author comes, we'll re-render
179
- const isLastInGroup = true;
180
- const groupTimestamp = this._lastGroupTimestamp;
181
-
182
- // Use utility function to build message HTML
183
- const msgHtml = buildMessageRowHtml(
184
- author,
185
- text,
186
- config,
187
- isSubsequent,
188
- groupTimestamp,
189
- isLastInGroup
190
- );
191
-
192
- // Append to container
193
- container.insertAdjacentHTML('beforeend', msgHtml);
194
-
195
- // Setup tooltip for new element using utility function
196
- const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
197
- if (newWrapper) {
198
- setupTooltipForElement(newWrapper);
199
- }
145
+ // Update state
146
+ this._lastAuthor = result.lastAuthor;
147
+ this._lastGroupTimestamp = result.lastGroupTimestamp;
200
148
 
201
- // Smooth scroll to bottom (skip in infinite mode)
149
+ // Scroll to bottom (skip in infinite mode)
202
150
  if (!this.hasAttribute('infinite')) {
203
- container.scrollTo({
204
- top: container.scrollHeight,
205
- behavior: 'smooth',
206
- });
207
-
208
- // Hide scroll button since we're scrolling to bottom
209
- if (this._scrollButtonVisible) {
210
- this._scrollButtonVisible = false;
211
- const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
212
- if (scrollButton) {
213
- scrollButton.classList.remove('visible');
214
- }
215
-
216
- // Dispatch hide event (always, regardless of button visibility)
217
- this.dispatchEvent(
218
- new CustomEvent('bb-scrollbuttonhide', {
219
- bubbles: true,
220
- composed: true,
221
- detail: { visible: false }
222
- })
223
- );
224
- }
151
+ this._scrollManager.scrollToBottom();
225
152
  }
226
153
  }
227
154
 
@@ -233,29 +160,10 @@ export class BBMsgHistory extends HTMLElement {
233
160
  disconnectedCallback() {
234
161
  this._mutationObserver?.disconnect();
235
162
  clearTimeout(this._debounceTimer);
236
- this._cleanupListeners();
237
- }
238
-
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);
253
- });
254
- this._scrollListeners = [];
163
+ this._eventTracker.cleanup();
255
164
  }
256
165
 
257
166
  private _setupMutationObserver() {
258
- // Observer was already created in constructor, just need to connect it
259
167
  this._mutationObserver?.observe(this, {
260
168
  childList: true,
261
169
  characterData: true,
@@ -269,231 +177,41 @@ export class BBMsgHistory extends HTMLElement {
269
177
  if (messages.length === 0) {
270
178
  this._lastAuthor = '';
271
179
  this._lastGroupTimestamp = undefined;
272
- this._renderEmpty();
180
+ this._renderer.renderEmpty(this.hasAttribute('loading'));
273
181
  return;
274
182
  }
275
183
 
276
- // First pass: determine which messages are last in their group
277
- const lastInGroupFlags: boolean[] = messages.map((msg, i) => {
278
- const next = messages[i + 1];
279
- return !next || !this._canGroupMessages(msg, next);
280
- });
281
-
282
- // Second pass: collect the timestamp for each group
283
- // Use the first non-empty timestamp in the group
284
- const groupTimestamps = new Map<number, string | undefined>();
285
- let currentGroupTimestamp: string | undefined;
286
-
287
- messages.forEach((msg, i) => {
288
- // Start of a new group
289
- if (i === 0 || !this._canGroupMessages(messages[i - 1], msg)) {
290
- currentGroupTimestamp = msg.timestamp;
291
- } else if (!currentGroupTimestamp && msg.timestamp) {
292
- // If no timestamp yet and current msg has one, use it
293
- currentGroupTimestamp = msg.timestamp;
294
- }
295
-
296
- // If this is the last message in the group, save the timestamp
297
- if (lastInGroupFlags[i]) {
298
- groupTimestamps.set(i, currentGroupTimestamp);
299
- currentGroupTimestamp = undefined;
300
- }
301
- });
302
-
303
- // Third pass: build HTML
304
- let lastAuthor = '';
305
- const messagesHtml = messages
306
- .map((msg, i) => {
307
- const { author, text } = msg;
308
- const config = resolveAuthorConfig(author, this._userAuthors);
309
-
310
- // Determine if this is a new author group (can't group with previous)
311
- const isFirstFromAuthor = i === 0 || !this._canGroupMessages(messages[i - 1], msg);
312
- lastAuthor = author;
313
- const isSubsequent = !isFirstFromAuthor;
314
-
315
- // Get timestamp if this is the last in group
316
- const isLastInGroup = lastInGroupFlags[i];
317
- const groupTimestamp = groupTimestamps.get(i);
318
-
319
- // Use utility function to build message HTML
320
- return buildMessageRowHtml(
321
- author,
322
- text,
323
- config,
324
- isSubsequent,
325
- groupTimestamp,
326
- isLastInGroup
327
- );
328
- })
329
- .join('');
184
+ // Process messages (single-pass algorithm)
185
+ const { processed, lastAuthor, lastGroupTimestamp } = this._messageProcessor.process(messages);
330
186
 
187
+ // Update state
331
188
  this._lastAuthor = lastAuthor;
189
+ this._lastGroupTimestamp = lastGroupTimestamp;
332
190
 
333
- // Check if we need to create or update the structure
334
- const historyContainer = this.shadowRoot!.querySelector('.history') as HTMLElement;
335
- const needsFullSetup = !historyContainer;
336
-
337
- if (needsFullSetup) {
338
- // First render - create full structure
339
- this._renderFullStructure(messagesHtml);
340
- } else {
341
- // Update only - preserve DOM structure, just update content
342
- this._updateContent(historyContainer, messagesHtml);
343
- }
344
- }
345
-
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
-
353
- const loadingOverlay = this.hasAttribute('loading')
354
- ? `<div class="loading-overlay" role="status" aria-label="Loading messages">
355
- <div class="loading-spinner"></div>
356
- </div>`
357
- : '';
358
-
191
+ // Render messages
192
+ const isLoading = this.hasAttribute('loading');
359
193
  const hideScrollButton = this.hasAttribute('hide-scroll-button');
194
+ const { wasAtBottom } = this._renderer.render(
195
+ processed,
196
+ this._userAuthors,
197
+ isLoading,
198
+ hideScrollButton
199
+ );
360
200
 
361
- this.shadowRoot!.innerHTML = `
362
- <style>${MAIN_STYLES}${LOADING_STYLES}</style>
363
- <div class="history" role="log" aria-live="polite" aria-label="Message history">
364
- ${messagesHtml}
365
- </div>
366
- ${hideScrollButton ? '' : buildScrollButtonHtml()}
367
- ${loadingOverlay}
368
- `;
369
-
201
+ // Setup scroll tracking and other post-render tasks
370
202
  this._setupAfterRender(wasAtBottom);
371
203
  }
372
204
 
373
- private _updateContent(historyContainer: HTMLElement, messagesHtml: string): void {
374
- // Preserve scroll position before update
375
- const scrollContainer = historyContainer;
376
- const wasAtBottom =
377
- scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
378
-
379
- // Update messages content only
380
- historyContainer.innerHTML = messagesHtml;
381
-
382
- // Update loading overlay
383
- this._updateLoadingOverlay();
384
-
385
- // Restore scroll position or scroll to bottom if we were there
386
- if (wasAtBottom) {
387
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
388
- }
389
-
390
- // Re-setup tooltips for new content
391
- setupTooltips(this.shadowRoot!);
392
- }
393
-
394
- private _updateLoadingOverlay(): void {
395
- const existingOverlay = this.shadowRoot!.querySelector('.loading-overlay');
396
- const shouldShow = this.hasAttribute('loading');
397
-
398
- if (shouldShow && !existingOverlay) {
399
- const overlay = document.createElement('div');
400
- overlay.className = 'loading-overlay';
401
- overlay.setAttribute('role', 'status');
402
- overlay.setAttribute('aria-label', 'Loading messages');
403
- overlay.innerHTML = '<div class="loading-spinner"></div>';
404
- this.shadowRoot!.appendChild(overlay);
405
- } else if (!shouldShow && existingOverlay) {
406
- existingOverlay.remove();
407
- }
408
- }
409
-
410
205
  private _setupAfterRender(shouldScrollToBottom = true): void {
411
206
  requestAnimationFrame(() => {
412
- const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
413
- const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
207
+ const container = this._renderer.getHistoryContainer();
208
+ const scrollButton = this._renderer.getScrollButton();
414
209
  const isInfinite = this.hasAttribute('infinite');
415
210
 
416
211
  if (container && !isInfinite) {
417
- if (shouldScrollToBottom) {
418
- container.scrollTop = container.scrollHeight;
419
- }
420
- this._setupScrollTracking(container, scrollButton, { skipInitialCheck: true });
421
- }
422
-
423
- if (scrollButton && !isInfinite) {
424
- scrollButton.addEventListener('click', () => {
425
- container?.scrollTo({
426
- top: container.scrollHeight,
427
- behavior: 'smooth',
428
- });
429
- });
212
+ // Initialize scroll manager
213
+ this._scrollManager.init(container, scrollButton, shouldScrollToBottom);
430
214
  }
431
-
432
- setupTooltips(this.shadowRoot!);
433
215
  });
434
216
  }
435
-
436
- private _renderEmpty() {
437
- const isLoading = this.hasAttribute('loading');
438
-
439
- if (isLoading) {
440
- // Show loading overlay with minimum height for better appearance
441
- this.shadowRoot!.innerHTML = `
442
- <style>${EMPTY_STYLES}${LOADING_STYLES}</style>
443
- <div style="position: relative; min-height: 120px;">
444
- <div class="loading-overlay" role="status" aria-label="Loading messages">
445
- <div class="loading-spinner"></div>
446
- </div>
447
- </div>
448
- `;
449
- } else {
450
- this.shadowRoot!.innerHTML = `
451
- <style>${EMPTY_STYLES}</style>
452
- <div class="empty-state">No messages</div>
453
- `;
454
- }
455
- }
456
-
457
- private _setupScrollTracking(
458
- container: HTMLElement,
459
- button: HTMLButtonElement | null,
460
- options?: { skipInitialCheck?: boolean }
461
- ): void {
462
- const checkScrollPosition = () => {
463
- const threshold = 50; // pixels from bottom
464
- const isAtBottom =
465
- container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
466
- const hasOverflow = container.scrollHeight > container.clientHeight;
467
- // Show button when not at bottom and content has overflow
468
- const shouldShow = !isAtBottom && hasOverflow;
469
-
470
- if (shouldShow !== this._scrollButtonVisible) {
471
- this._scrollButtonVisible = shouldShow;
472
- // Only toggle button visibility if button exists
473
- if (button) {
474
- button.classList.toggle('visible', shouldShow);
475
- }
476
-
477
- // Dispatch custom event (always, regardless of button visibility)
478
- this.dispatchEvent(
479
- new CustomEvent(shouldShow ? 'bb-scrollbuttonshow' : 'bb-scrollbuttonhide', {
480
- bubbles: true,
481
- composed: true,
482
- detail: { visible: shouldShow }
483
- })
484
- );
485
- }
486
- };
487
-
488
- // Check initial state unless skipped
489
- if (!options?.skipInitialCheck) {
490
- checkScrollPosition();
491
- }
492
-
493
- // Listen for scroll events with passive listener for performance
494
- this._addTrackedListener(container, 'scroll', checkScrollPosition);
495
-
496
- // Also check on resize
497
- this._addTrackedListener(window, 'resize', checkScrollPosition);
498
- }
499
217
  }
@@ -24,8 +24,9 @@ export const AUTHOR_CONFIG: Record<string, Omit<AuthorConfig, 'isCustomAvatar'>>
24
24
  textColor: THEME.gray[900],
25
25
  side: 'left',
26
26
  },
27
- 'GitHub': {
28
- 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
+ GitHub: {
28
+ avatar:
29
+ '<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>',
29
30
  side: 'left',
30
31
  bubbleColor: '#ecf4ec',
31
32
  textColor: THEME.gray[900],
@@ -160,7 +160,6 @@ export const MAIN_STYLES = `
160
160
  background: #ffffff;
161
161
  border-radius: 50%;
162
162
  overflow: hidden;
163
- cursor: help;
164
163
  }
165
164
 
166
165
  .avatar-wrapper--hidden {
@@ -183,38 +182,6 @@ export const MAIN_STYLES = `
183
182
  height: 100%;
184
183
  }
185
184
 
186
- /* Hover tooltip */
187
- .avatar-tooltip {
188
- position: fixed;
189
- padding: 0.25rem 0.5rem;
190
- background: ${THEME.gray[800]};
191
- color: ${THEME.gray[50]};
192
- font-size: 0.75rem;
193
- border-radius: 0.25rem;
194
- white-space: nowrap;
195
- opacity: 0;
196
- visibility: hidden;
197
- pointer-events: none;
198
- z-index: 10;
199
- font-weight: 500;
200
- letter-spacing: 0.02em;
201
- }
202
-
203
- .avatar-tooltip::after {
204
- content: '';
205
- position: absolute;
206
- top: calc(100% - 1px);
207
- left: 50%;
208
- transform: translateX(-50%);
209
- border: 4px solid transparent;
210
- border-top-color: ${THEME.gray[800]};
211
- }
212
-
213
- .avatar-wrapper:hover .avatar-tooltip {
214
- opacity: 1;
215
- visibility: visible;
216
- }
217
-
218
185
  /* Message content area */
219
186
  .msg-content {
220
187
  display: flex;
@@ -0,0 +1,120 @@
1
+ import type { Message } from '../types/index.js';
2
+
3
+ /**
4
+ * Extended message with grouping metadata
5
+ */
6
+ export interface ProcessedMessage extends Message {
7
+ /** Whether this is the first message from this author in the current group */
8
+ isFirstFromAuthor: boolean;
9
+ /** Whether this is the last message in the current group */
10
+ isLastInGroup: boolean;
11
+ /** The timestamp to display for this group (if any) */
12
+ groupTimestamp?: string;
13
+ }
14
+
15
+ /**
16
+ * Result of processing messages, including state for incremental updates
17
+ */
18
+ export interface ProcessResult {
19
+ /** Processed messages with grouping metadata */
20
+ processed: ProcessedMessage[];
21
+ /** The author of the last message */
22
+ lastAuthor: string;
23
+ /** The timestamp of the current group */
24
+ lastGroupTimestamp?: string;
25
+ }
26
+
27
+ /**
28
+ * MessageProcessor - Handles message grouping and metadata calculation
29
+ *
30
+ * Encapsulates the grouping algorithm to determine:
31
+ * - Which messages are first from an author (show avatar)
32
+ * - Which messages are last in a group (show timestamp)
33
+ * - Group timestamps for display
34
+ *
35
+ * Optimized to process messages in a single pass instead of multiple traversals.
36
+ */
37
+ export class MessageProcessor {
38
+ /**
39
+ * Check if two messages can be grouped together
40
+ * Messages can be grouped if they have the same author and compatible timestamps
41
+ *
42
+ * @param prev - Previous message (null if this is the first)
43
+ * @param curr - Current message
44
+ * @returns true if messages can be grouped
45
+ */
46
+ private canGroup(prev: Message | null, curr: Message): boolean {
47
+ if (!prev) return false;
48
+ if (prev.author !== curr.author) return false;
49
+ // Different timestamps = break group
50
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * Process messages to add grouping metadata
58
+ *
59
+ * This method performs a single-pass algorithm that:
60
+ * 1. Determines first/last status for each message in its group
61
+ * 2. Assigns group timestamps consistently
62
+ * 3. Tracks state for incremental updates
63
+ *
64
+ * @param messages - Raw messages from parser
65
+ * @returns Processed messages with grouping metadata and state
66
+ */
67
+ process(messages: Message[]): ProcessResult {
68
+ if (messages.length === 0) {
69
+ return { processed: [], lastAuthor: '' };
70
+ }
71
+
72
+ const processed: ProcessedMessage[] = [];
73
+ let lastAuthor = '';
74
+ let lastGroupTimestamp: string | undefined;
75
+
76
+ // Track group state
77
+ let currentGroupTimestamp: string | undefined;
78
+
79
+ for (let i = 0; i < messages.length; i++) {
80
+ const msg = messages[i];
81
+ const prev = i > 0 ? messages[i - 1] : null;
82
+ const next = i < messages.length - 1 ? messages[i + 1] : null;
83
+
84
+ // Determine if this is first from author
85
+ const isFirstFromAuthor = !this.canGroup(prev, msg);
86
+
87
+ // Start of new group - initialize group timestamp
88
+ if (isFirstFromAuthor) {
89
+ currentGroupTimestamp = msg.timestamp;
90
+ } else if (!currentGroupTimestamp && msg.timestamp) {
91
+ // If no timestamp yet and current msg has one, use it
92
+ currentGroupTimestamp = msg.timestamp;
93
+ }
94
+
95
+ // Determine if this is last in group
96
+ const isLastInGroup = !next || !this.canGroup(msg, next);
97
+
98
+ // Create processed message with metadata
99
+ const processedMsg: ProcessedMessage = {
100
+ ...msg,
101
+ isFirstFromAuthor,
102
+ isLastInGroup,
103
+ groupTimestamp: isLastInGroup ? currentGroupTimestamp : undefined,
104
+ };
105
+
106
+ processed.push(processedMsg);
107
+
108
+ // Update state tracking
109
+ lastAuthor = msg.author;
110
+
111
+ // If this is the last in group, reset group timestamp
112
+ if (isLastInGroup) {
113
+ lastGroupTimestamp = currentGroupTimestamp;
114
+ currentGroupTimestamp = undefined;
115
+ }
116
+ }
117
+
118
+ return { processed, lastAuthor, lastGroupTimestamp };
119
+ }
120
+ }