@bbki.ng/bb-msg-history 0.3.0 → 0.5.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 (58) hide show
  1. package/dist/component.d.ts +4 -0
  2. package/dist/component.js +44 -1
  3. package/dist/const/authors.js +6 -6
  4. package/dist/const/styles.js +156 -6
  5. package/dist/const/theme.js +14 -2
  6. package/dist/index.dev.js +1 -12
  7. package/dist/index.js.map +1 -0
  8. package/dist/src/component.d.ts +39 -0
  9. package/dist/src/component.js +184 -0
  10. package/dist/src/const/authors.d.ts +10 -0
  11. package/dist/src/const/authors.js +29 -0
  12. package/dist/src/const/styles.d.ts +12 -0
  13. package/dist/src/const/styles.js +425 -0
  14. package/dist/src/const/theme.d.ts +6 -0
  15. package/dist/src/const/theme.js +44 -0
  16. package/dist/src/index.d.ts +9 -0
  17. package/dist/src/index.js +12 -0
  18. package/dist/src/types/index.d.ts +34 -0
  19. package/dist/src/types/index.js +4 -0
  20. package/dist/src/utils/author-resolver.d.ts +11 -0
  21. package/dist/src/utils/author-resolver.js +75 -0
  22. package/dist/src/utils/avatar.d.ts +4 -0
  23. package/dist/src/utils/avatar.js +18 -0
  24. package/dist/src/utils/html.d.ts +12 -0
  25. package/dist/src/utils/html.js +33 -0
  26. package/dist/src/utils/message-builder.d.ts +13 -0
  27. package/dist/src/utils/message-builder.js +60 -0
  28. package/dist/src/utils/message-parser.d.ts +6 -0
  29. package/dist/src/utils/message-parser.js +22 -0
  30. package/dist/src/utils/registration.d.ts +8 -0
  31. package/dist/src/utils/registration.js +28 -0
  32. package/dist/src/utils/scroll-button.d.ts +4 -0
  33. package/dist/src/utils/scroll-button.js +12 -0
  34. package/dist/src/utils/tooltip.d.ts +5 -0
  35. package/dist/src/utils/tooltip.js +17 -0
  36. package/dist/tests/html.test.d.ts +1 -0
  37. package/dist/tests/html.test.js +35 -0
  38. package/dist/types/index.d.ts +1 -0
  39. package/dist/utils/author-resolver.js +7 -3
  40. package/dist/utils/avatar.js +8 -8
  41. package/dist/utils/message-builder.js +15 -4
  42. package/dist/utils/registration.js +3 -4
  43. package/dist/utils/scroll-button.d.ts +4 -0
  44. package/dist/utils/scroll-button.js +12 -0
  45. package/dist/vitest.config.d.ts +2 -0
  46. package/dist/vitest.config.js +8 -0
  47. package/package.json +32 -7
  48. package/src/component.ts +67 -13
  49. package/src/const/authors.ts +6 -6
  50. package/src/const/styles.ts +156 -6
  51. package/src/const/theme.ts +14 -2
  52. package/src/types/index.ts +1 -0
  53. package/src/utils/author-resolver.ts +8 -4
  54. package/src/utils/avatar.ts +8 -8
  55. package/src/utils/message-builder.ts +19 -5
  56. package/src/utils/message-parser.ts +5 -5
  57. package/src/utils/registration.ts +5 -5
  58. package/src/utils/scroll-button.ts +12 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * HTML utility functions
3
+ */
4
+ /**
5
+ * Escape HTML special characters to prevent XSS
6
+ */
7
+ export function escapeHtml(str) {
8
+ return str
9
+ .replace(/&/g, '&')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&#39;');
14
+ }
15
+ /**
16
+ * Wrap plain text/emoji avatar in a styled container
17
+ * If HTML tags are present, return as-is
18
+ */
19
+ export function wrapAvatarHtml(html) {
20
+ // If it looks like a single emoji or short text (no HTML tags), wrap in a styled div
21
+ if (!html.includes('<')) {
22
+ return `<div style="
23
+ width: 100%;
24
+ height: 100%;
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ font-size: 18px;
29
+ line-height: 1;
30
+ ">${html}</div>`;
31
+ }
32
+ return html;
33
+ }
@@ -0,0 +1,13 @@
1
+ import type { AuthorConfig } from '../types/index.js';
2
+ /**
3
+ * Build avatar HTML string
4
+ */
5
+ export declare function buildAvatarHtml(author: string, config: AuthorConfig, showAvatar: boolean): string;
6
+ /**
7
+ * Build a single message row HTML string
8
+ */
9
+ export declare function buildMessageRowHtml(author: string, text: string, config: AuthorConfig, isSubsequent: boolean): string;
10
+ /**
11
+ * Setup tooltip for a single avatar wrapper element
12
+ */
13
+ export declare function setupTooltipForElement(wrapper: Element): void;
@@ -0,0 +1,60 @@
1
+ import { THEME } from '../const/theme.js';
2
+ import { escapeHtml } from './html.js';
3
+ /**
4
+ * Build avatar HTML string
5
+ */
6
+ export function buildAvatarHtml(author, config, showAvatar) {
7
+ return `
8
+ <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
9
+ data-author="${escapeHtml(author)}">
10
+ <div class="avatar">${config.avatar}</div>
11
+ <div class="avatar-tooltip">${escapeHtml(author)}</div>
12
+ </div>
13
+ `;
14
+ }
15
+ /**
16
+ * Build a single message row HTML string
17
+ */
18
+ export function buildMessageRowHtml(author, text, config, isSubsequent) {
19
+ const showAvatar = !isSubsequent;
20
+ const side = config.side;
21
+ const avatarHtml = buildAvatarHtml(author, config, showAvatar);
22
+ // Build inline style only for custom colors (not defaults)
23
+ const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
24
+ const isDefaultTextColor = config.textColor === THEME.gray[900];
25
+ const inlineStyles = [];
26
+ if (!isDefaultBubbleColor) {
27
+ inlineStyles.push(`background-color: ${config.bubbleColor}`);
28
+ }
29
+ if (!isDefaultTextColor) {
30
+ inlineStyles.push(`color: ${config.textColor}`);
31
+ }
32
+ const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join('; ')}"` : '';
33
+ return `
34
+ <div class="msg-row msg-row--${side} ${isSubsequent ? 'msg-row--subsequent' : 'msg-row--new-author'}">
35
+ ${side === 'left' ? avatarHtml : ''}
36
+
37
+ <div class="msg-content">
38
+ <div class="msg-bubble msg-bubble--${side}"${styleAttr}>
39
+ ${escapeHtml(text)}
40
+ </div>
41
+ </div>
42
+
43
+ ${side === 'right' ? avatarHtml : ''}
44
+ </div>
45
+ `;
46
+ }
47
+ /**
48
+ * Setup tooltip for a single avatar wrapper element
49
+ */
50
+ export function setupTooltipForElement(wrapper) {
51
+ wrapper.addEventListener('mouseenter', () => {
52
+ const tooltip = wrapper.querySelector('.avatar-tooltip');
53
+ if (!tooltip)
54
+ return;
55
+ const rect = wrapper.getBoundingClientRect();
56
+ const tooltipRect = tooltip.getBoundingClientRect();
57
+ tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
58
+ tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
59
+ });
60
+ }
@@ -0,0 +1,6 @@
1
+ import type { Message } from '../types/index.js';
2
+ /**
3
+ * Parse text content into message array
4
+ * Format: `author: text` (one message per line)
5
+ */
6
+ export declare function parseMessages(textContent: string | null): Message[];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Parse text content into message array
3
+ * Format: `author: text` (one message per line)
4
+ */
5
+ export function parseMessages(textContent) {
6
+ const raw = textContent || '';
7
+ const messages = [];
8
+ for (const line of raw.split('\n')) {
9
+ const trimmed = line.trim();
10
+ if (!trimmed)
11
+ continue;
12
+ const colonIdx = trimmed.indexOf(':');
13
+ if (colonIdx <= 0)
14
+ continue;
15
+ const author = trimmed.slice(0, colonIdx).trim();
16
+ const text = trimmed.slice(colonIdx + 1).trim();
17
+ if (author && text) {
18
+ messages.push({ author, text });
19
+ }
20
+ }
21
+ return messages;
22
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Define the custom element
3
+ */
4
+ export declare function define(BBMsgHistoryClass: CustomElementConstructor, tagName?: string): void;
5
+ /**
6
+ * Initialize with fallback for unsupported browsers
7
+ */
8
+ export declare function initBBMsgHistory(BBMsgHistoryClass: CustomElementConstructor): void;
@@ -0,0 +1,28 @@
1
+ import { FALLBACK_STYLES } from '../const/styles.js';
2
+ /**
3
+ * Define the custom element
4
+ */
5
+ export function define(BBMsgHistoryClass, tagName = 'bb-msg-history') {
6
+ if (!customElements.get(tagName)) {
7
+ customElements.define(tagName, tagName === 'bb-msg-history' ? BBMsgHistoryClass : class extends BBMsgHistoryClass {
8
+ });
9
+ }
10
+ }
11
+ /**
12
+ * Initialize with fallback for unsupported browsers
13
+ */
14
+ export function initBBMsgHistory(BBMsgHistoryClass) {
15
+ try {
16
+ define(BBMsgHistoryClass);
17
+ }
18
+ catch (error) {
19
+ // eslint-disable-next-line no-console
20
+ console.warn('BBMsgHistory registration failed, falling back to plain text:', error);
21
+ document.querySelectorAll('bb-msg-history').forEach(el => {
22
+ const pre = document.createElement('pre');
23
+ pre.style.cssText = FALLBACK_STYLES;
24
+ pre.textContent = el.textContent || '';
25
+ el.replaceWith(pre);
26
+ });
27
+ }
28
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Build scroll-to-bottom button HTML string
3
+ */
4
+ export declare function buildScrollButtonHtml(): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Build scroll-to-bottom button HTML string
3
+ */
4
+ export function buildScrollButtonHtml() {
5
+ return `
6
+ <button class="scroll-to-bottom" aria-label="Scroll to bottom" title="Scroll to bottom">
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
8
+ <polyline points="6 9 12 15 18 9"></polyline>
9
+ </svg>
10
+ </button>
11
+ `;
12
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Setup dynamic tooltip positioning
3
+ * Tooltips are positioned fixed to avoid overflow clipping from parent containers
4
+ */
5
+ export declare function setupTooltips(shadowRoot: ShadowRoot): void;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Setup dynamic tooltip positioning
3
+ * Tooltips are positioned fixed to avoid overflow clipping from parent containers
4
+ */
5
+ export function setupTooltips(shadowRoot) {
6
+ shadowRoot.querySelectorAll('.avatar-wrapper').forEach(wrapper => {
7
+ wrapper.addEventListener('mouseenter', () => {
8
+ const tooltip = wrapper.querySelector('.avatar-tooltip');
9
+ if (!tooltip)
10
+ return;
11
+ const rect = wrapper.getBoundingClientRect();
12
+ const tooltipRect = tooltip.getBoundingClientRect();
13
+ tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
14
+ tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
15
+ });
16
+ });
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { escapeHtml, wrapAvatarHtml } from '../src/utils/html.js';
3
+ describe('escapeHtml', () => {
4
+ it('should escape HTML special characters', () => {
5
+ expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
6
+ });
7
+ it('should handle empty string', () => {
8
+ expect(escapeHtml('')).toBe('');
9
+ });
10
+ it('should handle string without special characters', () => {
11
+ expect(escapeHtml('Hello World')).toBe('Hello World');
12
+ });
13
+ it('should escape single quotes', () => {
14
+ expect(escapeHtml("'test'")).toBe('&#39;test&#39;');
15
+ });
16
+ it('should escape ampersands', () => {
17
+ expect(escapeHtml('Tom & Jerry')).toBe('Tom &amp; Jerry');
18
+ });
19
+ });
20
+ describe('wrapAvatarHtml', () => {
21
+ it('should wrap plain text in styled container', () => {
22
+ const result = wrapAvatarHtml('😀');
23
+ expect(result).toContain('<div style=');
24
+ expect(result).toContain('😀');
25
+ });
26
+ it('should return HTML as-is when tags are present', () => {
27
+ const html = '<img src="avatar.png" />';
28
+ expect(wrapAvatarHtml(html)).toBe(html);
29
+ });
30
+ it('should wrap emoji in styled container', () => {
31
+ const result = wrapAvatarHtml('🎉');
32
+ expect(result).toContain('display: flex');
33
+ expect(result).toContain('🎉');
34
+ });
35
+ });
@@ -28,6 +28,7 @@ export interface AuthorOptions {
28
28
  /** Theme color palette */
29
29
  export interface Theme {
30
30
  gray: Record<string, string>;
31
+ slate: Record<string, string>;
31
32
  red: Record<string, string>;
32
33
  yyPink: Record<string, string>;
33
34
  }
@@ -44,9 +44,13 @@ export function resolveAuthorConfig(author, userAuthors) {
44
44
  const config = AUTHOR_CONFIG[author];
45
45
  const firstChar = author.charAt(0);
46
46
  return {
47
- ...(config || { bubbleColor: THEME.gray[50], textColor: THEME.gray[900], side: 'left' }),
47
+ ...(config || {
48
+ bubbleColor: THEME.gray[50],
49
+ textColor: THEME.gray[900],
50
+ side: 'left',
51
+ }),
48
52
  avatar: generateLetterAvatar(firstChar),
49
- isCustomAvatar: false
53
+ isCustomAvatar: false,
50
54
  };
51
55
  }
52
56
  // 4. Built-in exact match
@@ -66,6 +70,6 @@ export function resolveAuthorConfig(author, userAuthors) {
66
70
  bubbleColor: THEME.gray[50],
67
71
  textColor: THEME.gray[900],
68
72
  side: 'left',
69
- isCustomAvatar: false
73
+ isCustomAvatar: false,
70
74
  };
71
75
  }
@@ -4,14 +4,14 @@ import { THEME } from '../const/theme.js';
4
4
  */
5
5
  export function generateLetterAvatar(letter) {
6
6
  return `<div style="
7
- width: 100%;
8
- height: 100%;
9
- display: flex;
10
- align-items: center;
11
- justify-content: center;
12
- background: #ffffff;
13
- color: ${THEME.gray[600]};
14
- font-size: 14px;
7
+ width: 100%;
8
+ height: 100%;
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ background: var(--bb-avatar-bg, #ffffff);
13
+ color: var(--bb-avatar-color, ${THEME.gray[600]});
14
+ font-size: 14px;
15
15
  font-weight: 600;
16
16
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
17
17
  ">${letter}</div>`;
@@ -1,3 +1,4 @@
1
+ import { THEME } from '../const/theme.js';
1
2
  import { escapeHtml } from './html.js';
2
3
  /**
3
4
  * Build avatar HTML string
@@ -18,17 +19,27 @@ export function buildMessageRowHtml(author, text, config, isSubsequent) {
18
19
  const showAvatar = !isSubsequent;
19
20
  const side = config.side;
20
21
  const avatarHtml = buildAvatarHtml(author, config, showAvatar);
22
+ // Build inline style only for custom colors (not defaults)
23
+ const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
24
+ const isDefaultTextColor = config.textColor === THEME.gray[900];
25
+ const inlineStyles = [];
26
+ if (!isDefaultBubbleColor) {
27
+ inlineStyles.push(`background-color: ${config.bubbleColor}`);
28
+ }
29
+ if (!isDefaultTextColor) {
30
+ inlineStyles.push(`color: ${config.textColor}`);
31
+ }
32
+ const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join('; ')}"` : '';
21
33
  return `
22
34
  <div class="msg-row msg-row--${side} ${isSubsequent ? 'msg-row--subsequent' : 'msg-row--new-author'}">
23
35
  ${side === 'left' ? avatarHtml : ''}
24
-
36
+
25
37
  <div class="msg-content">
26
- <div class="msg-bubble msg-bubble--${side}"
27
- style="background-color: ${config.bubbleColor}; color: ${config.textColor};">
38
+ <div class="msg-bubble msg-bubble--${side}"${styleAttr}>
28
39
  ${escapeHtml(text)}
29
40
  </div>
30
41
  </div>
31
-
42
+
32
43
  ${side === 'right' ? avatarHtml : ''}
33
44
  </div>
34
45
  `;
@@ -4,10 +4,8 @@ import { FALLBACK_STYLES } from '../const/styles.js';
4
4
  */
5
5
  export function define(BBMsgHistoryClass, tagName = 'bb-msg-history') {
6
6
  if (!customElements.get(tagName)) {
7
- customElements.define(tagName, tagName === 'bb-msg-history'
8
- ? BBMsgHistoryClass
9
- : class extends BBMsgHistoryClass {
10
- });
7
+ customElements.define(tagName, tagName === 'bb-msg-history' ? BBMsgHistoryClass : class extends BBMsgHistoryClass {
8
+ });
11
9
  }
12
10
  }
13
11
  /**
@@ -18,6 +16,7 @@ export function initBBMsgHistory(BBMsgHistoryClass) {
18
16
  define(BBMsgHistoryClass);
19
17
  }
20
18
  catch (error) {
19
+ // eslint-disable-next-line no-console
21
20
  console.warn('BBMsgHistory registration failed, falling back to plain text:', error);
22
21
  document.querySelectorAll('bb-msg-history').forEach(el => {
23
22
  const pre = document.createElement('pre');
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Build scroll-to-bottom button HTML string
3
+ */
4
+ export declare function buildScrollButtonHtml(): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Build scroll-to-bottom button HTML string
3
+ */
4
+ export function buildScrollButtonHtml() {
5
+ return `
6
+ <button class="scroll-to-bottom" aria-label="Scroll to bottom" title="Scroll to bottom">
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
8
+ <polyline points="6 9 12 15 18 9"></polyline>
9
+ </svg>
10
+ </button>
11
+ `;
12
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: {
4
+ environment: 'happy-dom',
5
+ globals: true,
6
+ include: ['tests/**/*.test.ts'],
7
+ },
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/bb-msg-history",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A chat-style message history web component",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -23,16 +23,41 @@
23
23
  "dist",
24
24
  "src"
25
25
  ],
26
- "scripts": {
27
- "start": "tsc -w",
28
- "preview": "python3 -m http.server 8000",
29
- "prepare": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle -o dist/index.js"
26
+ "lint-staged": {
27
+ "*.ts": [
28
+ "eslint --fix",
29
+ "prettier --write"
30
+ ]
30
31
  },
31
32
  "publishConfig": {
32
33
  "access": "public"
33
34
  },
34
35
  "devDependencies": {
36
+ "@eslint/js": "^9.21.0",
37
+ "@release-it/conventional-changelog": "^10.0.0",
38
+ "@typescript-eslint/eslint-plugin": "^8.25.0",
39
+ "@typescript-eslint/parser": "^8.25.0",
40
+ "eslint": "^9.21.0",
41
+ "globals": "^16.0.0",
42
+ "happy-dom": "^17.1.0",
43
+ "husky": "^9.1.7",
44
+ "lint-staged": "^15.4.3",
45
+ "prettier": "^3.5.2",
46
+ "release-it": "^18.0.0",
35
47
  "terser": "^5.46.0",
36
- "typescript": "^5.9.3"
48
+ "typescript": "^5.9.3",
49
+ "vitest": "^3.2.4"
50
+ },
51
+ "scripts": {
52
+ "start": "tsc -w",
53
+ "preview": "python3 -m http.server 8000",
54
+ "build": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
55
+ "lint": "eslint src/**/*.ts",
56
+ "lint:fix": "eslint src/**/*.ts --fix",
57
+ "format": "prettier --write \"src/**/*.ts\"",
58
+ "format:check": "prettier --check \"src/**/*.ts\"",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "release": "release-it"
37
62
  }
38
- }
63
+ }
package/src/component.ts CHANGED
@@ -4,17 +4,27 @@ import { parseMessages } from './utils/message-parser.js';
4
4
  import { resolveAuthorConfig } from './utils/author-resolver.js';
5
5
  import { setupTooltips } from './utils/tooltip.js';
6
6
  import { buildMessageRowHtml, setupTooltipForElement } from './utils/message-builder.js';
7
+ import { buildScrollButtonHtml } from './utils/scroll-button.js';
7
8
 
8
9
  export class BBMsgHistory extends HTMLElement {
9
10
  private _mutationObserver?: MutationObserver;
10
11
  private _userAuthors = new Map<string, AuthorOptions>();
11
12
  private _lastAuthor = '';
13
+ private _scrollButtonVisible = false;
14
+
15
+ static get observedAttributes() {
16
+ return ['theme'];
17
+ }
12
18
 
13
19
  constructor() {
14
20
  super();
15
21
  this.attachShadow({ mode: 'open' });
16
22
  }
17
23
 
24
+ attributeChangedCallback() {
25
+ this.render();
26
+ }
27
+
18
28
  /**
19
29
  * Configure an author's avatar, side, and colors.
20
30
  * Call before or after rendering — the component re-renders automatically.
@@ -51,22 +61,22 @@ export class BBMsgHistory extends HTMLElement {
51
61
  const currentText = this.textContent || '';
52
62
  const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
53
63
  this.textContent = currentText + separator + `${message.author}: ${message.text}`;
54
-
64
+
55
65
  // Temporarily disconnect observer to prevent recursive render
56
66
  this._mutationObserver?.disconnect();
57
-
67
+
58
68
  // Append single message without re-rendering entire list
59
69
  this._appendSingleMessage(message);
60
-
70
+
61
71
  // Reconnect observer
62
72
  this._setupMutationObserver();
63
-
73
+
64
74
  return this;
65
75
  }
66
76
 
67
77
  private _appendSingleMessage(message: Message): void {
68
78
  const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
69
-
79
+
70
80
  // If empty state or no container, do full render first
71
81
  if (!container) {
72
82
  this.render();
@@ -78,26 +88,33 @@ export class BBMsgHistory extends HTMLElement {
78
88
  const config = resolveAuthorConfig(author, this._userAuthors);
79
89
  const isFirstFromAuthor = author !== this._lastAuthor;
80
90
  this._lastAuthor = author;
81
-
91
+
82
92
  const isSubsequent = !isFirstFromAuthor;
83
-
93
+
84
94
  // Use utility function to build message HTML
85
95
  const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent);
86
-
96
+
87
97
  // Append to container
88
98
  container.insertAdjacentHTML('beforeend', msgHtml);
89
-
99
+
90
100
  // Setup tooltip for new element using utility function
91
101
  const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
92
102
  if (newWrapper) {
93
103
  setupTooltipForElement(newWrapper);
94
104
  }
95
-
105
+
96
106
  // Smooth scroll to bottom
97
107
  container.scrollTo({
98
108
  top: container.scrollHeight,
99
- behavior: 'smooth'
109
+ behavior: 'smooth',
100
110
  });
111
+
112
+ // Hide scroll button since we're scrolling to bottom
113
+ const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement;
114
+ if (scrollButton && this._scrollButtonVisible) {
115
+ this._scrollButtonVisible = false;
116
+ scrollButton.classList.remove('visible');
117
+ }
101
118
  }
102
119
 
103
120
  connectedCallback() {
@@ -124,7 +141,7 @@ export class BBMsgHistory extends HTMLElement {
124
141
 
125
142
  private render() {
126
143
  const messages = parseMessages(this.textContent);
127
-
144
+
128
145
  if (messages.length === 0) {
129
146
  this._lastAuthor = '';
130
147
  this._renderEmpty();
@@ -138,7 +155,7 @@ export class BBMsgHistory extends HTMLElement {
138
155
  const isFirstFromAuthor = author !== lastAuthor;
139
156
  lastAuthor = author;
140
157
  const isSubsequent = !isFirstFromAuthor;
141
-
158
+
142
159
  // Use utility function to build message HTML
143
160
  return buildMessageRowHtml(author, text, config, isSubsequent);
144
161
  })
@@ -151,12 +168,25 @@ export class BBMsgHistory extends HTMLElement {
151
168
  <div class="history" role="log" aria-live="polite" aria-label="Message history">
152
169
  ${messagesHtml}
153
170
  </div>
171
+ ${buildScrollButtonHtml()}
154
172
  `;
155
173
 
156
174
  requestAnimationFrame(() => {
157
175
  const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
176
+ const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement;
177
+
158
178
  if (container) {
159
179
  container.scrollTop = container.scrollHeight;
180
+ this._setupScrollTracking(container, scrollButton);
181
+ }
182
+
183
+ if (scrollButton) {
184
+ scrollButton.addEventListener('click', () => {
185
+ container?.scrollTo({
186
+ top: container.scrollHeight,
187
+ behavior: 'smooth',
188
+ });
189
+ });
160
190
  }
161
191
 
162
192
  setupTooltips(this.shadowRoot!);
@@ -169,4 +199,28 @@ export class BBMsgHistory extends HTMLElement {
169
199
  <div class="empty-state">No messages</div>
170
200
  `;
171
201
  }
202
+
203
+ private _setupScrollTracking(container: HTMLElement, button: HTMLButtonElement): void {
204
+ const checkScrollPosition = () => {
205
+ const threshold = 50; // pixels from bottom
206
+ const isAtBottom =
207
+ container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
208
+ const hasOverflow = container.scrollHeight > container.clientHeight;
209
+ const shouldShow = !isAtBottom && hasOverflow;
210
+
211
+ if (shouldShow !== this._scrollButtonVisible) {
212
+ this._scrollButtonVisible = shouldShow;
213
+ button.classList.toggle('visible', shouldShow);
214
+ }
215
+ };
216
+
217
+ // Check initial state
218
+ checkScrollPosition();
219
+
220
+ // Listen for scroll events with passive listener for performance
221
+ container.addEventListener('scroll', checkScrollPosition, { passive: true });
222
+
223
+ // Also check on resize
224
+ window.addEventListener('resize', checkScrollPosition, { passive: true });
225
+ }
172
226
  }