Skip to content

Commit 2c0c639

Browse files
committed
feat: improve MessageComposer ergonomics
1 parent b178abf commit 2c0c639

File tree

10 files changed

+175
-38
lines changed

10 files changed

+175
-38
lines changed

src/custom_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export interface CustomPollData {}
99
export interface CustomReactionData {}
1010
export interface CustomUserData {}
1111
export interface CustomThreadData {}
12+
export interface CustomMessageComposerData {}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './connection';
88
export * from './events';
99
export * from './insights';
1010
export * from './messageComposer';
11+
export * from './middleware';
1112
export * from './moderation';
1213
export * from './permissions';
1314
export * from './poll';
@@ -34,6 +35,7 @@ export type {
3435
CustomCommandData,
3536
CustomEventData,
3637
CustomMemberData,
38+
CustomMessageComposerData,
3739
CustomMessageData,
3840
CustomPollOptionData,
3941
CustomPollData,

src/messageComposer/CustomDataManager.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import type { CustomMessageData, DraftMessage, LocalMessage } from '..';
21
import { StateStore } from '..';
2+
import type {
3+
CustomMessageComposerData,
4+
CustomMessageData,
5+
DraftMessage,
6+
LocalMessage,
7+
} from '..';
38
import type { MessageComposer } from './messageComposer';
9+
import type { DeepPartial } from '../types.utility';
410

511
export type CustomDataManagerState = {
6-
data: CustomMessageData;
12+
message: CustomMessageData;
13+
custom: CustomMessageComposerData;
714
};
815

916
export type CustomDataManagerOptions = {
@@ -12,8 +19,9 @@ export type CustomDataManagerOptions = {
1219
};
1320

1421
const initState = (options: CustomDataManagerOptions): CustomDataManagerState => {
15-
if (!options) return { data: {} as CustomMessageData };
16-
return { data: {} as CustomMessageData };
22+
if (!options)
23+
return { message: {} as CustomMessageData, custom: {} as CustomMessageComposerData };
24+
return { message: {} as CustomMessageData, custom: {} as CustomMessageComposerData };
1725
};
1826

1927
export class CustomDataManager {
@@ -25,23 +33,36 @@ export class CustomDataManager {
2533
this.state = new StateStore<CustomDataManagerState>(initState({ composer, message }));
2634
}
2735

28-
get data() {
29-
return this.state.getLatestValue().data;
36+
get customMessageData() {
37+
return this.state.getLatestValue().message;
3038
}
3139

32-
isDataEqual = (
40+
get customComposerData() {
41+
return this.state.getLatestValue().custom;
42+
}
43+
44+
isMessageDataEqual = (
3345
nextState: CustomDataManagerState,
3446
previousState?: CustomDataManagerState,
35-
) => JSON.stringify(nextState.data) === JSON.stringify(previousState?.data);
47+
) => JSON.stringify(nextState.message) === JSON.stringify(previousState?.message);
3648

3749
initState = ({ message }: { message?: DraftMessage | LocalMessage } = {}) => {
3850
this.state.next(initState({ composer: this.composer, message }));
3951
};
4052

41-
setData(data: Partial<CustomMessageData>) {
53+
setMessageData(data: DeepPartial<CustomMessageData>) {
54+
this.state.partialNext({
55+
message: {
56+
...this.state.getLatestValue().message,
57+
...data,
58+
},
59+
});
60+
}
61+
62+
setCustomData(data: DeepPartial<CustomMessageComposerData>) {
4263
this.state.partialNext({
43-
data: {
44-
...this.state.getLatestValue().data,
64+
custom: {
65+
...this.state.getLatestValue().custom,
4566
...data,
4667
},
4768
});

src/messageComposer/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './attachmentIdentity';
22
export * from './attachmentManager';
33
export * from './configuration';
4+
export * from './CustomDataManager';
45
export * from './fileUtils';
56
export * from './linkPreviewsManager';
67
export * from './messageComposer';

src/messageComposer/messageComposer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ export class MessageComposer {
511511
this.customDataManager.state.subscribe((nextValue, previousValue) => {
512512
if (
513513
typeof previousValue !== 'undefined' &&
514-
!this.customDataManager.isDataEqual(nextValue, previousValue)
514+
!this.customDataManager.isMessageDataEqual(nextValue, previousValue)
515515
) {
516516
this.logStateUpdateTimestamp();
517517
}

src/messageComposer/middleware/messageComposer/customData.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const createCustomDataCompositionMiddleware = (composer: MessageComposer)
1111
input,
1212
nextHandler,
1313
}: MiddlewareHandlerParams<MessageComposerMiddlewareValueState>) => {
14-
const data = composer.customDataManager.data;
14+
const data = composer.customDataManager.customMessageData;
1515
if (!data) return nextHandler(input);
1616

1717
return nextHandler({
@@ -39,7 +39,7 @@ export const createDraftCustomDataCompositionMiddleware = (
3939
input,
4040
nextHandler,
4141
}: MiddlewareHandlerParams<MessageDraftComposerMiddlewareValueState>) => {
42-
const data = composer.customDataManager.data;
42+
const data = composer.customDataManager.customMessageData;
4343
if (!data) return nextHandler(input);
4444

4545
return nextHandler({

src/messageComposer/textComposer.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { TextComposerMiddlewareExecutor } from './middleware';
22
import { StateStore } from '../store';
33
import { logChatPromiseExecution } from '../utils';
4-
import type { TextComposerState, TextComposerSuggestion, TextSelection } from './types';
4+
import type {
5+
Suggestions,
6+
TextComposerState,
7+
TextComposerSuggestion,
8+
TextSelection,
9+
} from './types';
510
import type { MessageComposer } from './messageComposer';
611
import type { DraftMessage, LocalMessage, UserResponse } from '../types';
712

@@ -150,6 +155,11 @@ export class TextComposer {
150155
this.state.partialNext({ text });
151156
};
152157

158+
setSelection = (selection: TextSelection) => {
159+
if (!this.enabled) return;
160+
this.state.partialNext({ selection });
161+
};
162+
153163
insertText = ({ text, selection }: { text: string; selection?: TextSelection }) => {
154164
if (!this.enabled) return;
155165

@@ -184,6 +194,34 @@ export class TextComposer {
184194
});
185195
};
186196

197+
wrapSelection = ({
198+
head = '',
199+
selection,
200+
tail = '',
201+
}: {
202+
head?: string;
203+
selection?: TextSelection;
204+
tail?: string;
205+
}) => {
206+
if (!this.enabled) return;
207+
const currentSelection: TextSelection = selection ?? this.selection;
208+
const prependedText = this.text.slice(0, currentSelection.start);
209+
const selectedText = this.text.slice(currentSelection.start, currentSelection.end);
210+
const appendedText = this.text.slice(currentSelection.end);
211+
const finalSelection = {
212+
start: prependedText.length + head.length,
213+
end: prependedText.length + head.length + selectedText.length,
214+
};
215+
this.state.partialNext({
216+
text: [prependedText, head, selectedText, tail, appendedText].join(''),
217+
selection: finalSelection,
218+
});
219+
};
220+
221+
setSuggestions = (suggestions: Suggestions) => {
222+
this.state.partialNext({ suggestions });
223+
};
224+
187225
closeSuggestions = () => {
188226
const { suggestions } = this.state.getLatestValue();
189227
if (!suggestions) return;

test/unit/MessageComposer/CustomDataManager.test.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('CustomDataManager', () => {
3333

3434
describe('constructor', () => {
3535
it('should initialize with empty data', () => {
36-
expect(customDataManager.data).toEqual({});
36+
expect(customDataManager.customMessageData).toEqual({});
3737
});
3838

3939
it('should initialize with message data if provided', () => {
@@ -56,19 +56,19 @@ describe('CustomDataManager', () => {
5656
message,
5757
});
5858

59-
expect(managerWithMessage.data).toEqual({});
59+
expect(managerWithMessage.customMessageData).toEqual({});
6060
});
6161
});
6262

6363
describe('initState', () => {
6464
it('should reset state to empty data', () => {
6565
// Set some data first
66-
customDataManager.setData({ test: 'value' });
67-
expect(customDataManager.data).toEqual({ test: 'value' });
66+
customDataManager.setMessageData({ test: 'value' });
67+
expect(customDataManager.customMessageData).toEqual({ test: 'value' });
6868

6969
// Reset state
7070
customDataManager.initState();
71-
expect(customDataManager.data).toEqual({});
71+
expect(customDataManager.customMessageData).toEqual({});
7272
});
7373

7474
it('should reset state with message data if provided', () => {
@@ -87,42 +87,45 @@ describe('CustomDataManager', () => {
8787
};
8888

8989
customDataManager.initState({ message });
90-
expect(customDataManager.data).toEqual({});
90+
expect(customDataManager.customMessageData).toEqual({});
9191
});
9292
});
9393

9494
describe('setCustomData', () => {
9595
it('should update data with new values', () => {
96-
customDataManager.setData({ field1: 'value1' });
97-
expect(customDataManager.data).toEqual({ field1: 'value1' });
96+
customDataManager.setMessageData({ field1: 'value1' });
97+
expect(customDataManager.customMessageData).toEqual({ field1: 'value1' });
9898

99-
customDataManager.setData({ field2: 'value2' });
100-
expect(customDataManager.data).toEqual({ field1: 'value1', field2: 'value2' });
99+
customDataManager.setMessageData({ field2: 'value2' });
100+
expect(customDataManager.customMessageData).toEqual({
101+
field1: 'value1',
102+
field2: 'value2',
103+
});
101104
});
102105

103106
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+
customDataManager.setMessageData({ field1: 'value1' });
108+
customDataManager.setMessageData({ field1: 'new-value' });
109+
expect(customDataManager.customMessageData).toEqual({ field1: 'new-value' });
107110
});
108111
});
109112

110113
describe('isDataEqual', () => {
111114
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+
const state1 = { message: { field1: 'value1' } };
116+
const state2 = { message: { field1: 'value1' } };
117+
expect(customDataManager.isMessageDataEqual(state1, state2)).toBe(true);
115118
});
116119

117120
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+
const state1 = { message: { field1: 'value1' } };
122+
const state2 = { message: { field1: 'value2' } };
123+
expect(customDataManager.isMessageDataEqual(state1, state2)).toBe(false);
121124
});
122125

123126
it('should handle undefined previous state', () => {
124-
const state1 = { data: { field1: 'value1' } };
125-
expect(customDataManager.isDataEqual(state1, undefined)).toBe(false);
127+
const state1 = { message: { field1: 'value1' } };
128+
expect(customDataManager.isMessageDataEqual(state1, undefined)).toBe(false);
126129
});
127130
});
128131
});

test/unit/MessageComposer/middleware/messageComposer/customData.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('Custom Data Middleware', () => {
2929
describe('createCustomDataCompositionMiddleware', () => {
3030
it('should initialize with custom data', async () => {
3131
const data = { key: 'value' };
32-
composer.customDataManager.setData(data);
32+
composer.customDataManager.setMessageData(data);
3333
const middleware = createCustomDataCompositionMiddleware(composer);
3434
const state: MessageComposerMiddlewareValueState = {
3535
message: { id: '1', type: 'regular' },
@@ -91,7 +91,7 @@ describe('Custom Data Middleware', () => {
9191
describe('createDraftCustomDataCompositionMiddleware', () => {
9292
it('should initialize with custom data', async () => {
9393
const data = { key: 'value' };
94-
composer.customDataManager.setData(data);
94+
composer.customDataManager.setMessageData(data);
9595
const middleware = createDraftCustomDataCompositionMiddleware(composer);
9696
const state: MessageDraftComposerMiddlewareValueState = {
9797
draft: {

test/unit/MessageComposer/textComposer.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,77 @@ describe('TextComposer', () => {
491491
});
492492
});
493493

494+
describe('wrapSelection', () => {
495+
const message: LocalMessage = {
496+
id: 'test-message',
497+
type: 'regular',
498+
text: 'Hello world',
499+
};
500+
501+
it('should wrap selection from both sides', () => {
502+
const selection = { start: 0, end: 5 };
503+
const {
504+
messageComposer: { textComposer },
505+
} = setup({ composition: message });
506+
textComposer.wrapSelection({ head: '**', tail: '**', selection });
507+
expect(textComposer.text).toBe('**Hello** world');
508+
expect(textComposer.selection).toEqual({ start: 2, end: 7 });
509+
});
510+
511+
it('should wrap selection from the head side', () => {
512+
const selection = { start: 0, end: 5 };
513+
const {
514+
messageComposer: { textComposer },
515+
} = setup({ composition: message });
516+
textComposer.wrapSelection({ head: '**', selection });
517+
expect(textComposer.text).toBe('**Hello world');
518+
expect(textComposer.selection).toEqual({ start: 2, end: 7 });
519+
});
520+
521+
it('should wrap selection from the tail side', () => {
522+
const selection = { start: 0, end: 5 };
523+
const {
524+
messageComposer: { textComposer },
525+
} = setup({ composition: message });
526+
textComposer.wrapSelection({ tail: '**', selection });
527+
expect(textComposer.text).toBe('Hello** world');
528+
expect(textComposer.selection).toEqual({ start: 0, end: 5 });
529+
});
530+
531+
it('should wrap cursor', () => {
532+
const selection = { start: 5, end: 5 };
533+
const {
534+
messageComposer: { textComposer },
535+
} = setup({ composition: message });
536+
textComposer.wrapSelection({ head: '**', tail: '**', selection });
537+
expect(textComposer.text).toBe('Hello**** world');
538+
expect(textComposer.selection).toEqual({ start: 7, end: 7 });
539+
});
540+
541+
it('should avoid changes if text composition is disabled', () => {
542+
const selection = { start: 5, end: 5 };
543+
const {
544+
messageComposer: { textComposer },
545+
} = setup({ composition: message, config: { enabled: false } });
546+
const initialSelection = textComposer.selection;
547+
textComposer.wrapSelection({ head: '**', tail: '**', selection });
548+
expect(textComposer.text).toBe(message.text);
549+
expect(selection).not.toEqual(initialSelection);
550+
expect(textComposer.selection).toEqual(initialSelection);
551+
});
552+
553+
it('should use current selection if custom not provided', () => {
554+
const initialSelection = { start: 2, end: 3 };
555+
const {
556+
messageComposer: { textComposer },
557+
} = setup({ composition: message });
558+
textComposer.setSelection(initialSelection);
559+
textComposer.wrapSelection({ head: '**', tail: '**' });
560+
expect(textComposer.text).toBe('He**l**lo world');
561+
expect(textComposer.selection).toEqual({ start: 4, end: 5 });
562+
});
563+
});
564+
494565
describe('closeSuggestions', () => {
495566
const message: LocalMessage = {
496567
id: 'test-message',

0 commit comments

Comments
 (0)