Skip to content

Commit 0c07524

Browse files
MartinCupelaarnautov-antonkhushal87szuperazisekovanic
committed
feat: message composer (#1495)
This PR introduces a new message composer system that provides a robust foundation for message composition with support for drafts, middleware, and various composition features. - Message draft support for channels - Text composition middleware system - Command handling with case-insensitive search - Link preview integration - Mentions search with local member support - Notification management system - Local message type for better state management - Introduces `MessageComposer` class for managing message composition - Adds middleware system for text composition with extensible pipeline - Implements draft message handling with proper state management - Adds support for commands with trigger-based activation - Integrates link preview functionality during composition - Provides notification management for composition events - Introduces `LocalMessage` type for improved state handling Introduction of `LocalMessage` type. The `MessageResponse` is automatically transformed into LocalMessage type before being submitted to the state. - Added comprehensive test coverage for: - Message composition middleware - Command handling - Link preview integration - Mentions search - State management - Draft handling - Bundle size increase: +72.3 kB (+24.62%) - dist/cjs/index.browser.cjs: +19.4 kB - dist/cjs/index.node.cjs: +20 kB - dist/esm/index.js: +32.9 kB - Convert message composer into a plugin system - Further optimize bundle size - Add more middleware options for extensibility - Encapsulate interaction with the UI element representing the text editing area - Related to stream-chat-react#2669 - `linkifyjs@^4.2.0` BREAKING CHANGE: Replacement of FormatMessageResponse with LocalMessage type --------- Co-authored-by: Anton Arnautov <[email protected]> Co-authored-by: Khushal Agarwal <[email protected]> Co-authored-by: Zita Szupera <[email protected]> Co-authored-by: Ivan Sekovanikj <[email protected]>
1 parent 16cd81a commit 0c07524

87 files changed

Lines changed: 16395 additions & 161 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"form-data": "^4.0.0",
5757
"isomorphic-ws": "^5.0.0",
5858
"jsonwebtoken": "^9.0.2",
59+
"linkifyjs": "^4.2.0",
5960
"ws": "^8.18.1"
6061
},
6162
"devDependencies": {

src/channel.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ import type {
2727
EventAPIResponse,
2828
EventHandler,
2929
EventTypes,
30-
FormatMessageResponse,
3130
GetDraftResponse,
3231
GetMultipleMessagesAPIResponse,
3332
GetReactionsAPIResponse,
3433
GetRepliesAPIResponse,
34+
LocalMessage,
3535
MarkReadOptions,
3636
MarkUnreadOptions,
3737
MemberFilters,
@@ -70,6 +70,7 @@ import type {
7070
} from './types';
7171
import type { Role } from './permissions';
7272
import type { CustomChannelData } from './custom_types';
73+
import { MessageComposer } from './messageComposer';
7374

7475
/**
7576
* Channel - The Channel class manages it's own state.
@@ -104,6 +105,7 @@ export class Channel {
104105
isTyping: boolean;
105106
disconnected: boolean;
106107
push_preferences?: PushPreference;
108+
public readonly messageComposer: MessageComposer;
107109

108110
/**
109111
* constructor - Create a channel
@@ -147,6 +149,11 @@ export class Channel {
147149
this.lastTypingEvent = null;
148150
this.isTyping = false;
149151
this.disconnected = false;
152+
153+
this.messageComposer = new MessageComposer({
154+
client: this._client,
155+
compositionContext: this,
156+
});
150157
}
151158

152159
/**
@@ -421,7 +428,9 @@ export class Channel {
421428

422429
const url =
423430
this.getClient().baseURL +
424-
`/messages/${encodeURIComponent(messageID)}/reaction/${encodeURIComponent(reactionType)}`;
431+
`/messages/${encodeURIComponent(messageID)}/reaction/${encodeURIComponent(
432+
reactionType,
433+
)}`;
425434
//provided when server side request
426435
if (user_id) {
427436
return this.getClient().delete<ReactionAPIResponse>(url, { user_id });
@@ -950,7 +959,7 @@ export class Channel {
950959
*
951960
* @return {ReturnType<ChannelState['formatMessage']> | undefined} Description
952961
*/
953-
lastMessage(): FormatMessageResponse | undefined {
962+
lastMessage(): LocalMessage | undefined {
954963
// get last 5 messages, sort, return the latest
955964
// get a slice of the last 5
956965
let min = this.state.latestMessages.length - 5;
@@ -1176,7 +1185,7 @@ export class Channel {
11761185
}
11771186
}
11781187

1179-
_countMessageAsUnread(message: FormatMessageResponse | MessageResponse) {
1188+
_countMessageAsUnread(message: LocalMessage | MessageResponse) {
11801189
if (message.shadowed) return false;
11811190
if (message.silent) return false;
11821191
if (message.parent_id && !message.show_in_channel) return false;
@@ -1285,7 +1294,9 @@ export class Channel {
12851294
);
12861295
}
12871296

1288-
let queryURL = `${this.getClient().baseURL}/channels/${encodeURIComponent(this.type)}`;
1297+
let queryURL = `${this.getClient().baseURL}/channels/${encodeURIComponent(
1298+
this.type,
1299+
)}`;
12891300
if (this.id) {
12901301
queryURL += `/${encodeURIComponent(this.id)}`;
12911302
}
@@ -1342,6 +1353,10 @@ export class Channel {
13421353

13431354
this.getClient().polls.hydratePollCache(state.messages, true);
13441355

1356+
if (state.draft) {
1357+
this.messageComposer.initState({ composition: state.draft });
1358+
}
1359+
13451360
const areCapabilitiesChanged =
13461361
[...(state.channel.own_capabilities || [])].sort().join() !==
13471362
[
@@ -1898,7 +1913,9 @@ export class Channel {
18981913
if (!this.id) {
18991914
throw new Error('channel id is not defined');
19001915
}
1901-
return `${this.getClient().baseURL}/channels/${encodeURIComponent(this.type)}/${encodeURIComponent(this.id)}`;
1916+
return `${this.getClient().baseURL}/channels/${encodeURIComponent(
1917+
this.type,
1918+
)}/${encodeURIComponent(this.id)}`;
19021919
};
19031920

19041921
_checkInitialized() {

src/channel_state.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import type { Channel } from './channel';
22
import type {
33
ChannelMemberResponse,
44
Event,
5-
FormatMessageResponse,
5+
LocalMessage,
66
MessageResponse,
7+
MessageResponseBase,
78
MessageSet,
89
MessageSetType,
910
PendingMessageResponse,
@@ -122,7 +123,7 @@ export class ChannelState {
122123
* @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if message is not in the list (only used if addIfDoesNotExist is true)
123124
*/
124125
addMessageSorted(
125-
newMessage: MessageResponse,
126+
newMessage: MessageResponse | LocalMessage,
126127
timestampChanged = false,
127128
addIfDoesNotExist = true,
128129
messageSetToAddToIfDoesNotExist: MessageSetType = 'latest',
@@ -142,7 +143,8 @@ export class ChannelState {
142143
*
143144
* @param {MessageResponse} message `MessageResponse` object
144145
*/
145-
formatMessage = (message: MessageResponse) => formatMessage(message);
146+
formatMessage = (message: MessageResponse | MessageResponseBase | LocalMessage) =>
147+
formatMessage(message);
146148

147149
/**
148150
* addMessagesSorted - Add the list of messages to state and resorts the messages
@@ -155,7 +157,7 @@ export class ChannelState {
155157
*
156158
*/
157159
addMessagesSorted(
158-
newMessages: MessageResponse[],
160+
newMessages: (MessageResponse | LocalMessage)[],
159161
timestampChanged = false,
160162
initializing = false,
161163
addIfDoesNotExist = true,
@@ -180,7 +182,7 @@ export class ChannelState {
180182
if (isMessageFormatted) {
181183
message = messagesToAdd[i] as ReturnType<ChannelState['formatMessage']>;
182184
} else {
183-
message = this.formatMessage(messagesToAdd[i] as MessageResponse);
185+
message = this.formatMessage(messagesToAdd[i]);
184186

185187
if (message.user && this._channel?.cid) {
186188
/**
@@ -365,7 +367,7 @@ export class ChannelState {
365367
updated_at: m.updated_at?.toISOString(),
366368
}) as unknown as MessageResponse;
367369

368-
const update = (messages: FormatMessageResponse[]) => {
370+
const update = (messages: LocalMessage[]) => {
369371
const updatedMessages = messages.reduce<MessageResponse[]>((acc, msg) => {
370372
if (msg.quoted_message_id === message.id) {
371373
acc.push({
@@ -757,12 +759,11 @@ export class ChannelState {
757759
}
758760

759761
private findTargetMessageSet(
760-
newMessages: MessageResponse[],
762+
newMessages: (MessageResponse | LocalMessage)[],
761763
addIfDoesNotExist = true,
762764
messageSetToAddToIfDoesNotExist: MessageSetType = 'current',
763765
) {
764-
let messagesToAdd: (MessageResponse | ReturnType<ChannelState['formatMessage']>)[] =
765-
newMessages;
766+
let messagesToAdd: (MessageResponse | LocalMessage)[] = newMessages;
766767
let targetMessageSetIndex!: number;
767768
if (addIfDoesNotExist) {
768769
const overlappingMessageSetIndices = this.messageSets

0 commit comments

Comments
 (0)