Build low-latency Vision AI applications using our new open-source Vision AI SDK. ⭐️ on GitHub ->

React Native Activity Feed Tutorial

Build real-time activity feeds with the Stream Feeds React Native SDK.
This tutorial shows how to integrate Stream Feeds into a React Native app, publish activities, and render scalable, real-time timelines using a single codebase.

example of react feeds sdk

Stream's Activity Feed V3 SDK enables teams of all sizes to build scalable activity feeds. This SDK is designed to enable you to get a feed application up and running quickly and efficiently while supporting customization for complex use cases.

In this tutorial, we will use Stream's Activity Feed V3 SDK for React Native to:

  • Set up a simple activity feed application and connect it to Stream's Activity Feed V3 SDK
  • Create user and timeline feeds
  • Add activities, reactions and comments
  • Explore new content with "For you" feed

Here is a quick visual overview of the application we're building:

Project Setup and Installation

To follow the tutorial make sure you have installed:

  • Node.js - the tutorial is tested with v22 and v24
  • Yarn

To follow the tutorial you need to clone or download the starter application that has some boilerplate code:

We'll start the tutorial from the initial commit, if you wish to see the finished source code, it's the latest commit in the stream-feeds-react-native-tutorial repository.

bash
1
2
3
4
5
6
# or download zip from https://github.com/GetStream/stream-feeds-react-tutorial/releases/tag/initial-commit git clone git@github.com:GetStream/stream-feeds-react-native-tutorial.git cd stream-feeds-react-native-tutorial git checkout initial-commit yarn install

Install Stream's Feeds v3 React Native SDK:

bash
1
yarn add @stream-io/feeds-react-native-sdk

as well as its mandatory dependencies:

bash
1
yarn add @react-native-community/netinfo

The tutorial application is built using Expo and uses Expo 54, the SDK's version support can be found in the Installation guide

To make the tutorial as easy as possible, we generated credentials for you to pick up and use. These credentials consist of:

  • API_KEY - an API key that is used to identify your Stream application by our servers
  • id and token - authorization information of the current user
  • name - optional, used as a display name of the current user

To start using credentials, replace the contents of the ./user.ts file:

./user.ts (ts)
1
2
3
4
5
6
7
export const API_KEY = 'REPLACE_WITH_API_KEY'; export const CURRENT_USER = { id: 'REPLACE_WITH_USER_ID', name: 'REPLACE_WITH_USER_NAME', token: 'REPLACE_WITH_TOKEN', };

Security Note: In production applications, never expose your API secret or generate tokens on the client side. Tokens should always be generated on your backend server to ensure security. The credentials in this tutorial are for development purposes only.

Finally, we can run the application with:

bash
1
2
3
4
# ios yarn run ios # android yarn run android

For now, the application doesn't do much, but this will change as we complete the tutorial step by step.

Connect to the Stream API

Let's create and connect the demo user to the Stream API.

The starter application you cloned has all necessary files, but they're empty. You can update their content from the code snippets in the tutorial. No need to create any additional files.

To achieve this, we're using the useCreateFeedsClient hook and StreamFeeds context provider to make sure all components have access to the client instance. Let's update app/_layout.tsx:

app/_layout.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { DarkTheme, DefaultTheme, ThemeProvider, } from "@react-navigation/native"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import "react-native-reanimated"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { StreamFeeds, useCreateFeedsClient, } from "@stream-io/feeds-react-native-sdk"; import { API_KEY, CURRENT_USER } from "@/user"; export const unstable_settings = { anchor: "(tabs)", }; export default function RootLayout() { const colorScheme = useColorScheme(); const client = useCreateFeedsClient({ apiKey: API_KEY, tokenOrProvider: CURRENT_USER.token, userData: { id: CURRENT_USER.id, name: CURRENT_USER.name, }, }); if (!client) { return null; } return ( <StreamFeeds client={client}> <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="comments-modal" options={{ presentation: "modal", title: "Comments" }} /> </Stack> <StatusBar style="auto" /> </ThemeProvider> </StreamFeeds> ); }

For simplicity, the tutorial doesn't handle errors. In a real application, you should always make sure to handle errors from API requests. The Error handling guide provides more information on this topic.

Creating Feeds

In this step we're creating a few feeds using built-in feed groups. Before we dive into the code, let's understand the core concepts:

  • User Feed: A feed that contains all activities (posts) created by a specific user. Each user has their own user feed (e.g., user:alice).
  • Timeline Feed: A feed that contains activities from all the feeds that you follow. When you follow someone's user feed, their activities automatically appear in your timeline feed (this concept is called fan-out).
  • Follow Relationship: When you follow a user's feed, your timeline feed subscribes to their user feed. This means new activities from followed users automatically appear in your timeline.

Let's see what the concept looks like in code (no need to add this to your app yet):

ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SDK hooks for accessing client and connected user const client = useFeedsClient(); const connectedUser = useClientConnectedUser(); // Using user id for the feed id, but you can use any id you want to const ownFeed = client.feed('user', connectedUser.id); await ownFeed.getOrCreate({ // Turns on real-time updates watch: true }); const ownTimeline = client.feed('timeline', connectedUser.id, { // Social media apps usually don't add new activities from WebSocket // users need to pull to refresh activityAddedEventFilter: (event) => { return event.activity.user?.id === connectedUser.id; }, }); await ownTimeline.getOrCreate({watch: true});

To ensure our own posts are part of our timeline, we need to set up the follow relationship:

ts
1
2
3
4
5
6
7
// You typically create these relationships on your server-side, we do this here for simplicity const alreadyFollows = ownFeed.currentState.own_follows?.find( (follow) => follow.source_feed.feed === ownTimeline.feed, ); if (!alreadyFollows) { ownTimeline.follow(ownFeed); }

To make sure the user's feeds are easily accessible throughout the application, we create a React context provider for initializing feeds (contexts/own-feeds-context.tsx) and add this to app/_layout.tsx. The full code containing the React boilerplate can be added like so:

contexts/own-feeds-context.tsx
app/_layout.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { Feed, useClientConnectedUser, useFeedsClient, } from "@stream-io/feeds-react-native-sdk"; import { createContext, PropsWithChildren, useContext, useEffect, useState, } from "react"; type OwnFeedsContextValue = { ownFeed: Feed | undefined; ownTimeline: Feed | undefined; }; const OwnFeedsContext = createContext<OwnFeedsContextValue>({ ownFeed: undefined, ownTimeline: undefined, }); export const OwnFeedsContextProvider = ({ children }: PropsWithChildren) => { const [ownFeed, setOwnFeed] = useState<Feed>(); const [ownTimeline, setOwnTimeline] = useState<Feed>(); const client = useFeedsClient(); const connectedUser = useClientConnectedUser(); useEffect(() => { if (!connectedUser || !client) return; const feed = client.feed("user", connectedUser.id); setOwnFeed(feed); // Social media apps usually don't add new activities from WebSocket // users need to pull to refresh const timeline = client.feed("timeline", connectedUser.id, { activityAddedEventFilter: ({ activity }) => activity.user?.id === connectedUser.id, }); setOwnTimeline(timeline); Promise.all([ feed.getOrCreate({ watch: true }), timeline.getOrCreate({ watch: true }), ]).then(() => { // You typically create these relationships on your server-side, we do this here for simplicity const alreadyFollows = feed.currentState.own_follows?.find( (follow) => follow.source_feed.feed === timeline.feed, ); if (!alreadyFollows) timeline.follow(feed); }); return () => { setOwnFeed(undefined); setOwnTimeline(undefined); }; }, [connectedUser, client]); return ( <OwnFeedsContext.Provider value={{ ownFeed, ownTimeline }}> {children} </OwnFeedsContext.Provider> ); }; export const useOwnFeedsContext = () => useContext(OwnFeedsContext);

Activity List

Activity Feeds SDKs don't have UI components.

Now that we created feeds, we can create UI components to display the activities. To achieve this we're creating the following components:

  • Activity component - this will be very simple for now, and we'll extend it during the tutorial
  • ActivityList component to display activities, and paginate
  • We display the user's timeline feed on the Home page

The Activity component for now contains the most basic activity information (for example activity.text) and some HTML code to create a layout we can extend with new features.

Activity.tsx
ActivityList.tsx
app/(tabs)/index.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import React from "react"; import { StyleSheet } from "react-native"; import { ThemedView } from "@/components/themed-view"; import { ThemedText } from "@/components/themed-text"; import type { ActivityResponse } from "@stream-io/feeds-react-native-sdk"; type ActivityProps = { activity: ActivityResponse; }; export const Activity = ({ activity }: ActivityProps) => { const name = activity.user?.name || activity.user?.id || "Unknown"; const initial = name.charAt(0).toUpperCase(); const createdAt = activity.created_at instanceof Date ? activity.created_at : new Date(activity.created_at); const createdAtLabel = createdAt.toLocaleString(); return ( <ThemedView style={styles.card}> <ThemedView style={styles.row}> <ThemedView style={styles.avatarWrapper}> <ThemedView style={styles.avatar}> <ThemedText style={styles.avatarText}>{initial}</ThemedText> </ThemedView> </ThemedView> <ThemedView style={styles.content}> <ThemedView style={styles.headerRow}> <ThemedText style={styles.name} numberOfLines={1}> {name} </ThemedText> <ThemedText style={styles.timestamp} numberOfLines={1}> {createdAtLabel} </ThemedText> </ThemedView> {activity.text ? ( <ThemedText style={styles.text}>{activity.text}</ThemedText> ) : null} </ThemedView> </ThemedView> </ThemedView> ); }; const styles = StyleSheet.create({ card: { width: "100%", padding: 12, borderRadius: 12, backgroundColor: "#FFFFFF", borderWidth: 1, borderColor: "#E5E7EB", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 3, elevation: 2, }, row: { flexDirection: "row", alignItems: "flex-start", }, avatarWrapper: { marginRight: 12, }, avatar: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#6366F1", alignItems: "center", justifyContent: "center", }, avatarText: { color: "#FFFFFF", fontSize: 18, fontWeight: "600", }, content: { flex: 1, }, headerRow: { flexDirection: "row", alignItems: "center", marginBottom: 4, }, name: { fontSize: 14, fontWeight: "600", marginRight: 8, maxWidth: "50%", }, timestamp: { fontSize: 12, color: "#6B7280", flexShrink: 1, }, text: { fontSize: 14, color: "#111827", }, });

The activity list is currently empty. We'll change that in the next step. Before doing that, let's recap the important parts from this step:

  • We're using the StreamFeed context to make ownTimeline accessible to all components in a given subtree
  • Components then can use useFeedContext and useFeedActivities hooks to access the data they need
  • You can find the full list of feed state hooks on the Contexts and Hooks page of the documentation

Activity Composer

Let's create an ActivityComposer component to be able to post, and add it to the Home page:

As mentioned previously: users post on their user feed, and it automatically appears in their timeline feed via follow relationship.

ActivityComposer.tsx
app/(tabs)/index.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import React, { useCallback, useState } from "react"; import { View, Text, TextInput, StyleSheet, Pressable, Platform, } from "react-native"; import { useFeedContext } from "@stream-io/feeds-react-native-sdk"; export const ActivityComposer = () => { const feed = useFeedContext(); const [newText, setNewText] = useState(""); const canPost = newText.trim().length > 0; const sendActivity = useCallback(async () => { if (!feed || !canPost) return; await feed.addActivity({ text: newText, type: "post", }); setNewText(""); }, [feed, newText, canPost]); return ( <View style={styles.card}> <View style={styles.inner}> <TextInput style={styles.input} multiline placeholder="What is happening?" value={newText} onChangeText={setNewText} textAlignVertical="top" underlineColorAndroid="transparent" placeholderTextColor="#9CA3AF" /> <View style={styles.footerRow}> <Pressable onPress={sendActivity} disabled={!canPost} style={({ pressed }) => [ styles.button, !canPost && styles.buttonDisabled, pressed && canPost && styles.buttonPressed, ]} > <Text style={styles.buttonText}>Post</Text> </Pressable> </View> </View> </View> ); }; const styles = StyleSheet.create({ card: { padding: 12, borderRadius: 12, marginHorizontal: 16, marginTop: 8, backgroundColor: "#FFFFFF", borderWidth: 1, borderColor: "#E5E7EB", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 3, elevation: 2, }, inner: { width: "100%", flexDirection: "column", gap: 8, }, input: { minHeight: 80, maxHeight: 160, borderRadius: 10, borderWidth: 1, borderColor: "#E5E7EB", paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 8, fontSize: 14, color: "#111827", }, footerRow: { width: "100%", marginTop: 8, flexDirection: "row", justifyContent: "flex-end", alignItems: "center", }, button: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 999, backgroundColor: "#2563EB", }, buttonPressed: { opacity: 0.8, }, buttonDisabled: { backgroundColor: "#93C5FD", }, buttonText: { color: "#FFFFFF", fontWeight: "600", fontSize: 14, }, });

Go ahead and post something! It'll automatically appear on your timeline.

Explore Page

The "Explore" page uses the foryou feed to explore new content by showing popular activities.

The layout is similar to the Home page, we're reusing the ActivityList component, but there is no composer. Code for Explore.tsx:

app/(tabs)/explore.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useFeedsClient, useClientConnectedUser, StreamFeed, } from "@stream-io/feeds-react-native-sdk"; import { useEffect, useMemo } from "react"; import { ActivityList } from "@/components/activity/ActivityList"; export default () => { const client = useFeedsClient(); const currentUser = useClientConnectedUser(); const feed = useMemo(() => { if (!currentUser?.id || !client) { return undefined; } return client.feed("foryou", currentUser.id); }, [client, currentUser?.id]); useEffect(() => { if (feed) { feed.getOrCreate({ limit: 10 }); } }, [feed]); if (!feed) { return null; } return ( <StreamFeed feed={feed}> <ActivityList /> </StreamFeed> ); };

You may have noticed that we don't use watch: true for this feed. This is because the foryou feed uses the "popular" activity selector, which doesn't support real-time updates. Adding watch:true won't cause an error, but does nothing here. The documentation details how real-time updates work.

In the next step, we're adding a follow button, so we can follow users from the foryou page.

Follow and Unfollow

To implement following and unfollowing feeds we're adding:

  • FollowButton component
  • Extending the Activity component with the follow/unfollow button
FollowButton.tsx
Activity.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import React, { useCallback, useMemo } from "react"; import { Pressable, Text, StyleSheet } from "react-native"; import { useOwnFeedsContext } from "@/contexts/own-feeds-context"; import { FeedResponse, FollowResponse, useFeedsClient, useOwnFollows, } from "@stream-io/feeds-react-native-sdk"; export const FollowButton = ({ feed: activityFeed, }: { feed?: FeedResponse; }) => { const client = useFeedsClient(); const feed = useMemo(() => { if (!activityFeed) return; return client?.feed(activityFeed.group_id, activityFeed.id); }, [client, activityFeed]); const { ownTimeline } = useOwnFeedsContext(); const { own_follows: ownFollows } = useOwnFollows(feed) ?? {}; const ownFollow = useMemo( () => ownFollows && ownFollows.find( (follow: FollowResponse) => follow.source_feed.group_id === "timeline", ), [ownFollows], ); const isFollowing = ownFollow?.status === "accepted"; const follow = useCallback(async () => { await ownTimeline?.follow(feed!.feed); // Reload to pull new activities await ownTimeline?.getOrCreate({ watch: true }); }, [feed, ownTimeline]); const unfollow = useCallback(async () => { await ownTimeline?.unfollow(feed!.feed); // Reload to remove activities await ownTimeline?.getOrCreate({ watch: true }); }, [feed, ownTimeline]); const toggleFollow = useCallback(() => { if (isFollowing) unfollow(); else follow(); }, [isFollowing, follow, unfollow]); return ( <Pressable onPress={toggleFollow} style={({ pressed }) => [ styles.button, isFollowing ? styles.unfollow : styles.follow, pressed && styles.pressed, ]} > <Text style={styles.buttonText}> {isFollowing ? "Unfollow" : "Follow"} </Text> </Pressable> ); }; const styles = StyleSheet.create({ button: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8, alignItems: "center", justifyContent: "center", minWidth: 80, }, follow: { backgroundColor: "#2563EB", }, unfollow: { backgroundColor: "#DC2626", }, pressed: { opacity: 0.8, }, buttonText: { color: "white", fontWeight: "600", fontSize: 14, }, });

Let's walk through the steps:

  1. feed.follow and feed.unfollow lets us follow/unfollow feeds.
  2. To immediately see the results of the follow/unfollow, we're reloading the timeline feed with getOrCreate.
  3. In Activity component we're using activity.current_feed.own_follows to know if the user's timeline feed follows the feed or not
  • activity.current_feed has information about the feed the activity was posted to
  • It's useful if you're building Reddit-style applications where there is no 1:1 mapping between feeds and users
  • It lets you display name/image of the feed the activity belongs to.
  1. We create a feed instance so that we can easily listen for state changes to it through client.feed(...)
  2. Stream API also supports follow requests where approval from feed owner is required to follow

Now that the follow button is working, you can start following another user using the "Explore" page.

Reactions

To make our application more interactive, we'll add reactions for activities.

To achieve this, we need to implement the Reaction component and extend the Activity component:

Reaction.tsx
Activity.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import React, { useCallback } from "react"; import { Pressable, Text, StyleSheet, View } from "react-native"; import { ActivityResponse, useFeedsClient, } from "@stream-io/feeds-react-native-sdk"; type ToggleReactionProps = { activity: ActivityResponse; }; export const Reaction = ({ activity }: ToggleReactionProps) => { const client = useFeedsClient(); const hasReacted = activity.own_reactions?.length > 0; const likeCount = activity.reaction_groups?.like?.count ?? 0; const toggleReaction = useCallback(() => { if (!client) return; if (hasReacted) { client.deleteActivityReaction({ activity_id: activity.id, type: "like", }); } else { client.addActivityReaction({ activity_id: activity.id, type: "like", }); } }, [client, activity.id, hasReacted]); return ( <Pressable onPress={toggleReaction} style={({ pressed }) => [ styles.button, hasReacted && styles.buttonActive, pressed && styles.buttonPressed, ]} > <View style={styles.innerRow}> <Text style={styles.heart}>{hasReacted ? "❤️" : "🤍"}</Text> <Text style={styles.count}>{likeCount}</Text> </View> </Pressable> ); }; const styles = StyleSheet.create({ button: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 50, borderWidth: 1, borderColor: "#E5E7EB", backgroundColor: "#FFFFFF", width: 60, alignItems: "center", justifyContent: "center", }, buttonActive: { backgroundColor: "#2563EB20", borderColor: "#2563EB", }, buttonPressed: { opacity: 0.7, }, innerRow: { flexDirection: "row", alignItems: "center", gap: 4, }, heart: { fontSize: 16, }, count: { fontSize: 14, fontWeight: "600", color: "#111827", marginLeft: 4, }, });

You can now use the For You tab to follow your other user's feeds and to react to their activities.

Let's recap what we did in this step:

  1. client.addActivityReaction and client.deleteActivityReaction toggles reactions
  • type can be any string you'd like
  • Since the React Native SDK provides reactive state management, the UI is automatically updated anytime anything on the activity changes
  1. We use activity.own_reactions and activity.reaction_groups to get real-time reaction data for activity
  2. Some advanced features not shown in tutorial:
  • A single user can add multiple reactions to an activity
  • Comments can have reactions too
  • Checkout the activity reactions and comment reactions pages in the documentation for more information.

Comments

Comments are another good way to add interactivity to your app. To add this feature, we need to do the following tasks:

  • Implement CommentComposer to post comments
  • Implement CommentList to list comments
  • Implement the comments-modal screen so that we can navigate to it
  • Extend the Activity component with a new comment button

In order for us to be able to reliably load the comments section of any activity (ones in the For You feed as well, for example), we'll rely on the activityWithStateUpdates API.

This is very convenient for these particular types of situations, as since the activity itself needs to be serialized before we pass it to our router and this way we can simply rely only on passing the activityId and the modal will handle everything else.

To make everything easier, our useActivityComments hook also allows an ActivityWithStateUpdates instance to be passed instead of an ActivityResponse.

CommentComposer.tsx
Comment.tsx
CommentList.tsx
app/comments-modal.tsx
Activity.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import React, { useState, useCallback } from "react"; import { TextInput, Pressable, Text, StyleSheet, Platform, KeyboardAvoidingView, } from "react-native"; import { ActivityWithStateUpdates, useFeedsClient, } from "@stream-io/feeds-react-native-sdk"; type CommentComposerProps = { activity: ActivityWithStateUpdates; }; export const CommentComposer = ({ activity }: CommentComposerProps) => { const client = useFeedsClient(); const [commentDraft, setCommentDraft] = useState(""); const canReply = commentDraft.trim().length > 0; const addComment = useCallback(async () => { if (!client || !canReply) return; await client.addComment({ object_id: activity.id, object_type: "activity", comment: commentDraft, }); setCommentDraft(""); }, [client, activity.id, commentDraft, canReply]); return ( <KeyboardAvoidingView style={styles.container} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={Platform.OS === "ios" ? 128 : 0} > <TextInput style={styles.input} placeholder="Post your reply" value={commentDraft} onChangeText={setCommentDraft} placeholderTextColor="#9CA3AF" autoCapitalize="sentences" autoCorrect returnKeyType="send" onSubmitEditing={addComment} /> <Pressable onPress={addComment} disabled={!canReply} style={({ pressed }) => [ styles.button, !canReply && styles.buttonDisabled, pressed && canReply && styles.buttonPressed, ]} > <Text style={styles.buttonText}>Reply</Text> </Pressable> </KeyboardAvoidingView> ); }; const styles = StyleSheet.create({ container: { width: "100%", flexDirection: "row", alignItems: "center", gap: 8, marginTop: 8, paddingHorizontal: 12, }, input: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: "#E5E7EB", paddingHorizontal: 14, paddingVertical: Platform.OS === "ios" ? 10 : 8, fontSize: 14, color: "#111827", backgroundColor: "#FFFFFF", }, button: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: "#2563EB", alignItems: "center", justifyContent: "center", minWidth: 70, }, buttonDisabled: { backgroundColor: "#93C5FD", }, buttonPressed: { opacity: 0.8, }, buttonText: { color: "#FFFFFF", fontWeight: "600", fontSize: 14, }, });

Let's recap what happened in this step:

  1. client.addComment lets us create a comment
  2. useActivityComments lets you read and paginate comments
  1. activity.comment_count stores how many comments the activity has

Comments can be threaded/nested too (not shown in the tutorial).

Posting Images

Stream API allows attaching files to activities and comments. Let's extend our app with attaching images to activities. To achieve this we need to:

  • Implement the ImagePicker component to let users pick files, and upload them to Stream's CDN
  • Extend the ActivityComposer to send attachments with the activity as well as display previews of the attachments
  • Extend the Activity component to display the attachment
ImagePicker.tsx
ActivityComposer.tsx
Activity.tsx
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import React, { useCallback, useState } from "react"; import { Pressable, Text, StyleSheet, ActivityIndicator } from "react-native"; import * as ExpoImagePicker from "expo-image-picker"; import { ImageUploadResponse, StreamResponse, useFeedsClient, } from "@stream-io/feeds-react-native-sdk"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; type PhotoButtonProps = { onUpload: ( uploadedImage: StreamResponse<ImageUploadResponse> | undefined, ) => void; }; export const ImagePicker = ({ onUpload }: PhotoButtonProps) => { const client = useFeedsClient(); const [isUploading, setIsUploading] = useState<boolean>(false); const pickImage = useCallback(async () => { const result = await ExpoImagePicker.launchImageLibraryAsync({ mediaTypes: "images", allowsMultipleSelection: false, quality: 1, }); if (!result.canceled) { const asset = result.assets[0]; const file = { uri: asset.uri, name: asset.fileName ?? (asset.uri as string).split("/").reverse()[0], duration: asset.duration, type: asset.mimeType ?? "image/jpeg", }; setIsUploading(true); const uploadedFile = await client?.uploadImage({ file, }); setIsUploading(false); onUpload(uploadedFile); } }, [client, onUpload]); return ( <Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)} > <Pressable onPress={pickImage} disabled={isUploading} style={({ pressed }) => [ styles.button, pressed && !isUploading && styles.buttonPressed, isUploading && styles.buttonDisabled, ]} > {isUploading ? ( <ActivityIndicator size="small" /> ) : ( <Text style={styles.text}>📎</Text> )} </Pressable> </Animated.View> ); }; const styles = StyleSheet.create({ button: { alignItems: "center", justifyContent: "center", minHeight: 36, alignSelf: "flex-start", }, buttonPressed: { opacity: 0.8, }, buttonDisabled: { opacity: 0.7, }, content: { flexDirection: "row", alignItems: "center", justifyContent: "center", minWidth: 60, }, text: { color: "#FFFFFF", fontWeight: "600", fontSize: 30, }, });

Go ahead and post an image! Or send a URL, as Stream API can automatically attach URL metadata as an attachment.

Final Thoughts

In this tutorial, we built a fully-functioning React Native activity feed application with Stream's Activity Feed V3 SDK. We showed how easy it is to:

  • Set up a simple activity feed application and connect it to Stream's Activity Feed V3 SDK.
  • Create user and timeline feeds.
  • Add activities, reactions and comments.
  • Explore new content with "For you" feed.

Even though this was a long tutorial, Activity Feed V3 has even more features:

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding

If you're interested in a custom plan or have any questions, please contact us.