@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.
@@ -0,0 +1,286 @@
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, setupTooltipForElement } from '../utils/message-builder.js';
6
+ import { buildScrollButtonHtml } from '../utils/scroll-button.js';
7
+ import { setupTooltips } from '../utils/tooltip.js';
8
+
9
+ /**
10
+ * State for incremental message appending
11
+ */
12
+ export interface LastState {
13
+ author: string;
14
+ groupTimestamp?: string;
15
+ }
16
+
17
+ /**
18
+ * Result of a full render operation
19
+ */
20
+ export interface RenderResult {
21
+ wasAtBottom: boolean;
22
+ }
23
+
24
+ /**
25
+ * Result of an incremental append operation
26
+ */
27
+ export interface AppendResult {
28
+ success: boolean;
29
+ lastAuthor: string;
30
+ lastGroupTimestamp?: string;
31
+ }
32
+
33
+ /**
34
+ * Renderer - Manages all DOM rendering operations
35
+ *
36
+ * Centralizes DOM manipulation logic:
37
+ * - Full render of all messages
38
+ * - Incremental update when appending single messages
39
+ * - Empty state rendering
40
+ * - Loading overlay management
41
+ */
42
+ export class Renderer {
43
+ constructor(private shadowRoot: ShadowRoot) {}
44
+
45
+ /**
46
+ * Render the complete message history
47
+ *
48
+ * @param messages - Processed messages with grouping metadata
49
+ * @param authors - User-defined author configurations
50
+ * @param isLoading - Whether to show loading overlay
51
+ * @param hideScrollButton - Whether to hide the scroll-to-bottom button
52
+ * @returns Result indicating if we were at bottom before render
53
+ */
54
+ render(
55
+ messages: ProcessedMessage[],
56
+ authors: Map<string, AuthorOptions>,
57
+ isLoading: boolean,
58
+ hideScrollButton: boolean
59
+ ): RenderResult {
60
+ // Check if we need to create or update the structure
61
+ const historyContainer = this.shadowRoot.querySelector('.history') as HTMLElement | null;
62
+ const needsFullSetup = !historyContainer;
63
+
64
+ // Build messages HTML
65
+ const messagesHtml = this.buildMessagesHtml(messages, authors);
66
+
67
+ if (needsFullSetup) {
68
+ // First render - create full structure
69
+ return this.renderFullStructure(messagesHtml, isLoading, hideScrollButton);
70
+ } else {
71
+ // Update only - preserve DOM structure
72
+ return this.updateContent(historyContainer, messagesHtml, isLoading);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Build HTML string for all messages
78
+ */
79
+ private buildMessagesHtml(
80
+ messages: ProcessedMessage[],
81
+ authors: Map<string, AuthorOptions>
82
+ ): string {
83
+ return messages
84
+ .map(msg => {
85
+ const config = resolveAuthorConfig(msg.author, authors);
86
+
87
+ return buildMessageRowHtml(
88
+ msg.author,
89
+ msg.text,
90
+ config,
91
+ !msg.isFirstFromAuthor, // isSubsequent
92
+ msg.groupTimestamp,
93
+ msg.isLastInGroup
94
+ );
95
+ })
96
+ .join('');
97
+ }
98
+
99
+ /**
100
+ * Render full structure including styles, container, and scroll button
101
+ */
102
+ private renderFullStructure(
103
+ messagesHtml: string,
104
+ isLoading: boolean,
105
+ hideScrollButton: boolean
106
+ ): RenderResult {
107
+ // For initial render, we consider it as "was at bottom" to scroll down
108
+ const wasAtBottom = true;
109
+
110
+ const loadingOverlay = isLoading
111
+ ? `<div class="loading-overlay" role="status" aria-label="Loading messages">
112
+ <div class="loading-spinner"></div>
113
+ </div>`
114
+ : '';
115
+
116
+ this.shadowRoot.innerHTML = `
117
+ <style>${MAIN_STYLES}${LOADING_STYLES}</style>
118
+ <div class="history" role="log" aria-live="polite" aria-label="Message history">
119
+ ${messagesHtml}
120
+ </div>
121
+ ${hideScrollButton ? '' : buildScrollButtonHtml()}
122
+ ${loadingOverlay}
123
+ `;
124
+
125
+ return { wasAtBottom };
126
+ }
127
+
128
+ /**
129
+ * Update content while preserving DOM structure
130
+ */
131
+ private updateContent(
132
+ historyContainer: HTMLElement,
133
+ messagesHtml: string,
134
+ isLoading: boolean
135
+ ): RenderResult {
136
+ // Check scroll position before update
137
+ const scrollContainer = historyContainer;
138
+ const wasAtBottom =
139
+ scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
140
+
141
+ // Update messages content only
142
+ historyContainer.innerHTML = messagesHtml;
143
+
144
+ // Update loading overlay
145
+ this.updateLoadingOverlay(isLoading);
146
+
147
+ // Restore scroll position or scroll to bottom if we were there
148
+ if (wasAtBottom) {
149
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
150
+ }
151
+
152
+ // Re-setup tooltips for new content
153
+ setupTooltips(this.shadowRoot);
154
+
155
+ return { wasAtBottom };
156
+ }
157
+
158
+ /**
159
+ * Append a single message without full re-render
160
+ *
161
+ * @param message - The message to append
162
+ * @param authors - User-defined author configurations
163
+ * @param lastState - Previous state for grouping logic
164
+ * @returns Result indicating success and updated state
165
+ */
166
+ appendSingleMessage(
167
+ message: Message,
168
+ authors: Map<string, AuthorOptions>,
169
+ lastState: LastState
170
+ ): AppendResult {
171
+ const container = this.shadowRoot.querySelector('.history') as HTMLElement | null;
172
+
173
+ // If empty state or no container, signal that full render is needed
174
+ if (!container) {
175
+ return { success: false, lastAuthor: lastState.author };
176
+ }
177
+
178
+ const config = resolveAuthorConfig(message.author, authors);
179
+
180
+ // Determine grouping
181
+ const prevMessage: Message | null = lastState.author
182
+ ? { author: lastState.author, text: '', timestamp: lastState.groupTimestamp }
183
+ : null;
184
+
185
+ const canGroupWithLast = this.canGroupMessages(prevMessage, message);
186
+ const isFirstFromAuthor = !canGroupWithLast;
187
+
188
+ // Calculate new state
189
+ let lastGroupTimestamp = lastState.groupTimestamp;
190
+ if (isFirstFromAuthor) {
191
+ lastGroupTimestamp = message.timestamp;
192
+ } else if (!lastGroupTimestamp && message.timestamp) {
193
+ lastGroupTimestamp = message.timestamp;
194
+ }
195
+
196
+ // Build and append HTML
197
+ const msgHtml = buildMessageRowHtml(
198
+ message.author,
199
+ message.text,
200
+ config,
201
+ !isFirstFromAuthor, // isSubsequent
202
+ lastGroupTimestamp,
203
+ true // isLastInGroup - when appending, this is always last (for now)
204
+ );
205
+
206
+ container.insertAdjacentHTML('beforeend', msgHtml);
207
+
208
+ // Setup tooltip for new element
209
+ const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
210
+ if (newWrapper) {
211
+ setupTooltipForElement(newWrapper);
212
+ }
213
+
214
+ return {
215
+ success: true,
216
+ lastAuthor: message.author,
217
+ lastGroupTimestamp,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Check if two messages can be grouped
223
+ */
224
+ private canGroupMessages(prev: Message | null, curr: Message): boolean {
225
+ if (!prev) return false;
226
+ if (prev.author !== curr.author) return false;
227
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
228
+ return false;
229
+ }
230
+ return true;
231
+ }
232
+
233
+ /**
234
+ * Render empty state (no messages)
235
+ */
236
+ renderEmpty(isLoading: boolean): void {
237
+ if (isLoading) {
238
+ // Show loading overlay with minimum height
239
+ this.shadowRoot.innerHTML = `
240
+ <style>${EMPTY_STYLES}${LOADING_STYLES}</style>
241
+ <div style="position: relative; min-height: 120px;">
242
+ <div class="loading-overlay" role="status" aria-label="Loading messages">
243
+ <div class="loading-spinner"></div>
244
+ </div>
245
+ </div>
246
+ `;
247
+ } else {
248
+ this.shadowRoot.innerHTML = `
249
+ <style>${EMPTY_STYLES}</style>
250
+ <div class="empty-state">No messages</div>
251
+ `;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Update loading overlay visibility
257
+ */
258
+ updateLoadingOverlay(shouldShow: boolean): void {
259
+ const existingOverlay = this.shadowRoot.querySelector('.loading-overlay');
260
+
261
+ if (shouldShow && !existingOverlay) {
262
+ const overlay = document.createElement('div');
263
+ overlay.className = 'loading-overlay';
264
+ overlay.setAttribute('role', 'status');
265
+ overlay.setAttribute('aria-label', 'Loading messages');
266
+ overlay.innerHTML = '<div class="loading-spinner"></div>';
267
+ this.shadowRoot.appendChild(overlay);
268
+ } else if (!shouldShow && existingOverlay) {
269
+ existingOverlay.remove();
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Get the history container element
275
+ */
276
+ getHistoryContainer(): HTMLElement | null {
277
+ return this.shadowRoot.querySelector('.history') as HTMLElement | null;
278
+ }
279
+
280
+ /**
281
+ * Get the scroll-to-bottom button element
282
+ */
283
+ getScrollButton(): HTMLButtonElement | null {
284
+ return this.shadowRoot.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
285
+ }
286
+ }
@@ -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
+ }