@pmate/chat-sdk 0.11.3 → 0.11.5
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/README.md +21 -0
- package/lib/components/ChatInputDesktop.js +26 -25
- package/lib/components/ChatInputMobile.js +6 -10
- package/lib/components/ImageAttachmentTray.d.ts +11 -0
- package/lib/components/ImageAttachmentTray.js +9 -0
- package/lib/hooks/useAttachmentState.js +3 -3
- package/lib/hooks/useImageAttachments.d.ts +19 -0
- package/lib/hooks/useImageAttachments.js +68 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/runtime/readIndex.d.ts +3 -5
- package/lib/runtime/readIndex.js +29 -78
- package/lib/types/chatInput.d.ts +4 -0
- package/lib/ux/imagePaste.d.ts +13 -0
- package/lib/ux/imagePaste.js +23 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -106,3 +106,24 @@ await meetingClient.join(groupId, teamId, invitedProfileIds)
|
|
|
106
106
|
All commands for an established call/meeting carry its stored Team. Foreign-Team responses are ignored, and a mismatched join acknowledgment releases capture. Hub reach call events require Team context and are checked against the authoritative attach response before showing incoming UI. A native call ID can still be attached directly: the server supplies its authoritative Team.
|
|
107
107
|
|
|
108
108
|
This is a protocol-breaking minor release for the pre-1.0 SDK. It requires the Team-scoped call-api and updated notification consumers; coordinate deployment with the application's migration rollout. It does not migrate legacy meeting/group history itself.
|
|
109
|
+
|
|
110
|
+
## Clipboard images in custom composers
|
|
111
|
+
|
|
112
|
+
### Background
|
|
113
|
+
|
|
114
|
+
Applications with their own input UI need the same screenshot paste behavior as the SDK inputs. `handleImagePaste` reads the user's paste event without requesting clipboard access; it does not fetch HTML image URLs.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<textarea onPaste={event => handleImagePaste(event, {
|
|
118
|
+
disabled,
|
|
119
|
+
onImages: files => { void uploadImages(files).catch(showUploadError) },
|
|
120
|
+
})} />
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The handler leaves text-only pastes and events already handled by the host untouched. Image items take precedence over text/HTML; `clipboardData.files` is used when no readable image items exist. The host owns size/type validation, upload progress, error feedback and whether to preview or send the image. Both SDK inputs use this interface. Multiple attachments are appended without overwriting earlier selections.
|
|
124
|
+
|
|
125
|
+
### Draft preview and upload
|
|
126
|
+
|
|
127
|
+
`useImageAttachments({ uploadImage, disabled })` provides `onPaste`, `addFiles`, `attachments`, `pending`, `remove` and `retry`. Render `ImageAttachmentTray` inside the composer's surface with these attachments and the input tokens. Local previews appear immediately; uploads happen in the background. Removal/unmount revokes local object URLs, and late upload results cannot reinsert removed drafts. Upload errors remain visible and retryable.
|
|
128
|
+
|
|
129
|
+
Only send on explicit submission: block send while `pending`, deliver each ready attachment's `url`, and remove it after delivery succeeds. Keep unsuccessful drafts to allow retry without reuploading. For `ChatInputDesktop`, use `hasPendingContent` and `sendDisabled` with host-owned drafts. Unmount or key the draft owner when switching accounts, Teams or conversations.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { handleImagePaste } from "../ux/imagePaste";
|
|
2
3
|
import { useMentions } from "../hooks/useMentions";
|
|
3
4
|
import { MentionMenu } from "./MentionMenu";
|
|
4
5
|
import React from "react";
|
|
@@ -66,21 +67,26 @@ function DesktopInner(props) {
|
|
|
66
67
|
? "Transcript inserted. Edit or send."
|
|
67
68
|
: undefined));
|
|
68
69
|
const onSend = async () => {
|
|
69
|
-
if (!props.onSend) {
|
|
70
|
+
if (!props.onSend || props.disabled || props.sendDisabled || isUploadingImage || state.status === "sending") {
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
72
73
|
state.setStatus("sending");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
74
|
+
state.setError(null);
|
|
75
|
+
try {
|
|
76
|
+
await props.onSend({
|
|
77
|
+
text: state.text, mentions: mentions.getMentions(), attachments: state.attachments,
|
|
78
|
+
transcript: state.transcript || undefined,
|
|
79
|
+
});
|
|
80
|
+
state.setText("");
|
|
81
|
+
state.setTranscript("");
|
|
82
|
+
state.setAttachments([]);
|
|
83
|
+
props.onChange?.("");
|
|
84
|
+
state.setStatus("idle");
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
state.setError(error instanceof Error ? error.message : "Failed to send message");
|
|
88
|
+
state.setStatus("error");
|
|
89
|
+
}
|
|
84
90
|
};
|
|
85
91
|
const handleImageFile = async (file) => {
|
|
86
92
|
if (!props.image?.uploadImage) {
|
|
@@ -119,16 +125,11 @@ function DesktopInner(props) {
|
|
|
119
125
|
};
|
|
120
126
|
const handlePaste = (event) => {
|
|
121
127
|
props.onPaste?.(event);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!images.length)
|
|
128
|
-
return;
|
|
129
|
-
event.preventDefault();
|
|
130
|
-
for (const image of images)
|
|
131
|
-
void handleImageFile(image);
|
|
128
|
+
handleImagePaste(event, {
|
|
129
|
+
disabled: props.disabled || props.enableImage === false || state.status === "sending",
|
|
130
|
+
onImages: images => { for (const image of images)
|
|
131
|
+
void handleImageFile(image); },
|
|
132
|
+
});
|
|
132
133
|
};
|
|
133
134
|
const insertEmoji = (emoji) => {
|
|
134
135
|
const textarea = textComposerRef.current;
|
|
@@ -229,7 +230,7 @@ function DesktopInner(props) {
|
|
|
229
230
|
event.keyCode === 229)
|
|
230
231
|
return;
|
|
231
232
|
event.preventDefault();
|
|
232
|
-
if (state.text.trim() || state.attachments.length > 0)
|
|
233
|
+
if (state.text.trim() || state.attachments.length > 0 || props.hasPendingContent)
|
|
233
234
|
void onSend();
|
|
234
235
|
}, style: {
|
|
235
236
|
flex: 1,
|
|
@@ -254,7 +255,7 @@ function DesktopInner(props) {
|
|
|
254
255
|
color: voice.status === "recording" || voice.status === "transcribing" ? tokens.color.iconActive : tokens.color.iconDefault,
|
|
255
256
|
cursor: props.disabled || props.enableVoice === false ? "not-allowed" : "pointer",
|
|
256
257
|
opacity: props.disabled || props.enableVoice === false ? 0.4 : 1,
|
|
257
|
-
}, "aria-label": voice.status === "recording" ? "停止录音并转文字" : voice.status === "transcribing" ? "正在转写语音" : "开始语音输入", children: voice.status === "recording" ? _jsx(StopCircleOutlineIcon, { size: tokens.sizing.desktopIcon }) : _jsx(MicOutlineIcon, { size: tokens.sizing.desktopIcon, strokeWidth: 2 }) }), _jsx(EmojiTrigger, { ref: emojiAnchorRef, "data-icon-size": tokens.sizing.desktopIcon, style: sideActionStyle, onClick: () => { setIsMoreOpen(false); setIsEmojiOpen((prev) => !prev); } }), _jsx(SendAction, { tokens: tokens, disabled: props.disabled || state.status === "sending" || (!state.text.trim() && state.attachments.length === 0), "aria-label": "\u53D1\u9001\u6D88\u606F", onClick: () => void onSend(), style: { width: tokens.sizing.desktopSendAction, height: tokens.sizing.desktopSendAction, padding: 0, borderRadius: 999, fontSize: tokens.sizing.desktopSendIcon, lineHeight: `${tokens.sizing.desktopSendAction}px` }, children: "\u2191" })] })] })] })) : _jsxs("div", { style: {
|
|
258
|
+
}, "aria-label": voice.status === "recording" ? "停止录音并转文字" : voice.status === "transcribing" ? "正在转写语音" : "开始语音输入", children: voice.status === "recording" ? _jsx(StopCircleOutlineIcon, { size: tokens.sizing.desktopIcon }) : _jsx(MicOutlineIcon, { size: tokens.sizing.desktopIcon, strokeWidth: 2 }) }), _jsx(EmojiTrigger, { ref: emojiAnchorRef, "data-icon-size": tokens.sizing.desktopIcon, style: sideActionStyle, onClick: () => { setIsMoreOpen(false); setIsEmojiOpen((prev) => !prev); } }), _jsx(SendAction, { tokens: tokens, disabled: props.disabled || props.sendDisabled || isUploadingImage || state.status === "sending" || (!state.text.trim() && state.attachments.length === 0 && !props.hasPendingContent), "aria-label": "\u53D1\u9001\u6D88\u606F", onClick: () => void onSend(), style: { width: tokens.sizing.desktopSendAction, height: tokens.sizing.desktopSendAction, padding: 0, borderRadius: 999, fontSize: tokens.sizing.desktopSendIcon, lineHeight: `${tokens.sizing.desktopSendAction}px` }, children: "\u2191" })] })] })] })) : _jsxs("div", { style: {
|
|
258
259
|
display: "grid",
|
|
259
260
|
gridTemplateColumns: "32px minmax(0, 1fr) 32px 32px",
|
|
260
261
|
alignItems: "center",
|
|
@@ -300,7 +301,7 @@ function DesktopInner(props) {
|
|
|
300
301
|
const shouldSend = props.submitShortcut === "meta-enter" ? event.metaKey || event.ctrlKey : !event.shiftKey;
|
|
301
302
|
if (shouldSend) {
|
|
302
303
|
event.preventDefault();
|
|
303
|
-
if (state.text.trim() || state.attachments.length > 0) {
|
|
304
|
+
if (state.text.trim() || state.attachments.length > 0 || props.hasPendingContent) {
|
|
304
305
|
void onSend();
|
|
305
306
|
}
|
|
306
307
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { handleImagePaste } from "../ux/imagePaste";
|
|
2
3
|
import { useMentions } from "../hooks/useMentions";
|
|
3
4
|
import { MentionMenu } from "./MentionMenu";
|
|
4
5
|
import React from "react";
|
|
@@ -123,16 +124,11 @@ function MobileInner(props) {
|
|
|
123
124
|
};
|
|
124
125
|
const handlePaste = (event) => {
|
|
125
126
|
props.onPaste?.(event);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!images.length)
|
|
132
|
-
return;
|
|
133
|
-
event.preventDefault();
|
|
134
|
-
for (const image of images)
|
|
135
|
-
void handleImageFile(image);
|
|
127
|
+
handleImagePaste(event, {
|
|
128
|
+
disabled: props.disabled || props.enableImage === false || state.status === "sending",
|
|
129
|
+
onImages: images => { for (const image of images)
|
|
130
|
+
void handleImageFile(image); },
|
|
131
|
+
});
|
|
136
132
|
};
|
|
137
133
|
const isRecordingOverlayVisible = state.status === "recording" || state.status === "transcribing";
|
|
138
134
|
const isTranscriptConfirmVisible = state.status === "transcript-ready" && Boolean(state.transcript);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ImageDraftAttachment } from "../hooks/useImageAttachments";
|
|
2
|
+
import type { ChatInputTokens } from "../types/chatInput";
|
|
3
|
+
/** Shared inline draft UI for host-owned inputs. Uses the same tokens as SDK composers. */
|
|
4
|
+
export declare function ImageAttachmentTray({ attachments, tokens, onRemove, onRetry, disabled, locale }: {
|
|
5
|
+
attachments: ImageDraftAttachment[];
|
|
6
|
+
tokens: ChatInputTokens;
|
|
7
|
+
onRemove: (id: string) => void;
|
|
8
|
+
onRetry: (id: string) => void;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
locale?: "en" | "zh";
|
|
11
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
/** Shared inline draft UI for host-owned inputs. Uses the same tokens as SDK composers. */
|
|
4
|
+
export function ImageAttachmentTray({ attachments, tokens, onRemove, onRetry, disabled, locale = "en" }) {
|
|
5
|
+
if (!attachments.length)
|
|
6
|
+
return null;
|
|
7
|
+
return _jsx("div", { "aria-label": locale === "zh" ? "待发送图片" : "Image drafts", style: { display: "flex", flexWrap: "wrap", gap: 10, padding: 12, minWidth: 0 }, children: attachments.map(item => _jsxs("div", { style: { position: "relative", width: 112, maxWidth: "100%", borderRadius: 12,
|
|
8
|
+
border: `1px solid ${item.status === "error" ? tokens.color.statusError : tokens.color.borderDefault}`, overflow: "hidden", background: tokens.color.surfaceInputDefault }, children: [_jsx("img", { src: item.previewUrl, alt: item.name, style: { display: "block", width: "100%", height: 96, objectFit: "contain" } }), _jsx("button", { type: "button", disabled: disabled, "aria-label": `${locale === "zh" ? "移除" : "Remove"} ${item.name}`, onClick: () => onRemove(item.id), style: { position: "absolute", top: 4, right: 4, width: 24, height: 24, borderRadius: 99, border: 0, background: tokens.color.surfaceInputFocus, color: tokens.color.textPrimary, cursor: "pointer" }, children: "\u00D7" }), item.status === "uploading" ? _jsx("div", { role: "status", style: { fontSize: 12, padding: 6, color: tokens.color.textSecondary }, children: locale === "zh" ? "正在上传…" : "Uploading…" }) : null, item.status === "error" ? _jsxs("div", { style: { fontSize: 12, padding: 6, overflowWrap: "anywhere", color: tokens.color.statusError }, children: [_jsx("span", { role: "alert", children: item.error }), _jsx("button", { type: "button", disabled: disabled, onClick: () => onRetry(item.id), style: { display: "block", marginTop: 4, cursor: "pointer" }, children: locale === "zh" ? "重试上传" : "Retry upload" })] }) : null] }, item.id)) });
|
|
9
|
+
}
|
|
@@ -4,8 +4,8 @@ export function useAttachmentState() {
|
|
|
4
4
|
return {
|
|
5
5
|
attachments,
|
|
6
6
|
addAttachment(attachment) {
|
|
7
|
-
setAttachments([
|
|
8
|
-
...
|
|
7
|
+
setAttachments(current => [
|
|
8
|
+
...current,
|
|
9
9
|
{
|
|
10
10
|
id: crypto.randomUUID(),
|
|
11
11
|
kind: "image",
|
|
@@ -15,7 +15,7 @@ export function useAttachmentState() {
|
|
|
15
15
|
setStatus("attachment-selected");
|
|
16
16
|
},
|
|
17
17
|
removeAttachment(id) {
|
|
18
|
-
setAttachments(
|
|
18
|
+
setAttachments(current => current.filter((item) => item.id !== id));
|
|
19
19
|
},
|
|
20
20
|
};
|
|
21
21
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ClipboardEventHandler } from "react";
|
|
2
|
+
import type { ChatAttachment } from "../types/attachment";
|
|
3
|
+
import type { ChatImageUploadResult } from "../types/chatInput";
|
|
4
|
+
export type ImageDraftAttachment = ChatAttachment & {
|
|
5
|
+
status: "uploading" | "ready" | "error";
|
|
6
|
+
error?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Local previews appear immediately. Uploading never sends a chat message. */
|
|
9
|
+
export declare function useImageAttachments(options: {
|
|
10
|
+
uploadImage?: (file: File) => Promise<ChatImageUploadResult>;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}): {
|
|
13
|
+
attachments: ImageDraftAttachment[];
|
|
14
|
+
addFiles: (files: File[]) => void;
|
|
15
|
+
onPaste: ClipboardEventHandler<HTMLTextAreaElement>;
|
|
16
|
+
pending: boolean;
|
|
17
|
+
remove(id: string): void;
|
|
18
|
+
retry(id: string): void;
|
|
19
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { handleImagePaste } from "../ux/imagePaste";
|
|
3
|
+
/** Local previews appear immediately. Uploading never sends a chat message. */
|
|
4
|
+
export function useImageAttachments(options) {
|
|
5
|
+
const [attachments, setAttachments] = useState([]);
|
|
6
|
+
const entries = useRef(new Map());
|
|
7
|
+
const active = useRef(true);
|
|
8
|
+
const sync = () => { if (active.current)
|
|
9
|
+
setAttachments([...entries.current.values()]); };
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
active.current = true;
|
|
12
|
+
return () => {
|
|
13
|
+
active.current = false;
|
|
14
|
+
for (const item of entries.current.values())
|
|
15
|
+
if (item.previewUrl)
|
|
16
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
17
|
+
entries.current.clear();
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
async function upload(item) {
|
|
21
|
+
item.status = "uploading";
|
|
22
|
+
item.error = undefined;
|
|
23
|
+
sync();
|
|
24
|
+
try {
|
|
25
|
+
const result = options.uploadImage ? await options.uploadImage(item.file) : undefined;
|
|
26
|
+
if (entries.current.get(item.id) !== item)
|
|
27
|
+
return;
|
|
28
|
+
item.url = result?.url;
|
|
29
|
+
item.status = "ready";
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (entries.current.get(item.id) !== item)
|
|
33
|
+
return;
|
|
34
|
+
item.status = "error";
|
|
35
|
+
item.error = error instanceof Error ? error.message : "Image upload failed";
|
|
36
|
+
}
|
|
37
|
+
sync();
|
|
38
|
+
}
|
|
39
|
+
function addFiles(files) {
|
|
40
|
+
if (options.disabled)
|
|
41
|
+
return;
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
const item = { id: crypto.randomUUID(), kind: "image", name: file.name,
|
|
44
|
+
file, previewUrl: URL.createObjectURL(file), status: "uploading" };
|
|
45
|
+
entries.current.set(item.id, item);
|
|
46
|
+
void upload(item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const onPaste = event => {
|
|
50
|
+
handleImagePaste(event, { disabled: options.disabled, onImages: addFiles });
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
attachments, addFiles, onPaste,
|
|
54
|
+
pending: attachments.some(item => item.status !== "ready"),
|
|
55
|
+
remove(id) {
|
|
56
|
+
const item = entries.current.get(id);
|
|
57
|
+
if (item?.previewUrl)
|
|
58
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
59
|
+
entries.current.delete(id);
|
|
60
|
+
sync();
|
|
61
|
+
},
|
|
62
|
+
retry(id) {
|
|
63
|
+
const item = entries.current.get(id);
|
|
64
|
+
if (!options.disabled && item?.status === "error")
|
|
65
|
+
void upload(item);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -38,3 +38,6 @@ export * from "./runtime/meeting";
|
|
|
38
38
|
export * from "./runtime/teamScope";
|
|
39
39
|
export * from "./runtime/teamMigrations";
|
|
40
40
|
export * from "./runtime/socialReadClient";
|
|
41
|
+
export * from "./ux/imagePaste";
|
|
42
|
+
export * from "./hooks/useImageAttachments";
|
|
43
|
+
export * from "./components/ImageAttachmentTray";
|
package/lib/index.js
CHANGED
|
@@ -38,3 +38,6 @@ export * from "./runtime/meeting";
|
|
|
38
38
|
export * from "./runtime/teamScope";
|
|
39
39
|
export * from "./runtime/teamMigrations";
|
|
40
40
|
export * from "./runtime/socialReadClient";
|
|
41
|
+
export * from "./ux/imagePaste";
|
|
42
|
+
export * from "./hooks/useImageAttachments";
|
|
43
|
+
export * from "./components/ImageAttachmentTray";
|
|
@@ -82,14 +82,12 @@ export interface ReadIndexPersistence {
|
|
|
82
82
|
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
83
83
|
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
84
84
|
}
|
|
85
|
-
/**
|
|
85
|
+
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
86
86
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
87
|
-
private
|
|
88
|
-
private
|
|
89
|
-
private database?;
|
|
87
|
+
private database;
|
|
88
|
+
private profiles;
|
|
90
89
|
private closed;
|
|
91
90
|
constructor(factory?: IDBFactory, name?: string);
|
|
92
|
-
private getDatabase;
|
|
93
91
|
private transaction;
|
|
94
92
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
93
|
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
1
2
|
import { isValidChatTeamId } from "./thread.js";
|
|
2
3
|
export class ReadSyncGap extends Error {
|
|
3
4
|
cursor;
|
|
@@ -162,107 +163,57 @@ export function unreadInThread(state, threadId) {
|
|
|
162
163
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
163
164
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
164
165
|
}
|
|
165
|
-
/**
|
|
166
|
+
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
166
167
|
export class IndexedDbReadIndex {
|
|
167
|
-
factory;
|
|
168
|
-
name;
|
|
169
168
|
database;
|
|
169
|
+
profiles;
|
|
170
170
|
closed = false;
|
|
171
171
|
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
|
|
172
|
-
this.factory = factory;
|
|
173
|
-
this.name = name;
|
|
174
172
|
if (!factory)
|
|
175
173
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return this.database;
|
|
182
|
-
let abandoned = false;
|
|
183
|
-
const pending = new Promise((resolve, reject) => {
|
|
184
|
-
const request = this.factory.open(this.name);
|
|
185
|
-
request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains("profiles"))
|
|
186
|
-
request.result.createObjectStore("profiles"); };
|
|
187
|
-
request.onsuccess = () => {
|
|
188
|
-
const db = request.result;
|
|
189
|
-
if (abandoned || this.closed) {
|
|
190
|
-
db.close();
|
|
191
|
-
reject(new Error("READ_STORAGE_CLOSED"));
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const invalidate = () => { if (this.database === pending)
|
|
195
|
-
this.database = undefined; };
|
|
196
|
-
db.onversionchange = () => { db.close(); invalidate(); };
|
|
197
|
-
db.onclose = invalidate;
|
|
198
|
-
resolve(db);
|
|
199
|
-
};
|
|
200
|
-
request.onerror = () => { abandoned = true; reject(request.error); };
|
|
201
|
-
request.onblocked = () => { abandoned = true; reject(new Error("READ_STORAGE_BLOCKED")); };
|
|
202
|
-
});
|
|
203
|
-
this.database = pending;
|
|
204
|
-
void pending.catch(() => { if (this.database === pending)
|
|
205
|
-
this.database = undefined; });
|
|
206
|
-
return pending;
|
|
174
|
+
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
175
|
+
// The empty schema retains the legacy out-of-line primary key and records.
|
|
176
|
+
this.database.version(1).stores({ profiles: "" });
|
|
177
|
+
this.profiles = this.database.table("profiles");
|
|
178
|
+
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
207
179
|
}
|
|
208
180
|
async transaction(mode, execute) {
|
|
209
181
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
210
|
-
const pending = this.getDatabase(), db = await pending;
|
|
211
182
|
if (this.closed)
|
|
212
183
|
throw new Error("READ_STORAGE_CLOSED");
|
|
213
|
-
let
|
|
184
|
+
let started = false;
|
|
214
185
|
try {
|
|
215
|
-
|
|
186
|
+
return await this.database.transaction(mode, this.profiles, async () => {
|
|
187
|
+
if (this.closed)
|
|
188
|
+
throw new Error("READ_STORAGE_CLOSED");
|
|
189
|
+
started = true;
|
|
190
|
+
return execute();
|
|
191
|
+
});
|
|
216
192
|
}
|
|
217
193
|
catch (error) {
|
|
218
|
-
if (
|
|
194
|
+
if (this.closed)
|
|
195
|
+
throw new Error("READ_STORAGE_CLOSED");
|
|
196
|
+
// Retry only failed transaction creation, never a transform or write.
|
|
197
|
+
if (attempt || started || this.closed || error?.name !== "InvalidStateError")
|
|
219
198
|
throw error;
|
|
220
|
-
|
|
221
|
-
db.close();
|
|
222
|
-
if (this.database === pending)
|
|
223
|
-
this.database = undefined;
|
|
224
|
-
continue;
|
|
199
|
+
this.database.close({ disableAutoOpen: false });
|
|
225
200
|
}
|
|
226
|
-
// Start requests synchronously after creation, including on WebKit.
|
|
227
|
-
return execute(transaction);
|
|
228
201
|
}
|
|
229
202
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
230
203
|
}
|
|
231
204
|
async load(appId, profileId) {
|
|
232
|
-
return this.transaction("
|
|
233
|
-
const request = transaction.objectStore("profiles").get([appId, profileId]);
|
|
234
|
-
transaction.oncomplete = () => resolve(request.result);
|
|
235
|
-
transaction.onerror = () => reject(transaction.error);
|
|
236
|
-
transaction.onabort = () => reject(transaction.error ?? new Error("READ_STORAGE_ABORTED"));
|
|
237
|
-
}));
|
|
205
|
+
return this.transaction("r", () => this.profiles.get([appId, profileId]));
|
|
238
206
|
}
|
|
239
207
|
async update(appId, profileId, transform) {
|
|
240
|
-
return this.transaction("
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
248
|
-
throw new Error("READ_IDENTITY_MISMATCH");
|
|
249
|
-
store.put(next, [appId, profileId]);
|
|
250
|
-
}
|
|
251
|
-
catch (error) {
|
|
252
|
-
failure = error;
|
|
253
|
-
transaction.abort();
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
transaction.oncomplete = () => resolve(next);
|
|
257
|
-
transaction.onerror = () => reject(failure ?? transaction.error);
|
|
258
|
-
transaction.onabort = () => reject(failure ?? transaction.error ?? new Error("READ_STORAGE_ABORTED"));
|
|
259
|
-
}));
|
|
208
|
+
return this.transaction("rw", async () => {
|
|
209
|
+
const next = transform(await this.profiles.get([appId, profileId]) ?? createReadIndex(appId, profileId));
|
|
210
|
+
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
211
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
212
|
+
await this.profiles.put(next, [appId, profileId]);
|
|
213
|
+
return next;
|
|
214
|
+
});
|
|
260
215
|
}
|
|
261
|
-
async close() { this.closed = true;
|
|
262
|
-
const db = await pending.catch(error => { if (error?.message !== "READ_STORAGE_CLOSED")
|
|
263
|
-
throw error; return undefined; });
|
|
264
|
-
db?.close();
|
|
265
|
-
} }
|
|
216
|
+
async close() { this.closed = true; this.database.close(); }
|
|
266
217
|
}
|
|
267
218
|
export function sourcesInConversation(state, conversationId) {
|
|
268
219
|
return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
|
package/lib/types/chatInput.d.ts
CHANGED
|
@@ -140,6 +140,10 @@ export type ChatInputBaseProps = {
|
|
|
140
140
|
audio?: Blob;
|
|
141
141
|
mode?: "text" | "voice";
|
|
142
142
|
}) => Promise<void> | void;
|
|
143
|
+
/** Host-owned drafts can enable sending even when the SDK text/attachment list is empty. */
|
|
144
|
+
hasPendingContent?: boolean;
|
|
145
|
+
/** Blocks submission while host-owned drafts are uploading or invalid. */
|
|
146
|
+
sendDisabled?: boolean;
|
|
143
147
|
enableVoice?: boolean;
|
|
144
148
|
enableEmoji?: boolean;
|
|
145
149
|
enableImage?: boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Clipboard image handling for SDK inputs and host-owned composers. No clipboard permission request. */
|
|
2
|
+
export type ImagePasteEvent = {
|
|
3
|
+
clipboardData: Pick<DataTransfer, "items" | "files"> | null;
|
|
4
|
+
defaultPrevented: boolean;
|
|
5
|
+
preventDefault(): void;
|
|
6
|
+
};
|
|
7
|
+
/** Prefer items; files is used when the browser exposes no readable image items. */
|
|
8
|
+
export declare function getClipboardImages(data: ImagePasteEvent["clipboardData"]): File[];
|
|
9
|
+
/** Consumes image pastes only. The host owns validation, upload, progress and errors. */
|
|
10
|
+
export declare function handleImagePaste(event: ImagePasteEvent, options: {
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
onImages: (files: File[]) => void;
|
|
13
|
+
}): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Prefer items; files is used when the browser exposes no readable image items. */
|
|
2
|
+
export function getClipboardImages(data) {
|
|
3
|
+
if (!data)
|
|
4
|
+
return [];
|
|
5
|
+
const images = Array.from(data.items).flatMap(item => {
|
|
6
|
+
if (item.kind !== "file" || !item.type.startsWith("image/"))
|
|
7
|
+
return [];
|
|
8
|
+
const file = item.getAsFile();
|
|
9
|
+
return file ? [file] : [];
|
|
10
|
+
});
|
|
11
|
+
return images.length ? images : Array.from(data.files).filter(file => file.type.startsWith("image/"));
|
|
12
|
+
}
|
|
13
|
+
/** Consumes image pastes only. The host owns validation, upload, progress and errors. */
|
|
14
|
+
export function handleImagePaste(event, options) {
|
|
15
|
+
if (event.defaultPrevented || options.disabled)
|
|
16
|
+
return false;
|
|
17
|
+
const images = getClipboardImages(event.clipboardData);
|
|
18
|
+
if (!images.length)
|
|
19
|
+
return false;
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
options.onImages(images);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/chat-sdk",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@emoji-mart/react": "^1.1.1",
|
|
43
43
|
"@msgpack/msgpack": "^3.1.2",
|
|
44
44
|
"@noble/hashes": "^2.4.0",
|
|
45
|
+
"dexie": "^4.0.4",
|
|
45
46
|
"jotai": "^2.16.1"
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|