Skip to content

Commit f327974

Browse files
authored
feat: idempotent follow - getOrCreateFollows (#183)
🎫 Ticket: https://linear.app/stream/issue/REACT-680/integrate-and-document-getorcreatefollows πŸ“‘ Docs: TODO ### πŸ’‘ Overview ### πŸ“ Implementation notes
1 parent d9230ba commit f327974

File tree

5 files changed

+176
-33
lines changed

5 files changed

+176
-33
lines changed

β€Žpackages/feeds-client/__integration-tests__/docs-snippets/follows.test.tsβ€Ž

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ describe('Follows page', () => {
1313
const user: UserRequest = getTestUser();
1414
let feed: Feed;
1515
let timeline: Feed;
16+
let feed2: Feed;
1617

1718
beforeAll(async () => {
1819
client = createTestClient();
1920
await client.connectUser(user, createTestTokenGenerator(user));
2021
feed = client.feed('user', crypto.randomUUID());
2122
await feed.getOrCreate();
23+
feed2 = client.feed('user', crypto.randomUUID());
24+
await feed2.getOrCreate();
2225
});
2326

2427
it(`Follow & Unfollows`, async () => {
@@ -31,6 +34,20 @@ describe('Follows page', () => {
3134
reason: 'investment',
3235
},
3336
});
37+
38+
await client.follow({
39+
source: timeline.feed,
40+
target: feed2.feed,
41+
});
42+
});
43+
44+
it('Unfollow', async () => {
45+
await timeline.unfollow(feed.feed);
46+
47+
await client.unfollow({
48+
source: timeline.feed,
49+
target: feed2.feed,
50+
});
3451
});
3552

3653
it(`Query follows`, async () => {
@@ -161,6 +178,40 @@ describe('Follows page', () => {
161178
});
162179
});
163180

181+
it('Batch follow & unfollow', async () => {
182+
const response = await client.getOrCreateFollows({
183+
follows: [
184+
{
185+
source: timeline.feed,
186+
target: feed.feed,
187+
// Optional
188+
push_preference: 'all',
189+
custom: {
190+
reason: 'investment',
191+
},
192+
},
193+
{
194+
source: timeline.feed,
195+
target: feed2.feed,
196+
},
197+
],
198+
});
199+
200+
console.log('Created follows:', response.created);
201+
console.log('Follows:', response.follows);
202+
203+
await client.getOrCreateUnfollows({
204+
follows: [
205+
{
206+
source: timeline.feed,
207+
target: feed.feed,
208+
},
209+
],
210+
});
211+
212+
console.log('Follows that were removed:', response.follows);
213+
});
214+
164215
afterAll(async () => {
165216
await feed.delete({ hard_delete: true });
166217
await timeline.delete({ hard_delete: true });

β€Žpackages/feeds-client/__integration-tests__/feed-follow-unfollow.test.tsβ€Ž

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ describe('Feed follow and unfollow', () => {
1515
let serverClient: StreamClient;
1616
let client: FeedsClient;
1717
let feed: ReturnType<FeedsClient['feed']>;
18-
const user: UserRequest = getTestUser();
19-
const secondUser: UserRequest = getTestUser();
20-
const feedId = crypto.randomUUID();
21-
const secondUserFeedId = crypto.randomUUID();
22-
const secondUserTimelineId = crypto.randomUUID();
18+
const user: UserRequest = getTestUser('current');
19+
const secondUser: UserRequest = getTestUser('second');
20+
const thirdUser: UserRequest = getTestUser('third');
2321
let secondUserFeed: ReturnType<StreamClient['feeds']['feed']>;
2422
let secondUserTimeline: ReturnType<StreamClient['feeds']['feed']>;
23+
let thirdUserFeed: ReturnType<StreamClient['feeds']['feed']>;
24+
let thirdUserTimeline: ReturnType<StreamClient['feeds']['feed']>;
2525

2626
beforeAll(async () => {
2727
// Create second user and their feeds using server client
@@ -30,22 +30,31 @@ describe('Feed follow and unfollow', () => {
3030

3131
client = createTestClient();
3232
await client.connectUser(user, createTestTokenGenerator(user));
33-
feed = client.feed('user', feedId);
33+
feed = client.feed('user', user.id);
3434

3535
// Create user feed for second user
36-
secondUserFeed = serverClient.feeds.feed('user', secondUserFeedId);
36+
secondUserFeed = serverClient.feeds.feed('user', secondUser.id);
3737
await secondUserFeed.getOrCreate({
3838
user: { id: secondUser.id },
3939
});
4040

4141
// Create timeline feed for second user
42-
secondUserTimeline = serverClient.feeds.feed(
43-
'timeline',
44-
secondUserTimelineId,
45-
);
42+
secondUserTimeline = serverClient.feeds.feed('timeline', secondUser.id);
4643
await secondUserTimeline.getOrCreate({
4744
user: { id: secondUser.id },
4845
});
46+
47+
// Create user feed for third user
48+
thirdUserFeed = serverClient.feeds.feed('user', thirdUser.id);
49+
await thirdUserFeed.getOrCreate({
50+
user: { id: thirdUser.id },
51+
});
52+
53+
// Create timeline feed for third user
54+
thirdUserTimeline = serverClient.feeds.feed('timeline', thirdUser.id);
55+
await thirdUserTimeline.getOrCreate({
56+
user: { id: thirdUser.id },
57+
});
4958
});
5059

5160
beforeEach(async () => {
@@ -63,24 +72,76 @@ describe('Feed follow and unfollow', () => {
6372
expect(feed.currentState.following_count).toEqual(1);
6473
});
6574

75+
it(`should update state when doing getOrCreateFollows and some feeds already followed`, async () => {
76+
const initialFollowingCount = feed.currentState.following_count ?? 0;
77+
const initialFollowing = feed.currentState.following ?? [];
78+
79+
await client.getOrCreateFollows({
80+
follows: [
81+
// Already followed
82+
{ source: feed.feed, target: secondUserFeed.feed },
83+
// Not yet followed
84+
{ source: feed.feed, target: thirdUserFeed.feed },
85+
],
86+
});
87+
88+
expect(feed.currentState.following).toHaveLength(
89+
initialFollowing.length + 1,
90+
);
91+
expect(feed.currentState.following_count).toEqual(
92+
initialFollowingCount + 1,
93+
);
94+
});
95+
6696
it('should update state when I unfollow someone', async () => {
97+
const initialFollowingCount = feed.currentState.following_count ?? 0;
98+
const initialFollowing = feed.currentState.following ?? [];
99+
67100
await feed.unfollow(secondUserFeed.feed);
68101

69-
expect(feed.currentState.following).toHaveLength(0);
70-
expect(feed.currentState.following_count).toEqual(0);
102+
expect(feed.currentState.following).toHaveLength(
103+
initialFollowing.length - 1,
104+
);
105+
expect(feed.currentState.following_count).toEqual(
106+
initialFollowingCount - 1,
107+
);
108+
});
109+
110+
it(`should update state when doing getOrCreateUnfollows and some feeds already unfollowed`, async () => {
111+
const initialFollowingCount = feed.currentState.following_count ?? 0;
112+
const initialFollowing = feed.currentState.following ?? [];
113+
114+
await client.getOrCreateUnfollows({
115+
follows: [
116+
// Already unfollowed
117+
{ source: feed.feed, target: secondUserFeed.feed },
118+
// Not yet unfollowed
119+
{ source: feed.feed, target: thirdUserFeed.feed },
120+
],
121+
});
122+
123+
expect(feed.currentState.following).toHaveLength(
124+
initialFollowing.length - 1,
125+
);
126+
expect(feed.currentState.following_count).toEqual(
127+
initialFollowingCount - 1,
128+
);
71129
});
72130

73131
afterAll(async () => {
74132
// Clean up feeds
75-
await feed.delete();
133+
await feed.delete({ hard_delete: true });
76134

77135
await secondUserFeed.delete({ hard_delete: true });
78136
await secondUserTimeline.delete({ hard_delete: true });
79137

138+
await thirdUserFeed.delete({ hard_delete: true });
139+
await thirdUserTimeline.delete({ hard_delete: true });
140+
80141
await client.disconnectUser();
81142

82143
await serverClient.deleteUsers({
83-
user_ids: [secondUser.id],
144+
user_ids: [secondUser.id, thirdUser.id],
84145
user: 'hard',
85146
});
86147
});

β€Žpackages/feeds-client/src/feeds-client/feeds-client.tsβ€Ž

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import type {
1414
FileUploadRequest,
1515
FollowBatchRequest,
1616
FollowRequest,
17+
FollowResponse,
1718
GetOrCreateFeedRequest,
1819
ImageUploadRequest,
1920
OwnBatchRequest,
2021
PollResponse,
2122
PollVotesResponse,
2223
QueryFeedsRequest,
2324
QueryPollVotesRequest,
25+
UnfollowBatchRequest,
2426
UpdateActivityRequest,
2527
UpdateActivityResponse,
2628
UpdateCommentRequest,
@@ -764,36 +766,41 @@ export class FeedsClient extends FeedsApi {
764766
// For follow API endpoints we update the state after HTTP response to allow queryFeeds with watch: false
765767
async follow(request: FollowRequest) {
766768
const response = await super.follow(request);
767-
768-
[
769-
response.follow.source_feed.feed,
770-
response.follow.target_feed.feed,
771-
].forEach((fid) => {
772-
const feeds = this.findAllActiveFeedsByFid(fid);
773-
feeds.forEach((f) => handleFollowCreated.bind(f)(response, false));
774-
});
769+
this.updateStateFromFollows([response.follow]);
775770

776771
return response;
777772
}
778773

774+
/**
775+
* @deprecated Use getOrCreateFollows instead
776+
* @param request
777+
* @returns
778+
*/
779779
async followBatch(request: FollowBatchRequest) {
780780
const response = await super.followBatch(request);
781+
this.updateStateFromFollows(response.follows);
781782

782-
response.follows.forEach((follow) => {
783-
const feeds = this.findAllActiveFeedsByFid(follow.source_feed.feed);
784-
feeds.forEach((f) => handleFollowCreated.bind(f)({ follow }, false));
785-
});
783+
return response;
784+
}
785+
786+
async getOrCreateFollows(request: FollowBatchRequest) {
787+
const response = await super.getOrCreateFollows(request);
788+
789+
this.updateStateFromFollows(response.created);
786790

787791
return response;
788792
}
789793

790-
async unfollow(request: FollowRequest) {
794+
async unfollow(request: { source: string; target: string }) {
791795
const response = await super.unfollow(request);
796+
this.updateStateFromUnfollows([response.follow]);
792797

793-
[request.source, request.target].forEach((fid) => {
794-
const feeds = this.findAllActiveFeedsByFid(fid);
795-
feeds.forEach((f) => handleFollowDeleted.bind(f)(response, false));
796-
});
798+
return response;
799+
}
800+
801+
async getOrCreateUnfollows(request: UnfollowBatchRequest) {
802+
const response = await super.getOrCreateUnfollows(request);
803+
this.updateStateFromUnfollows(response.follows);
797804

798805
return response;
799806
}
@@ -922,4 +929,24 @@ export class FeedsClient extends FeedsApi {
922929
.map((a) => getFeed.call(a)!),
923930
];
924931
}
932+
933+
private updateStateFromFollows(follows: FollowResponse[]) {
934+
follows.forEach((follow) => {
935+
const feeds = [
936+
...this.findAllActiveFeedsByFid(follow.source_feed.feed),
937+
...this.findAllActiveFeedsByFid(follow.target_feed.feed),
938+
];
939+
feeds.forEach((f) => handleFollowCreated.bind(f)({ follow }, false));
940+
});
941+
}
942+
943+
private updateStateFromUnfollows(follows: FollowResponse[]) {
944+
follows.forEach((follow) => {
945+
const feeds = [
946+
...this.findAllActiveFeedsByFid(follow.source_feed.feed),
947+
...this.findAllActiveFeedsByFid(follow.target_feed.feed),
948+
];
949+
feeds.forEach((f) => handleFollowDeleted.bind(f)({ follow }, false));
950+
});
951+
}
925952
}

β€Žpackages/feeds-client/src/gen/model-decoders/decoders.tsβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,8 @@ decoders.FeedsReactionResponse = (input?: Record<string, any>) => {
10321032

10331033
decoders.FollowBatchResponse = (input?: Record<string, any>) => {
10341034
const typeMappings: TypeMapping = {
1035+
created: { type: 'FollowResponse', isSingle: false },
1036+
10351037
follows: { type: 'FollowResponse', isSingle: false },
10361038
};
10371039
return decode(typeMappings, input);

β€Žpackages/feeds-client/src/gen/models/index.tsβ€Ž

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2990,6 +2990,8 @@ export interface FollowBatchRequest {
29902990
export interface FollowBatchResponse {
29912991
duration: string;
29922992

2993+
created: FollowResponse[];
2994+
29932995
follows: FollowResponse[];
29942996
}
29952997

@@ -4011,7 +4013,7 @@ export interface OnlyUserID {
40114013
export interface OwnBatchRequest {
40124014
feeds: string[];
40134015

4014-
fields?: 'own_follows' | 'own_capabilities' | 'own_membership';
4016+
fields?: string[];
40154017
}
40164018

40174019
export interface OwnBatchResponse {

0 commit comments

Comments
Β (0)