Skip to content

Commit 0c2d04d

Browse files
committed
feat: add custom data message composer
1 parent 11ecf89 commit 0c2d04d

File tree

9 files changed

+429
-13
lines changed

9 files changed

+429
-13
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { CustomMessageData, DraftMessage, LocalMessage } from '..';
2+
import { StateStore } from '..';
3+
import type { MessageComposer } from './messageComposer';
4+
5+
export type CustomDataManagerState = {
6+
data: CustomMessageData;
7+
};
8+
9+
export type CustomDataManagerOptions = {
10+
composer: MessageComposer;
11+
message?: DraftMessage | LocalMessage;
12+
};
13+
14+
const initState = (options: CustomDataManagerOptions): CustomDataManagerState => {
15+
if (!options) return { data: {} as CustomMessageData };
16+
return { data: {} as CustomMessageData };
17+
};
18+
19+
export class CustomDataManager {
20+
composer: MessageComposer;
21+
state: StateStore<CustomDataManagerState>;
22+
23+
constructor({ composer, message }: CustomDataManagerOptions) {
24+
this.composer = composer;
25+
this.state = new StateStore<CustomDataManagerState>(initState({ composer, message }));
26+
}
27+
28+
get data() {
29+
return this.state.getLatestValue().data;
30+
}
31+
32+
isDataEqual = (
33+
nextState: CustomDataManagerState,
34+
previousState?: CustomDataManagerState,
35+
) => JSON.stringify(nextState.data) === JSON.stringify(previousState?.data);
36+
37+
initState = ({ message }: { message?: DraftMessage | LocalMessage } = {}) => {
38+
this.state.next(initState({ composer: this.composer, message }));
39+
};
40+
41+
setData(data: Partial<CustomMessageData>) {
42+
this.state.partialNext({
43+
data: {
44+
...this.state.getLatestValue().data,
45+
...data,
46+
},
47+
});
48+
}
49+
}

src/messageComposer/configuration/types.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { LinkPreview } from '../linkPreviewsManager';
22
import type { FileUploadFilter } from '../attachmentManager';
33
import type { FileLike, FileReference } from '../types';
4-
import type { StreamChat } from '../../client';
4+
5+
export type MinimumUploadRequestResult = { file: string; thumb_url?: string };
6+
7+
export type UploadRequestFn = (
8+
fileLike: FileReference | FileLike,
9+
) => Promise<MinimumUploadRequestResult>;
510

611
export type DraftsConfiguration = {
712
enabled: boolean;
@@ -23,9 +28,7 @@ export type AttachmentManagerConfig = {
2328
maxNumberOfFilesPerMessage: number;
2429
// todo: refactor this. We want a pipeline where it would be possible to customize the preparation, upload, and post-upload steps.
2530
/** Function that allows to customize the upload request. */
26-
doUploadRequest?: (
27-
fileLike: FileReference | FileLike,
28-
) => ReturnType<StreamChat['sendFile']>;
31+
doUploadRequest?: UploadRequestFn;
2932
};
3033
export type LinkPreviewConfig = {
3134
/** Custom function to react to link preview dismissal */

src/messageComposer/messageComposer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AttachmentManager } from './attachmentManager';
2+
import { CustomDataManager } from './CustomDataManager';
23
import { LinkPreviewsManager } from './linkPreviewsManager';
34
import { PollComposer } from './pollComposer';
45
import { TextComposer } from './textComposer';
@@ -124,6 +125,7 @@ export class MessageComposer {
124125
linkPreviewsManager: LinkPreviewsManager;
125126
textComposer: TextComposer;
126127
pollComposer: PollComposer;
128+
customDataManager: CustomDataManager;
127129
// todo: mediaRecorder: MediaRecorderController;
128130

129131
private unsubscribeFunctions: Set<() => void> = new Set();
@@ -178,6 +180,7 @@ export class MessageComposer {
178180
});
179181
this.textComposer = new TextComposer({ composer: this, message });
180182
this.pollComposer = new PollComposer({ composer: this });
183+
this.customDataManager = new CustomDataManager({ composer: this, message });
181184

182185
this.editingAuditState = new StateStore<EditingAuditState>(
183186
this.initEditingAuditState(composition),
@@ -274,7 +277,7 @@ export class MessageComposer {
274277
}
275278

276279
get hasSendableData() {
277-
return (
280+
return !!(
278281
(!this.attachmentManager.uploadsInProgressCount &&
279282
(!this.textComposer.textIsEmpty ||
280283
this.attachmentManager.successfulUploadsCount > 0)) ||
@@ -323,6 +326,7 @@ export class MessageComposer {
323326
this.attachmentManager.initState({ message });
324327
this.linkPreviewsManager.initState({ message });
325328
this.textComposer.initState({ message });
329+
this.customDataManager.initState({ message });
326330
this.state.next(initState(composition));
327331
if (
328332
composition &&
@@ -369,6 +373,7 @@ export class MessageComposer {
369373
this.unsubscribeFunctions.add(this.subscribeAttachmentManagerStateChanged());
370374
this.unsubscribeFunctions.add(this.subscribeLinkPreviewsManagerStateChanged());
371375
this.unsubscribeFunctions.add(this.subscribePollComposerStateChanged());
376+
this.unsubscribeFunctions.add(this.subscribeCustomDataManagerStateChanged());
372377
this.unsubscribeFunctions.add(this.subscribeMessageComposerStateChanged());
373378

374379
if (this.config.drafts.enabled) {
@@ -549,6 +554,13 @@ export class MessageComposer {
549554
}
550555
});
551556

557+
private subscribeCustomDataManagerStateChanged = () =>
558+
this.customDataManager.state.subscribe((nextValue, previousValue) => {
559+
if (!this.customDataManager.isDataEqual(nextValue, previousValue)) {
560+
this.logStateUpdateTimestamp();
561+
}
562+
});
563+
552564
private subscribeMessageComposerStateChanged = () =>
553565
this.state.subscribe((nextValue, previousValue) => {
554566
const isActualStateChange =
@@ -574,6 +586,7 @@ export class MessageComposer {
574586
this.linkPreviewsManager.initState();
575587
this.textComposer.initState();
576588
this.pollComposer.initState();
589+
this.customDataManager.initState();
577590
this.initState();
578591
};
579592

src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import type {
2626
MessageDraftComposerMiddlewareExecutorOptions,
2727
MessageDraftComposerMiddlewareValueState,
2828
} from './types';
29-
import {} from './types';
29+
import {
30+
createCustomDataCompositionMiddleware,
31+
createDraftCustomDataCompositionMiddleware,
32+
} from './customData';
3033

3134
export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor<MessageComposerMiddlewareValueState> {
3235
constructor({ composer }: MessageComposerMiddlewareExecutorOptions) {
@@ -38,6 +41,7 @@ export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor<Messag
3841
createAttachmentsCompositionMiddleware(composer),
3942
createLinkPreviewsCompositionMiddleware(composer),
4043
createMessageComposerStateCompositionMiddleware(composer),
44+
createCustomDataCompositionMiddleware(composer),
4145
createCompositionValidationMiddleware(composer),
4246
createCompositionDataCleanupMiddleware(composer),
4347
]);
@@ -54,6 +58,7 @@ export class MessageDraftComposerMiddlewareExecutor extends MiddlewareExecutor<M
5458
createDraftAttachmentsCompositionMiddleware(composer),
5559
createDraftLinkPreviewsCompositionMiddleware(composer),
5660
createDraftMessageComposerStateCompositionMiddleware(composer),
61+
createDraftCustomDataCompositionMiddleware(composer),
5762
createDraftCompositionValidationMiddleware(composer),
5863
]);
5964
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { MiddlewareHandlerParams } from '../../../middleware';
2+
import type { MessageComposer } from '../../messageComposer';
3+
import type {
4+
MessageComposerMiddlewareValueState,
5+
MessageDraftComposerMiddlewareValueState,
6+
} from './types';
7+
8+
export const createCustomDataCompositionMiddleware = (composer: MessageComposer) => ({
9+
id: 'stream-io/message-composer-middleware/custom-data',
10+
compose: ({
11+
input,
12+
nextHandler,
13+
}: MiddlewareHandlerParams<MessageComposerMiddlewareValueState>) => {
14+
const data = composer.customDataManager.data;
15+
if (!data) return nextHandler(input);
16+
17+
return nextHandler({
18+
...input,
19+
state: {
20+
...input.state,
21+
localMessage: {
22+
...input.state.localMessage,
23+
...data,
24+
},
25+
message: {
26+
...input.state.message,
27+
...data,
28+
},
29+
},
30+
});
31+
},
32+
});
33+
34+
export const createDraftCustomDataCompositionMiddleware = (
35+
composer: MessageComposer,
36+
) => ({
37+
id: 'stream-io/message-composer-middleware/draft-custom-data',
38+
compose: ({
39+
input,
40+
nextHandler,
41+
}: MiddlewareHandlerParams<MessageDraftComposerMiddlewareValueState>) => {
42+
const data = composer.customDataManager.data;
43+
if (!data) return nextHandler(input);
44+
45+
return nextHandler({
46+
...input,
47+
state: {
48+
...input.state,
49+
draft: {
50+
...input.state.draft,
51+
...data,
52+
},
53+
},
54+
});
55+
},
56+
});

src/messageComposer/middleware/messageComposer/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './attachments';
22
export * from './cleanData';
3+
export * from './customData';
34
export * from './compositionValidation';
45
export * from './linkPreviews';
56
export * from './MessageComposerMiddlewareExecutor';
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { CustomDataManager } from '../../../src/messageComposer/CustomDataManager';
3+
import { MessageComposer } from '../../../src/messageComposer/messageComposer';
4+
import { Channel } from '../../../src/channel';
5+
import { StreamChat } from '../../../src/client';
6+
import { LocalMessage } from '../../../src/types';
7+
8+
describe('CustomDataManager', () => {
9+
let customDataManager: CustomDataManager;
10+
let mockComposer: MessageComposer;
11+
let mockChannel: Channel;
12+
let mockClient: StreamChat;
13+
14+
beforeEach(() => {
15+
// Reset mocks
16+
vi.clearAllMocks();
17+
18+
// Setup mocks
19+
mockClient = new StreamChat('apiKey', 'apiSecret');
20+
mockClient.user = { id: 'user-id', name: 'Test User' };
21+
22+
mockChannel = mockClient.channel('channelType', 'channelId');
23+
mockComposer = new MessageComposer({
24+
client: mockClient,
25+
compositionContext: mockChannel,
26+
});
27+
28+
// Create instance
29+
customDataManager = new CustomDataManager({
30+
composer: mockComposer,
31+
});
32+
});
33+
34+
describe('constructor', () => {
35+
it('should initialize with empty data', () => {
36+
expect(customDataManager.data).toEqual({});
37+
});
38+
39+
it('should initialize with message data if provided', () => {
40+
const message: LocalMessage = {
41+
custom_field: 'test-value',
42+
id: 'test-message-id',
43+
text: 'Test message',
44+
type: 'regular',
45+
attachments: [],
46+
mentioned_users: [],
47+
created_at: new Date(),
48+
deleted_at: null,
49+
pinned_at: null,
50+
status: 'sent',
51+
updated_at: new Date(),
52+
};
53+
54+
const managerWithMessage = new CustomDataManager({
55+
composer: mockComposer,
56+
message,
57+
});
58+
59+
expect(managerWithMessage.data).toEqual({});
60+
});
61+
});
62+
63+
describe('initState', () => {
64+
it('should reset state to empty data', () => {
65+
// Set some data first
66+
customDataManager.setData({ test: 'value' });
67+
expect(customDataManager.data).toEqual({ test: 'value' });
68+
69+
// Reset state
70+
customDataManager.initState();
71+
expect(customDataManager.data).toEqual({});
72+
});
73+
74+
it('should reset state with message data if provided', () => {
75+
const message: LocalMessage = {
76+
custom_field: 'test-value',
77+
id: 'test-message-id',
78+
text: 'Test message',
79+
type: 'regular',
80+
attachments: [],
81+
mentioned_users: [],
82+
created_at: new Date(),
83+
deleted_at: null,
84+
pinned_at: null,
85+
status: 'sent',
86+
updated_at: new Date(),
87+
};
88+
89+
customDataManager.initState({ message });
90+
expect(customDataManager.data).toEqual({});
91+
});
92+
});
93+
94+
describe('setCustomData', () => {
95+
it('should update data with new values', () => {
96+
customDataManager.setData({ field1: 'value1' });
97+
expect(customDataManager.data).toEqual({ field1: 'value1' });
98+
99+
customDataManager.setData({ field2: 'value2' });
100+
expect(customDataManager.data).toEqual({ field1: 'value1', field2: 'value2' });
101+
});
102+
103+
it('should override existing values', () => {
104+
customDataManager.setData({ field1: 'value1' });
105+
customDataManager.setData({ field1: 'new-value' });
106+
expect(customDataManager.data).toEqual({ field1: 'new-value' });
107+
});
108+
});
109+
110+
describe('isDataEqual', () => {
111+
it('should return true for equal data', () => {
112+
const state1 = { data: { field1: 'value1' } };
113+
const state2 = { data: { field1: 'value1' } };
114+
expect(customDataManager.isDataEqual(state1, state2)).toBe(true);
115+
});
116+
117+
it('should return false for different data', () => {
118+
const state1 = { data: { field1: 'value1' } };
119+
const state2 = { data: { field1: 'value2' } };
120+
expect(customDataManager.isDataEqual(state1, state2)).toBe(false);
121+
});
122+
123+
it('should handle undefined previous state', () => {
124+
const state1 = { data: { field1: 'value1' } };
125+
expect(customDataManager.isDataEqual(state1, undefined)).toBe(false);
126+
});
127+
});
128+
});

0 commit comments

Comments
 (0)