Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/feat/search-controller' into fea…
Browse files Browse the repository at this point in the history
…t/search-controller
  • Loading branch information
MartinCupela committed Jan 23, 2025
2 parents 95b924a + ac334bc commit 8ace803
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 17 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.

## [8.51.0](https://github.com/GetStream/stream-chat-js/compare/v8.50.0...v8.51.0) (2025-01-17)


### Features

* **threads:** handle custom data ([#1428](https://github.com/GetStream/stream-chat-js/issues/1428)) ([964f008](https://github.com/GetStream/stream-chat-js/commit/964f008dfca0c7740995d708dec12cdfbeb1b8b8))

## [8.50.0](https://github.com/GetStream/stream-chat-js/compare/v8.49.0...v8.50.0) (2025-01-16)


Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stream-chat",
"version": "8.50.0",
"version": "8.51.0",
"description": "JS SDK for the Stream Chat API",
"author": "GetStream",
"homepage": "https://getstream.io/chat/",
Expand Down
30 changes: 18 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,20 +2754,25 @@ export class StreamChat<StreamChatGenerics extends ExtendableGenerics = DefaultG
*
* @returns {{ threads: Thread<StreamChatGenerics>[], next: string }} Returns the list of threads and the next cursor.
*/
async queryThreads(options?: QueryThreadsOptions) {
const opts = {
async queryThreads(options: QueryThreadsOptions = {}) {
const optionsWithDefaults = {
limit: 10,
participant_limit: 10,
reply_limit: 3,
watch: true,
...options,
};

const res = await this.post<QueryThreadsAPIResponse<StreamChatGenerics>>(this.baseURL + `/threads`, opts);
const response = await this.post<QueryThreadsAPIResponse<StreamChatGenerics>>(
`${this.baseURL}/threads`,
optionsWithDefaults,
);

return {
threads: res.threads.map((thread) => new Thread({ client: this, threadData: thread })),
next: res.next,
threads: response.threads.map(
(thread) => new Thread<StreamChatGenerics>({ client: this, threadData: thread }),
),
next: response.next,
};
}

Expand All @@ -2784,22 +2789,22 @@ export class StreamChat<StreamChatGenerics extends ExtendableGenerics = DefaultG
*/
async getThread(messageId: string, options: GetThreadOptions = {}) {
if (!messageId) {
throw Error('Please specify the message id when calling partialUpdateThread');
throw Error('Please specify the messageId when calling getThread');
}

const opts = {
const optionsWithDefaults = {
participant_limit: 100,
reply_limit: 3,
watch: true,
...options,
};

const res = await this.get<GetThreadAPIResponse<StreamChatGenerics>>(
this.baseURL + `/threads/${encodeURIComponent(messageId)}`,
opts,
const response = await this.get<GetThreadAPIResponse<StreamChatGenerics>>(
`${this.baseURL}/threads/${encodeURIComponent(messageId)}`,
optionsWithDefaults,
);

return new Thread<StreamChatGenerics>({ client: this, threadData: res.thread });
return new Thread<StreamChatGenerics>({ client: this, threadData: response.thread });
}

/**
Expand Down Expand Up @@ -2827,6 +2832,7 @@ export class StreamChat<StreamChatGenerics extends ExtendableGenerics = DefaultG
'reply_count',
'participants',
'channel',
'custom',
];

for (const key in { ...partialThreadObject.set, ...partialThreadObject.unset }) {
Expand All @@ -2838,7 +2844,7 @@ export class StreamChat<StreamChatGenerics extends ExtendableGenerics = DefaultG
}

return await this.patch<GetThreadAPIResponse<StreamChatGenerics>>(
this.baseURL + `/threads/${encodeURIComponent(messageId)}`,
`${this.baseURL}/threads/${encodeURIComponent(messageId)}`,
partialThreadObject,
);
}
Expand Down
1 change: 1 addition & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const EVENT_MAP = {
'reaction.deleted': true,
'reaction.new': true,
'reaction.updated': true,
'thread.updated': true,
'typing.start': true,
'typing.stop': true,
'user.banned': true,
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export * from './search_controller';
export * from './segment';
export * from './signing';
export * from './store';
export * from './thread';
export { Thread } from './thread';
export type { ThreadState, ThreadReadState, ThreadRepliesPagination, ThreadUserReadState } from './thread';
export * from './thread_manager';
export * from './token_manager';
export * from './types';
Expand Down
68 changes: 66 additions & 2 deletions src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
MessageResponse,
ReadResponse,
ThreadResponse,
ThreadResponseCustomData,
UserResponse,
} from './types';
import { addToMessageList, findIndexInSortedArray, formatMessage, throttle } from './utils';
Expand All @@ -27,6 +28,7 @@ export type ThreadState<SCG extends ExtendableGenerics = DefaultGenerics> = {
active: boolean;
channel: Channel<SCG>;
createdAt: Date;
custom: ThreadResponseCustomData;
deletedAt: Date | null;
isLoading: boolean;
isStateStale: boolean;
Expand All @@ -40,6 +42,7 @@ export type ThreadState<SCG extends ExtendableGenerics = DefaultGenerics> = {
read: ThreadReadState;
replies: Array<FormatMessageResponse<SCG>>;
replyCount: number;
title: string;
updatedAt: Date | null;
};

Expand All @@ -65,6 +68,43 @@ export type ThreadReadState<SCG extends ExtendableGenerics = DefaultGenerics> =
const DEFAULT_PAGE_LIMIT = 50;
const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }];
const MARK_AS_READ_THROTTLE_TIMEOUT = 1000;
// TODO: remove this once we move to API v2
export const THREAD_RESPONSE_RESERVED_KEYS: Record<keyof ThreadResponse, true> = {
channel: true,
channel_cid: true,
created_at: true,
created_by_user_id: true,
parent_message_id: true,
title: true,
updated_at: true,
latest_replies: true,
active_participant_count: true,
deleted_at: true,
last_message_at: true,
participant_count: true,
reply_count: true,
read: true,
thread_participants: true,
created_by: true,
parent_message: true,
};

// TODO: remove this once we move to API v2
const constructCustomDataObject = <T extends ThreadResponse>(threadData: T) => {
const custom: ThreadResponseCustomData = {};

for (const key in threadData) {
if (THREAD_RESPONSE_RESERVED_KEYS[key as keyof ThreadResponse]) {
continue;
}

const customKey = key as keyof ThreadResponseCustomData;

custom[customKey] = threadData[customKey];
}

return custom;
};

export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
public readonly state: StateStore<ThreadState<SCG>>;
Expand All @@ -87,12 +127,15 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
: [];

this.state = new StateStore<ThreadState<SCG>>({
// local only
active: false,
isLoading: false,
isStateStale: false,
// 99.9% should never change
channel,
createdAt: new Date(threadData.created_at),
// rest
deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
isLoading: false,
isStateStale: false,
pagination: repliesPaginationFromInitialThread(threadData),
parentMessage: formatMessage(threadData.parent_message),
participants: threadData.thread_participants,
Expand All @@ -102,6 +145,8 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
replies: threadData.latest_replies.map(formatMessage),
replyCount: threadData.reply_count ?? 0,
updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null,
title: threadData.title,
custom: constructCustomDataObject(threadData),
});

this.id = threadData.parent_message_id;
Expand Down Expand Up @@ -186,6 +231,7 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
return;
}

this.unsubscribeFunctions.add(this.subscribeThreadUpdated());
this.unsubscribeFunctions.add(this.subscribeMarkActiveThreadRead());
this.unsubscribeFunctions.add(this.subscribeReloadActiveStaleThread());
this.unsubscribeFunctions.add(this.subscribeMarkThreadStale());
Expand All @@ -195,6 +241,24 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
this.unsubscribeFunctions.add(this.subscribeMessageUpdated());
};

private subscribeThreadUpdated = () => {
return this.client.on('thread.updated', (event) => {
if (!event.thread || event.thread.parent_message_id !== this.id) {
return;
}

const threadData = event.thread;

this.state.partialNext({
title: threadData.title,
updatedAt: new Date(threadData.updated_at),
deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
// TODO: use threadData.custom once we move to API v2
custom: constructCustomDataObject(threadData),
});
}).unsubscribe;
};

private subscribeMarkActiveThreadRead = () => {
return this.state.subscribeWithSelector(
(nextValue) => ({
Expand Down
8 changes: 7 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,10 @@ export type GetMessageAPIResponse<
StreamChatGenerics extends ExtendableGenerics = DefaultGenerics
> = SendMessageAPIResponse<StreamChatGenerics>;

export interface ThreadResponse<SCG extends ExtendableGenerics = DefaultGenerics> {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ThreadResponseCustomData {}

export interface ThreadResponse<SCG extends ExtendableGenerics = DefaultGenerics> extends ThreadResponseCustomData {
// FIXME: according to OpenAPI, `channel` could be undefined but since cid is provided I'll asume that it's wrong
channel: ChannelResponse<SCG>;
channel_cid: string;
Expand All @@ -531,6 +534,7 @@ export interface ThreadResponse<SCG extends ExtendableGenerics = DefaultGenerics
parent_message_id: string;
title: string;
updated_at: string;
active_participant_count?: number;
created_by?: UserResponse<SCG>;
deleted_at?: string;
last_message_at?: string;
Expand All @@ -547,6 +551,8 @@ export interface ThreadResponse<SCG extends ExtendableGenerics = DefaultGenerics
user?: UserResponse<SCG>;
user_id?: string;
}>;
// TODO: when moving to API v2 we should do this instead
// custom: ThreadResponseCustomData;
}

// TODO: Figure out a way to strongly type set and unset.
Expand Down
65 changes: 65 additions & 0 deletions test/unit/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ThreadResponse,
THREAD_MANAGER_INITIAL_STATE,
} from '../../src';
import { THREAD_RESPONSE_RESERVED_KEYS } from '../../src/thread';

const TEST_USER_ID = 'observer';

Expand Down Expand Up @@ -561,6 +562,69 @@ describe('Threads 2.0', () => {
thread.unregisterSubscriptions();
});

describe('Event: thread.updated', () => {
it('ignores incoming event if the data do not match (parent_message_id)', () => {
const thread = createTestThread({ title: 'A' });
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();
expect(stateBefore.title).to.eq('A');

client.dispatchEvent({
type: 'thread.updated',
thread: generateThreadResponse(channelResponse, generateMsg(), { title: 'B' }),
});

const stateAfter = thread.state.getLatestValue();
expect(stateAfter.title).to.eq('A');
});

it('correctly updates thread-level properties', () => {
const thread = createTestThread({ title: 'A' });
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();
expect(stateBefore.title).to.eq('A');

client.dispatchEvent({
type: 'thread.updated',
thread: generateThreadResponse(channelResponse, generateMsg({ id: parentMessageResponse.id }), {
title: 'B',
}),
});

const stateAfter = thread.state.getLatestValue();
expect(stateAfter.title).to.eq('B');
});

it('properly handles custom data', () => {
const customKey1 = uuidv4();
const customKey2 = uuidv4();

const thread = createTestThread({ [customKey1]: 1, [customKey2]: { key: 1 } });
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();

expect(stateBefore.custom).to.not.have.keys(Object.keys(THREAD_RESPONSE_RESERVED_KEYS));
expect(stateBefore.custom).to.have.keys([customKey1, customKey2]);
expect(stateBefore.custom[customKey1]).to.equal(1);

client.dispatchEvent({
type: 'thread.updated',
thread: generateThreadResponse(channelResponse, generateMsg({ id: parentMessageResponse.id }), {
[customKey1]: 2,
}),
});

const stateAfter = thread.state.getLatestValue();

expect(stateAfter.custom).to.not.have.keys(Object.keys(THREAD_RESPONSE_RESERVED_KEYS));
expect(stateAfter.custom).to.not.have.property(customKey2);
expect(stateAfter.custom[customKey1]).to.equal(2);
});
});

describe('Event: user.watching.stop', () => {
it('ignores incoming event if the data do not match (channel or user.id)', () => {
const thread = createTestThread();
Expand Down Expand Up @@ -1207,6 +1271,7 @@ describe('Threads 2.0', () => {
await threadManager.reload();
expect(stubbedQueryThreads.calledWithMatch({ limit: 25 })).to.be.true;
});

it('skips reload if there were no updates since the latest reload', async () => {
threadManager.state.partialNext({ ready: true });
await threadManager.reload();
Expand Down

0 comments on commit 8ace803

Please sign in to comment.