@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.
- package/dist/component.d.ts +9 -7
- package/dist/component.js +64 -256
- package/dist/const/authors.js +1 -1
- package/dist/core/message-processor.d.ts +56 -0
- package/dist/core/message-processor.js +85 -0
- package/dist/core/renderer.d.ts +87 -0
- package/dist/core/renderer.js +204 -0
- package/dist/core/scroll-manager.d.ts +54 -0
- package/dist/core/scroll-manager.js +119 -0
- package/dist/utils/event-tracker.d.ts +23 -0
- package/dist/utils/event-tracker.js +33 -0
- package/package.json +15 -14
- package/src/component.ts +77 -318
- package/src/const/authors.ts +3 -2
- package/src/core/message-processor.ts +120 -0
- package/src/core/renderer.ts +286 -0
- package/src/core/scroll-manager.ts +148 -0
- package/src/utils/event-tracker.ts +38 -0
package/src/component.ts
CHANGED
|
@@ -1,17 +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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
22
|
|
|
16
23
|
static get observedAttributes() {
|
|
17
24
|
return ['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'];
|
|
@@ -20,10 +27,25 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
20
27
|
constructor() {
|
|
21
28
|
super();
|
|
22
29
|
this.attachShadow({ mode: 'open' });
|
|
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
|
|
40
|
+
this._mutationObserver = new MutationObserver(() => {
|
|
41
|
+
clearTimeout(this._debounceTimer);
|
|
42
|
+
this._debounceTimer = setTimeout(() => this.render(), 50);
|
|
43
|
+
});
|
|
23
44
|
}
|
|
24
45
|
|
|
25
|
-
attributeChangedCallback(name: string) {
|
|
26
|
-
if (
|
|
46
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
|
47
|
+
if (oldValue === newValue) return;
|
|
48
|
+
if (['theme', 'loading', 'hide-scroll-bar', 'infinite', 'hide-scroll-button'].includes(name)) {
|
|
27
49
|
this.render();
|
|
28
50
|
}
|
|
29
51
|
}
|
|
@@ -72,19 +94,24 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
72
94
|
* el.appendMessage({ author: 'bob', text: 'How are you?' });
|
|
73
95
|
*/
|
|
74
96
|
appendMessage(message: Message): this {
|
|
97
|
+
// Temporarily disconnect observer BEFORE updating textContent to prevent double render
|
|
98
|
+
this._mutationObserver?.disconnect();
|
|
99
|
+
clearTimeout(this._debounceTimer);
|
|
100
|
+
|
|
75
101
|
// Update textContent
|
|
76
102
|
const currentText = this.textContent || '';
|
|
77
103
|
const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
|
|
78
104
|
this.textContent = currentText + separator + `${message.author}: ${message.text}`;
|
|
79
105
|
|
|
80
|
-
// Temporarily disconnect observer to prevent recursive render
|
|
81
|
-
this._mutationObserver?.disconnect();
|
|
82
|
-
|
|
83
106
|
// Append single message without re-rendering entire list
|
|
84
107
|
this._appendSingleMessage(message);
|
|
85
108
|
|
|
86
109
|
// Reconnect observer
|
|
87
|
-
this.
|
|
110
|
+
this._mutationObserver?.observe(this, {
|
|
111
|
+
childList: true,
|
|
112
|
+
characterData: true,
|
|
113
|
+
subtree: true,
|
|
114
|
+
});
|
|
88
115
|
|
|
89
116
|
return this;
|
|
90
117
|
}
|
|
@@ -96,105 +123,32 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
96
123
|
* el.scrollToBottom(); // Scroll with smooth animation
|
|
97
124
|
*/
|
|
98
125
|
scrollToBottom(): this {
|
|
99
|
-
|
|
100
|
-
return this;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const container = this.shadowRoot?.querySelector('.history') as HTMLElement | null;
|
|
104
|
-
if (!container) {
|
|
105
|
-
return this;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
container.scrollTo({
|
|
109
|
-
top: container.scrollHeight,
|
|
110
|
-
behavior: 'smooth',
|
|
111
|
-
});
|
|
112
|
-
|
|
126
|
+
this._scrollManager.scrollToBottom();
|
|
113
127
|
return this;
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Internal: Append a single message with incremental DOM update
|
|
132
|
+
*/
|
|
116
133
|
private _appendSingleMessage(message: Message): void {
|
|
117
|
-
const
|
|
134
|
+
const result = this._renderer.appendSingleMessage(message, this._userAuthors, {
|
|
135
|
+
author: this._lastAuthor,
|
|
136
|
+
groupTimestamp: this._lastGroupTimestamp,
|
|
137
|
+
});
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
if (!result.success) {
|
|
140
|
+
// Container not ready, do full render
|
|
121
141
|
this.render();
|
|
122
142
|
return;
|
|
123
143
|
}
|
|
124
144
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
129
|
-
|
|
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);
|
|
135
|
-
|
|
136
|
-
const isFirstFromAuthor = !canGroupWithLast;
|
|
137
|
-
this._lastAuthor = author;
|
|
138
|
-
|
|
139
|
-
const isSubsequent = !isFirstFromAuthor;
|
|
140
|
-
|
|
141
|
-
// Update group timestamp tracking
|
|
142
|
-
if (isFirstFromAuthor) {
|
|
143
|
-
// Start new group
|
|
144
|
-
this._lastGroupTimestamp = timestamp;
|
|
145
|
-
} else if (!this._lastGroupTimestamp && timestamp) {
|
|
146
|
-
// If no timestamp in group yet and current has one, use it
|
|
147
|
-
this._lastGroupTimestamp = timestamp;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// When appending, we assume this IS the last in group (for now)
|
|
151
|
-
// If another message from same author comes, we'll re-render
|
|
152
|
-
const isLastInGroup = true;
|
|
153
|
-
const groupTimestamp = this._lastGroupTimestamp;
|
|
154
|
-
|
|
155
|
-
// Use utility function to build message HTML
|
|
156
|
-
const msgHtml = buildMessageRowHtml(
|
|
157
|
-
author,
|
|
158
|
-
text,
|
|
159
|
-
config,
|
|
160
|
-
isSubsequent,
|
|
161
|
-
groupTimestamp,
|
|
162
|
-
isLastInGroup
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
// Append to container
|
|
166
|
-
container.insertAdjacentHTML('beforeend', msgHtml);
|
|
145
|
+
// Update state
|
|
146
|
+
this._lastAuthor = result.lastAuthor;
|
|
147
|
+
this._lastGroupTimestamp = result.lastGroupTimestamp;
|
|
167
148
|
|
|
168
|
-
//
|
|
169
|
-
const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
|
|
170
|
-
if (newWrapper) {
|
|
171
|
-
setupTooltipForElement(newWrapper);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Smooth scroll to bottom (skip in infinite mode)
|
|
149
|
+
// Scroll to bottom (skip in infinite mode)
|
|
175
150
|
if (!this.hasAttribute('infinite')) {
|
|
176
|
-
|
|
177
|
-
top: container.scrollHeight,
|
|
178
|
-
behavior: 'smooth',
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// Hide scroll button since we're scrolling to bottom
|
|
182
|
-
if (this._scrollButtonVisible) {
|
|
183
|
-
this._scrollButtonVisible = false;
|
|
184
|
-
const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement | null;
|
|
185
|
-
if (scrollButton) {
|
|
186
|
-
scrollButton.classList.remove('visible');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Dispatch hide event (always, regardless of button visibility)
|
|
190
|
-
this.dispatchEvent(
|
|
191
|
-
new CustomEvent('bb-scrollbuttonhide', {
|
|
192
|
-
bubbles: true,
|
|
193
|
-
composed: true,
|
|
194
|
-
detail: { visible: false }
|
|
195
|
-
})
|
|
196
|
-
);
|
|
197
|
-
}
|
|
151
|
+
this._scrollManager.scrollToBottom();
|
|
198
152
|
}
|
|
199
153
|
}
|
|
200
154
|
|
|
@@ -205,15 +159,12 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
205
159
|
|
|
206
160
|
disconnectedCallback() {
|
|
207
161
|
this._mutationObserver?.disconnect();
|
|
162
|
+
clearTimeout(this._debounceTimer);
|
|
163
|
+
this._eventTracker.cleanup();
|
|
208
164
|
}
|
|
209
165
|
|
|
210
166
|
private _setupMutationObserver() {
|
|
211
|
-
|
|
212
|
-
this._mutationObserver = new MutationObserver(() => {
|
|
213
|
-
clearTimeout(debounceTimer);
|
|
214
|
-
debounceTimer = setTimeout(() => this.render(), 50);
|
|
215
|
-
});
|
|
216
|
-
this._mutationObserver.observe(this, {
|
|
167
|
+
this._mutationObserver?.observe(this, {
|
|
217
168
|
childList: true,
|
|
218
169
|
characterData: true,
|
|
219
170
|
subtree: true,
|
|
@@ -226,233 +177,41 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
226
177
|
if (messages.length === 0) {
|
|
227
178
|
this._lastAuthor = '';
|
|
228
179
|
this._lastGroupTimestamp = undefined;
|
|
229
|
-
this.
|
|
180
|
+
this._renderer.renderEmpty(this.hasAttribute('loading'));
|
|
230
181
|
return;
|
|
231
182
|
}
|
|
232
183
|
|
|
233
|
-
//
|
|
234
|
-
const
|
|
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
|
-
// First pass: determine which messages are last in their group
|
|
244
|
-
const lastInGroupFlags: boolean[] = messages.map((msg, i) => {
|
|
245
|
-
const next = messages[i + 1];
|
|
246
|
-
return !next || !canGroup(msg, next);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// Second pass: collect the timestamp for each group
|
|
250
|
-
// Use the first non-empty timestamp in the group
|
|
251
|
-
const groupTimestamps = new Map<number, string | undefined>();
|
|
252
|
-
let currentGroupTimestamp: string | undefined;
|
|
253
|
-
|
|
254
|
-
messages.forEach((msg, i) => {
|
|
255
|
-
// Start of a new group
|
|
256
|
-
if (i === 0 || !canGroup(messages[i - 1], msg)) {
|
|
257
|
-
currentGroupTimestamp = msg.timestamp;
|
|
258
|
-
} else if (!currentGroupTimestamp && msg.timestamp) {
|
|
259
|
-
// If no timestamp yet and current msg has one, use it
|
|
260
|
-
currentGroupTimestamp = msg.timestamp;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// If this is the last message in the group, save the timestamp
|
|
264
|
-
if (lastInGroupFlags[i]) {
|
|
265
|
-
groupTimestamps.set(i, currentGroupTimestamp);
|
|
266
|
-
currentGroupTimestamp = undefined;
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// Third pass: build HTML
|
|
271
|
-
let lastAuthor = '';
|
|
272
|
-
const messagesHtml = messages
|
|
273
|
-
.map((msg, i) => {
|
|
274
|
-
const { author, text } = msg;
|
|
275
|
-
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
276
|
-
|
|
277
|
-
// Determine if this is a new author group (can't group with previous)
|
|
278
|
-
const isFirstFromAuthor = i === 0 || !canGroup(messages[i - 1], msg);
|
|
279
|
-
lastAuthor = author;
|
|
280
|
-
const isSubsequent = !isFirstFromAuthor;
|
|
281
|
-
|
|
282
|
-
// Get timestamp if this is the last in group
|
|
283
|
-
const isLastInGroup = lastInGroupFlags[i];
|
|
284
|
-
const groupTimestamp = groupTimestamps.get(i);
|
|
285
|
-
|
|
286
|
-
// Use utility function to build message HTML
|
|
287
|
-
return buildMessageRowHtml(
|
|
288
|
-
author,
|
|
289
|
-
text,
|
|
290
|
-
config,
|
|
291
|
-
isSubsequent,
|
|
292
|
-
groupTimestamp,
|
|
293
|
-
isLastInGroup
|
|
294
|
-
);
|
|
295
|
-
})
|
|
296
|
-
.join('');
|
|
184
|
+
// Process messages (single-pass algorithm)
|
|
185
|
+
const { processed, lastAuthor, lastGroupTimestamp } = this._messageProcessor.process(messages);
|
|
297
186
|
|
|
187
|
+
// Update state
|
|
298
188
|
this._lastAuthor = lastAuthor;
|
|
189
|
+
this._lastGroupTimestamp = lastGroupTimestamp;
|
|
299
190
|
|
|
300
|
-
//
|
|
301
|
-
const
|
|
302
|
-
const needsFullSetup = !historyContainer;
|
|
303
|
-
|
|
304
|
-
if (needsFullSetup) {
|
|
305
|
-
// First render - create full structure
|
|
306
|
-
this._renderFullStructure(messagesHtml);
|
|
307
|
-
} else {
|
|
308
|
-
// Update only - preserve DOM structure, just update content
|
|
309
|
-
this._updateContent(historyContainer, messagesHtml);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private _renderFullStructure(messagesHtml: string): void {
|
|
314
|
-
const loadingOverlay = this.hasAttribute('loading')
|
|
315
|
-
? `<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
316
|
-
<div class="loading-spinner"></div>
|
|
317
|
-
</div>`
|
|
318
|
-
: '';
|
|
319
|
-
|
|
191
|
+
// Render messages
|
|
192
|
+
const isLoading = this.hasAttribute('loading');
|
|
320
193
|
const hideScrollButton = this.hasAttribute('hide-scroll-button');
|
|
194
|
+
const { wasAtBottom } = this._renderer.render(
|
|
195
|
+
processed,
|
|
196
|
+
this._userAuthors,
|
|
197
|
+
isLoading,
|
|
198
|
+
hideScrollButton
|
|
199
|
+
);
|
|
321
200
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
<div class="history" role="log" aria-live="polite" aria-label="Message history">
|
|
325
|
-
${messagesHtml}
|
|
326
|
-
</div>
|
|
327
|
-
${hideScrollButton ? '' : buildScrollButtonHtml()}
|
|
328
|
-
${loadingOverlay}
|
|
329
|
-
`;
|
|
330
|
-
|
|
331
|
-
this._setupAfterRender();
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
private _updateContent(historyContainer: HTMLElement, messagesHtml: string): void {
|
|
335
|
-
// Preserve scroll position before update
|
|
336
|
-
const scrollContainer = historyContainer;
|
|
337
|
-
const wasAtBottom =
|
|
338
|
-
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
|
|
339
|
-
|
|
340
|
-
// Update messages content only
|
|
341
|
-
historyContainer.innerHTML = messagesHtml;
|
|
342
|
-
|
|
343
|
-
// Update loading overlay
|
|
344
|
-
this._updateLoadingOverlay();
|
|
345
|
-
|
|
346
|
-
// Restore scroll position or scroll to bottom if we were there
|
|
347
|
-
if (wasAtBottom) {
|
|
348
|
-
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Re-setup tooltips for new content
|
|
352
|
-
setupTooltips(this.shadowRoot!);
|
|
201
|
+
// Setup scroll tracking and other post-render tasks
|
|
202
|
+
this._setupAfterRender(wasAtBottom);
|
|
353
203
|
}
|
|
354
204
|
|
|
355
|
-
private
|
|
356
|
-
const existingOverlay = this.shadowRoot!.querySelector('.loading-overlay');
|
|
357
|
-
const shouldShow = this.hasAttribute('loading');
|
|
358
|
-
|
|
359
|
-
if (shouldShow && !existingOverlay) {
|
|
360
|
-
const overlay = document.createElement('div');
|
|
361
|
-
overlay.className = 'loading-overlay';
|
|
362
|
-
overlay.setAttribute('role', 'status');
|
|
363
|
-
overlay.setAttribute('aria-label', 'Loading messages');
|
|
364
|
-
overlay.innerHTML = '<div class="loading-spinner"></div>';
|
|
365
|
-
this.shadowRoot!.appendChild(overlay);
|
|
366
|
-
} else if (!shouldShow && existingOverlay) {
|
|
367
|
-
existingOverlay.remove();
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private _setupAfterRender(): void {
|
|
205
|
+
private _setupAfterRender(shouldScrollToBottom = true): void {
|
|
372
206
|
requestAnimationFrame(() => {
|
|
373
|
-
const container = this.
|
|
374
|
-
const scrollButton = this.
|
|
207
|
+
const container = this._renderer.getHistoryContainer();
|
|
208
|
+
const scrollButton = this._renderer.getScrollButton();
|
|
375
209
|
const isInfinite = this.hasAttribute('infinite');
|
|
376
210
|
|
|
377
211
|
if (container && !isInfinite) {
|
|
378
|
-
|
|
379
|
-
this.
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (scrollButton && !isInfinite) {
|
|
383
|
-
scrollButton.addEventListener('click', () => {
|
|
384
|
-
container?.scrollTo({
|
|
385
|
-
top: container.scrollHeight,
|
|
386
|
-
behavior: 'smooth',
|
|
387
|
-
});
|
|
388
|
-
});
|
|
212
|
+
// Initialize scroll manager
|
|
213
|
+
this._scrollManager.init(container, scrollButton, shouldScrollToBottom);
|
|
389
214
|
}
|
|
390
|
-
|
|
391
|
-
setupTooltips(this.shadowRoot!);
|
|
392
215
|
});
|
|
393
216
|
}
|
|
394
|
-
|
|
395
|
-
private _renderEmpty() {
|
|
396
|
-
const isLoading = this.hasAttribute('loading');
|
|
397
|
-
|
|
398
|
-
if (isLoading) {
|
|
399
|
-
// Show loading overlay with minimum height for better appearance
|
|
400
|
-
this.shadowRoot!.innerHTML = `
|
|
401
|
-
<style>${EMPTY_STYLES}${LOADING_STYLES}</style>
|
|
402
|
-
<div style="position: relative; min-height: 120px;">
|
|
403
|
-
<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
404
|
-
<div class="loading-spinner"></div>
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
`;
|
|
408
|
-
} else {
|
|
409
|
-
this.shadowRoot!.innerHTML = `
|
|
410
|
-
<style>${EMPTY_STYLES}</style>
|
|
411
|
-
<div class="empty-state">No messages</div>
|
|
412
|
-
`;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
private _setupScrollTracking(
|
|
417
|
-
container: HTMLElement,
|
|
418
|
-
button: HTMLButtonElement | null,
|
|
419
|
-
options?: { skipInitialCheck?: boolean }
|
|
420
|
-
): void {
|
|
421
|
-
const checkScrollPosition = () => {
|
|
422
|
-
const threshold = 50; // pixels from bottom
|
|
423
|
-
const isAtBottom =
|
|
424
|
-
container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
425
|
-
const hasOverflow = container.scrollHeight > container.clientHeight;
|
|
426
|
-
// Show button when not at bottom and content has overflow
|
|
427
|
-
const shouldShow = !isAtBottom && hasOverflow;
|
|
428
|
-
|
|
429
|
-
if (shouldShow !== this._scrollButtonVisible) {
|
|
430
|
-
this._scrollButtonVisible = shouldShow;
|
|
431
|
-
// Only toggle button visibility if button exists
|
|
432
|
-
if (button) {
|
|
433
|
-
button.classList.toggle('visible', shouldShow);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Dispatch custom event (always, regardless of button visibility)
|
|
437
|
-
this.dispatchEvent(
|
|
438
|
-
new CustomEvent(shouldShow ? 'bb-scrollbuttonshow' : 'bb-scrollbuttonhide', {
|
|
439
|
-
bubbles: true,
|
|
440
|
-
composed: true,
|
|
441
|
-
detail: { visible: shouldShow }
|
|
442
|
-
})
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
// Check initial state unless skipped
|
|
448
|
-
if (!options?.skipInitialCheck) {
|
|
449
|
-
checkScrollPosition();
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Listen for scroll events with passive listener for performance
|
|
453
|
-
container.addEventListener('scroll', checkScrollPosition, { passive: true });
|
|
454
|
-
|
|
455
|
-
// Also check on resize
|
|
456
|
-
window.addEventListener('resize', checkScrollPosition, { passive: true });
|
|
457
|
-
}
|
|
458
217
|
}
|
package/src/const/authors.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
avatar:
|
|
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],
|
|
@@ -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
|
+
}
|