@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.
- package/dist/component.d.ts +1 -0
- package/dist/component.js +64 -5
- package/dist/const/styles.js +29 -1
- package/dist/index.dev.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/message-builder.d.ts +5 -1
- package/dist/utils/message-builder.js +11 -2
- package/dist/utils/message-parser.d.ts +2 -1
- package/dist/utils/message-parser.js +21 -5
- package/package.json +15 -14
- package/src/component.ts +88 -5
- package/src/const/styles.ts +29 -1
- package/src/types/index.ts +1 -0
- package/src/utils/message-builder.ts +14 -2
- package/src/utils/message-parser.ts +24 -5
- package/dist/src/component.d.ts +0 -39
- package/dist/src/component.js +0 -184
- package/dist/src/const/authors.d.ts +0 -10
- package/dist/src/const/authors.js +0 -29
- package/dist/src/const/styles.d.ts +0 -12
- package/dist/src/const/styles.js +0 -425
- package/dist/src/const/theme.d.ts +0 -6
- package/dist/src/const/theme.js +0 -44
- package/dist/src/index.d.ts +0 -9
- package/dist/src/index.js +0 -12
- package/dist/src/types/index.d.ts +0 -34
- package/dist/src/types/index.js +0 -4
- package/dist/src/utils/author-resolver.d.ts +0 -11
- package/dist/src/utils/author-resolver.js +0 -75
- package/dist/src/utils/avatar.d.ts +0 -4
- package/dist/src/utils/avatar.js +0 -18
- package/dist/src/utils/html.d.ts +0 -12
- package/dist/src/utils/html.js +0 -33
- package/dist/src/utils/message-builder.d.ts +0 -13
- package/dist/src/utils/message-builder.js +0 -60
- package/dist/src/utils/message-parser.d.ts +0 -6
- package/dist/src/utils/message-parser.js +0 -22
- package/dist/src/utils/registration.d.ts +0 -8
- package/dist/src/utils/registration.js +0 -28
- package/dist/src/utils/scroll-button.d.ts +0 -4
- package/dist/src/utils/scroll-button.js +0 -12
- package/dist/src/utils/tooltip.d.ts +0 -5
- package/dist/src/utils/tooltip.js +0 -17
- package/dist/tests/html.test.d.ts +0 -1
- package/dist/tests/html.test.js +0 -35
- package/dist/vitest.config.d.ts +0 -2
- package/dist/vitest.config.js +0 -8
package/dist/component.d.ts
CHANGED
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
|
-
|
|
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((
|
|
179
|
+
.map((msg, i) => {
|
|
180
|
+
const { author, text } = msg;
|
|
126
181
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
127
|
-
|
|
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;
|
package/dist/const/styles.js
CHANGED
|
@@ -113,7 +113,7 @@ export const MAIN_STYLES = `
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
.msg-row--subsequent {
|
|
116
|
-
margin-top: 0.
|
|
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
|
|
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":"
|
|
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":[]}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
16
|
-
const text =
|
|
27
|
+
const author = remainingLine.slice(0, colonIdx).trim();
|
|
28
|
+
const text = remainingLine.slice(colonIdx + 1).trim();
|
|
17
29
|
if (author && text) {
|
|
18
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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((
|
|
222
|
+
.map((msg, i) => {
|
|
223
|
+
const { author, text } = msg;
|
|
154
224
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
155
|
-
|
|
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(
|
|
236
|
+
return buildMessageRowHtml(
|
|
237
|
+
author,
|
|
238
|
+
text,
|
|
239
|
+
config,
|
|
240
|
+
isSubsequent,
|
|
241
|
+
groupTimestamp,
|
|
242
|
+
isLastInGroup
|
|
243
|
+
);
|
|
161
244
|
})
|
|
162
245
|
.join('');
|
|
163
246
|
|
package/src/const/styles.ts
CHANGED
|
@@ -114,7 +114,7 @@ export const MAIN_STYLES = `
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
.msg-row--subsequent {
|
|
117
|
-
margin-top: 0.
|
|
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 {
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
19
|
-
const text =
|
|
33
|
+
const author = remainingLine.slice(0, colonIdx).trim();
|
|
34
|
+
const text = remainingLine.slice(colonIdx + 1).trim();
|
|
20
35
|
|
|
21
36
|
if (author && text) {
|
|
22
|
-
|
|
37
|
+
const message: Message = { author, text };
|
|
38
|
+
if (timestamp) {
|
|
39
|
+
message.timestamp = timestamp;
|
|
40
|
+
}
|
|
41
|
+
messages.push(message);
|
|
23
42
|
}
|
|
24
43
|
}
|
|
25
44
|
|
package/dist/src/component.d.ts
DELETED
|
@@ -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
|
-
}
|