Skip to content

Commit 919c4af

Browse files
feat(react-sdk): uploading attachments (#16)
1 parent 052a042 commit 919c4af

File tree

10 files changed

+434
-219
lines changed

10 files changed

+434
-219
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Key React components include `AIMarkdown`, `StreamingMessage`, and `AIMessageCom
99
## Build, Test, and Development Commands
1010
- `pnpm install` — install workspace deps from `pnpm-lock.yaml`.
1111
- `pnpm packages:build:all` / `pnpm examples:build:all` — rebuild SDKs (Vite, `tsc`, Sass, bob) or demos.
12-
- `pnpm --filter @stream-io/ai-chat-react dev` — watch-build the React SDK; `pnpm --filter ./packages/react-native-sdk start` and `pnpm --filter ./packages/node-sdk dev` serve RN/Node watch modes.
12+
- `pnpm --filter @stream-io/chat-react-ai dev` — watch-build the React SDK; `pnpm --filter ./packages/react-native-sdk start` and `pnpm --filter ./packages/node-sdk dev` serve RN/Node watch modes.
1313
- `pnpm packages:test:all` or `pnpm --filter ./packages/react-sdk exec vitest run` — run Vitest across the repo or inside one package.
1414
- `pnpm lint:all` / `pnpm prettier:fix-all` — enforce lint and format rules before pushing.
1515

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ pnpm examples:build:all
3434
pnpm packages:test:all
3535

3636
# Test a specific package
37-
pnpm --filter @stream-io/ai-chat-react test
37+
pnpm --filter @stream-io/chat-react-ai test
3838
pnpm --filter ./packages/react-native-sdk test
3939

4040
# Lint everything
@@ -143,7 +143,7 @@ Both React and React Native SDKs use `@stream-io/state-store` for component stat
143143
- Both: `react-syntax-highlighter` for code blocks
144144

145145
**Styling:**
146-
- React SDK: SCSS files compiled to CSS, consumers import from `@stream-io/ai-chat-react/styles/*`
146+
- React SDK: SCSS files compiled to CSS, consumers import from `@stream-io/chat-react-ai/styles/*`
147147
- React Native: StyleSheet-based, no external styles
148148

149149
## TypeScript Configuration

examples/react-example/src/components/EmptyState/EmptyState.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const EmptyState = () => {
1313
setActiveChannel(
1414
client.channel('messaging', `ai-${nanoId()}`, {
1515
members: [client.userID as string],
16+
// @ts-expect-error fix - this is a hack that allows custom upload funtion to run
17+
own_capabilities: ['upload-file'],
1618
}),
1719
);
1820
}

examples/react-example/src/components/MessageInputBar/MessageInputBar.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
color: var(--ai-demo-text-primary);
5757
border-color: var(--ai-demo-border);
5858
border-radius: 8px;
59-
padding: 0.5rem 0.75rem;
59+
// padding: 0.5rem 0.75rem;
6060
font-family: var(--ai-demo-font-family);
6161
font-size: 0.875rem;
6262
cursor: pointer;
Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,139 @@
11
import { AIMessageComposer } from '@stream-io/chat-react-ai';
2+
import { useEffect } from 'react';
23
import {
4+
isImageFile,
5+
type Channel,
6+
type LocalUploadAttachment,
7+
type UploadRequestFn,
8+
} from 'stream-chat';
9+
import {
10+
useAttachmentsForPreview,
311
useChannelActionContext,
412
useChannelStateContext,
13+
useChatContext,
514
useMessageComposer,
615
} from 'stream-chat-react';
716
import { startAiAgent } from '../../api.ts';
817
import './MessageInputBar.scss';
918

19+
const isWatchedByAI = (channel: Channel) => {
20+
return Object.keys(channel.state.watchers).some((watcher) =>
21+
watcher.startsWith('ai-bot'),
22+
);
23+
};
24+
1025
export const MessageInputBar = () => {
26+
const { client } = useChatContext();
1127
const { updateMessage, sendMessage } = useChannelActionContext();
1228
const { channel } = useChannelStateContext();
1329
const composer = useMessageComposer();
1430

31+
const { attachments } = useAttachmentsForPreview();
32+
33+
useEffect(() => {
34+
if (!composer) return;
35+
36+
const upload: UploadRequestFn = (file) => {
37+
const f = isImageFile(file) ? client.uploadImage : client.uploadFile;
38+
39+
return f.call(client, file as File);
40+
};
41+
42+
const previousDefault = composer.attachmentManager.doDefaultUploadRequest;
43+
44+
composer.attachmentManager.setCustomUploadFn(upload);
45+
46+
return () => composer.attachmentManager.setCustomUploadFn(previousDefault);
47+
}, [client, composer]);
48+
1549
return (
1650
<div className="ai-demo-message-input-bar">
1751
<AIMessageComposer
52+
onChange={(e) => {
53+
const input = e.currentTarget.elements.namedItem(
54+
'attachments',
55+
) as HTMLInputElement | null;
56+
57+
const files = input?.files ?? null;
58+
59+
if (files) {
60+
composer.attachmentManager.uploadFiles(files);
61+
}
62+
}}
1863
onSubmit={async (e) => {
1964
const event = e;
20-
const target = (event.currentTarget ??
21-
event.target) as HTMLFormElement | null;
2265
event.preventDefault();
2366

24-
const formData = new FormData(event.currentTarget);
67+
const target = event.currentTarget;
68+
69+
const formData = new FormData(target);
2570

26-
const t = formData.get('message');
71+
const message = formData.get('message');
2772
const model = formData.get('model');
2873

29-
composer.textComposer.setText(t as string);
74+
composer.textComposer.setText(message as string);
3075

31-
const d = await composer.compose();
76+
const composedData = await composer.compose();
3277

33-
if (!d) return;
78+
if (!composedData) return;
3479

35-
target?.reset();
80+
target.reset();
3681
composer.clear();
3782

38-
if (channel.initialized) {
39-
const isAiAgentActive = Object.keys(channel.state.watchers).some(
40-
(userId) => userId.startsWith('ai-bot'),
41-
);
42-
if (!isAiAgentActive) {
43-
await startAiAgent(channel, model);
44-
}
45-
46-
await sendMessage(d);
47-
} else {
48-
updateMessage(d?.localMessage);
83+
updateMessage(composedData?.localMessage);
4984

85+
if (!channel.initialized) {
5086
await channel.watch();
87+
}
5188

52-
// TODO: wrap in retry (in case channel creation takes longer)
89+
if (!isWatchedByAI(channel)) {
5390
await startAiAgent(channel, model);
54-
55-
await sendMessage(d);
5691
}
92+
93+
await sendMessage(composedData);
5794
}}
58-
/>
95+
>
96+
<AIMessageComposer.AttachmentPreview>
97+
{attachments.map((attachment) => (
98+
<AIMessageComposer.AttachmentPreview.Item
99+
key={attachment.localMetadata.id}
100+
file={attachment.localMetadata.file as File}
101+
state={attachment.localMetadata.uploadState}
102+
imagePreviewSource={
103+
attachment.thumb_url ||
104+
(attachment.localMetadata.previewUri as string)
105+
}
106+
onDelete={() => {
107+
composer.attachmentManager.removeAttachments([
108+
attachment.localMetadata.id,
109+
]);
110+
}}
111+
onRetry={() => {
112+
composer.attachmentManager.uploadAttachment(
113+
attachment as LocalUploadAttachment,
114+
);
115+
}}
116+
/>
117+
))}
118+
</AIMessageComposer.AttachmentPreview>
119+
<AIMessageComposer.TextInput name="message" />
120+
<div
121+
style={{
122+
display: 'flex',
123+
gap: '1rem',
124+
justifyContent: 'space-between',
125+
alignItems: 'center',
126+
}}
127+
>
128+
<div style={{ display: 'flex', gap: '.25rem', alignItems: 'center' }}>
129+
<AIMessageComposer.FileInput name="attachments" />
130+
<AIMessageComposer.SpeechToTextButton />
131+
<AIMessageComposer.ModelSelect name="model" />
132+
</div>
133+
134+
<AIMessageComposer.SubmitButton />
135+
</div>
136+
</AIMessageComposer>
59137
</div>
60138
);
61139
};

0 commit comments

Comments
 (0)