@bbki.ng/bb-msg-history 0.4.0 → 0.6.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.
@@ -3,8 +3,11 @@ export declare class BBMsgHistory extends HTMLElement {
3
3
  private _mutationObserver?;
4
4
  private _userAuthors;
5
5
  private _lastAuthor;
6
+ private _lastGroupTimestamp;
6
7
  private _scrollButtonVisible;
8
+ static get observedAttributes(): string[];
7
9
  constructor();
10
+ attributeChangedCallback(): void;
8
11
  /**
9
12
  * Configure an author's avatar, side, and colors.
10
13
  * Call before or after rendering — the component re-renders automatically.
package/dist/component.js CHANGED
@@ -5,6 +5,9 @@ import { setupTooltips } from './utils/tooltip.js';
5
5
  import { buildMessageRowHtml, setupTooltipForElement } from './utils/message-builder.js';
6
6
  import { buildScrollButtonHtml } from './utils/scroll-button.js';
7
7
  export class BBMsgHistory extends HTMLElement {
8
+ static get observedAttributes() {
9
+ return ['theme'];
10
+ }
8
11
  constructor() {
9
12
  super();
10
13
  this._userAuthors = new Map();
@@ -12,6 +15,9 @@ export class BBMsgHistory extends HTMLElement {
12
15
  this._scrollButtonVisible = false;
13
16
  this.attachShadow({ mode: 'open' });
14
17
  }
18
+ attributeChangedCallback() {
19
+ this.render();
20
+ }
15
21
  /**
16
22
  * Configure an author's avatar, side, and colors.
17
23
  * Call before or after rendering — the component re-renders automatically.
@@ -63,12 +69,30 @@ export class BBMsgHistory extends HTMLElement {
63
69
  }
64
70
  const author = message.author;
65
71
  const text = message.text;
72
+ const timestamp = message.timestamp;
66
73
  const config = resolveAuthorConfig(author, this._userAuthors);
67
- const isFirstFromAuthor = author !== this._lastAuthor;
74
+ // Check if this can group with the last message
75
+ // Same author AND (no timestamp conflict)
76
+ const canGroupWithLast = author === this._lastAuthor &&
77
+ (!this._lastGroupTimestamp || !timestamp || this._lastGroupTimestamp === timestamp);
78
+ const isFirstFromAuthor = !canGroupWithLast;
68
79
  this._lastAuthor = author;
69
80
  const isSubsequent = !isFirstFromAuthor;
81
+ // Update group timestamp tracking
82
+ if (isFirstFromAuthor) {
83
+ // Start new group
84
+ this._lastGroupTimestamp = timestamp;
85
+ }
86
+ else if (!this._lastGroupTimestamp && timestamp) {
87
+ // If no timestamp in group yet and current has one, use it
88
+ this._lastGroupTimestamp = timestamp;
89
+ }
90
+ // When appending, we assume this IS the last in group (for now)
91
+ // If another message from same author comes, we'll re-render
92
+ const isLastInGroup = true;
93
+ const groupTimestamp = this._lastGroupTimestamp;
70
94
  // Use utility function to build message HTML
71
- const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent);
95
+ const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent, groupTimestamp, isLastInGroup);
72
96
  // Append to container
73
97
  container.insertAdjacentHTML('beforeend', msgHtml);
74
98
  // Setup tooltip for new element using utility function
@@ -79,7 +103,7 @@ export class BBMsgHistory extends HTMLElement {
79
103
  // Smooth scroll to bottom
80
104
  container.scrollTo({
81
105
  top: container.scrollHeight,
82
- behavior: 'smooth'
106
+ behavior: 'smooth',
83
107
  });
84
108
  // Hide scroll button since we're scrolling to bottom
85
109
  const scrollButton = this.shadowRoot.querySelector('.scroll-to-bottom');
@@ -111,18 +135,59 @@ export class BBMsgHistory extends HTMLElement {
111
135
  const messages = parseMessages(this.textContent);
112
136
  if (messages.length === 0) {
113
137
  this._lastAuthor = '';
138
+ this._lastGroupTimestamp = undefined;
114
139
  this._renderEmpty();
115
140
  return;
116
141
  }
142
+ // Helper: Check if two messages can be grouped (same author, no timestamp conflict)
143
+ const canGroup = (prev, curr) => {
144
+ if (prev.author !== curr.author)
145
+ return false;
146
+ // Different timestamps = break group
147
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
148
+ return false;
149
+ }
150
+ return true;
151
+ };
152
+ // First pass: determine which messages are last in their group
153
+ const lastInGroupFlags = messages.map((msg, i) => {
154
+ const next = messages[i + 1];
155
+ return !next || !canGroup(msg, next);
156
+ });
157
+ // Second pass: collect the timestamp for each group
158
+ // Use the first non-empty timestamp in the group
159
+ const groupTimestamps = new Map();
160
+ let currentGroupTimestamp;
161
+ messages.forEach((msg, i) => {
162
+ // Start of a new group
163
+ if (i === 0 || !canGroup(messages[i - 1], msg)) {
164
+ currentGroupTimestamp = msg.timestamp;
165
+ }
166
+ else if (!currentGroupTimestamp && msg.timestamp) {
167
+ // If no timestamp yet and current msg has one, use it
168
+ currentGroupTimestamp = msg.timestamp;
169
+ }
170
+ // If this is the last message in the group, save the timestamp
171
+ if (lastInGroupFlags[i]) {
172
+ groupTimestamps.set(i, currentGroupTimestamp);
173
+ currentGroupTimestamp = undefined;
174
+ }
175
+ });
176
+ // Third pass: build HTML
117
177
  let lastAuthor = '';
118
178
  const messagesHtml = messages
119
- .map(({ author, text }) => {
179
+ .map((msg, i) => {
180
+ const { author, text } = msg;
120
181
  const config = resolveAuthorConfig(author, this._userAuthors);
121
- const isFirstFromAuthor = author !== lastAuthor;
182
+ // Determine if this is a new author group (can't group with previous)
183
+ const isFirstFromAuthor = i === 0 || !canGroup(messages[i - 1], msg);
122
184
  lastAuthor = author;
123
185
  const isSubsequent = !isFirstFromAuthor;
186
+ // Get timestamp if this is the last in group
187
+ const isLastInGroup = lastInGroupFlags[i];
188
+ const groupTimestamp = groupTimestamps.get(i);
124
189
  // Use utility function to build message HTML
125
- return buildMessageRowHtml(author, text, config, isSubsequent);
190
+ return buildMessageRowHtml(author, text, config, isSubsequent, groupTimestamp, isLastInGroup);
126
191
  })
127
192
  .join('');
128
193
  this._lastAuthor = lastAuthor;
@@ -144,7 +209,7 @@ export class BBMsgHistory extends HTMLElement {
144
209
  scrollButton.addEventListener('click', () => {
145
210
  container?.scrollTo({
146
211
  top: container.scrollHeight,
147
- behavior: 'smooth'
212
+ behavior: 'smooth',
148
213
  });
149
214
  });
150
215
  }
@@ -8,20 +8,20 @@ export const AUTHOR_CONFIG = {
8
8
  avatar: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 48 48" fill="none"><path d="M29.1152 21.3106C32.0605 21.3106 34.4481 18.9101 34.4481 15.9489V24.6457C34.4481 25.7585 33.5508 26.6607 32.444 26.6607H15.1207C14.0138 26.6607 13.1166 25.7585 13.1166 24.6457V15.9489C13.1166 18.9101 15.5042 21.3106 18.4494 21.3106C21.3947 21.3106 23.7823 18.9101 23.7823 15.9489C23.7823 18.9101 26.17 21.3106 29.1152 21.3106Z" fill="${THEME.gray[400]}"/><path d="M23.7823 15.9373L23.7823 15.9489C23.7823 15.9451 23.7823 15.9412 23.7823 15.9373Z" fill="${THEME.gray[400]}"/><path d="M23.1143 28.004C23.1205 30.9598 25.5057 33.3541 28.4472 33.3541C31.3886 33.3541 33.7738 30.9598 33.7801 28.004H23.1143Z" fill="${THEME.gray[400]}"/><path d="M13.7846 28.004C13.7846 28.0079 13.7846 28.0117 13.7846 28.0156C13.7908 30.9714 16.1761 33.3657 19.1175 33.3657C22.0589 33.3657 24.4442 30.9714 24.4504 28.0156H13.7846V28.004Z" fill="${THEME.gray[400]}"/><path d="M14.4527 15.9373C14.4527 16.6792 13.8545 17.2806 13.1166 17.2806C12.3786 17.2806 11.7805 16.6792 11.7805 15.9373C11.7805 15.1954 12.3786 14.594 13.1166 14.594C13.8545 14.594 14.4527 15.1954 14.4527 15.9373Z" fill="${THEME.gray[400]}"/><path d="M25.1184 15.2657C25.1184 16.0076 24.5202 16.609 23.7823 16.609C23.0444 16.609 22.4462 16.0076 22.4462 15.2657C22.4462 14.5238 23.0444 13.9224 23.7823 13.9224C24.5202 13.9224 25.1184 14.5238 25.1184 15.2657Z" fill="${THEME.gray[400]}"/><path d="M35.7842 15.9373C35.7842 16.6792 35.186 17.2806 34.4481 17.2806C33.7102 17.2806 33.112 16.6792 33.112 15.9373C33.112 15.1954 33.7102 14.594 34.4481 14.594C35.186 14.594 35.7842 15.1954 35.7842 15.9373Z" fill="${THEME.gray[400]}"/></svg>`,
9
9
  bubbleColor: THEME.gray[100],
10
10
  textColor: THEME.gray[900],
11
- side: 'right'
11
+ side: 'right',
12
12
  },
13
- 'xwy': {
13
+ xwy: {
14
14
  avatar: `<svg width="28" height="28" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.821 17.5305C10.709 18.17 9.68345 19.4423 9.22624 20.1359C9.11159 20.3099 9.21615 20.5428 9.42038 20.5839L12.67 21.2381C12.8291 21.2702 12.9328 21.4275 12.9084 21.5879C11.3004 32.1653 21.5275 36.7547 28.6638 33.0597C28.7443 33.018 28.8408 33.0139 28.9245 33.0487C32.8032 34.6598 35.967 34.5662 37.8217 34.3099C38.131 34.2671 38.1505 33.841 37.855 33.7401C29.1343 30.7633 26.0152 24.5245 25.5144 18.8022C25.3835 17.3066 23.8172 13.2016 19.2675 13.0058C15.7934 12.8563 13.6137 15.6103 13.0319 17.325C12.9986 17.4231 12.9201 17.5004 12.821 17.5305Z" fill="${THEME.yyPink[100]}"/><circle cx="17.6178" cy="18.2688" r="0.995689" fill="white"/></svg>`,
15
15
  bubbleColor: THEME.yyPink[50],
16
16
  textColor: THEME.gray[900],
17
- side: 'left'
17
+ side: 'left',
18
18
  },
19
- '小乌鸦': {
19
+ 小乌鸦: {
20
20
  avatar: `<svg width="28" height="28" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.821 17.5305C10.709 18.17 9.68345 19.4423 9.22624 20.1359C9.11159 20.3099 9.21615 20.5428 9.42038 20.5839L12.67 21.2381C12.8291 21.2702 12.9328 21.4275 12.9084 21.5879C11.3004 32.1653 21.5275 36.7547 28.6638 33.0597C28.7443 33.018 28.8408 33.0139 28.9245 33.0487C32.8032 34.6598 35.967 34.5662 37.8217 34.3099C38.131 34.2671 38.1505 33.841 37.855 33.7401C29.1343 30.7633 26.0152 24.5245 25.5144 18.8022C25.3835 17.3066 23.8172 13.2016 19.2675 13.0058C15.7934 12.8563 13.6137 15.6103 13.0319 17.325C12.9986 17.4231 12.9201 17.5004 12.821 17.5305Z" fill="${THEME.yyPink[100]}"/><circle cx="17.6178" cy="18.2688" r="0.995689" fill="white"/></svg>`,
21
21
  bubbleColor: THEME.yyPink[50],
22
22
  textColor: THEME.gray[900],
23
- side: 'left'
24
- }
23
+ side: 'left',
24
+ },
25
25
  };
26
26
  /**
27
27
  * Authors that should use first-character avatar instead of SVG
@@ -12,6 +12,8 @@ export const MAIN_STYLES = `
12
12
  "Noto Color Emoji";
13
13
  --bb-bg-color: ${THEME.gray[50]};
14
14
  --bb-max-height: 600px;
15
+ --bb-avatar-bg: #ffffff;
16
+ --bb-avatar-color: ${THEME.gray[600]};
15
17
  }
16
18
 
17
19
  .history {
@@ -111,7 +113,7 @@ export const MAIN_STYLES = `
111
113
  }
112
114
 
113
115
  .msg-row--subsequent {
114
- margin-top: 0.125rem;
116
+ margin-top: 0.375rem;
115
117
  }
116
118
 
117
119
  .msg-row--new-author {
@@ -190,6 +192,34 @@ export const MAIN_STYLES = `
190
192
  .msg-content {
191
193
  display: flex;
192
194
  flex-direction: column;
195
+ position: relative;
196
+ }
197
+
198
+ /* Timestamp styles */
199
+ .msg-timestamp {
200
+ position: absolute;
201
+ font-size: 11px;
202
+ color: ${THEME.gray[400]};
203
+ opacity: 0;
204
+ visibility: hidden;
205
+ transition: opacity 0.2s ease, visibility 0.2s ease;
206
+ white-space: nowrap;
207
+ bottom: -12px;
208
+ line-height: 1;
209
+ pointer-events: none;
210
+ }
211
+
212
+ .msg-timestamp--left {
213
+ left: 0;
214
+ }
215
+
216
+ .msg-timestamp--right {
217
+ right: 0;
218
+ }
219
+
220
+ .msg-row:hover .msg-timestamp {
221
+ opacity: 1;
222
+ visibility: visible;
193
223
  }
194
224
 
195
225
  .msg-bubble {
@@ -212,6 +242,7 @@ export const MAIN_STYLES = `
212
242
  /* Right bubble */
213
243
  .msg-bubble--right {
214
244
  border-bottom-right-radius: 0.25rem;
245
+ color: ${THEME.gray[900]};
215
246
  }
216
247
 
217
248
  /* Empty state */
@@ -254,15 +285,71 @@ export const MAIN_STYLES = `
254
285
  }
255
286
  }
256
287
 
257
- /* Dark mode */
288
+ /* Dark mode styles - shared between media query and attribute */
289
+ :host([theme="dark"]) {
290
+ --bb-bg-color: ${THEME.gray[900]};
291
+ --bb-avatar-bg: ${THEME.slate[600]};
292
+ --bb-avatar-color: ${THEME.slate[200]};
293
+ }
294
+
295
+ :host([theme="dark"]) .history {
296
+ background-color: transparent;
297
+ scrollbar-color: ${THEME.gray[600]} transparent;
298
+ }
299
+
300
+ :host([theme="dark"]) .history::-webkit-scrollbar-thumb {
301
+ background: ${THEME.gray[600]};
302
+ }
303
+
304
+ :host([theme="dark"]) .history::-webkit-scrollbar-thumb:hover {
305
+ background: ${THEME.gray[500]};
306
+ }
307
+
308
+ :host([theme="dark"]) .msg-bubble {
309
+ color: ${THEME.slate[100]};
310
+ }
311
+
312
+ :host([theme="dark"]) .msg-bubble--right {
313
+ background-color: ${THEME.slate[700]};
314
+ color: ${THEME.slate[100]};
315
+ }
316
+
317
+ :host([theme="dark"]) .msg-bubble--left {
318
+ background-color: ${THEME.slate[800]};
319
+ border: 1px solid ${THEME.slate[700]};
320
+ color: ${THEME.slate[100]};
321
+ }
322
+
323
+ :host([theme="dark"]) .avatar-wrapper {
324
+ background: ${THEME.slate[600]};
325
+ }
326
+
327
+ :host([theme="dark"]) .empty-state {
328
+ color: ${THEME.gray[500]};
329
+ }
330
+
331
+ :host([theme="dark"]) .scroll-to-bottom {
332
+ background: ${THEME.slate[800]};
333
+ border: none;
334
+ color: ${THEME.slate[300]};
335
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
336
+ }
337
+
338
+ :host([theme="dark"]) .scroll-to-bottom:hover {
339
+ color: ${THEME.slate[200]};
340
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
341
+ }
342
+
343
+ /* System dark mode preference */
258
344
  @media (prefers-color-scheme: dark) {
259
345
  :host {
260
346
  --bb-bg-color: ${THEME.gray[900]};
347
+ --bb-avatar-bg: ${THEME.slate[600]};
348
+ --bb-avatar-color: ${THEME.slate[200]};
261
349
  }
262
350
 
263
351
  .history {
264
352
  background-color: transparent;
265
- /* Firefox scrollbar dark mode */
266
353
  scrollbar-color: ${THEME.gray[600]} transparent;
267
354
  }
268
355
 
@@ -275,16 +362,22 @@ export const MAIN_STYLES = `
275
362
  }
276
363
 
277
364
  .msg-bubble {
278
- color: ${THEME.gray[100]};
365
+ color: ${THEME.slate[100]};
366
+ }
367
+
368
+ .msg-bubble--right {
369
+ background-color: ${THEME.slate[700]};
370
+ color: ${THEME.slate[100]};
279
371
  }
280
372
 
281
373
  .msg-bubble--left {
282
- background-color: ${THEME.gray[700]};
283
- color: ${THEME.gray[100]};
374
+ background-color: ${THEME.slate[800]};
375
+ border: 1px solid ${THEME.slate[700]};
376
+ color: ${THEME.slate[100]};
284
377
  }
285
378
 
286
379
  .avatar-wrapper {
287
- background: ${THEME.gray[800]};
380
+ background: ${THEME.slate[600]};
288
381
  }
289
382
 
290
383
  .empty-state {
@@ -292,14 +385,14 @@ export const MAIN_STYLES = `
292
385
  }
293
386
 
294
387
  .scroll-to-bottom {
295
- background: transparent;
388
+ background: ${THEME.slate[800]};
296
389
  border: none;
297
- color: ${THEME.gray[400]};
390
+ color: ${THEME.slate[300]};
298
391
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
299
392
  }
300
393
 
301
394
  .scroll-to-bottom:hover {
302
- color: ${THEME.gray[300]};
395
+ color: ${THEME.slate[200]};
303
396
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
304
397
  }
305
398
  }
@@ -6,7 +6,7 @@ export const THEME = {
6
6
  gray: {
7
7
  50: '#f9fafb',
8
8
  100: '#f3f4f6',
9
- 200: '#e5e7eb',
9
+ 200: '#f5f5f5',
10
10
  300: '#d1d5db',
11
11
  400: '#9ca3af',
12
12
  500: '#6b7280',
@@ -15,6 +15,18 @@ export const THEME = {
15
15
  800: '#1f2937',
16
16
  900: '#111827',
17
17
  },
18
+ slate: {
19
+ 50: '#f8fafc',
20
+ 100: '#f1f5f9',
21
+ 200: '#e2e8f0',
22
+ 300: '#cbd5e1',
23
+ 400: '#94a3b8',
24
+ 500: '#64748b',
25
+ 600: '#475569',
26
+ 700: '#334155',
27
+ 800: '#1e293b',
28
+ 900: '#0f172a',
29
+ },
18
30
  red: {
19
31
  50: '#fef2f2',
20
32
  100: '#fee2e2',
@@ -28,5 +40,5 @@ export const THEME = {
28
40
  50: '#fdf4f4',
29
41
  100: '#fbd1d2',
30
42
  150: '#f8babc',
31
- }
43
+ },
32
44
  };
@@ -0,0 +1 @@
1
+ {"version":3,"names":["BBMsgHistory","initBBMsgHistory","document","readyState","addEventListener","define"],"sources":["dist/index.js"],"mappings":"OAASA,iBAAoB,wBACpBC,qBAAwB,0BAEL,YAAxBC,SAASC,WACTD,SAASE,iBAAiB,mBAAoB,IAAMH,iBAAiBD,eAGrEC,iBAAiBD,qBAGZA,qBACAK,WAAc","ignoreList":[]}
@@ -5,6 +5,7 @@
5
5
  export interface Message {
6
6
  author: string;
7
7
  text: string;
8
+ timestamp?: string;
8
9
  }
9
10
  /** Internal author configuration with resolved values */
10
11
  export interface AuthorConfig {
@@ -28,6 +29,7 @@ export interface AuthorOptions {
28
29
  /** Theme color palette */
29
30
  export interface Theme {
30
31
  gray: Record<string, string>;
32
+ slate: Record<string, string>;
31
33
  red: Record<string, string>;
32
34
  yyPink: Record<string, string>;
33
35
  }
@@ -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>`;
@@ -3,10 +3,14 @@ import type { AuthorConfig } from '../types/index.js';
3
3
  * Build avatar HTML string
4
4
  */
5
5
  export declare function buildAvatarHtml(author: string, config: AuthorConfig, showAvatar: boolean): string;
6
+ /**
7
+ * Build timestamp HTML string
8
+ */
9
+ export declare function buildTimestampHtml(timestamp: string, side: 'left' | 'right'): string;
6
10
  /**
7
11
  * Build a single message row HTML string
8
12
  */
9
- export declare function buildMessageRowHtml(author: string, text: string, config: AuthorConfig, isSubsequent: boolean): string;
13
+ export declare function buildMessageRowHtml(author: string, text: string, config: AuthorConfig, isSubsequent: boolean, timestamp?: string, isLastInGroup?: boolean): string;
10
14
  /**
11
15
  * Setup tooltip for a single avatar wrapper element
12
16
  */
@@ -1,34 +1,54 @@
1
+ import { THEME } from '../const/theme.js';
1
2
  import { escapeHtml } from './html.js';
2
3
  /**
3
4
  * Build avatar HTML string
4
5
  */
5
6
  export function buildAvatarHtml(author, config, showAvatar) {
6
7
  return `
7
- <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
8
+ <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
8
9
  data-author="${escapeHtml(author)}">
9
10
  <div class="avatar">${config.avatar}</div>
10
11
  <div class="avatar-tooltip">${escapeHtml(author)}</div>
11
12
  </div>
12
13
  `;
13
14
  }
15
+ /**
16
+ * Build timestamp HTML string
17
+ */
18
+ export function buildTimestampHtml(timestamp, side) {
19
+ return `<span class="msg-timestamp msg-timestamp--${side}">${escapeHtml(timestamp)}</span>`;
20
+ }
14
21
  /**
15
22
  * Build a single message row HTML string
16
23
  */
17
- export function buildMessageRowHtml(author, text, config, isSubsequent) {
24
+ export function buildMessageRowHtml(author, text, config, isSubsequent, timestamp, isLastInGroup) {
18
25
  const showAvatar = !isSubsequent;
19
26
  const side = config.side;
20
27
  const avatarHtml = buildAvatarHtml(author, config, showAvatar);
28
+ // Only show timestamp on the last message of a group
29
+ const timestampHtml = isLastInGroup && timestamp ? buildTimestampHtml(timestamp, side) : '';
30
+ // Build inline style only for custom colors (not defaults)
31
+ const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
32
+ const isDefaultTextColor = config.textColor === THEME.gray[900];
33
+ const inlineStyles = [];
34
+ if (!isDefaultBubbleColor) {
35
+ inlineStyles.push(`background-color: ${config.bubbleColor}`);
36
+ }
37
+ if (!isDefaultTextColor) {
38
+ inlineStyles.push(`color: ${config.textColor}`);
39
+ }
40
+ const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join('; ')}"` : '';
21
41
  return `
22
42
  <div class="msg-row msg-row--${side} ${isSubsequent ? 'msg-row--subsequent' : 'msg-row--new-author'}">
23
43
  ${side === 'left' ? avatarHtml : ''}
24
-
44
+
25
45
  <div class="msg-content">
26
- <div class="msg-bubble msg-bubble--${side}"
27
- style="background-color: ${config.bubbleColor}; color: ${config.textColor};">
46
+ ${timestampHtml}
47
+ <div class="msg-bubble msg-bubble--${side}"${styleAttr}>
28
48
  ${escapeHtml(text)}
29
49
  </div>
30
50
  </div>
31
-
51
+
32
52
  ${side === 'right' ? avatarHtml : ''}
33
53
  </div>
34
54
  `;
@@ -1,6 +1,7 @@
1
1
  import type { Message } from '../types/index.js';
2
2
  /**
3
3
  * Parse text content into message array
4
- * Format: `author: text` (one message per line)
4
+ * Format: `[timestamp] author: text` or `author: text` (one message per line)
5
+ * Timestamp is optional for backward compatibility
5
6
  */
6
7
  export declare function parseMessages(textContent: string | null): Message[];
@@ -1,21 +1,37 @@
1
1
  /**
2
2
  * Parse text content into message array
3
- * Format: `author: text` (one message per line)
3
+ * Format: `[timestamp] author: text` or `author: text` (one message per line)
4
+ * Timestamp is optional for backward compatibility
4
5
  */
5
6
  export function parseMessages(textContent) {
6
7
  const raw = textContent || '';
7
8
  const messages = [];
9
+ // Pattern: [timestamp] author: text (space between timestamp and author is optional)
10
+ const timestampPattern = /^\[([^\]]+)\]\s*(.+)$/;
8
11
  for (const line of raw.split('\n')) {
9
12
  const trimmed = line.trim();
10
13
  if (!trimmed)
11
14
  continue;
12
- const colonIdx = trimmed.indexOf(':');
15
+ let remainingLine = trimmed;
16
+ let timestamp;
17
+ // Try to extract timestamp
18
+ const timestampMatch = trimmed.match(timestampPattern);
19
+ if (timestampMatch) {
20
+ timestamp = timestampMatch[1].trim();
21
+ remainingLine = timestampMatch[2];
22
+ }
23
+ // Find the author:text separator (colon followed by space)
24
+ const colonIdx = remainingLine.indexOf(':');
13
25
  if (colonIdx <= 0)
14
26
  continue;
15
- const author = trimmed.slice(0, colonIdx).trim();
16
- const text = trimmed.slice(colonIdx + 1).trim();
27
+ const author = remainingLine.slice(0, colonIdx).trim();
28
+ const text = remainingLine.slice(colonIdx + 1).trim();
17
29
  if (author && text) {
18
- messages.push({ author, text });
30
+ const message = { author, text };
31
+ if (timestamp) {
32
+ message.timestamp = timestamp;
33
+ }
34
+ messages.push(message);
19
35
  }
20
36
  }
21
37
  return messages;
@@ -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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/bb-msg-history",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A chat-style message history web component",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -26,13 +26,39 @@
26
26
  "scripts": {
27
27
  "start": "tsc -w",
28
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"
29
+ "prepare": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
30
+ "build": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
31
+ "lint": "eslint src/**/*.ts",
32
+ "lint:fix": "eslint src/**/*.ts --fix",
33
+ "format": "prettier --write \"src/**/*.ts\"",
34
+ "format:check": "prettier --check \"src/**/*.ts\"",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "release": "release-it"
38
+ },
39
+ "lint-staged": {
40
+ "*.ts": [
41
+ "eslint --fix",
42
+ "prettier --write"
43
+ ]
30
44
  },
31
45
  "publishConfig": {
32
46
  "access": "public"
33
47
  },
34
48
  "devDependencies": {
49
+ "@eslint/js": "^9.21.0",
50
+ "@release-it/conventional-changelog": "^10.0.0",
51
+ "@typescript-eslint/eslint-plugin": "^8.25.0",
52
+ "@typescript-eslint/parser": "^8.25.0",
53
+ "eslint": "^9.21.0",
54
+ "globals": "^16.0.0",
55
+ "happy-dom": "^17.1.0",
56
+ "husky": "^9.1.7",
57
+ "lint-staged": "^15.4.3",
58
+ "prettier": "^3.5.2",
59
+ "release-it": "^18.0.0",
35
60
  "terser": "^5.46.0",
36
- "typescript": "^5.9.3"
61
+ "typescript": "^5.9.3",
62
+ "vitest": "^3.2.4"
37
63
  }
38
64
  }