@bbki.ng/bb-msg-history 0.5.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.
Files changed (48) hide show
  1. package/dist/component.d.ts +1 -0
  2. package/dist/component.js +64 -5
  3. package/dist/const/styles.js +29 -1
  4. package/dist/index.dev.js +12 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/types/index.d.ts +1 -0
  7. package/dist/utils/message-builder.d.ts +5 -1
  8. package/dist/utils/message-builder.js +11 -2
  9. package/dist/utils/message-parser.d.ts +2 -1
  10. package/dist/utils/message-parser.js +21 -5
  11. package/package.json +15 -14
  12. package/src/component.ts +88 -5
  13. package/src/const/styles.ts +29 -1
  14. package/src/types/index.ts +1 -0
  15. package/src/utils/message-builder.ts +14 -2
  16. package/src/utils/message-parser.ts +24 -5
  17. package/dist/src/component.d.ts +0 -39
  18. package/dist/src/component.js +0 -184
  19. package/dist/src/const/authors.d.ts +0 -10
  20. package/dist/src/const/authors.js +0 -29
  21. package/dist/src/const/styles.d.ts +0 -12
  22. package/dist/src/const/styles.js +0 -425
  23. package/dist/src/const/theme.d.ts +0 -6
  24. package/dist/src/const/theme.js +0 -44
  25. package/dist/src/index.d.ts +0 -9
  26. package/dist/src/index.js +0 -12
  27. package/dist/src/types/index.d.ts +0 -34
  28. package/dist/src/types/index.js +0 -4
  29. package/dist/src/utils/author-resolver.d.ts +0 -11
  30. package/dist/src/utils/author-resolver.js +0 -75
  31. package/dist/src/utils/avatar.d.ts +0 -4
  32. package/dist/src/utils/avatar.js +0 -18
  33. package/dist/src/utils/html.d.ts +0 -12
  34. package/dist/src/utils/html.js +0 -33
  35. package/dist/src/utils/message-builder.d.ts +0 -13
  36. package/dist/src/utils/message-builder.js +0 -60
  37. package/dist/src/utils/message-parser.d.ts +0 -6
  38. package/dist/src/utils/message-parser.js +0 -22
  39. package/dist/src/utils/registration.d.ts +0 -8
  40. package/dist/src/utils/registration.js +0 -28
  41. package/dist/src/utils/scroll-button.d.ts +0 -4
  42. package/dist/src/utils/scroll-button.js +0 -12
  43. package/dist/src/utils/tooltip.d.ts +0 -5
  44. package/dist/src/utils/tooltip.js +0 -17
  45. package/dist/tests/html.test.d.ts +0 -1
  46. package/dist/tests/html.test.js +0 -35
  47. package/dist/vitest.config.d.ts +0 -2
  48. package/dist/vitest.config.js +0 -8
@@ -3,6 +3,7 @@ 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;
7
8
  static get observedAttributes(): string[];
8
9
  constructor();
package/dist/component.js CHANGED
@@ -69,12 +69,30 @@ export class BBMsgHistory extends HTMLElement {
69
69
  }
70
70
  const author = message.author;
71
71
  const text = message.text;
72
+ const timestamp = message.timestamp;
72
73
  const config = resolveAuthorConfig(author, this._userAuthors);
73
- 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;
74
79
  this._lastAuthor = author;
75
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;
76
94
  // Use utility function to build message HTML
77
- const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent);
95
+ const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent, groupTimestamp, isLastInGroup);
78
96
  // Append to container
79
97
  container.insertAdjacentHTML('beforeend', msgHtml);
80
98
  // Setup tooltip for new element using utility function
@@ -117,18 +135,59 @@ export class BBMsgHistory extends HTMLElement {
117
135
  const messages = parseMessages(this.textContent);
118
136
  if (messages.length === 0) {
119
137
  this._lastAuthor = '';
138
+ this._lastGroupTimestamp = undefined;
120
139
  this._renderEmpty();
121
140
  return;
122
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
123
177
  let lastAuthor = '';
124
178
  const messagesHtml = messages
125
- .map(({ author, text }) => {
179
+ .map((msg, i) => {
180
+ const { author, text } = msg;
126
181
  const config = resolveAuthorConfig(author, this._userAuthors);
127
- 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);
128
184
  lastAuthor = author;
129
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);
130
189
  // Use utility function to build message HTML
131
- return buildMessageRowHtml(author, text, config, isSubsequent);
190
+ return buildMessageRowHtml(author, text, config, isSubsequent, groupTimestamp, isLastInGroup);
132
191
  })
133
192
  .join('');
134
193
  this._lastAuthor = lastAuthor;
@@ -113,7 +113,7 @@ export const MAIN_STYLES = `
113
113
  }
114
114
 
115
115
  .msg-row--subsequent {
116
- margin-top: 0.125rem;
116
+ margin-top: 0.375rem;
117
117
  }
118
118
 
119
119
  .msg-row--new-author {
@@ -192,6 +192,34 @@ export const MAIN_STYLES = `
192
192
  .msg-content {
193
193
  display: flex;
194
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;
195
223
  }
196
224
 
197
225
  .msg-bubble {
package/dist/index.dev.js CHANGED
@@ -1 +1,12 @@
1
- import{BBMsgHistory}from"./component.js";import{initBBMsgHistory}from"./utils/registration.js";"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>initBBMsgHistory(BBMsgHistory)):initBBMsgHistory(BBMsgHistory);export{BBMsgHistory};export{define}from"./utils/registration.js";
1
+ import { BBMsgHistory } from './component.js';
2
+ import { initBBMsgHistory } from './utils/registration.js';
3
+ // Auto-initialize
4
+ if (document.readyState === 'loading') {
5
+ document.addEventListener('DOMContentLoaded', () => initBBMsgHistory(BBMsgHistory));
6
+ }
7
+ else {
8
+ initBBMsgHistory(BBMsgHistory);
9
+ }
10
+ // Re-exports
11
+ export { BBMsgHistory };
12
+ export { define } from './utils/registration.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"names":["BBMsgHistory","initBBMsgHistory","document","readyState","addEventListener","define"],"sources":["dist/index.js"],"mappings":"OAAOA,iBAAiB,wBAAwBC,qBAAqB,0BAA0B,YAAYC,SAASC,WAAWD,SAASE,iBAAiB,mBAAmB,IAAIH,iBAAiBD,eAAeC,iBAAiBD,qBAAqBA,qBAAqBK,WAAW","ignoreList":[]}
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 {
@@ -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
  */
@@ -5,20 +5,28 @@ import { escapeHtml } from './html.js';
5
5
  */
6
6
  export function buildAvatarHtml(author, config, showAvatar) {
7
7
  return `
8
- <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
8
+ <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
9
9
  data-author="${escapeHtml(author)}">
10
10
  <div class="avatar">${config.avatar}</div>
11
11
  <div class="avatar-tooltip">${escapeHtml(author)}</div>
12
12
  </div>
13
13
  `;
14
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
+ }
15
21
  /**
16
22
  * Build a single message row HTML string
17
23
  */
18
- export function buildMessageRowHtml(author, text, config, isSubsequent) {
24
+ export function buildMessageRowHtml(author, text, config, isSubsequent, timestamp, isLastInGroup) {
19
25
  const showAvatar = !isSubsequent;
20
26
  const side = config.side;
21
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) : '';
22
30
  // Build inline style only for custom colors (not defaults)
23
31
  const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
24
32
  const isDefaultTextColor = config.textColor === THEME.gray[900];
@@ -35,6 +43,7 @@ export function buildMessageRowHtml(author, text, config, isSubsequent) {
35
43
  ${side === 'left' ? avatarHtml : ''}
36
44
 
37
45
  <div class="msg-content">
46
+ ${timestampHtml}
38
47
  <div class="msg-bubble msg-bubble--${side}"${styleAttr}>
39
48
  ${escapeHtml(text)}
40
49
  </div>
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/bb-msg-history",
3
- "version": "0.5.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",
@@ -23,6 +23,19 @@
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 --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
+ },
26
39
  "lint-staged": {
27
40
  "*.ts": [
28
41
  "eslint --fix",
@@ -47,17 +60,5 @@
47
60
  "terser": "^5.46.0",
48
61
  "typescript": "^5.9.3",
49
62
  "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"
62
63
  }
63
- }
64
+ }
package/src/component.ts CHANGED
@@ -10,6 +10,7 @@ export class BBMsgHistory extends HTMLElement {
10
10
  private _mutationObserver?: MutationObserver;
11
11
  private _userAuthors = new Map<string, AuthorOptions>();
12
12
  private _lastAuthor = '';
13
+ private _lastGroupTimestamp: string | undefined;
13
14
  private _scrollButtonVisible = false;
14
15
 
15
16
  static get observedAttributes() {
@@ -85,14 +86,43 @@ export class BBMsgHistory extends HTMLElement {
85
86
 
86
87
  const author = message.author;
87
88
  const text = message.text;
89
+ const timestamp = message.timestamp;
88
90
  const config = resolveAuthorConfig(author, this._userAuthors);
89
- const isFirstFromAuthor = author !== this._lastAuthor;
91
+
92
+ // Check if this can group with the last message
93
+ // Same author AND (no timestamp conflict)
94
+ const canGroupWithLast =
95
+ author === this._lastAuthor &&
96
+ (!this._lastGroupTimestamp || !timestamp || this._lastGroupTimestamp === timestamp);
97
+
98
+ const isFirstFromAuthor = !canGroupWithLast;
90
99
  this._lastAuthor = author;
91
100
 
92
101
  const isSubsequent = !isFirstFromAuthor;
93
102
 
103
+ // Update group timestamp tracking
104
+ if (isFirstFromAuthor) {
105
+ // Start new group
106
+ this._lastGroupTimestamp = timestamp;
107
+ } else if (!this._lastGroupTimestamp && timestamp) {
108
+ // If no timestamp in group yet and current has one, use it
109
+ this._lastGroupTimestamp = timestamp;
110
+ }
111
+
112
+ // When appending, we assume this IS the last in group (for now)
113
+ // If another message from same author comes, we'll re-render
114
+ const isLastInGroup = true;
115
+ const groupTimestamp = this._lastGroupTimestamp;
116
+
94
117
  // Use utility function to build message HTML
95
- const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent);
118
+ const msgHtml = buildMessageRowHtml(
119
+ author,
120
+ text,
121
+ config,
122
+ isSubsequent,
123
+ groupTimestamp,
124
+ isLastInGroup
125
+ );
96
126
 
97
127
  // Append to container
98
128
  container.insertAdjacentHTML('beforeend', msgHtml);
@@ -144,20 +174,73 @@ export class BBMsgHistory extends HTMLElement {
144
174
 
145
175
  if (messages.length === 0) {
146
176
  this._lastAuthor = '';
177
+ this._lastGroupTimestamp = undefined;
147
178
  this._renderEmpty();
148
179
  return;
149
180
  }
150
181
 
182
+ // Helper: Check if two messages can be grouped (same author, no timestamp conflict)
183
+ const canGroup = (prev: Message, curr: Message): boolean => {
184
+ if (prev.author !== curr.author) return false;
185
+ // Different timestamps = break group
186
+ if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
187
+ return false;
188
+ }
189
+ return true;
190
+ };
191
+
192
+ // First pass: determine which messages are last in their group
193
+ const lastInGroupFlags: boolean[] = messages.map((msg, i) => {
194
+ const next = messages[i + 1];
195
+ return !next || !canGroup(msg, next);
196
+ });
197
+
198
+ // Second pass: collect the timestamp for each group
199
+ // Use the first non-empty timestamp in the group
200
+ const groupTimestamps = new Map<number, string | undefined>();
201
+ let currentGroupTimestamp: string | undefined;
202
+
203
+ messages.forEach((msg, i) => {
204
+ // Start of a new group
205
+ if (i === 0 || !canGroup(messages[i - 1], msg)) {
206
+ currentGroupTimestamp = msg.timestamp;
207
+ } else if (!currentGroupTimestamp && msg.timestamp) {
208
+ // If no timestamp yet and current msg has one, use it
209
+ currentGroupTimestamp = msg.timestamp;
210
+ }
211
+
212
+ // If this is the last message in the group, save the timestamp
213
+ if (lastInGroupFlags[i]) {
214
+ groupTimestamps.set(i, currentGroupTimestamp);
215
+ currentGroupTimestamp = undefined;
216
+ }
217
+ });
218
+
219
+ // Third pass: build HTML
151
220
  let lastAuthor = '';
152
221
  const messagesHtml = messages
153
- .map(({ author, text }) => {
222
+ .map((msg, i) => {
223
+ const { author, text } = msg;
154
224
  const config = resolveAuthorConfig(author, this._userAuthors);
155
- const isFirstFromAuthor = author !== lastAuthor;
225
+
226
+ // Determine if this is a new author group (can't group with previous)
227
+ const isFirstFromAuthor = i === 0 || !canGroup(messages[i - 1], msg);
156
228
  lastAuthor = author;
157
229
  const isSubsequent = !isFirstFromAuthor;
158
230
 
231
+ // Get timestamp if this is the last in group
232
+ const isLastInGroup = lastInGroupFlags[i];
233
+ const groupTimestamp = groupTimestamps.get(i);
234
+
159
235
  // Use utility function to build message HTML
160
- return buildMessageRowHtml(author, text, config, isSubsequent);
236
+ return buildMessageRowHtml(
237
+ author,
238
+ text,
239
+ config,
240
+ isSubsequent,
241
+ groupTimestamp,
242
+ isLastInGroup
243
+ );
161
244
  })
162
245
  .join('');
163
246
 
@@ -114,7 +114,7 @@ export const MAIN_STYLES = `
114
114
  }
115
115
 
116
116
  .msg-row--subsequent {
117
- margin-top: 0.125rem;
117
+ margin-top: 0.375rem;
118
118
  }
119
119
 
120
120
  .msg-row--new-author {
@@ -193,6 +193,34 @@ export const MAIN_STYLES = `
193
193
  .msg-content {
194
194
  display: flex;
195
195
  flex-direction: column;
196
+ position: relative;
197
+ }
198
+
199
+ /* Timestamp styles */
200
+ .msg-timestamp {
201
+ position: absolute;
202
+ font-size: 11px;
203
+ color: ${THEME.gray[400]};
204
+ opacity: 0;
205
+ visibility: hidden;
206
+ transition: opacity 0.2s ease, visibility 0.2s ease;
207
+ white-space: nowrap;
208
+ bottom: -12px;
209
+ line-height: 1;
210
+ pointer-events: none;
211
+ }
212
+
213
+ .msg-timestamp--left {
214
+ left: 0;
215
+ }
216
+
217
+ .msg-timestamp--right {
218
+ right: 0;
219
+ }
220
+
221
+ .msg-row:hover .msg-timestamp {
222
+ opacity: 1;
223
+ visibility: visible;
196
224
  }
197
225
 
198
226
  .msg-bubble {
@@ -6,6 +6,7 @@
6
6
  export interface Message {
7
7
  author: string;
8
8
  text: string;
9
+ timestamp?: string;
9
10
  }
10
11
 
11
12
  /** Internal author configuration with resolved values */
@@ -7,7 +7,7 @@ import { escapeHtml } from './html.js';
7
7
  */
8
8
  export function buildAvatarHtml(author: string, config: AuthorConfig, showAvatar: boolean): string {
9
9
  return `
10
- <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
10
+ <div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
11
11
  data-author="${escapeHtml(author)}">
12
12
  <div class="avatar">${config.avatar}</div>
13
13
  <div class="avatar-tooltip">${escapeHtml(author)}</div>
@@ -15,6 +15,13 @@ export function buildAvatarHtml(author: string, config: AuthorConfig, showAvatar
15
15
  `;
16
16
  }
17
17
 
18
+ /**
19
+ * Build timestamp HTML string
20
+ */
21
+ export function buildTimestampHtml(timestamp: string, side: 'left' | 'right'): string {
22
+ return `<span class="msg-timestamp msg-timestamp--${side}">${escapeHtml(timestamp)}</span>`;
23
+ }
24
+
18
25
  /**
19
26
  * Build a single message row HTML string
20
27
  */
@@ -22,11 +29,15 @@ export function buildMessageRowHtml(
22
29
  author: string,
23
30
  text: string,
24
31
  config: AuthorConfig,
25
- isSubsequent: boolean
32
+ isSubsequent: boolean,
33
+ timestamp?: string,
34
+ isLastInGroup?: boolean
26
35
  ): string {
27
36
  const showAvatar = !isSubsequent;
28
37
  const side = config.side;
29
38
  const avatarHtml = buildAvatarHtml(author, config, showAvatar);
39
+ // Only show timestamp on the last message of a group
40
+ const timestampHtml = isLastInGroup && timestamp ? buildTimestampHtml(timestamp, side) : '';
30
41
 
31
42
  // Build inline style only for custom colors (not defaults)
32
43
  const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
@@ -47,6 +58,7 @@ export function buildMessageRowHtml(
47
58
  ${side === 'left' ? avatarHtml : ''}
48
59
 
49
60
  <div class="msg-content">
61
+ ${timestampHtml}
50
62
  <div class="msg-bubble msg-bubble--${side}"${styleAttr}>
51
63
  ${escapeHtml(text)}
52
64
  </div>
@@ -2,24 +2,43 @@ import type { Message } from '../types/index.js';
2
2
 
3
3
  /**
4
4
  * Parse text content into message array
5
- * Format: `author: text` (one message per line)
5
+ * Format: `[timestamp] author: text` or `author: text` (one message per line)
6
+ * Timestamp is optional for backward compatibility
6
7
  */
7
8
  export function parseMessages(textContent: string | null): Message[] {
8
9
  const raw = textContent || '';
9
10
  const messages: Message[] = [];
10
11
 
12
+ // Pattern: [timestamp] author: text (space between timestamp and author is optional)
13
+ const timestampPattern = /^\[([^\]]+)\]\s*(.+)$/;
14
+
11
15
  for (const line of raw.split('\n')) {
12
16
  const trimmed = line.trim();
13
17
  if (!trimmed) continue;
14
18
 
15
- const colonIdx = trimmed.indexOf(':');
19
+ let remainingLine = trimmed;
20
+ let timestamp: string | undefined;
21
+
22
+ // Try to extract timestamp
23
+ const timestampMatch = trimmed.match(timestampPattern);
24
+ if (timestampMatch) {
25
+ timestamp = timestampMatch[1].trim();
26
+ remainingLine = timestampMatch[2];
27
+ }
28
+
29
+ // Find the author:text separator (colon followed by space)
30
+ const colonIdx = remainingLine.indexOf(':');
16
31
  if (colonIdx <= 0) continue;
17
32
 
18
- const author = trimmed.slice(0, colonIdx).trim();
19
- const text = trimmed.slice(colonIdx + 1).trim();
33
+ const author = remainingLine.slice(0, colonIdx).trim();
34
+ const text = remainingLine.slice(colonIdx + 1).trim();
20
35
 
21
36
  if (author && text) {
22
- messages.push({ author, text });
37
+ const message: Message = { author, text };
38
+ if (timestamp) {
39
+ message.timestamp = timestamp;
40
+ }
41
+ messages.push(message);
23
42
  }
24
43
  }
25
44
 
@@ -1,39 +0,0 @@
1
- import type { AuthorOptions, Message } from './types/index.js';
2
- export declare class BBMsgHistory extends HTMLElement {
3
- private _mutationObserver?;
4
- private _userAuthors;
5
- private _lastAuthor;
6
- private _scrollButtonVisible;
7
- static get observedAttributes(): string[];
8
- constructor();
9
- attributeChangedCallback(): void;
10
- /**
11
- * Configure an author's avatar, side, and colors.
12
- * Call before or after rendering — the component re-renders automatically.
13
- *
14
- * @example
15
- * el.setAuthor('alice', { avatar: '🐱', side: 'right', bubbleColor: '#e0f2fe' });
16
- * el.setAuthor('bob', { avatar: '<img src="bob.png" />', side: 'left' });
17
- */
18
- setAuthor(name: string, options: AuthorOptions): this;
19
- /**
20
- * Remove a previously set author config.
21
- */
22
- removeAuthor(name: string): this;
23
- /**
24
- * Append a message to the history.
25
- * Automatically scrolls to the new message with smooth animation.
26
- *
27
- * @example
28
- * el.appendMessage({ author: 'alice', text: 'Hello!' });
29
- * el.appendMessage({ author: 'bob', text: 'How are you?' });
30
- */
31
- appendMessage(message: Message): this;
32
- private _appendSingleMessage;
33
- connectedCallback(): void;
34
- disconnectedCallback(): void;
35
- private _setupMutationObserver;
36
- private render;
37
- private _renderEmpty;
38
- private _setupScrollTracking;
39
- }