@texuli/chat 0.0.0-stage → 0.1.0-next.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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/composer/Composer.svelte +227 -0
- package/dist/composer/Composer.svelte.d.ts +20 -0
- package/dist/composer/TypingIndicator.svelte +90 -0
- package/dist/composer/TypingIndicator.svelte.d.ts +11 -0
- package/dist/composer/index.d.ts +4 -0
- package/dist/composer/index.js +3 -0
- package/dist/composer/labels.d.ts +9 -0
- package/dist/composer/labels.js +8 -0
- package/dist/composer/typing-labels.d.ts +5 -0
- package/dist/composer/typing-labels.js +1 -0
- package/dist/css.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/thread/ChatThread.svelte +71 -0
- package/dist/thread/ChatThread.svelte.d.ts +23 -0
- package/dist/thread/MessageList.svelte +409 -0
- package/dist/thread/MessageList.svelte.d.ts +22 -0
- package/dist/thread/group-messages.d.ts +3 -0
- package/dist/thread/group-messages.js +42 -0
- package/dist/thread/index.d.ts +6 -0
- package/dist/thread/index.js +5 -0
- package/dist/thread/labels.d.ts +3 -0
- package/dist/thread/labels.js +8 -0
- package/dist/thread/rich-text.d.ts +13 -0
- package/dist/thread/rich-text.js +29 -0
- package/dist/thread/types.d.ts +19 -0
- package/dist/thread/types.js +1 -0
- package/dist/thread/virtual-window.d.ts +39 -0
- package/dist/thread/virtual-window.js +117 -0
- package/package.json +31 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dailybot, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @texuli/chat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Chat interfaces: thread, message list, composer and typing indicator, generic for people and agents.
|
|
4
|
+
|
|
5
|
+
**Status:** scaffolded. Its contents are built by the PLAN_001 tasks T-chat-thread and T-chat-composer; per-item notes live in `docs/` here until they are folded into this README.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { Button } from 'texuli/button';
|
|
4
|
+
import { type ClassValue, cx } from 'texuli/cx';
|
|
5
|
+
import { autosizeTextarea } from 'texuli/input';
|
|
6
|
+
import { type ComposerLabels, DEFAULT_COMPOSER_LABELS } from './labels.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
value?: string;
|
|
10
|
+
/** Return false to keep the draft, or reject to show the translated error. */
|
|
11
|
+
onSubmit: (message: string) => unknown;
|
|
12
|
+
labels?: Partial<ComposerLabels>;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** External pending state, for example while a parent queues a send. */
|
|
15
|
+
sending?: boolean;
|
|
16
|
+
maxRows?: number;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
/** Attachment and other controls rendered beside the send button. */
|
|
19
|
+
attachments?: Snippet;
|
|
20
|
+
class?: ClassValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const id = $props.id();
|
|
24
|
+
let {
|
|
25
|
+
value = $bindable(''),
|
|
26
|
+
onSubmit,
|
|
27
|
+
labels: labelOverrides = {},
|
|
28
|
+
disabled = false,
|
|
29
|
+
sending = false,
|
|
30
|
+
maxRows = 6,
|
|
31
|
+
maxLength,
|
|
32
|
+
attachments,
|
|
33
|
+
class: className,
|
|
34
|
+
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
let field = $state<HTMLTextAreaElement | null>(null);
|
|
37
|
+
let pending = $state(false);
|
|
38
|
+
let error = $state(false);
|
|
39
|
+
let composing = $state(false);
|
|
40
|
+
const labels = $derived({ ...DEFAULT_COMPOSER_LABELS, ...labelOverrides });
|
|
41
|
+
const busy = $derived(sending || pending);
|
|
42
|
+
const canSend = $derived(!disabled && !busy && value.trim().length > 0);
|
|
43
|
+
const hintId = `chat-composer-${id}-hint`;
|
|
44
|
+
const errorId = `chat-composer-${id}-error`;
|
|
45
|
+
|
|
46
|
+
function resize(): void {
|
|
47
|
+
const element = field;
|
|
48
|
+
if (!element) return;
|
|
49
|
+
autosizeTextarea(element);
|
|
50
|
+
const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight) || 24;
|
|
51
|
+
const maxSize = lineHeight * Math.max(1, maxRows) + 16;
|
|
52
|
+
if (element.scrollHeight > maxSize) {
|
|
53
|
+
element.style.blockSize = `${maxSize}px`;
|
|
54
|
+
element.style.overflowY = 'auto';
|
|
55
|
+
} else {
|
|
56
|
+
element.style.overflowY = 'hidden';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
void value;
|
|
62
|
+
resize();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function submit(): Promise<void> {
|
|
66
|
+
if (!canSend) return;
|
|
67
|
+
const message = value.trim();
|
|
68
|
+
error = false;
|
|
69
|
+
pending = true;
|
|
70
|
+
try {
|
|
71
|
+
const result = await onSubmit(message);
|
|
72
|
+
if (result !== false) value = '';
|
|
73
|
+
} catch {
|
|
74
|
+
error = true;
|
|
75
|
+
} finally {
|
|
76
|
+
pending = false;
|
|
77
|
+
field?.focus();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
82
|
+
if (
|
|
83
|
+
event.key === 'Enter' &&
|
|
84
|
+
!event.shiftKey &&
|
|
85
|
+
!event.isComposing &&
|
|
86
|
+
!composing &&
|
|
87
|
+
event.keyCode !== 229
|
|
88
|
+
) {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
void submit();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<form
|
|
96
|
+
class={cx('tx-chat-composer', className)}
|
|
97
|
+
data-tx-chat-composer
|
|
98
|
+
onsubmit={(event) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
void submit();
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<label class="visually-hidden" for={`chat-composer-${id}`}>{labels.input}</label>
|
|
104
|
+
<textarea
|
|
105
|
+
bind:this={field}
|
|
106
|
+
value={value}
|
|
107
|
+
id={`chat-composer-${id}`}
|
|
108
|
+
aria-describedby={error ? `${hintId} ${errorId}` : hintId}
|
|
109
|
+
aria-invalid={error || undefined}
|
|
110
|
+
placeholder={labels.placeholder}
|
|
111
|
+
disabled={disabled || busy}
|
|
112
|
+
rows="1"
|
|
113
|
+
maxlength={maxLength}
|
|
114
|
+
class="tx-chat-composer__field"
|
|
115
|
+
oninput={(event) => {
|
|
116
|
+
value = event.currentTarget.value;
|
|
117
|
+
error = false;
|
|
118
|
+
resize();
|
|
119
|
+
}}
|
|
120
|
+
onkeydown={onKeydown}
|
|
121
|
+
oncompositionstart={() => (composing = true)}
|
|
122
|
+
oncompositionend={() => (composing = false)}
|
|
123
|
+
></textarea>
|
|
124
|
+
<div class="tx-chat-composer__actions">
|
|
125
|
+
<div class="tx-chat-composer__attachments">{@render attachments?.()}</div>
|
|
126
|
+
<Button
|
|
127
|
+
type="submit"
|
|
128
|
+
variant="outline"
|
|
129
|
+
size="sm"
|
|
130
|
+
disabled={!canSend}
|
|
131
|
+
loading={busy}
|
|
132
|
+
loadingLabel={labels.sending}
|
|
133
|
+
>
|
|
134
|
+
{labels.send}
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
<span id={hintId} class="visually-hidden">{labels.hint}</span>
|
|
138
|
+
{#if error}<p id={errorId} class="tx-chat-composer__error" role="alert">{labels.sendError}</p>{/if}
|
|
139
|
+
</form>
|
|
140
|
+
|
|
141
|
+
<style>
|
|
142
|
+
@layer tx.components {
|
|
143
|
+
.tx-chat-composer {
|
|
144
|
+
display: grid;
|
|
145
|
+
gap: 0.5rem;
|
|
146
|
+
border: 1px solid var(--tx-border, CanvasText);
|
|
147
|
+
border-radius: var(--tx-radius, 0.75rem);
|
|
148
|
+
background: var(--tx-surface, Canvas);
|
|
149
|
+
padding: 0.75rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.tx-chat-composer:focus-within {
|
|
153
|
+
border-color: var(--tx-ring, Highlight);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.tx-chat-composer__field {
|
|
157
|
+
box-sizing: border-box;
|
|
158
|
+
inline-size: 100%;
|
|
159
|
+
min-inline-size: 0;
|
|
160
|
+
min-block-size: 2.5rem;
|
|
161
|
+
max-block-size: none;
|
|
162
|
+
margin: 0;
|
|
163
|
+
border: 1px solid var(--tx-input, var(--tx-border, CanvasText));
|
|
164
|
+
border-radius: var(--tx-radius, 0.375rem);
|
|
165
|
+
background: var(--tx-surface, Canvas);
|
|
166
|
+
color: var(--tx-foreground, CanvasText);
|
|
167
|
+
padding: 0.625rem 0.75rem;
|
|
168
|
+
font-family: var(--tx-font-sans, system-ui, sans-serif);
|
|
169
|
+
line-height: 1.4;
|
|
170
|
+
resize: none;
|
|
171
|
+
overflow-y: hidden;
|
|
172
|
+
font-size: 1rem;
|
|
173
|
+
outline: none;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.tx-chat-composer__field:focus-visible {
|
|
177
|
+
border-color: var(--tx-ring, Highlight);
|
|
178
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--tx-ring, Highlight) 30%, transparent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.tx-chat-composer__field:disabled {
|
|
182
|
+
cursor: not-allowed;
|
|
183
|
+
opacity: 0.6;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.tx-chat-composer__field::placeholder {
|
|
187
|
+
color: var(--tx-muted-foreground, GrayText);
|
|
188
|
+
opacity: 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.tx-chat-composer__actions {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: end;
|
|
194
|
+
justify-content: space-between;
|
|
195
|
+
gap: 0.75rem;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.tx-chat-composer__attachments {
|
|
199
|
+
display: flex;
|
|
200
|
+
min-inline-size: 0;
|
|
201
|
+
flex-wrap: wrap;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: 0.5rem;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.tx-chat-composer__error {
|
|
207
|
+
margin: 0;
|
|
208
|
+
color: var(--tx-destructive, Mark);
|
|
209
|
+
font-size: 0.875rem;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.visually-hidden {
|
|
213
|
+
position: absolute;
|
|
214
|
+
inline-size: 1px;
|
|
215
|
+
block-size: 1px;
|
|
216
|
+
overflow: hidden;
|
|
217
|
+
clip-path: inset(50%);
|
|
218
|
+
white-space: nowrap;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@media (min-width: 640px) {
|
|
222
|
+
.tx-chat-composer__field {
|
|
223
|
+
font-size: 0.875rem;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type ClassValue } from 'texuli/cx';
|
|
3
|
+
import { type ComposerLabels } from './labels.js';
|
|
4
|
+
interface Props {
|
|
5
|
+
value?: string;
|
|
6
|
+
/** Return false to keep the draft, or reject to show the translated error. */
|
|
7
|
+
onSubmit: (message: string) => unknown;
|
|
8
|
+
labels?: Partial<ComposerLabels>;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** External pending state, for example while a parent queues a send. */
|
|
11
|
+
sending?: boolean;
|
|
12
|
+
maxRows?: number;
|
|
13
|
+
maxLength?: number;
|
|
14
|
+
/** Attachment and other controls rendered beside the send button. */
|
|
15
|
+
attachments?: Snippet;
|
|
16
|
+
class?: ClassValue;
|
|
17
|
+
}
|
|
18
|
+
declare const Composer: import("svelte").Component<Props, {}, "value">;
|
|
19
|
+
type Composer = ReturnType<typeof Composer>;
|
|
20
|
+
export default Composer;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type ClassValue, cx } from 'texuli/cx';
|
|
3
|
+
import type { TypingLabels } from './typing-labels.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LABELS: TypingLabels = {
|
|
6
|
+
one: (name) => `${name} is typing`,
|
|
7
|
+
two: (first, second) => `${first} and ${second} are typing`,
|
|
8
|
+
many: (count) => `${count} people are typing`,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
names: readonly string[];
|
|
13
|
+
labels?: Partial<TypingLabels>;
|
|
14
|
+
showLabel?: boolean;
|
|
15
|
+
class?: ClassValue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { names, labels: labelOverrides = {}, showLabel = false, class: className }: Props = $props();
|
|
19
|
+
const labels = $derived({ ...DEFAULT_LABELS, ...labelOverrides });
|
|
20
|
+
const message = $derived.by(() => {
|
|
21
|
+
if (names.length === 0) return '';
|
|
22
|
+
if (names.length === 1) return labels.one(names[0] ?? '');
|
|
23
|
+
if (names.length === 2) return labels.two(names[0] ?? '', names[1] ?? '');
|
|
24
|
+
return labels.many(names.length);
|
|
25
|
+
});
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div class={cx('tx-chat-typing', className)} role="status" aria-live="polite" aria-atomic="true">
|
|
29
|
+
{#if message}
|
|
30
|
+
<span class={cx('tx-chat-typing__label', !showLabel && 'visually-hidden')}>{message}</span>
|
|
31
|
+
<span class="tx-chat-typing__dots" aria-hidden="true">
|
|
32
|
+
<span></span><span></span><span></span>
|
|
33
|
+
</span>
|
|
34
|
+
{/if}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
@layer tx.components {
|
|
39
|
+
.tx-chat-typing {
|
|
40
|
+
display: flex;
|
|
41
|
+
min-block-size: 1.5rem;
|
|
42
|
+
align-items: center;
|
|
43
|
+
color: var(--tx-muted-foreground, GrayText);
|
|
44
|
+
font-size: 0.875rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.tx-chat-typing__dots {
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
gap: 0.25rem;
|
|
50
|
+
margin-inline-start: 0.5rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.tx-chat-typing__dots span {
|
|
54
|
+
inline-size: 0.375rem;
|
|
55
|
+
block-size: 0.375rem;
|
|
56
|
+
border-radius: 50%;
|
|
57
|
+
background: currentColor;
|
|
58
|
+
animation: tx-chat-pulse 1s ease-in-out infinite alternate;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.tx-chat-typing__dots span:nth-child(2) {
|
|
62
|
+
animation-delay: 150ms;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.tx-chat-typing__dots span:nth-child(3) {
|
|
66
|
+
animation-delay: 300ms;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.visually-hidden {
|
|
70
|
+
position: absolute;
|
|
71
|
+
inline-size: 1px;
|
|
72
|
+
block-size: 1px;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
clip-path: inset(50%);
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@keyframes tx-chat-pulse {
|
|
79
|
+
to {
|
|
80
|
+
opacity: 0.35;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (prefers-reduced-motion: reduce) {
|
|
85
|
+
.tx-chat-typing__dots span {
|
|
86
|
+
animation: none;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ClassValue } from 'texuli/cx';
|
|
2
|
+
import type { TypingLabels } from './typing-labels.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
names: readonly string[];
|
|
5
|
+
labels?: Partial<TypingLabels>;
|
|
6
|
+
showLabel?: boolean;
|
|
7
|
+
class?: ClassValue;
|
|
8
|
+
}
|
|
9
|
+
declare const TypingIndicator: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type TypingIndicator = ReturnType<typeof TypingIndicator>;
|
|
11
|
+
export default TypingIndicator;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DEFAULT_COMPOSER_LABELS = {
|
|
2
|
+
input: 'Message',
|
|
3
|
+
placeholder: 'Write a message…',
|
|
4
|
+
hint: 'Press Enter to send. Press Shift+Enter for a new line.',
|
|
5
|
+
send: 'Send message',
|
|
6
|
+
sending: 'Sending message',
|
|
7
|
+
sendError: 'Your message could not be sent. Please try again.',
|
|
8
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/css.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css' {}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { Composer, type ComposerLabels, DEFAULT_COMPOSER_LABELS, TypingIndicator, type TypingLabels, } from './composer/index.js';
|
|
2
|
+
export { type ChatAuthor, type ChatMessage, type ChatMessageStatus, ChatThread, groupMessages, type MessageGroup, MessageList, type MessageTextPart, parseMessageText, } from './thread/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// @texuli/chat: entries are added by the PLAN_001 tasks that build this package (docs/ARCHITECTURE.md).
|
|
2
|
+
export { Composer, DEFAULT_COMPOSER_LABELS, TypingIndicator, } from "./composer/index.js";
|
|
3
|
+
export { ChatThread, groupMessages, MessageList, parseMessageText, } from "./thread/index.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { type ClassValue, cx } from 'texuli/cx';
|
|
4
|
+
import Composer from '../composer/Composer.svelte';
|
|
5
|
+
import TypingIndicator from '../composer/TypingIndicator.svelte';
|
|
6
|
+
import type { MessageListLabels } from './labels.js';
|
|
7
|
+
import MessageList from './MessageList.svelte';
|
|
8
|
+
import type { ChatMessage } from './types.js';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
messages: ChatMessage[];
|
|
12
|
+
currentUserId?: string;
|
|
13
|
+
typing?: string[];
|
|
14
|
+
draft?: string;
|
|
15
|
+
onSend?: (body: string) => unknown;
|
|
16
|
+
sending?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
body?: Snippet<[ChatMessage]>;
|
|
20
|
+
header?: Snippet;
|
|
21
|
+
emptyText?: string;
|
|
22
|
+
locale?: string;
|
|
23
|
+
labels?: Partial<MessageListLabels>;
|
|
24
|
+
class?: ClassValue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
messages,
|
|
29
|
+
currentUserId,
|
|
30
|
+
typing = [],
|
|
31
|
+
draft = $bindable(''),
|
|
32
|
+
onSend,
|
|
33
|
+
sending = false,
|
|
34
|
+
disabled = false,
|
|
35
|
+
placeholder,
|
|
36
|
+
body,
|
|
37
|
+
header,
|
|
38
|
+
emptyText,
|
|
39
|
+
locale,
|
|
40
|
+
labels,
|
|
41
|
+
class: className,
|
|
42
|
+
}: Props = $props();
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<section class={cx('tx-chat-thread', className)} data-tx-chat-thread>
|
|
46
|
+
{@render header?.()}
|
|
47
|
+
<MessageList {messages} {currentUserId} {body} {emptyText} {locale} {labels}>
|
|
48
|
+
{#snippet footer()}
|
|
49
|
+
<TypingIndicator names={typing} class="tx-chat-thread__typing" />
|
|
50
|
+
{/snippet}
|
|
51
|
+
</MessageList>
|
|
52
|
+
{#if onSend}
|
|
53
|
+
<div class="tx-chat-thread__composer">
|
|
54
|
+
<Composer
|
|
55
|
+
bind:value={draft}
|
|
56
|
+
onSubmit={onSend}
|
|
57
|
+
{sending}
|
|
58
|
+
{disabled}
|
|
59
|
+
labels={placeholder ? { placeholder } : undefined}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
{/if}
|
|
63
|
+
</section>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
@layer tx.components {
|
|
67
|
+
.tx-chat-thread { display: flex; min-block-size: 0; flex: 1; flex-direction: column; background: var(--tx-surface, Canvas); color: var(--tx-foreground, CanvasText); }
|
|
68
|
+
.tx-chat-thread__composer { border-block-start: 1px solid var(--tx-border, ButtonBorder); padding: 0.75rem; padding-block-end: max(0.75rem, env(safe-area-inset-bottom)); }
|
|
69
|
+
.tx-chat-thread__typing { margin-block-start: 0.75rem; }
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type ClassValue } from 'texuli/cx';
|
|
3
|
+
import type { MessageListLabels } from './labels.js';
|
|
4
|
+
import type { ChatMessage } from './types.js';
|
|
5
|
+
interface Props {
|
|
6
|
+
messages: ChatMessage[];
|
|
7
|
+
currentUserId?: string;
|
|
8
|
+
typing?: string[];
|
|
9
|
+
draft?: string;
|
|
10
|
+
onSend?: (body: string) => unknown;
|
|
11
|
+
sending?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
body?: Snippet<[ChatMessage]>;
|
|
15
|
+
header?: Snippet;
|
|
16
|
+
emptyText?: string;
|
|
17
|
+
locale?: string;
|
|
18
|
+
labels?: Partial<MessageListLabels>;
|
|
19
|
+
class?: ClassValue;
|
|
20
|
+
}
|
|
21
|
+
declare const ChatThread: import("svelte").Component<Props, {}, "draft">;
|
|
22
|
+
type ChatThread = ReturnType<typeof ChatThread>;
|
|
23
|
+
export default ChatThread;
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { Avatar } from 'texuli/avatar';
|
|
5
|
+
import { type ClassValue, cx } from 'texuli/cx';
|
|
6
|
+
import { groupMessages } from './group-messages.js';
|
|
7
|
+
import { DEFAULT_MESSAGE_LIST_LABELS, type MessageListLabels } from './labels.js';
|
|
8
|
+
import { parseMessageText } from './rich-text.js';
|
|
9
|
+
import type { ChatMessage, ChatMessageStatus } from './types.js';
|
|
10
|
+
import { MessageVirtualWindow, type VirtualWindow } from './virtual-window.js';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
messages: ChatMessage[];
|
|
14
|
+
currentUserId?: string;
|
|
15
|
+
/** Optional renderer for attachments or application-owned rich content. */
|
|
16
|
+
body?: Snippet<[ChatMessage]>;
|
|
17
|
+
footer?: Snippet;
|
|
18
|
+
emptyText?: string;
|
|
19
|
+
locale?: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
labels?: Partial<MessageListLabels>;
|
|
22
|
+
/** Virtualization starts at this number of author groups. Set to Infinity to disable. */
|
|
23
|
+
virtualizeAfter?: number;
|
|
24
|
+
overscan?: number;
|
|
25
|
+
class?: ClassValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
messages,
|
|
30
|
+
currentUserId,
|
|
31
|
+
body,
|
|
32
|
+
footer,
|
|
33
|
+
emptyText = 'No messages yet. Say hello!',
|
|
34
|
+
locale,
|
|
35
|
+
label = 'Messages',
|
|
36
|
+
labels: labelOverrides = {},
|
|
37
|
+
virtualizeAfter = 100,
|
|
38
|
+
overscan = 5,
|
|
39
|
+
class: className,
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
|
|
42
|
+
let viewport = $state<HTMLDivElement | null>(null);
|
|
43
|
+
let scrollTop = $state(0);
|
|
44
|
+
let viewportSize = $state(360);
|
|
45
|
+
let revision = $state(0);
|
|
46
|
+
let list = new MessageVirtualWindow(0);
|
|
47
|
+
let stickToBottom = true;
|
|
48
|
+
let previousCount = 0;
|
|
49
|
+
let previousFirstId: string | undefined;
|
|
50
|
+
let previousLastId: string | undefined;
|
|
51
|
+
let firstIdBeforeUpdate: string | undefined;
|
|
52
|
+
let scrollHeightBeforeUpdate = 0;
|
|
53
|
+
let scrollTopBeforeUpdate = 0;
|
|
54
|
+
let anchorBeforeUpdate: ScrollAnchor | null = null;
|
|
55
|
+
/** The virtual window as last rendered: row starts here match the DOM. */
|
|
56
|
+
let rendered: VirtualWindow | null = null;
|
|
57
|
+
let remeasureFrame = 0;
|
|
58
|
+
|
|
59
|
+
/** A rendered row and where it sat, used to keep it still while offsets change. */
|
|
60
|
+
interface ScrollAnchor {
|
|
61
|
+
key: string;
|
|
62
|
+
start: number;
|
|
63
|
+
scrollTop: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const groups = $derived(groupMessages(messages));
|
|
67
|
+
const labels = $derived({ ...DEFAULT_MESSAGE_LIST_LABELS, ...labelOverrides });
|
|
68
|
+
const renderGroups = $derived.by(() =>
|
|
69
|
+
groups.flatMap((group) => {
|
|
70
|
+
const rows: Array<typeof group & { key: string; showHeader: boolean }> = [];
|
|
71
|
+
for (let offset = 0; offset < group.messages.length; offset += 20) {
|
|
72
|
+
rows.push({
|
|
73
|
+
...group,
|
|
74
|
+
key: `${group.key}-${offset}`,
|
|
75
|
+
messages: group.messages.slice(offset, offset + 20),
|
|
76
|
+
dayStart: offset === 0 ? group.dayStart : null,
|
|
77
|
+
showHeader: offset === 0,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
const rowKeys = $derived(renderGroups.map((row) => row.key));
|
|
84
|
+
const useVirtual = $derived(messages.length >= Math.max(1, virtualizeAfter));
|
|
85
|
+
const rows = $derived.by((): VirtualWindow => {
|
|
86
|
+
revision;
|
|
87
|
+
list.resize(renderGroups.length, rowKeys);
|
|
88
|
+
if (useVirtual) {
|
|
89
|
+
rendered = list.window(scrollTop, viewportSize, overscan);
|
|
90
|
+
return rendered;
|
|
91
|
+
}
|
|
92
|
+
rendered = null;
|
|
93
|
+
return {
|
|
94
|
+
totalSize: 0,
|
|
95
|
+
items: renderGroups.map((row, index) => ({
|
|
96
|
+
index,
|
|
97
|
+
key: row.key,
|
|
98
|
+
start: 0,
|
|
99
|
+
size: 0,
|
|
100
|
+
measured: false,
|
|
101
|
+
})),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const statusText = $derived({
|
|
106
|
+
sending: labels.sending,
|
|
107
|
+
sent: labels.sent,
|
|
108
|
+
delivered: labels.delivered,
|
|
109
|
+
read: labels.read,
|
|
110
|
+
failed: labels.failed,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function dateFor(value: ChatMessage['at']): Date | null {
|
|
114
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
115
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatTime(value: ChatMessage['at']): string {
|
|
119
|
+
const date = dateFor(value);
|
|
120
|
+
if (!date) return '';
|
|
121
|
+
try {
|
|
122
|
+
return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date);
|
|
123
|
+
} catch {
|
|
124
|
+
return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatDay(value: Date): string {
|
|
129
|
+
try {
|
|
130
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(value);
|
|
131
|
+
} catch {
|
|
132
|
+
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function nearBottom(element: HTMLElement): boolean {
|
|
137
|
+
return element.scrollHeight - element.scrollTop - element.clientHeight < 96;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onScroll(event: Event): void {
|
|
141
|
+
const element = event.currentTarget as HTMLDivElement;
|
|
142
|
+
scrollTop = element.scrollTop;
|
|
143
|
+
viewportSize = element.clientHeight || viewportSize;
|
|
144
|
+
stickToBottom = nearBottom(element);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The topmost rendered row in view, with the start it was rendered at. The
|
|
148
|
+
// model can be ahead of the DOM (a row measured, its re-render still waiting
|
|
149
|
+
// for a frame), so anchors come from the rendered window, never the model.
|
|
150
|
+
function captureAnchor(element: HTMLElement): ScrollAnchor | null {
|
|
151
|
+
if (!rendered || rendered.items.length === 0) return null;
|
|
152
|
+
const top = element.scrollTop;
|
|
153
|
+
const canvasTop = (element.firstElementChild as HTMLElement | null)?.offsetTop ?? 0;
|
|
154
|
+
const row =
|
|
155
|
+
rendered.items.find((item) => canvasTop + item.start + item.size > top) ??
|
|
156
|
+
rendered.items[rendered.items.length - 1];
|
|
157
|
+
return row ? { key: row.key, start: row.start, scrollTop: top } : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Moves the scroll position by however far the anchor row moved, so the
|
|
161
|
+
// reader keeps seeing the same message. False when the row no longer exists.
|
|
162
|
+
function restoreAnchor(element: HTMLElement, anchor: ScrollAnchor): boolean {
|
|
163
|
+
const index = list.indexOfKey(anchor.key);
|
|
164
|
+
if (index < 0) return false;
|
|
165
|
+
element.scrollTop = anchor.scrollTop + (list.offsetOf(index) - anchor.start);
|
|
166
|
+
scrollTop = element.scrollTop;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Rows measured late re-render once per frame. Rows above the anchor change
|
|
171
|
+
// the offsets below them, so the anchor is held in place across the re-render.
|
|
172
|
+
function scheduleRemeasure(): void {
|
|
173
|
+
if (remeasureFrame) return;
|
|
174
|
+
remeasureFrame = requestAnimationFrame(() => {
|
|
175
|
+
remeasureFrame = 0;
|
|
176
|
+
const element = viewport;
|
|
177
|
+
const anchor = element && useVirtual && !stickToBottom ? captureAnchor(element) : null;
|
|
178
|
+
revision += 1;
|
|
179
|
+
if (element && anchor) void tick().then(() => restoreAnchor(element, anchor));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
$effect(() => () => {
|
|
184
|
+
if (remeasureFrame) cancelAnimationFrame(remeasureFrame);
|
|
185
|
+
remeasureFrame = 0;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
function measureRow(node: HTMLElement, key: string) {
|
|
189
|
+
let rowKey = key;
|
|
190
|
+
const observer =
|
|
191
|
+
typeof ResizeObserver === 'undefined'
|
|
192
|
+
? undefined
|
|
193
|
+
: new ResizeObserver(([entry]) => {
|
|
194
|
+
const height = entry?.borderBoxSize?.[0]?.blockSize ?? entry?.contentRect.height;
|
|
195
|
+
if (height && list.measureKey(rowKey, height)) scheduleRemeasure();
|
|
196
|
+
});
|
|
197
|
+
observer?.observe(node);
|
|
198
|
+
return {
|
|
199
|
+
update(nextKey: string) {
|
|
200
|
+
rowKey = nextKey;
|
|
201
|
+
},
|
|
202
|
+
destroy() {
|
|
203
|
+
observer?.disconnect();
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Content that grows after a stick (rows measured late, fonts, a typing
|
|
209
|
+
// footer) keeps the list at the bottom while the reader is there.
|
|
210
|
+
$effect(() => {
|
|
211
|
+
const element = viewport;
|
|
212
|
+
if (!element || typeof ResizeObserver === 'undefined') return;
|
|
213
|
+
const resize = new ResizeObserver(() => {
|
|
214
|
+
if (stickToBottom) element.scrollTop = element.scrollHeight;
|
|
215
|
+
});
|
|
216
|
+
const observeChildren = () => {
|
|
217
|
+
resize.disconnect();
|
|
218
|
+
for (const child of element.children) resize.observe(child);
|
|
219
|
+
};
|
|
220
|
+
observeChildren();
|
|
221
|
+
const children = new MutationObserver(observeChildren);
|
|
222
|
+
children.observe(element, { childList: true });
|
|
223
|
+
return () => {
|
|
224
|
+
children.disconnect();
|
|
225
|
+
resize.disconnect();
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
$effect.pre(() => {
|
|
230
|
+
void messages.length;
|
|
231
|
+
firstIdBeforeUpdate = messages[0]?.id;
|
|
232
|
+
if (viewport) {
|
|
233
|
+
stickToBottom = nearBottom(viewport);
|
|
234
|
+
scrollHeightBeforeUpdate = viewport.scrollHeight;
|
|
235
|
+
scrollTopBeforeUpdate = viewport.scrollTop;
|
|
236
|
+
anchorBeforeUpdate = captureAnchor(viewport);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
$effect(() => {
|
|
241
|
+
const count = messages.length;
|
|
242
|
+
const firstId = messages[0]?.id;
|
|
243
|
+
const last = messages[count - 1];
|
|
244
|
+
const prepended =
|
|
245
|
+
previousCount > 0 && count > previousCount && firstIdBeforeUpdate !== previousFirstId;
|
|
246
|
+
const ownNewMessage =
|
|
247
|
+
count > previousCount &&
|
|
248
|
+
last !== undefined &&
|
|
249
|
+
last.id !== previousLastId &&
|
|
250
|
+
last.author.id === currentUserId;
|
|
251
|
+
const shouldStick = count > previousCount && (stickToBottom || ownNewMessage);
|
|
252
|
+
previousCount = count;
|
|
253
|
+
previousFirstId = firstId;
|
|
254
|
+
previousLastId = last?.id;
|
|
255
|
+
if (!viewport) return;
|
|
256
|
+
const element = viewport;
|
|
257
|
+
if (shouldStick) {
|
|
258
|
+
void tick().then(() => {
|
|
259
|
+
element.scrollTop = element.scrollHeight;
|
|
260
|
+
scrollTop = element.scrollTop;
|
|
261
|
+
});
|
|
262
|
+
} else if (prepended) {
|
|
263
|
+
// Virtual rows: keep the anchor row still, using model offsets that the
|
|
264
|
+
// DOM has just rendered. Comparing scrollHeight before and after would mix
|
|
265
|
+
// a stale DOM height with measurements that landed in between.
|
|
266
|
+
const anchor = useVirtual ? anchorBeforeUpdate : null;
|
|
267
|
+
void tick().then(() => {
|
|
268
|
+
if (anchor && restoreAnchor(element, anchor)) return;
|
|
269
|
+
element.scrollTop = scrollTopBeforeUpdate + (element.scrollHeight - scrollHeightBeforeUpdate);
|
|
270
|
+
scrollTop = element.scrollTop;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
</script>
|
|
275
|
+
|
|
276
|
+
<!-- The focusable log provides keyboard scrolling and polite announcements for appended messages. -->
|
|
277
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
278
|
+
<div
|
|
279
|
+
bind:this={viewport}
|
|
280
|
+
class={cx('tx-chat-message-list', className)}
|
|
281
|
+
data-tx-chat-message-list
|
|
282
|
+
data-virtualized={useVirtual || undefined}
|
|
283
|
+
role="log"
|
|
284
|
+
aria-label={label}
|
|
285
|
+
aria-live="polite"
|
|
286
|
+
aria-relevant="additions text"
|
|
287
|
+
tabindex="0"
|
|
288
|
+
onscroll={onScroll}
|
|
289
|
+
>
|
|
290
|
+
{#if messages.length === 0}
|
|
291
|
+
<p class="tx-chat-message-list__empty">{emptyText}</p>
|
|
292
|
+
{:else}
|
|
293
|
+
<div class={cx('tx-chat-message-list__canvas', useVirtual && 'tx-chat-message-list__canvas--virtual')} style:block-size={useVirtual ? `${rows.totalSize}px` : undefined}>
|
|
294
|
+
{#each rows.items as row (row.key)}
|
|
295
|
+
{@const group = renderGroups[row.index]}
|
|
296
|
+
{@const first = group?.messages[0]}
|
|
297
|
+
{@const own = group !== undefined && currentUserId !== undefined && group.author.id === currentUserId}
|
|
298
|
+
{#if group && first}
|
|
299
|
+
<div
|
|
300
|
+
class={cx(
|
|
301
|
+
'tx-chat-message-group',
|
|
302
|
+
useVirtual && 'tx-chat-message-group--virtual',
|
|
303
|
+
own && 'tx-chat-message-group--own'
|
|
304
|
+
)}
|
|
305
|
+
data-message-group={group.key}
|
|
306
|
+
use:measureRow={row.key}
|
|
307
|
+
style:inset-block-start={useVirtual ? `${row.start}px` : undefined}
|
|
308
|
+
style:block-size={useVirtual && row.measured ? `${row.size}px` : undefined}
|
|
309
|
+
>
|
|
310
|
+
{#if group.showHeader && group.dayStart}
|
|
311
|
+
<div class="tx-chat-message-list__day" role="separator">
|
|
312
|
+
<span>{formatDay(group.dayStart)}</span>
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
{#if !own && group.showHeader}
|
|
316
|
+
<Avatar name={group.author.name} src={group.author.avatarUrl} size="sm" decorative class="tx-chat-message-group__avatar" />
|
|
317
|
+
{:else if !own}
|
|
318
|
+
<span class="tx-chat-message-group__avatar-spacer" aria-hidden="true"></span>
|
|
319
|
+
{/if}
|
|
320
|
+
<div class={cx('tx-chat-message-group__content', own && 'tx-chat-message-group__content--own')}>
|
|
321
|
+
{#if group.showHeader}
|
|
322
|
+
<p class="tx-chat-message-group__meta">
|
|
323
|
+
{#if !own}<span class="tx-chat-message-group__author">{group.author.name}</span>{:else}<span class="tx-chat-message-group__you">{labels.you}</span>{/if}
|
|
324
|
+
{#if dateFor(first.at)}<time datetime={dateFor(first.at)?.toISOString()}>{formatTime(first.at)}</time>{/if}
|
|
325
|
+
</p>
|
|
326
|
+
{/if}
|
|
327
|
+
{#each group.messages as message (message.id)}
|
|
328
|
+
<div class={cx('tx-chat-message', own && 'tx-chat-message--own', message.status === 'failed' && 'tx-chat-message--failed')}>
|
|
329
|
+
{#if body}
|
|
330
|
+
{@render body(message)}
|
|
331
|
+
{:else}
|
|
332
|
+
{#each parseMessageText(message.body) as part}
|
|
333
|
+
{#if part.type === 'strong'}<strong>{part.value}</strong>
|
|
334
|
+
{:else if part.type === 'em'}<em>{part.value}</em>
|
|
335
|
+
{:else if part.type === 'code'}<code>{part.value}</code>
|
|
336
|
+
{:else if part.type === 'link'}<a href={part.href}>{part.value}</a>
|
|
337
|
+
{:else}{part.value}{/if}
|
|
338
|
+
{/each}
|
|
339
|
+
{/if}
|
|
340
|
+
</div>
|
|
341
|
+
{#if message.status === 'failed' || (own && message.status && message === group.messages[group.messages.length - 1])}
|
|
342
|
+
<p class={cx('tx-chat-message-group__status', message.status === 'failed' && 'tx-chat-message-group__status--failed')}>
|
|
343
|
+
{statusText[message.status]}
|
|
344
|
+
</p>
|
|
345
|
+
{/if}
|
|
346
|
+
{/each}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
{/if}
|
|
350
|
+
{/each}
|
|
351
|
+
</div>
|
|
352
|
+
{/if}
|
|
353
|
+
{@render footer?.()}
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<style>
|
|
357
|
+
@layer tx.components {
|
|
358
|
+
.tx-chat-message-list {
|
|
359
|
+
position: relative;
|
|
360
|
+
display: flex;
|
|
361
|
+
min-block-size: 0;
|
|
362
|
+
flex: 1;
|
|
363
|
+
flex-direction: column;
|
|
364
|
+
overflow: auto;
|
|
365
|
+
overscroll-behavior: contain;
|
|
366
|
+
padding: 1rem;
|
|
367
|
+
outline: none;
|
|
368
|
+
color: var(--tx-foreground, CanvasText);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.tx-chat-message-list:focus-visible {
|
|
372
|
+
outline: 2px solid var(--tx-ring, Highlight);
|
|
373
|
+
outline-offset: -2px;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.tx-chat-message-list__canvas { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
377
|
+
.tx-chat-message-list__canvas--virtual { position: relative; display: block; flex: none; }
|
|
378
|
+
.tx-chat-message-group--virtual { position: absolute; inset-inline: 0; }
|
|
379
|
+
.tx-chat-message-group { display: flex; align-items: flex-start; gap: 0.625rem; min-inline-size: 0; }
|
|
380
|
+
:global(.tx-chat-message-group__avatar .tx-avatar__surface[data-tone]) { color: CanvasText; }
|
|
381
|
+
.tx-chat-message-group__avatar { margin-block-start: 1.25rem; flex: none; }
|
|
382
|
+
.tx-chat-message-group__avatar-spacer { inline-size: 1.75rem; flex: none; }
|
|
383
|
+
.tx-chat-message-group__content { display: flex; min-inline-size: 0; max-inline-size: min(85%, 42rem); flex-direction: column; align-items: flex-start; gap: 0.25rem; }
|
|
384
|
+
.tx-chat-message-group__content--own { margin-inline-start: auto; align-items: flex-end; }
|
|
385
|
+
.tx-chat-message-group__meta { display: flex; align-items: baseline; gap: 0.5rem; margin: 0; padding-inline: 0.25rem; color: var(--tx-muted-foreground, GrayText); font-size: 0.75rem; }
|
|
386
|
+
.tx-chat-message-group__author { color: var(--tx-foreground, CanvasText); font-weight: 600; }
|
|
387
|
+
.tx-chat-message-group__you { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
388
|
+
.tx-chat-message { max-inline-size: 100%; overflow-wrap: anywhere; white-space: pre-wrap; border-radius: 1rem 1rem 1rem 0.25rem; background: var(--tx-muted, ButtonFace); padding: 0.5rem 0.875rem; }
|
|
389
|
+
.tx-chat-message--own { border-radius: 1rem 1rem 0.25rem 1rem; background: var(--tx-primary, Highlight); color: var(--tx-primary-foreground, HighlightText); }
|
|
390
|
+
.tx-chat-message--failed { outline: 2px solid var(--tx-destructive, Mark); }
|
|
391
|
+
.tx-chat-message :global(a) { color: inherit; text-decoration: underline; text-underline-offset: 0.15em; }
|
|
392
|
+
.tx-chat-message code { border-radius: 0.25rem; background: var(--tx-surface, Canvas); color: var(--tx-foreground, CanvasText); padding-inline: 0.2em; font-family: var(--tx-font-mono, ui-monospace, monospace); }
|
|
393
|
+
.tx-chat-message-group__status { margin: 0; color: var(--tx-muted-foreground, GrayText); font-size: 0.7rem; }
|
|
394
|
+
.tx-chat-message-group__status--failed { color: var(--tx-destructive, Mark); font-weight: 600; }
|
|
395
|
+
.tx-chat-message-list__day { position: relative; display: flex; align-items: center; justify-content: center; margin-block: 0.35rem; color: var(--tx-muted-foreground, GrayText); font-size: 0.75rem; }
|
|
396
|
+
.tx-chat-message-list__day::before { position: absolute; inset-inline: 0; block-size: 1px; background: var(--tx-border, ButtonBorder); content: ''; }
|
|
397
|
+
.tx-chat-message-list__day span { position: relative; background: var(--tx-surface, Canvas); padding-inline: 0.5rem; }
|
|
398
|
+
.tx-chat-message-list__empty { margin: auto; color: var(--tx-muted-foreground, GrayText); text-align: center; }
|
|
399
|
+
|
|
400
|
+
@media (min-width: 640px) {
|
|
401
|
+
.tx-chat-message-group__content { max-inline-size: min(70%, 42rem); }
|
|
402
|
+
}
|
|
403
|
+
@media (forced-colors: active) {
|
|
404
|
+
.tx-chat-message { border: 1px solid CanvasText; }
|
|
405
|
+
.tx-chat-message-list__day::before { background: CanvasText; }
|
|
406
|
+
}
|
|
407
|
+
@media (prefers-reduced-motion: reduce) { .tx-chat-message-list { scroll-behavior: auto; } }
|
|
408
|
+
}
|
|
409
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type ClassValue } from 'texuli/cx';
|
|
3
|
+
import { type MessageListLabels } from './labels.js';
|
|
4
|
+
import type { ChatMessage } from './types.js';
|
|
5
|
+
interface Props {
|
|
6
|
+
messages: ChatMessage[];
|
|
7
|
+
currentUserId?: string;
|
|
8
|
+
/** Optional renderer for attachments or application-owned rich content. */
|
|
9
|
+
body?: Snippet<[ChatMessage]>;
|
|
10
|
+
footer?: Snippet;
|
|
11
|
+
emptyText?: string;
|
|
12
|
+
locale?: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
labels?: Partial<MessageListLabels>;
|
|
15
|
+
/** Virtualization starts at this number of author groups. Set to Infinity to disable. */
|
|
16
|
+
virtualizeAfter?: number;
|
|
17
|
+
overscan?: number;
|
|
18
|
+
class?: ClassValue;
|
|
19
|
+
}
|
|
20
|
+
declare const MessageList: import("svelte").Component<Props, {}, "">;
|
|
21
|
+
type MessageList = ReturnType<typeof MessageList>;
|
|
22
|
+
export default MessageList;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function toDate(value) {
|
|
2
|
+
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
3
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
4
|
+
}
|
|
5
|
+
function sameLocalDay(left, right) {
|
|
6
|
+
return (left.getFullYear() === right.getFullYear() &&
|
|
7
|
+
left.getMonth() === right.getMonth() &&
|
|
8
|
+
left.getDate() === right.getDate());
|
|
9
|
+
}
|
|
10
|
+
/** Group adjacent messages from the same author when they are at most five minutes apart. */
|
|
11
|
+
export function groupMessages(messages, windowMs = 5 * 60_000) {
|
|
12
|
+
const groups = [];
|
|
13
|
+
let previous;
|
|
14
|
+
for (const message of messages) {
|
|
15
|
+
const at = toDate(message.at);
|
|
16
|
+
const previousAt = previous?.at;
|
|
17
|
+
const isNewDay = at !== null &&
|
|
18
|
+
(previousAt === null || previousAt === undefined || !sameLocalDay(previousAt, at));
|
|
19
|
+
const last = groups[groups.length - 1];
|
|
20
|
+
const continues = last !== undefined &&
|
|
21
|
+
!isNewDay &&
|
|
22
|
+
previous?.authorId === message.author.id &&
|
|
23
|
+
at !== null &&
|
|
24
|
+
previousAt !== null &&
|
|
25
|
+
previousAt !== undefined &&
|
|
26
|
+
at.getTime() >= previousAt.getTime() &&
|
|
27
|
+
at.getTime() - previousAt.getTime() <= Math.max(0, windowMs);
|
|
28
|
+
if (continues && last) {
|
|
29
|
+
last.messages.push(message);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
groups.push({
|
|
33
|
+
key: message.id,
|
|
34
|
+
author: message.author,
|
|
35
|
+
messages: [message],
|
|
36
|
+
dayStart: isNewDay ? at : null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
previous = { authorId: message.author.id, at };
|
|
40
|
+
}
|
|
41
|
+
return groups;
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as ChatThread } from './ChatThread.svelte';
|
|
2
|
+
export { groupMessages } from './group-messages.js';
|
|
3
|
+
export { DEFAULT_MESSAGE_LIST_LABELS, type MessageListLabels } from './labels.js';
|
|
4
|
+
export { default as MessageList } from './MessageList.svelte';
|
|
5
|
+
export { type MessageTextPart, parseMessageText } from './rich-text.js';
|
|
6
|
+
export type { ChatAuthor, ChatMessage, ChatMessageStatus, MessageGroup } from './types.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as ChatThread } from './ChatThread.svelte';
|
|
2
|
+
export { groupMessages } from "./group-messages.js";
|
|
3
|
+
export { DEFAULT_MESSAGE_LIST_LABELS } from "./labels.js";
|
|
4
|
+
export { default as MessageList } from './MessageList.svelte';
|
|
5
|
+
export { parseMessageText } from "./rich-text.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type MessageTextPart = {
|
|
2
|
+
type: 'text';
|
|
3
|
+
value: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: 'strong' | 'em' | 'code';
|
|
6
|
+
value: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'link';
|
|
9
|
+
value: string;
|
|
10
|
+
href: string;
|
|
11
|
+
};
|
|
12
|
+
/** Parse a small inline subset into text-only nodes; links use the untrusted URL policy. */
|
|
13
|
+
export declare function parseMessageText(value: string): MessageTextPart[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { safeHref } from '@texuli/core/url-policy';
|
|
2
|
+
const INLINE = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|`([^`\n]+)`|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
3
|
+
/** Parse a small inline subset into text-only nodes; links use the untrusted URL policy. */
|
|
4
|
+
export function parseMessageText(value) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
let cursor = 0;
|
|
7
|
+
for (const match of value.matchAll(INLINE)) {
|
|
8
|
+
const index = match.index ?? 0;
|
|
9
|
+
if (index > cursor)
|
|
10
|
+
parts.push({ type: 'text', value: value.slice(cursor, index) });
|
|
11
|
+
const raw = match[0];
|
|
12
|
+
if (match[1])
|
|
13
|
+
parts.push({ type: 'strong', value: match[1] });
|
|
14
|
+
else if (match[2])
|
|
15
|
+
parts.push({ type: 'em', value: match[2] });
|
|
16
|
+
else if (match[3])
|
|
17
|
+
parts.push({ type: 'code', value: match[3] });
|
|
18
|
+
else if (match[4] && match[5]) {
|
|
19
|
+
const href = safeHref(match[5], 'untrusted');
|
|
20
|
+
parts.push(href ? { type: 'link', value: match[4], href } : { type: 'text', value: raw });
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
parts.push({ type: 'text', value: raw });
|
|
24
|
+
cursor = index + raw.length;
|
|
25
|
+
}
|
|
26
|
+
if (cursor < value.length)
|
|
27
|
+
parts.push({ type: 'text', value: value.slice(cursor) });
|
|
28
|
+
return parts;
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ChatMessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
|
|
2
|
+
export interface ChatAuthor {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
avatarUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ChatMessage {
|
|
8
|
+
id: string;
|
|
9
|
+
author: ChatAuthor;
|
|
10
|
+
body: string;
|
|
11
|
+
at: Date | string | number;
|
|
12
|
+
status?: ChatMessageStatus;
|
|
13
|
+
}
|
|
14
|
+
export interface MessageGroup {
|
|
15
|
+
key: string;
|
|
16
|
+
author: ChatAuthor;
|
|
17
|
+
messages: ChatMessage[];
|
|
18
|
+
dayStart: Date | null;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface VirtualRow {
|
|
2
|
+
index: number;
|
|
3
|
+
key: string;
|
|
4
|
+
start: number;
|
|
5
|
+
size: number;
|
|
6
|
+
measured: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface VirtualWindow {
|
|
9
|
+
totalSize: number;
|
|
10
|
+
items: VirtualRow[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Variable-height list offsets for chat history. Measurements are kept by row
|
|
14
|
+
* key, so prepending older rows keeps every height with its own row instead of
|
|
15
|
+
* shifting it onto whichever row now holds the same index.
|
|
16
|
+
*/
|
|
17
|
+
export declare class MessageVirtualWindow {
|
|
18
|
+
private count;
|
|
19
|
+
private readonly estimate;
|
|
20
|
+
private readonly heights;
|
|
21
|
+
private keys;
|
|
22
|
+
private keySource;
|
|
23
|
+
private indexByKey;
|
|
24
|
+
private tree;
|
|
25
|
+
constructor(count: number, estimate?: number);
|
|
26
|
+
/** Sets the row count; `keys` names each row (rows without a key fall back to their index). */
|
|
27
|
+
resize(count: number, keys?: readonly string[]): void;
|
|
28
|
+
measure(index: number, height: number): boolean;
|
|
29
|
+
/** Measures the row named `key`; returns false when no such row exists. */
|
|
30
|
+
measureKey(key: string, height: number): boolean;
|
|
31
|
+
/** The current index of the row named `key`, or -1. */
|
|
32
|
+
indexOfKey(key: string): number;
|
|
33
|
+
offsetOf(index: number): number;
|
|
34
|
+
window(scrollOffset: number, viewportSize: number, overscan?: number): VirtualWindow;
|
|
35
|
+
private indexAt;
|
|
36
|
+
private prefixDelta;
|
|
37
|
+
private add;
|
|
38
|
+
private rebuild;
|
|
39
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variable-height list offsets for chat history. Measurements are kept by row
|
|
3
|
+
* key, so prepending older rows keeps every height with its own row instead of
|
|
4
|
+
* shifting it onto whichever row now holds the same index.
|
|
5
|
+
*/
|
|
6
|
+
export class MessageVirtualWindow {
|
|
7
|
+
count;
|
|
8
|
+
estimate;
|
|
9
|
+
heights = new Map();
|
|
10
|
+
keys = [];
|
|
11
|
+
keySource;
|
|
12
|
+
indexByKey = new Map();
|
|
13
|
+
tree = [];
|
|
14
|
+
constructor(count, estimate = 112) {
|
|
15
|
+
this.count = 0;
|
|
16
|
+
this.estimate = Math.max(1, estimate);
|
|
17
|
+
this.resize(count);
|
|
18
|
+
}
|
|
19
|
+
/** Sets the row count; `keys` names each row (rows without a key fall back to their index). */
|
|
20
|
+
resize(count, keys) {
|
|
21
|
+
const next = Math.max(0, Math.floor(count));
|
|
22
|
+
if (keys !== undefined && keys === this.keySource && next === this.count)
|
|
23
|
+
return;
|
|
24
|
+
this.count = next;
|
|
25
|
+
this.keySource = keys;
|
|
26
|
+
this.keys = Array.from({ length: next }, (_, index) => keys?.[index] ?? `#${index}`);
|
|
27
|
+
this.indexByKey = new Map(this.keys.map((key, index) => [key, index]));
|
|
28
|
+
for (const key of this.heights.keys())
|
|
29
|
+
if (!this.indexByKey.has(key))
|
|
30
|
+
this.heights.delete(key);
|
|
31
|
+
this.rebuild();
|
|
32
|
+
}
|
|
33
|
+
measure(index, height) {
|
|
34
|
+
if (!Number.isInteger(index) ||
|
|
35
|
+
index < 0 ||
|
|
36
|
+
index >= this.count ||
|
|
37
|
+
!Number.isFinite(height) ||
|
|
38
|
+
height <= 0)
|
|
39
|
+
return false;
|
|
40
|
+
const key = this.keys[index];
|
|
41
|
+
const previous = this.heights.get(key) ?? this.estimate;
|
|
42
|
+
if (previous === height)
|
|
43
|
+
return false;
|
|
44
|
+
this.heights.set(key, height);
|
|
45
|
+
this.add(index, height - previous);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/** Measures the row named `key`; returns false when no such row exists. */
|
|
49
|
+
measureKey(key, height) {
|
|
50
|
+
return this.measure(this.indexOfKey(key), height);
|
|
51
|
+
}
|
|
52
|
+
/** The current index of the row named `key`, or -1. */
|
|
53
|
+
indexOfKey(key) {
|
|
54
|
+
return this.indexByKey.get(key) ?? -1;
|
|
55
|
+
}
|
|
56
|
+
offsetOf(index) {
|
|
57
|
+
const end = Math.min(this.count, Math.max(0, Math.floor(index)));
|
|
58
|
+
return end * this.estimate + this.prefixDelta(end);
|
|
59
|
+
}
|
|
60
|
+
window(scrollOffset, viewportSize, overscan = 5) {
|
|
61
|
+
const totalSize = this.offsetOf(this.count);
|
|
62
|
+
if (!this.count || !Number.isFinite(viewportSize) || viewportSize <= 0)
|
|
63
|
+
return { totalSize, items: [] };
|
|
64
|
+
const startOffset = Math.max(0, Number.isFinite(scrollOffset) ? scrollOffset : 0);
|
|
65
|
+
const first = this.indexAt(startOffset);
|
|
66
|
+
const last = this.indexAt(Math.max(startOffset, startOffset + viewportSize - 0.01));
|
|
67
|
+
const from = Math.max(0, first - Math.max(0, Math.floor(overscan)));
|
|
68
|
+
const to = Math.min(this.count, last + 1 + Math.max(0, Math.floor(overscan)));
|
|
69
|
+
const items = [];
|
|
70
|
+
for (let index = from; index < to; index += 1) {
|
|
71
|
+
const key = this.keys[index];
|
|
72
|
+
const height = this.heights.get(key);
|
|
73
|
+
items.push({
|
|
74
|
+
index,
|
|
75
|
+
key,
|
|
76
|
+
start: this.offsetOf(index),
|
|
77
|
+
size: height ?? this.estimate,
|
|
78
|
+
measured: height !== undefined,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return { totalSize, items };
|
|
82
|
+
}
|
|
83
|
+
indexAt(offset) {
|
|
84
|
+
if (!this.count)
|
|
85
|
+
return 0;
|
|
86
|
+
const target = Math.min(Math.max(0, offset), Math.max(0, this.offsetOf(this.count) - 0.01));
|
|
87
|
+
let low = 0;
|
|
88
|
+
let high = this.count;
|
|
89
|
+
while (low < high) {
|
|
90
|
+
const middle = Math.floor((low + high) / 2);
|
|
91
|
+
if (this.offsetOf(middle + 1) <= target)
|
|
92
|
+
low = middle + 1;
|
|
93
|
+
else
|
|
94
|
+
high = middle;
|
|
95
|
+
}
|
|
96
|
+
return Math.min(low, this.count - 1);
|
|
97
|
+
}
|
|
98
|
+
prefixDelta(end) {
|
|
99
|
+
let sum = 0;
|
|
100
|
+
for (let cursor = end; cursor > 0; cursor -= cursor & -cursor)
|
|
101
|
+
sum += this.tree[cursor] ?? 0;
|
|
102
|
+
return sum;
|
|
103
|
+
}
|
|
104
|
+
add(index, delta) {
|
|
105
|
+
for (let cursor = index + 1; cursor <= this.count; cursor += cursor & -cursor) {
|
|
106
|
+
this.tree[cursor] = (this.tree[cursor] ?? 0) + delta;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
rebuild() {
|
|
110
|
+
this.tree = new Array(this.count + 1).fill(0);
|
|
111
|
+
for (let index = 0; index < this.count; index += 1) {
|
|
112
|
+
const height = this.heights.get(this.keys[index]);
|
|
113
|
+
if (height !== undefined)
|
|
114
|
+
this.add(index, height - this.estimate);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texuli/chat",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.0",
|
|
4
|
+
"description": "Chat interfaces: thread, message list, composer and typing indicator, generic for people and agents.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"**/*.css"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"svelte": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@texuli/core": "0.1.0-next.0",
|
|
22
|
+
"@texuli/primitives": "0.1.0-next.0",
|
|
23
|
+
"@texuli/tokens": "0.1.0-next.0",
|
|
24
|
+
"texuli": "0.1.0-next.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"svelte": "^5.57.2"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"tag": "next"
|
|
32
|
+
}
|
|
33
|
+
}
|