@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
@@ -0,0 +1,276 @@
1
+ import type { AuthorOptions, Message } from '../types/index.js';
2
+ import type { ProcessedMessage } from './message-processor.js';
3
+ import { EMPTY_STYLES, LOADING_STYLES, MAIN_STYLES } from '../const/styles.js';
4
+ import { resolveAuthorConfig } from '../utils/author-resolver.js';
5
+ import { buildMessageRowHtml } from '../utils/message-builder.js';
6
+ import { buildScrollButtonHtml } from '../utils/scroll-button.js';
7
+
8
+ /**
9
+ * State for incremental message appending
10
+ */
11
+ export interface LastState {
12
+ author: string;
13
+ groupTimestamp?: string;
14
+ }
15
+
16
+ /**
17
+ * Result of a full render operation
18
+ */
19
+ export interface RenderResult {
20
+ wasAtBottom: boolean;
21
+ }
22
+
23
+ /**
24
+ * Result of an incremental append operation
25
+ */
26
+ export interface AppendResult {
27
+ success: boolean;
28
+ lastAuthor: string;
29
+ lastGroupTimestamp?: string;
30
+ }
31
+
32
+ /**
33
+ * Renderer - Manages all DOM rendering operations
34
+ *
35
+ * Centralizes DOM manipulation logic:
36
+ * - Full render of all messages
37
+ * - Incremental update when appending single messages
38
+ * - Empty state rendering
39
+ * - Loading overlay management
40
+ */
41
+ export class Renderer {
42
+ constructor(private shadowRoot: ShadowRoot) {}
43
+
44
+ /**
45
+ * Render the complete message history
46
+ *
47
+ * @param messages - Processed messages with grouping metadata
48
+ * @param authors - User-defined author configurations
49
+ * @param isLoading - Whether to show loading overlay
50
+ * @param hideScrollButton - Whether to hide the scroll-to-bottom button
51
+ * @returns Result indicating if we were at bottom before render
52
+ */
53
+ render(
54
+ messages: ProcessedMessage[],
55
+ authors: Map<string, AuthorOptions>,
56
+ isLoading: boolean,
57
+ hideScrollButton: boolean
58
+ ): RenderResult {
59
+ // Check if we need to create or update the structure
60
+ const historyContainer = this.shadowRoot.querySelector('.history') as HTMLElement | null;
61
+ const needsFullSetup = !historyContainer;
62
+
63
+ // Build messages HTML
64
+ const messagesHtml = this.buildMessagesHtml(messages, authors);
65
+
66
+ if (needsFullSetup) {
67
+ // First render - create full structure
68
+ return this.renderFullStructure(messagesHtml, isLoading, hideScrollButton);
69
+ } else {
70
+ // Update only - preserve DOM structure
71
+ return this.updateContent(historyContainer, messagesHtml, isLoading);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Build HTML string for all messages
77
+ */
78
+ private buildMessagesHtml(
79
+ messages: ProcessedMessage[],
80
+ authors: Map<string, AuthorOptions>
81
+ ): string {
82
+ return messages
83
+ .map(msg => {
84
+ const config = resolveAuthorConfig(msg.author, authors);
85
+
86
+ return buildMessageRowHtml(
87
+ msg.author,
88
+ msg.text,
89
+ config,
90
+ !msg.isFirstFromAuthor, // isSubsequent
91
+ msg.groupTimestamp,
92
+ msg.isLastInGroup
93
+ );
94
+ })
95
+ .join('');
96
+ }
97
+
98
+ /**
99
+ * Render full structure including styles, container, and scroll button
100
+ */
101
+ private renderFullStructure(
102
+ messagesHtml: string,
103
+ isLoading: boolean,
104
+ hideScrollButton: boolean
105
+ ): RenderResult {
106
+ // For initial render, we consider it as "was at bottom" to scroll down
107
+ const wasAtBottom = true;
108
+
109
+ const loadingOverlay = isLoading
110
+ ? `<div class="loading-overlay" role="status" aria-label="Loading messages">
111
+ <div class="loading-spinner"></div>
112
+ </div>`
113
+ : '';
114
+
115
+ this.shadowRoot.innerHTML = `
116
+ <style>${MAIN_STYLES}${LOADING_STYLES}</style>
117
+ <div class="history" role="log" aria-live="polite" aria-label="Message history">
118
+ ${messagesHtml}
119
+ </div>
120
+ ${hideScrollButton ? '' : buildScrollButtonHtml()}
121
+ ${loadingOverlay}
122
+ `;
123
+
124
+ return { wasAtBottom };
125
+ }
126
+
127
+ /**
128
+ * Update content while preserving DOM structure
129
+ */
130
+ private updateContent(
131
+ historyContainer: HTMLElement,
132
+ messagesHtml: string,
133
+ isLoading: boolean
134
+ ): RenderResult {
135
+ // Check scroll position before update
136
+ const scrollContainer = historyContainer;
137
+ const wasAtBottom =
138
+ scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
139
+
140
+ // Update messages content only
141
+ historyContainer.innerHTML = messagesHtml;
142
+
143
+ // Update loading overlay
144
+ this.updateLoadingOverlay(isLoading);
145
+
146
+ // Restore scroll position or scroll to bottom if we were there
147
+ if (wasAtBottom) {
148
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
149
+ }
150
+
151
+ return { wasAtBottom };
152
+ }
153
+
154
+ /**
155
+ * Append a single message without full re-render
156
+ *
157
+ * @param message - The message to append
158
+ * @param authors - User-defined author configurations
159
+ * @param lastState - Previous state for grouping logic
160
+ * @returns Result indicating success and updated state
161
+ */
162
+ appendSingleMessage(
163
+ message: Message,
164
+ authors: Map<string, AuthorOptions>,
165
+ lastState: LastState
166
+ ): AppendResult {
167
+ const container = this.shadowRoot.querySelector('.history') as HTMLElement | null;
168
+
169
+ // If empty state or no container, signal that full render is needed
170
+ if (!container) {
171
+ return { success: false, lastAuthor: lastState.author };
172
+ }
173
+
174
+ const config = resolveAuthorConfig(message.author, authors);
175
+
176
+ // Determine grouping
177
+ const prevMessage: Message | null = lastState.author
178
+ ? { author: lastState.author, text: '', timestamp: lastState.groupTimestamp }
179
+ : null;
180
+
181
+ const canGroupWithLast = this.canGroupMessages(prevMessage, message);
182
+ const isFirstFromAuthor = !canGroupWithLast;
183
+
184
+ // Calculate new state
185
+ let lastGroupTimestamp = lastState.groupTimestamp;
186
+ if (isFirstFromAuthor) {
187
+ lastGroupTimestamp = message.timestamp;
188
+ } else if (!lastGroupTimestamp && message.timestamp) {
189
+ lastGroupTimestamp = message.timestamp;
190
+ }
191
+
192
+ // Build and append HTML
193
+ const msgHtml = buildMessageRowHtml(
194
+ message.author,
195
+ message.text,
196
+ config,
197
+ !isFirstFromAuthor, // isSubsequent
198
+ lastGroupTimestamp,
199
+ true // isLastInGroup - when appending, this is always last (for now)
200
+ );
201
+
202
+ container.insertAdjacentHTML('beforeend', msgHtml);
203
+
204
+ return {
205
+ success: true,
206
+ lastAuthor: message.author,
207
+ lastGroupTimestamp,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Check if two messages can be grouped
213
+ */
214
+ private canGroupMessages(prev: Message | null, curr: Message): boolean {
215
+ if (!prev) return false;
216
+ if (prev.author !== curr.author) return false;
217
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
218
+ return false;
219
+ }
220
+ return true;
221
+ }
222
+
223
+ /**
224
+ * Render empty state (no messages)
225
+ */
226
+ renderEmpty(isLoading: boolean): void {
227
+ if (isLoading) {
228
+ // Show loading overlay with minimum height
229
+ this.shadowRoot.innerHTML = `
230
+ <style>${EMPTY_STYLES}${LOADING_STYLES}</style>
231
+ <div style="position: relative; min-height: 120px;">
232
+ <div class="loading-overlay" role="status" aria-label="Loading messages">
233
+ <div class="loading-spinner"></div>
234
+ </div>
235
+ </div>
236
+ `;
237
+ } else {
238
+ this.shadowRoot.innerHTML = `
239
+ <style>${EMPTY_STYLES}</style>
240
+ <div class="empty-state">No messages</div>
241
+ `;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Update loading overlay visibility
247
+ */
248
+ updateLoadingOverlay(shouldShow: boolean): void {
249
+ const existingOverlay = this.shadowRoot.querySelector('.loading-overlay');
250
+
251
+ if (shouldShow && !existingOverlay) {
252
+ const overlay = document.createElement('div');
253
+ overlay.className = 'loading-overlay';
254
+ overlay.setAttribute('role', 'status');
255
+ overlay.setAttribute('aria-label', 'Loading messages');
256
+ overlay.innerHTML = '<div class="loading-spinner"></div>';
257
+ this.shadowRoot.appendChild(overlay);
258
+ } else if (!shouldShow && existingOverlay) {
259
+ existingOverlay.remove();
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Get the history container element
265
+ */
266
+ getHistoryContainer(): HTMLElement | null {
267
+ return this.shadowRoot.querySelector('.history') as HTMLElement | null;
268
+ }
269
+
270
+ /**
271
+ * Get the scroll-to-bottom button element
272
+ */
273
+ getScrollButton(): HTMLButtonElement | null {
274
+ return this.shadowRoot.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
275
+ }
276
+ }
@@ -0,0 +1,148 @@
1
+ import type { EventTracker } from '../utils/event-tracker.js';
2
+
3
+ /**
4
+ * ScrollManager - Handles scroll behavior, position detection, and scroll button visibility
5
+ *
6
+ * Isolates all scroll-related logic from the main component:
7
+ * - Scroll to bottom with smooth animation
8
+ * - Detect when user is near/at bottom of content
9
+ * - Control scroll button visibility based on scroll position
10
+ * - Dispatch custom events for scroll button state changes
11
+ */
12
+ export class ScrollManager {
13
+ private container?: HTMLElement;
14
+ private button?: HTMLButtonElement | null;
15
+ private isButtonVisible = false;
16
+
17
+ // Threshold in pixels from bottom to consider "at bottom"
18
+ private readonly BOTTOM_THRESHOLD = 50;
19
+
20
+ constructor(
21
+ private host: HTMLElement,
22
+ private shadowRoot: ShadowRoot,
23
+ private eventTracker: EventTracker,
24
+ private onVisibilityChange: (visible: boolean) => void
25
+ ) {}
26
+
27
+ /**
28
+ * Initialize scroll tracking on the container
29
+ *
30
+ * @param container - The scrollable history container
31
+ * @param button - Optional scroll-to-bottom button element
32
+ * @param skipInitialCheck - If true, skip the initial position check (for initial render)
33
+ */
34
+ init(container: HTMLElement, button?: HTMLButtonElement | null, skipInitialCheck = false): void {
35
+ this.container = container;
36
+ this.button = button ?? null;
37
+
38
+ if (!skipInitialCheck) {
39
+ this.checkPosition();
40
+ }
41
+
42
+ // Listen for scroll events with passive listener for performance
43
+ this.eventTracker.add(container, 'scroll', () => this.checkPosition(), { passive: true });
44
+
45
+ // Also check on resize
46
+ this.eventTracker.add(window, 'resize', () => this.checkPosition());
47
+
48
+ // Setup button click handler
49
+ if (button && !this.host.hasAttribute('infinite')) {
50
+ button.addEventListener('click', () => this.scrollToBottom());
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check current scroll position and update button visibility
56
+ * Dispatches custom events when visibility changes
57
+ */
58
+ checkPosition(): void {
59
+ if (!this.container) return;
60
+
61
+ const isAtBottom = this.isAtBottom();
62
+ const hasOverflow = this.container.scrollHeight > this.container.clientHeight;
63
+
64
+ // Show button when not at bottom and content has overflow
65
+ const shouldShow = !isAtBottom && hasOverflow;
66
+
67
+ if (shouldShow !== this.isButtonVisible) {
68
+ this.isButtonVisible = shouldShow;
69
+
70
+ // Update button UI
71
+ if (this.button) {
72
+ this.button.classList.toggle('visible', shouldShow);
73
+ }
74
+
75
+ // Notify parent component
76
+ this.onVisibilityChange(shouldShow);
77
+
78
+ // Dispatch custom event
79
+ this.host.dispatchEvent(
80
+ new CustomEvent(shouldShow ? 'bb-scrollbuttonshow' : 'bb-scrollbuttonhide', {
81
+ bubbles: true,
82
+ composed: true,
83
+ detail: { visible: shouldShow },
84
+ })
85
+ );
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Scroll the container to the bottom
91
+ *
92
+ * @param behavior - Scroll behavior: 'smooth' or 'auto'
93
+ */
94
+ scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
95
+ if (!this.container || this.host.hasAttribute('infinite')) {
96
+ return;
97
+ }
98
+
99
+ this.container.scrollTo({
100
+ top: this.container.scrollHeight,
101
+ behavior,
102
+ });
103
+
104
+ // Hide button since we're scrolling to bottom
105
+ if (this.isButtonVisible) {
106
+ this.isButtonVisible = false;
107
+ if (this.button) {
108
+ this.button.classList.remove('visible');
109
+ }
110
+ this.onVisibilityChange(false);
111
+ this.host.dispatchEvent(
112
+ new CustomEvent('bb-scrollbuttonhide', {
113
+ bubbles: true,
114
+ composed: true,
115
+ detail: { visible: false },
116
+ })
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Check if the container is currently at or near the bottom
123
+ *
124
+ * @returns true if within threshold pixels of bottom
125
+ */
126
+ isAtBottom(): boolean {
127
+ if (!this.container) return true;
128
+
129
+ const distanceFromBottom =
130
+ this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight;
131
+
132
+ return distanceFromBottom < this.BOTTOM_THRESHOLD;
133
+ }
134
+
135
+ /**
136
+ * Get the current button visibility state
137
+ */
138
+ get isVisible(): boolean {
139
+ return this.isButtonVisible;
140
+ }
141
+
142
+ /**
143
+ * Get the scrollable container element
144
+ */
145
+ getContainer(): HTMLElement | undefined {
146
+ return this.container;
147
+ }
148
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * EventTracker - Utility class for tracking and managing event listeners
3
+ *
4
+ * Provides centralized event listener registration and cleanup,
5
+ * preventing memory leaks by ensuring all listeners are removed when no longer needed.
6
+ */
7
+ export class EventTracker {
8
+ private listeners: Array<{
9
+ el: EventTarget;
10
+ type: string;
11
+ fn: EventListener;
12
+ options?: AddEventListenerOptions;
13
+ }> = [];
14
+
15
+ /**
16
+ * Add an event listener and track it for automatic cleanup
17
+ *
18
+ * @param el - Event target element
19
+ * @param type - Event type (e.g., 'scroll', 'click')
20
+ * @param fn - Event listener function
21
+ * @param options - Optional addEventListener options
22
+ */
23
+ add(el: EventTarget, type: string, fn: EventListener, options?: AddEventListenerOptions): void {
24
+ el.addEventListener(type, fn, options);
25
+ this.listeners.push({ el, type, fn, options });
26
+ }
27
+
28
+ /**
29
+ * Remove all tracked event listeners
30
+ * Should be called when the component is disconnected to prevent memory leaks
31
+ */
32
+ cleanup(): void {
33
+ this.listeners.forEach(({ el, type, fn, options }) => {
34
+ el.removeEventListener(type, fn, options);
35
+ });
36
+ this.listeners = [];
37
+ }
38
+ }
@@ -10,7 +10,6 @@ export function buildAvatarHtml(author: string, config: AuthorConfig, showAvatar
10
10
  <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
11
11
  data-author="${escapeHtml(author)}">
12
12
  <div class="avatar">${config.avatar}</div>
13
- <div class="avatar-tooltip">${escapeHtml(author)}</div>
14
13
  </div>
15
14
  `;
16
15
  }
@@ -68,17 +67,3 @@ export function buildMessageRowHtml(
68
67
  </div>
69
68
  `;
70
69
  }
71
-
72
- /**
73
- * Setup tooltip for a single avatar wrapper element
74
- */
75
- export function setupTooltipForElement(wrapper: Element): void {
76
- wrapper.addEventListener('mouseenter', () => {
77
- const tooltip = wrapper.querySelector('.avatar-tooltip') as HTMLElement;
78
- if (!tooltip) return;
79
- const rect = wrapper.getBoundingClientRect();
80
- const tooltipRect = tooltip.getBoundingClientRect();
81
- tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
82
- tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
83
- });
84
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Setup dynamic tooltip positioning
3
- * Tooltips are positioned fixed to avoid overflow clipping from parent containers
4
- */
5
- export function setupTooltips(shadowRoot: ShadowRoot): void {
6
- shadowRoot.querySelectorAll('.avatar-wrapper').forEach(wrapper => {
7
- wrapper.addEventListener('mouseenter', () => {
8
- const tooltip = wrapper.querySelector('.avatar-tooltip') as HTMLElement;
9
- if (!tooltip) return;
10
- const rect = wrapper.getBoundingClientRect();
11
- const tooltipRect = tooltip.getBoundingClientRect();
12
- tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
13
- tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
14
- });
15
- });
16
- }