Skip to content

Commit 0ac37bf

Browse files
authored
feat: own_followings hook, add restrict_replies to sample app (#208)
🎫 Ticket: https://linear.app/stream/issue/REACT-736/integrate-restrict-reply-feature πŸ“‘ Docs: https://github.com/GetStream/docs-content/pull/895/files ### πŸ’‘ Overview ### πŸ“ Implementation notes <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Activities can restrict who may reply (everyone / people you follow / nobody) * Feed logic now respects a user's followings to drive follow-related UI * **UI** * Comment input and Reply buttons are shown/hidden based on reply restrictions * New post composer lets you choose who can reply * Search results show a "follows you" indicator * **Tests** * Added integration tests for reply restrictions and comment input visibility <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4d47e94 commit 0ac37bf

File tree

11 files changed

+196
-65
lines changed

11 files changed

+196
-65
lines changed

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ describe('Activities page', () => {
6868
console.log(response.activity?.parent);
6969
});
7070

71+
it(`restrict replies`, async () => {
72+
await feed.addActivity({
73+
type: 'post',
74+
text: 'apple stock will go up',
75+
restrict_replies: 'people_i_follow', // Options: "everyone", "people_i_follow", "nobody"
76+
});
77+
});
78+
7179
it('restricted visibility', async () => {
7280
const response = await feed.addActivity({
7381
type: 'post',

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,21 @@ describe('Comments page', () => {
156156
});
157157
});
158158

159+
it(`Show/hide comment input`, async () => {
160+
// Lists follow relationships where
161+
// - target feed is owned by current user
162+
// - source feed is owned by activity author
163+
const currentFeed = client.feed(
164+
activity.current_feed?.group_id!,
165+
activity.current_feed?.id!,
166+
);
167+
console.log(currentFeed.currentState.own_followings);
168+
169+
await feed.getOrCreate({
170+
enrichment_options: { enrich_own_followings: true },
171+
});
172+
});
173+
159174
it('Deleting comments', async () => {
160175
await client.deleteComment({
161176
id: comment.id,

β€Žpackages/feeds-client/src/activity-with-state-updates/activity-with-state-updates.tsβ€Ž

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { StateStore } from '@stream-io/state-store';
2-
import type { Feed, FeedState } from '../feed';
2+
import { addActivitiesToState, type Feed, type FeedState } from '../feed';
33
import type { FeedsClient } from '../feeds-client';
44
import type { ActivityResponse } from '../gen/models';
55
import {
@@ -164,8 +164,13 @@ export class ActivityWithStateUpdates {
164164
}) {
165165
this.feed = connectActivityToFeed.call(this.feedsClient, { fid });
166166

167+
const { activities } = addActivitiesToState.bind(this.feed)(
168+
[initialState],
169+
[],
170+
'start',
171+
);
167172
this.feed?.state.partialNext({
168-
activities: [initialState],
173+
activities,
169174
});
170175
}
171176

β€Žpackages/feeds-client/src/bindings/react/hooks/feed-state-hooks/index.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './useAggregatedActivities';
1010
export * from './useIsAggregatedActivityRead';
1111
export * from './useIsAggregatedActivitySeen';
1212
export * from './useActivityComments';
13+
export * from './useOwnFollowings';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useStateStore } from '@stream-io/state-store/react-bindings';
2+
3+
import { useFeedContext } from '../../contexts/StreamFeedContext';
4+
import type { Feed, FeedState } from '../../../../feed';
5+
6+
/**
7+
* A React hook that returns a reactive array of feeds that the feeds's owner is following and is owned by the current user.
8+
*/
9+
export const useOwnFollowings = (feedFromProps?: Feed) => {
10+
const feedFromContext = useFeedContext();
11+
const feed = feedFromProps ?? feedFromContext;
12+
13+
return useStateStore(feed?.state, selector);
14+
};
15+
16+
const selector = ({ own_followings }: FeedState) => ({
17+
own_followings,
18+
});

β€Žsample-apps/react-sample-app/app/activity/[id]/page.tsxβ€Ž

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from '@stream-io/feeds-react-sdk';
1010
import {
1111
FeedOwnCapability,
12+
useClientConnectedUser,
1213
useFeedsClient,
1314
useOwnCapabilities,
1415
useStateStore,
@@ -41,6 +42,7 @@ export default function ActivityPage() {
4142
function ActivityPageContent() {
4243
const params = useParams<{ id: string }>();
4344
const client = useFeedsClient();
45+
const user = useClientConnectedUser();
4446
const { logErrorAndDisplayNotification, logError } = useErrorContext();
4547
const [editedActivityText, setEditedActivityText] = useState('');
4648
const [isEditing, setIsEditing] = useState(false);
@@ -66,7 +68,7 @@ function ActivityPageContent() {
6668
}, [client, params?.id]);
6769

6870
useEffect(() => {
69-
if (!activityWithStateUpdates) {
71+
if (!activityWithStateUpdates || !user?.id) {
7072
return;
7173
}
7274

@@ -79,7 +81,11 @@ function ActivityPageContent() {
7981
const [group, id] = fid.split(':');
8082
_feed = client?.feed(group, id);
8183
setFeed(_feed);
82-
if (!_feed?.currentState.watch && !_feed?.currentState.is_loading) {
84+
if (
85+
!(_feed?.id === user.id) &&
86+
!_feed?.currentState.watch &&
87+
!_feed?.currentState.is_loading
88+
) {
8389
shouldStopWatching = true;
8490
return _feed
8591
?.getOrCreate({
@@ -103,6 +109,7 @@ function ActivityPageContent() {
103109
logError,
104110
activityWithStateUpdates,
105111
client,
112+
user?.id,
106113
]);
107114

108115
const ownCapabilities = useOwnCapabilities(feed);

β€Žsample-apps/react-sample-app/app/components/NewActivity.tsxβ€Ž

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
useOwnCapabilities,
77
} from '@stream-io/feeds-react-sdk';
88
import { useErrorContext } from '../error-context';
9-
import type { FormEvent} from 'react';
9+
import type { FormEvent } from 'react';
1010
import { useMemo, useState } from 'react';
1111
import { ActivityComposer } from './activity/ActivityComposer';
1212
import { LoadingIndicator } from './LoadingIndicator';
@@ -18,6 +18,9 @@ export const NewActivity = () => {
1818
const [isSending, setIsSending] = useState<boolean>(false);
1919
const [activityText, setActivityText] = useState('');
2020
const [files, setFiles] = useState<FileList | null>(null);
21+
const [restrictReplies, setRestrictReplies] = useState<
22+
'everyone' | 'people_i_follow' | 'nobody'
23+
>('everyone');
2124

2225
const ownCapabilities = useOwnCapabilities();
2326
const canPost = useMemo(
@@ -51,6 +54,7 @@ export const NewActivity = () => {
5154
await feed?.addActivity({
5255
type: 'post',
5356
text: activityText,
57+
restrict_replies: restrictReplies,
5458
attachments: fileResponses.map((response, index) => {
5559
const isImage = isImageFile(files![index]);
5660
return {
@@ -90,6 +94,25 @@ export const NewActivity = () => {
9094
}
9195
}}
9296
/>
97+
<div className="w-full flex items-center gap-2">
98+
<label htmlFor="restrict-replies" className="text-sm text-gray-700">
99+
Who can reply:
100+
</label>
101+
<select
102+
id="restrict-replies"
103+
value={restrictReplies}
104+
onChange={(e) =>
105+
setRestrictReplies(
106+
e.target.value as 'everyone' | 'people_i_follow' | 'nobody',
107+
)
108+
}
109+
className="px-3 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
110+
>
111+
<option value="everyone">Everyone</option>
112+
<option value="people_i_follow">People I follow</option>
113+
<option value="nobody">Nobody</option>
114+
</select>
115+
</div>
93116
<button
94117
type="submit"
95118
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none"

β€Žsample-apps/react-sample-app/app/components/Search/SearchResults/SearchResultItem.tsxβ€Ž

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { ComponentType } from 'react';
2-
import type {
3-
Feed,
4-
FeedState} from '@stream-io/feeds-react-sdk';
2+
import type { Feed, FeedState } from '@stream-io/feeds-react-sdk';
53
import {
64
useStateStore,
75
type ActivityResponse,
@@ -25,14 +23,16 @@ export type ActivitySearchResultItemProps = {
2523
const selector = (nextValue: FeedState) => ({
2624
own_follows: nextValue.own_follows ?? [],
2725
created_by: nextValue.created_by,
26+
own_followings: nextValue.own_followings ?? [],
2827
});
2928
export const FeedSearchResultItem = ({ item }: FeedSearchResultItemProps) => {
3029
const { ownTimeline } = useOwnFeedsContext();
3130

32-
const { own_follows: ownFollows, created_by: createdBy } = useStateStore(
33-
item.state,
34-
selector,
35-
);
31+
const {
32+
own_follows: ownFollows,
33+
own_followings: ownFollowings,
34+
created_by: createdBy,
35+
} = useStateStore(item.state, selector);
3636

3737
const isFollowing =
3838
ownFollows.some(
@@ -45,9 +45,14 @@ export const FeedSearchResultItem = ({ item }: FeedSearchResultItemProps) => {
4545
data-testid="search-result-feed"
4646
role="option"
4747
>
48-
<Link className="underline text-blue-500" href={`/users/${item.id}`}>
49-
{createdBy?.name ?? item.id}
50-
</Link>
48+
<div>
49+
<Link className="underline text-blue-500" href={`/users/${item.id}`}>
50+
{createdBy?.name ?? item.id}
51+
</Link>
52+
{ownFollowings.length > 0 && (
53+
<span className="text-sm text-gray-500"> - follows you</span>
54+
)}
55+
</div>
5156
<button
5257
className="text-sm px-2 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none"
5358
onClick={() => {

β€Žsample-apps/react-sample-app/app/components/comments/ActivityCommentSection.tsxβ€Ž

Lines changed: 82 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import type { FormEvent} from 'react';
2-
import { useCallback, useEffect, useRef, useState } from 'react';
1+
import type { FormEvent } from 'react';
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
import type {
44
Feed,
5-
ActivityWithStateUpdates} from '@stream-io/feeds-react-sdk';
5+
ActivityWithStateUpdates,
6+
} from '@stream-io/feeds-react-sdk';
67
import {
78
type ActivityResponse,
89
type CommentResponse,
910
useActivityComments,
11+
useClientConnectedUser,
1012
useFeedsClient,
13+
useOwnFollowings,
1114
} from '@stream-io/feeds-react-sdk';
1215
import { PaginatedList } from '../PaginatedList';
1316
import { Comment } from './Comment';
@@ -24,8 +27,41 @@ export const ActivityCommentSection = ({
2427
activity: ActivityResponse;
2528
}) => {
2629
const client = useFeedsClient();
30+
const currentUser = useClientConnectedUser();
2731
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
2832

33+
const currentFeed = useMemo(() => {
34+
if (!activity.current_feed || !client) {
35+
return undefined;
36+
}
37+
return client.feed(
38+
activity.current_feed.group_id,
39+
activity.current_feed.id,
40+
);
41+
}, [activity.current_feed, client]);
42+
43+
const { own_followings: ownFollowings = [] } =
44+
useOwnFollowings(currentFeed) ?? {};
45+
46+
const canComment = useMemo(() => {
47+
if (currentUser?.id === activity.user.id) {
48+
return true;
49+
}
50+
switch (activity.restrict_replies) {
51+
case 'nobody':
52+
return false;
53+
case 'people_i_follow':
54+
return ownFollowings?.length > 0;
55+
default:
56+
return true;
57+
}
58+
}, [
59+
activity.restrict_replies,
60+
ownFollowings,
61+
currentUser?.id,
62+
activity.user.id,
63+
]);
64+
2965
const {
3066
comments = [],
3167
loadNextPage,
@@ -103,55 +139,60 @@ export const ActivityCommentSection = ({
103139
</h2>
104140
</div>
105141

106-
{parent && (
107-
<div className="text-black p-2 flex items-center justify-between bg-gray-100 rounded-lg mb-4">
108-
<div className="flex items-center gap-2">
109-
<strong>Replying to:</strong>
142+
{canComment && (
143+
<>
144+
{parent && (
145+
<div className="text-black p-2 flex items-center justify-between bg-gray-100 rounded-lg mb-4">
146+
<div className="flex items-center gap-2">
147+
<strong>Replying to:</strong>
148+
<button
149+
className="text-blue-600 hover:underline"
150+
onClick={() => scrollToComment(parent)}
151+
>
152+
{parent.text}
153+
</button>
154+
</div>
155+
<button
156+
className="ml-2 text-blue-600 hover:underline"
157+
onClick={() => setParent(null)}
158+
>
159+
Cancel reply
160+
</button>
161+
</div>
162+
)}
163+
164+
<form className="mb-6" onSubmit={handleSubmit}>
165+
<div className="py-2 px-4 mb-4 bg-white rounded-lg rounded-t-lg border border-gray-200">
166+
<label htmlFor="comment" className="sr-only">
167+
Your comment
168+
</label>
169+
<textarea
170+
ref={textareaRef}
171+
id="comment"
172+
name="comment-text"
173+
rows={6}
174+
className="px-0 w-full text-sm text-gray-900 border-0 focus:ring-0 focus:outline-none"
175+
placeholder="Write a comment..."
176+
required
177+
/>
178+
</div>
110179
<button
111-
className="text-blue-600 hover:underline"
112-
onClick={() => scrollToComment(parent)}
180+
type="submit"
181+
className=" px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none"
113182
>
114-
{parent.text}
183+
Post comment
115184
</button>
116-
</div>
117-
<button
118-
className="ml-2 text-blue-600 hover:underline"
119-
onClick={() => setParent(null)}
120-
>
121-
Cancel reply
122-
</button>
123-
</div>
185+
</form>
186+
</>
124187
)}
125188

126-
<form className="mb-6" onSubmit={handleSubmit}>
127-
<div className="py-2 px-4 mb-4 bg-white rounded-lg rounded-t-lg border border-gray-200">
128-
<label htmlFor="comment" className="sr-only">
129-
Your comment
130-
</label>
131-
<textarea
132-
ref={textareaRef}
133-
id="comment"
134-
name="comment-text"
135-
rows={6}
136-
className="px-0 w-full text-sm text-gray-900 border-0 focus:ring-0 focus:outline-none"
137-
placeholder="Write a comment..."
138-
required
139-
/>
140-
</div>
141-
<button
142-
type="submit"
143-
className=" px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none"
144-
>
145-
Post comment
146-
</button>
147-
</form>
148-
149189
<PaginatedList
150190
items={comments}
151191
isLoading={isLoadingNextPage}
152192
hasNext={hasNextPage}
153193
renderItem={(c) => (
154194
<Comment
195+
canReply={canComment}
155196
feed={feed}
156197
activityWithStateUpdates={activityWithStateUpdates}
157198
level={0}

0 commit comments

Comments
Β (0)