In our last article, we successfully implemented the feed for our social network. We designed the UI to display posts, integrating user avatars, post content, and placeholder buttons for interaction. We also ensured that posts load dynamically using infinite scrolling and pull-to-refresh.
At this point, our feed visually resembles a fully functional social media experience, but the buttons remain non-functional. That changes today.
What We’ll Cover in This Chapter
In this article, we will wire up the buttons, bringing interactivity to the feed. Specifically, we will implement:
-
Profile Navigation:
- Clicking on an author's profile picture will navigate the user to the author’s profile.
- If the post belongs to the current user, clicking the avatar will take them to their own profile.
-
Liking Posts:
- Clicking the heart icon will toggle a like on the post.
- If a user has already liked a post in the past, the button will reflect that immediately when the feed loads (thanks to Replyke's data).
- Clicking again will remove the like.
-
Opening the Comment Section:
- Clicking the chat bubble icon will open a comment section sheet, allowing users to view and leave comments.
-
Managing Bookmarks:
- Clicking the bookmark icon will open the collections sheet, enabling users to save posts to their collections.
By the end of this article, all essential post interactions will be fully functional, making our app feel more like a real social platform. Let's dive in!
Accumulated reading time so far: 37 minutes.
Part 7 completed repo on GitHub for reference
Configuring Avatar Interactivity
To make the user avatars interactive, we will use the useUser
hook from Replyke to get access to the current user. Based on whether the id
of the current user matches the author's id
, we will navigate accordingly using the router:
import React from "react";
import { View, TouchableOpacity, Text, Pressable } from "react-native";
import { useEntity, useUser } from "@replyke/expo";
import { UserAvatar } from "@replyke/ui-core-react-native";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import AntDesign from "@expo/vector-icons/AntDesign";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useRouter } from "expo-router";
function PostActions() {
const router = useRouter();
const { user } = useUser();
const { entity } = useEntity();
if (!entity) return null;
return (
<View
className="absolute right-4 bottom-20 z-50 items-center gap-6 bg-black/30 p-3 pb-4 rounded-2xl"
style={{ columnGap: 12 }}
>
{/* User avatar and follow button */}
<Pressable
onPress={() =>
entity.user!.id === user?.id
? router.navigate("/(tabs)/profile")
: router.navigate(`/account/${entity.user!.id}`)
}
>
<UserAvatar user={entity.user} size={46} borderRadius={8} />
</Pressable>
{/* LIKE BUTTON */}
<TouchableOpacity className="items-center gap-1.5">
<AntDesign name="heart" size={36} color="white" />
<Text className="text-white overflow-visible">
{entity?.upvotes.length}
</Text>
</TouchableOpacity>
{/* OPEN COMMENT SECTION */}
<TouchableOpacity className="items-center gap-1.5">
<Ionicons name="chatbubble" size={36} color="#ffffff" />
{(entity.repliesCount || 0) > 0 && (
<Text className="text-[#9ca3af]">{entity.repliesCount}</Text>
)}
</TouchableOpacity>
{/* BOOKMARK BUTTON */}
<TouchableOpacity>
<FontAwesome name="bookmark" size={32} color="#ffffff99" />
</TouchableOpacity>
</View>
);
}
export default PostActions;
With this implementation, clicking on an avatar will navigate users to the correct profile based on ownership. Next, we will move on to wiring up the like button!
Configuring the Like Button
The like button allows users to express appreciation for a post. To make this work, we retrieve additional properties from the useEntity
hook:
userUpvotedEntity
: A boolean indicating if the current user has already liked the post.upvoteEntity()
: A function to add an upvote.removeEntityUpvote()
: A function to remove an existing upvote.
When the like button is pressed:
If the user is not logged in, an alert prompts them to log in or create an account.
If the user has already liked the post, clicking the button removes the like.
If the user has not liked the post, clicking the button adds a like.
New code after adding like functionality:
import React from "react";
import { View, TouchableOpacity, Text, Pressable, Alert } from "react-native";
import { useEntity, useUser } from "@replyke/expo";
import { UserAvatar } from "@replyke/ui-core-react-native";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import AntDesign from "@expo/vector-icons/AntDesign";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useRouter } from "expo-router";
function PostActions() {
const router = useRouter();
const { user } = useUser();
const { entity, userUpvotedEntity, upvoteEntity, removeEntityUpvote } =
useEntity();
if (!entity) return null;
return (
<View
className="absolute right-4 bottom-20 z-50 items-center gap-6 bg-black/30 p-3 pb-4 rounded-2xl"
style={{ columnGap: 12 }}
>
{/* User avatar and follow button */}
<Pressable
onPress={() =>
entity.user!.id === user?.id
? router.navigate("/(tabs)/profile")
: router.navigate(`/account/${entity.user!.id}`)
}
>
<UserAvatar user={entity.user} size={46} borderRadius={8} />
</Pressable>
{/* LIKE BUTTON */}
<TouchableOpacity
onPress={() => {
if (!user) {
Alert.alert(
"Oops! Login Required. Please sign in or create an account to continue."
);
return;
}
if (userUpvotedEntity) {
removeEntityUpvote?.();
} else {
upvoteEntity?.();
}
}}
className="items-center gap-1.5"
>
<AntDesign name="heart" size={36} color="white" />
<Text className="text-white overflow-visible">
{entity?.upvotes.length}
</Text>
</TouchableOpacity>
{/* OPEN COMMNENT SECTION */}
<TouchableOpacity className="items-center gap-1.5">
<Ionicons name="chatbubble" size={36} color="#ffffff" />
{(entity.repliesCount || 0) > 0 && (
<Text className="text-[#9ca3af]">{entity.repliesCount}</Text>
)}
</TouchableOpacity>
{/* BOOKMARK BUTTON */}
<TouchableOpacity>
<FontAwesome name="bookmark" size={32} color="#ffffff99" />
</TouchableOpacity>
</View>
);
}
export default PostActions;
This ensures that the button properly reflects the user's past interactions with the post, making the experience seamless and intuitive.
With this implementation, clicking on the heart will either like or remove a like from the user. Next, we will configure the comment section button!
Preparing for Comment and Bookmark Buttons
Both the comment section and bookmark feature require opening bottom sheets. Instead of creating a separate sheet for each post, we should place a single instance of these sheets at the highest level of the app. This way, all screens (Home Feed, Profile Feed, Account Feed) can reuse the same sheets.
To achieve this, we will:
Create a context provider that manages the opening and closing of these sheets.
Wrap our app with this provider so any screen can trigger a sheet.
Implement dummy sheets initially to confirm that everything works before adding actual content.
Next, we will build the context provider and set up the foundation for our reusable bottom sheets!
Installing Required Dependencies
To get started, install the required dependencies:
npx expo install @gorhom/bottom-sheet react-native-reanimated react-native-gesture-handler
Note: React Native Gesture Handler v3 and React Native Reanimated v3 require additional setup steps. Follow their respective installation guides:
Additionally, ensure your app is wrapped with GestureHandlerRootView
in app/_layout.tsx
as follows:
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function Layout() {
return (
<GestureHandlerRootView>
<ReplykeProvider projectId={process.env.EXPO_PUBLIC_REPLYKE_PROJECT_ID!}>
<SafeAreaProvider>
<SafeAreaView className="flex-1">
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</SafeAreaView>
<StatusBar style="dark" />
</SafeAreaProvider>
</ReplykeProvider>
</GestureHandlerRootView>
);
}
Creating the Sheet Manager Context
Next, create a new folder in the root of your project called context
, and inside it, create a file named SheetManagerContext.tsx
. Add the following code:
import { useRef } from "react";
import React, { createContext, useState } from "react";
import BottomSheet from "@gorhom/bottom-sheet";
import { BottomSheetMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
type SheetManagerContext = {
commentSetionSheetRef: React.RefObject<BottomSheetMethods>;
collectionsSheetRef: React.RefObject<BottomSheetMethods>;
openCommentSectionSheet: (newEntityId?: string) => void;
closeCommentSectionSheet: () => void;
openCollectionsSheet: (newEntityId?: string) => void;
closeCollectionsSheet: () => void;
commmentsEntityId: string | null;
collectionsEntityId: string | null;
};
export const SheetManagerContext = createContext<Partial<SheetManagerContext>>(
{}
);
export const SheetManagerProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
// Ref for the comment section sheet
const commentSetionSheetRef = useRef<BottomSheet>(null);
// Ref for the collections sheet
const collectionsSheetRef = useRef<BottomSheet>(null);
// The entity for which the user is the comment section
const [commmentsEntityId, setCommentsEntityId] = useState<string | null>(null);
// The entity the user wants to add to a collection
const [collectionsEntityId, setCollectionsEntityId] = useState<string | null>(null);
// Function to open the comment section sheet and set the entity
const openCommentSectionSheet = (newEntityId?: string) => {
if (newEntityId) setCommentsEntityId(newEntityId);
commentSetionSheetRef.current?.snapToIndex(0);
};
// Function to close the comment section sheet and reset the entity
const closeCommentSectionSheet = () => {
commentSetionSheetRef.current?.close();
setCommentsEntityId(null);
};
// Function to open the collections sheet and set the entity
const openCollectionsSheet = (newEntityId?: string) => {
if (newEntityId) setCollectionsEntityId(newEntityId);
collectionsSheetRef.current?.snapToIndex(0);
};
// Function to close the collections sheet and reset the entity
const closeCollectionsSheet = () => {
collectionsSheetRef.current?.close();
setCollectionsEntityId(null);
};
return (
<SheetManagerContext.Provider
value={{
commentSetionSheetRef,
collectionsSheetRef,
openCommentSectionSheet,
closeCommentSectionSheet,
openCollectionsSheet,
closeCollectionsSheet,
commmentsEntityId,
collectionsEntityId,
}}
>
{children}
</SheetManagerContext.Provider>
);
};
Adding the Sheet Manager Provider
In the _layout.tsx
file at the root of your app directory, wrap the ReplykeProvider
with SheetManagerProvider
as follows:
<GestureHandlerRootView>
<ReplykeProvider projectId={process.env.EXPO_PUBLIC_REPLYKE_PROJECT_ID!}>
<SheetManagerProvider>
<SafeAreaProvider>
<SafeAreaView className="flex-1">
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</SafeAreaView>
<StatusBar style="dark" />
</SafeAreaProvider>
</SheetManagerProvider>
</ReplykeProvider>
</GestureHandlerRootView>
Creating the Hook for Using the Sheet Manager
Create a new folder called hooks
at the root of your project. Inside it, create a file named useSheetManager.tsx
and add the following code:
import { useContext } from "react";
import { SheetManagerContext } from "../context/SheetManagerContext";
export default function useSheetManager() {
return useContext(SheetManagerContext);
}
Explanation
Now that everything is set up, here’s what we’ve done:
- Installed dependencies: We installed
@gorhom/bottom-sheet
for managing sheets, along with its dependencies. - Set up
GestureHandlerRootView
: This ensures smooth gesture handling, which is required for bottom sheets. - Created
SheetManagerContext
:- Holds references for the comment section and collections bottom sheets.
- Provides functions to open and close these sheets.
- Stores the entity IDs associated with each sheet.
- Wrapped the app in
SheetManagerProvider
: This makes the sheet context available throughout the app. - Created a custom hook
useSheetManager
: This simplifies access to the sheet manager from any component.
With this foundation in place, we can now easily open and close the bottom sheets from anywhere in our app. Next, we will implement the actual sheets and connect them to the post actions!
Creating and Integrating Bottom Sheets for Comments and Collections
Now that we have set up our sheet manager, we need to create two separate bottom sheets for comments and collections. These sheets will open when users tap on their respective buttons in a post.
Step 1: Create New Component Files
Navigate to the components/shared
directory and create the following two files:
CommentSectionSheet.tsx
import React, { useCallback, useMemo, useState } from "react";
import {
Platform,
KeyboardAvoidingView,
Keyboard,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import useSheetManager from "../../hooks/useSheetManager";
import { cn } from "../../utils/cn";
const CommentSectionSheet = () => {
const { commentSetionSheetRef } = useSheetManager();
const snapPoints = useMemo(() => ["100%"], []);
const [sheetOpen, setSheetOpen] = useState(false);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<SafeAreaView className={cn("flex-1 absolute inset-0", sheetOpen ? "" : "pointer-events-none")}>
<View className="flex-1 relative">
<BottomSheet
ref={commentSetionSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={renderBackdrop}
onChange={(state) => {
setSheetOpen(state > -1);
if (state === -1) {
Keyboard.dismiss();
}
}}
>
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} className="flex-1">
<BottomSheetView className="flex-1">
<Text>Comment Section Sheet</Text>
</BottomSheetView>
</KeyboardAvoidingView>
</BottomSheet>
</View>
</SafeAreaView>
);
};
export default CommentSectionSheet;
CollectionsSheet.tsx
import React, { useCallback, useMemo, useState } from "react";
import {
Platform,
KeyboardAvoidingView,
Keyboard,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import useSheetManager from "../../hooks/useSheetManager";
import { cn } from "../../utils/cn";
const CollectionsSheet = () => {
const { collectionsSheetRef } = useSheetManager();
const snapPoints = useMemo(() => ["100%"], []);
const [sheetOpen, setSheetOpen] = useState(false);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<SafeAreaView className={cn("flex-1 absolute inset-0", sheetOpen ? "" : "pointer-events-none")}>
<View className="flex-1 relative">
<BottomSheet
ref={collectionsSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={renderBackdrop}
onChange={(state) => {
setSheetOpen(state > -1);
if (state === -1) {
Keyboard.dismiss();
}
}}
>
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} className="flex-1">
<BottomSheetView className="flex-1">
<Text>Collections Sheet</Text>
</BottomSheetView>
</KeyboardAvoidingView>
</BottomSheet>
</View>
</SafeAreaView>
);
};
export default CollectionsSheet;
Step 2: Integrate the Sheets in app/_layout.tsx
Now, we need to integrate these sheets into our app layout so they can be accessed globally. Modify app/_layout.tsx
as follows:
import "../global.css";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import "react-native-reanimated";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { ReplykeProvider } from "@replyke/expo";
import { SheetManagerProvider } from "../context/SheetManagerContext";
import CommentSectionSheet from "../components/shared/CommentSectionSheet";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import CollectionsSheet from "../components/shared/CollectionsSheet";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<GestureHandlerRootView>
<ReplykeProvider projectId={process.env.EXPO_PUBLIC_REPLYKE_PROJECT_ID!}>
<SheetManagerProvider>
<SafeAreaProvider>
<SafeAreaView className="flex-1">
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<CommentSectionSheet />
<CollectionsSheet />
</SafeAreaView>
<StatusBar style="dark" />
</SafeAreaProvider>
</SheetManagerProvider>
</ReplykeProvider>
</GestureHandlerRootView>
);
}
Step 3: Wire Up the Buttons in PostActions.tsx
Modify PostActions.tsx
to use the useSheetManager
hook:
const { openCommentSectionSheet, openCollectionsSheet } = useSheetManager();
{/* OPEN COMMENT SECTION */}
<TouchableOpacity onPress={() => openCommentSectionSheet?.(entity.id)} className="items-center gap-1.5">
<Ionicons name="chatbubble" size={36} color="#ffffff" />
{(entity.repliesCount || 0) > 0 && <Text className="text-[#9ca3af]">{entity.repliesCount}</Text>}
</TouchableOpacity>
{/* BOOKMARK BUTTON */}
<TouchableOpacity onPress={() => openCollectionsSheet?.(entity.id)}>
<FontAwesome name="bookmark" size={32} color="#ffffff99" />
</TouchableOpacity>
Save your work, and now when you click the buttons, the respective sheets should open!
Integrating the Comment Section Sheet
Step 1: Extend useSheetManager
Modify your CommentSectionSheet.tsx
to include additional values from useSheetManager
:
const { commentSetionSheetRef, commmentsEntityId, closeCommentSectionSheet } =
useSheetManager();
Step 2: Install Required Dependency
Run the following command to install the Replyke comment section package:
npx expo install @replyke/comments-social-react-native
Step 3: Configure the Comment Section
The useSocialComments
hook requires a style object and a callbacks object.
Style Object
To get the default styles:
const styleConfig = useSocialStyle();
To customize specific styles:
const customStyles = useMemo<Partial<UseSocialStyleProps>>(
() => ({
newCommentFormProps: {
verticalPadding: 16,
paddingLeft: 24,
paddingRight: 24,
},
}),
[]
);
const styleConfig = useSocialStyle(customStyles);
Callbacks Object
These callbacks handle user interactions outside the comment section:
const callbacks: SocialStyleCallbacks = useMemo(
() => ({
currentUserClickCallback: () => {
Keyboard.dismiss();
closeCommentSectionSheet?.();
router.navigate("/(tabs)/profile");
},
otherUserClickCallback: (userId: string) => {
Keyboard.dismiss();
closeCommentSectionSheet?.();
router.navigate(`/account/${userId}`);
},
loginRequiredCallback: () => {
Alert.alert(
"Oops! Login Required. Please sign in or create an account to continue."
);
},
}),
[]
);
Step 4: Retrieve the Comment Section Components
const { CommentSectionProvider, CommentsFeed, NewCommentForm, SortByButton } =
useSocialComments({
entityId: commmentsEntityId,
styleConfig,
callbacks,
});
Step 5: Replace Placeholder with the Comment Section
Replace:
<Text>Comment Section Sheet</Text>
With:
<CommentSectionProvider>
<View className="flex-row gap-2 px-4 items-center mb-2">
<View className="flex-1" />
<SortByButton priority="top" activeView={<Text className="bg-black py-2 px-3 rounded-md text-white text-sm">Top</Text>} nonActiveView={<Text className="bg-gray-200 py-2 px-3 rounded-md text-sm">Top</Text>} />
<SortByButton priority="new" activeView={<Text className="bg-black py-2 px-3 rounded-md text-white text-sm">New</Text>} nonActiveView={<Text className="bg-gray-200 py-2 px-3 rounded-md text-sm">New</Text>} />
</View>
<CommentsFeed />
<NewCommentForm ref={commentFormRef} />
</CommentSectionProvider>
Step 6: Auto-focus the Comment Input
To automatically open the keyboard:
const commentFormRef = useRef<{ focus: () => void } | null>(null);
useEffect(() => {
if (!commmentsEntityId) return;
const timeout = setTimeout(() => {
if (commentFormRef.current) {
commentFormRef.current.focus();
}
}, 1000);
return () => clearTimeout(timeout);
}, [commmentsEntityId]);
Pass the ref to NewCommentForm
:
<NewCommentForm ref={commentFormRef} />
With this, the comment section is fully functional. Users can now open comment sections for each post independently.
This should be our final CommentSectionSheet component:
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Platform,
KeyboardAvoidingView,
Keyboard,
Text,
View,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
SocialStyleCallbacks,
useSocialComments,
useSocialStyle,
UseSocialStyleProps,
} from "@replyke/comments-social-react-native";
import { useRouter } from "expo-router";
import useSheetManager from "../../hooks/useSheetManager";
import { cn } from "../../utils/cn";
const CommentSectionSheet = () => {
const router = useRouter();
const { commentSetionSheetRef, commmentsEntityId, closeCommentSectionSheet } =
useSheetManager();
const snapPoints = useMemo(() => ["100%"], []);
const [sheetOpen, setSheetOpen] = useState(false);
const customStyles = useMemo<Partial<UseSocialStyleProps>>(
() => ({
newCommentFormProps: {
verticalPadding: 16,
paddingLeft: 24,
paddingRight: 24,
},
}),
[]
);
const styleConfig = useSocialStyle(customStyles);
const callbacks: SocialStyleCallbacks = useMemo(
() => ({
currentUserClickCallback: () => {
Keyboard.dismiss();
closeCommentSectionSheet?.();
router.navigate("/(tabs)/profile");
},
otherUserClickCallback: (userId: string) => {
Keyboard.dismiss();
closeCommentSectionSheet?.();
router.navigate(`/account/${userId}`);
},
loginRequiredCallback: () => {
Alert.alert(
"Oops! Login Required. Please sign in or create an account to continue."
);
},
}),
[]
);
const { CommentSectionProvider, CommentsFeed, NewCommentForm, SortByButton } =
useSocialComments({
entityId: commmentsEntityId,
styleConfig,
callbacks,
});
const commentFormRef = useRef<{ focus: () => void } | null>(null);
useEffect(() => {
if (!commmentsEntityId) return;
const timeout = setTimeout(() => {
if (commentFormRef.current) {
commentFormRef.current.focus();
}
}, 1000);
return () => clearTimeout(timeout);
}, [commmentsEntityId]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<SafeAreaView
className={cn(
"flex-1 absolute inset-0",
sheetOpen ? "" : "pointer-events-none"
)}
>
<View className="flex-1 relative">
<BottomSheet
ref={commentSetionSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={renderBackdrop}
onChange={(state) => {
setSheetOpen(state > -1);
if (state === -1) {
Keyboard.dismiss();
}
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<BottomSheetView className="flex-1">
<CommentSectionProvider>
<View className="flex-row gap-2 px-4 items-center mb-2">
<View className="flex-1" />
<SortByButton
priority="top"
activeView={
<Text className="bg-black py-2 px-3 rounded-md text-white text-sm">
Top
</Text>
}
nonActiveView={
<Text className="bg-gray-200 py-2 px-3 rounded-md text-sm">
Top
</Text>
}
/>
<SortByButton
priority="new"
activeView={
<Text className="bg-black py-2 px-3 rounded-md text-white text-sm">
New
</Text>
}
nonActiveView={
<Text className="bg-gray-200 py-2 px-3 rounded-md text-sm">
New
</Text>
}
/>
</View>
<CommentsFeed />
<NewCommentForm ref={commentFormRef} />
</CommentSectionProvider>
</BottomSheetView>
</KeyboardAvoidingView>
</BottomSheet>
</View>
</SafeAreaView>
);
};
export default CommentSectionSheet;
Finalizing the Collections Sheet Component
Step 1: Organizing the Files
To keep the CollectionsSheet
component structured, we:
- Move
CollectionsSheet.tsx
into a newCollectionsSheet/
folder - Create an
index.ts
file insideCollectionsSheet/
with the following content:
import CollectionsSheet from "./CollectionsSheet";
export default CollectionsSheet;
Now, the import path remains unchanged.
Step 2: Creating Supporting Components
CollectionsSheetHeader.tsx
Manages the top bar of the sheet, handling collection actions like navigating back, renaming collections, or saving/removing items.
import React, { useEffect, useState } from "react";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import { handleError, useLists } from "@replyke/expo";
import { cn } from "../../../utils/cn";
import useSheetManager from "../../../hooks/useSheetManager";
const styles = {
button: "py-2 px-4 bg-gray-300 rounded-xl",
buttonActive: "py-2 px-4 bg-blue-500 rounded-xl",
textCancel: "text-left text-lg font-medium text-gray-500",
textAction: "text-right text-lg font-medium",
headerText: "flex-1 text-xl font-medium text-center text-stone-700",
container: "flex-row py-4 px-4 items-center gap-2",
};
const CollectionsSheetHeader = ({
isCreateListView,
setIsCreateListView,
newListName,
setNewListName,
}: {
isCreateListView: boolean;
setIsCreateListView: React.Dispatch<React.SetStateAction<boolean>>;
newListName: string;
setNewListName: React.Dispatch<React.SetStateAction<string>>;
}) => {
const {
currentList,
isEntityInList,
goBack,
addToList,
removeFromList,
createList,
updateList,
} = useLists();
const { collectionsEntityId: entityId } = useSheetManager();
const [isEditingName, setIsEditingName] = useState(false);
const [updatedListName, setUpdatedListName] = useState("");
useEffect(() => {
if (!currentList) return;
setUpdatedListName(currentList.name);
}, [currentList]);
const handleCreateList = async (): Promise<void> => {
try {
setIsCreateListView(false);
if (newListName.length > 2) {
await createList?.({ listName: newListName });
}
setNewListName("");
} catch (err) {
handleError(err, "Failed to create list: ");
}
};
const renderLeftButton = () => {
if (isCreateListView) {
return (
<TouchableOpacity
onPress={() => setIsCreateListView(false)}
className={styles.button}
>
<Text className={styles.textCancel}>Cancel</Text>
</TouchableOpacity>
);
}
if (currentList?.parentId) {
return (
<TouchableOpacity onPress={() => goBack?.()} className={styles.button}>
<Text className={styles.textCancel}>Back</Text>
</TouchableOpacity>
);
}
return <View className="w-1/4" />;
};
const renderRightButton = () => {
if (isCreateListView) {
return (
<TouchableOpacity
onPress={handleCreateList}
className={
newListName.length > 2 ? styles.buttonActive : styles.button
}
>
<Text
className={cn(
styles.textAction,
newListName.length > 2 ? "text-white" : "text-gray-500"
)}
>
Create
</Text>
</TouchableOpacity>
);
}
if (entityId && isEntityInList?.(entityId)) {
return (
<TouchableOpacity
onPress={() => removeFromList?.({ entityId })}
className={cn(styles.button, "bg-transparent")}
>
<Text className={cn(styles.textAction, "text-red-300")}>Remove</Text>
</TouchableOpacity>
);
}
return (
<TouchableOpacity
onPress={() => entityId && addToList?.({ entityId })}
className={styles.buttonActive}
>
<Text className={cn(styles.textAction, "text-white")}>Save</Text>
</TouchableOpacity>
);
};
if (isEditingName) {
return (
<View className={styles.container}>
<TextInput
value={updatedListName}
onChangeText={setUpdatedListName}
placeholder="Change List Name"
className="border border-gray-400 rounded-2xl py-2.5 px-4 flex-1"
numberOfLines={1}
multiline={false}
style={{ lineHeight: 24 }}
/>
<TouchableOpacity
onPress={() => {
setIsEditingName(false);
setUpdatedListName(currentList?.name || "");
}}
className={styles.button}
>
<Text className={styles.textCancel}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
if (updatedListName.length <= 2) return;
if (!entityId) return;
if (!currentList) return;
updateList?.({
listId: currentList.id,
update: { name: updatedListName },
});
setIsEditingName(false);
}}
className={styles.button}
>
<Text
className={cn(
styles.textAction,
updatedListName.length > 2 ? "text-blue-400" : "text-gray-500"
)}
>
Save
</Text>
</TouchableOpacity>
</View>
);
}
return (
<View className={styles.container}>
<View className="w-1/4 items-start">{renderLeftButton()}</View>
<Text
className={styles.headerText}
onLongPress={() => {
if (currentList?.parentId) setIsEditingName(true);
}}
>
{isCreateListView
? "Create a Collection"
: currentList?.parentId
? currentList.name
: "Collections"}
</Text>
<View className="w-1/4 items-end">{renderRightButton()}</View>
</View>
);
};
export default CollectionsSheetHeader;
CurrentCollectionItems.tsx
Displays all entities inside the current collection.
import React from "react";
import { Image, View, FlatList } from "react-native";
import { useLists } from "@replyke/expo";
import { Skeleton } from "@replyke/ui-core-react-native";
function CurrentCollectionItems() {
const { currentList } = useLists();
return (
<View>
{currentList ? (
<FlatList
data={currentList?.entities}
keyExtractor={(item) => item.id!}
renderItem={({ item: entity }) => (
<Image
source={{ uri: entity.media?.[0].publicPath }}
className="size-20 rounded-xl"
resizeMode="cover"
/>
)}
contentContainerStyle={{ padding: 14 }}
/>
) : (
<FlatList
data={[1, 2]}
keyExtractor={(item) => item.toString()}
renderItem={() => (
<View className="flex-row gap-3 px-4 py-2.5 items-center">
<Skeleton
style={{
height: 36,
width: 36,
borderRadius: 20,
backgroundColor: "#d1d5db",
}}
/>
<Skeleton
style={{
height: 10,
width: "70%",
borderRadius: 6,
backgroundColor: "#d1d5db",
}}
/>
</View>
)}
/>
)}
</View>
);
}
export default CurrentCollectionItems;
CreateNewCollection.tsx
Provides an interface for users to create a new collection.
import React from "react";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
function CreateNewCollection({
isCreateCollectionView,
setIsCreateCollectionView,
newCollectionName,
setNewCollectionName,
}: {
isCreateCollectionView: boolean;
setIsCreateCollectionView: (state: boolean) => void;
newCollectionName: string;
setNewCollectionName: (value: string) => void;
}) {
return (
<View className="border-t border-b border-gray-300 p-3 mt-3">
{isCreateCollectionView ? (
<TextInput
value={newCollectionName}
onChangeText={setNewCollectionName}
placeholder="Create a collection.."
placeholderTextColor="#9ca3af"
className="bg-gray-200 rounded-2xl p-4 text-gray-600"
/>
) : (
<TouchableOpacity onPress={() => setIsCreateCollectionView(true)}>
<Text className="text-lg font-medium text-blue-500 m-2">
Create a new collection
</Text>
</TouchableOpacity>
)}
</View>
);
}
export default CreateNewCollection;
SubCollectionsList.tsx
Lists sub-collections within the current collection, allowing navigation.
import React from "react";
import { FlatList, Pressable, Text, View } from "react-native";
import Entypo from "@expo/vector-icons/Entypo";
import { useLists } from "@replyke/expo";
import { Skeleton } from "@replyke/ui-core-react-native";
function SubCollectionsList() {
const { subLists, loading, openList } = useLists();
return (
<View className="py-5">
{loading ? (
<FlatList
data={[1, 2]}
keyExtractor={(item) => item.toString()}
renderItem={() => (
<View className="flex-row gap-3 px-4 py-2.5 items-center">
<Skeleton
style={{
height: 36,
width: 36,
borderRadius: 20,
backgroundColor: "#d1d5db",
}}
/>
<Skeleton
style={{
height: 10,
width: "70%",
borderRadius: 6,
backgroundColor: "#d1d5db",
}}
/>
</View>
)}
/>
) : (
<FlatList
data={subLists}
keyExtractor={(item) => item.id}
renderItem={({ item: subList }) => (
<Pressable
onPress={() => openList?.(subList)}
className="px-4 py-2.5 flex-row gap-3 items-center"
>
<View className="bg-gray-700 p-2 rounded-2xl">
<Entypo name="list" size={20} color="#fff" />
</View>
<Text>{subList.name}</Text>
</Pressable>
)}
/>
)}
</View>
);
}
export default SubCollectionsList;
Step 3: Implementing CollectionsSheet.tsx
The final component integrates all the sub-components and ensures they work within the ListsProvider
.
import React, { useCallback, useMemo, useState } from "react";
import { Platform, KeyboardAvoidingView, Keyboard, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import useSheetManager from "../../../hooks/useSheetManager";
import { cn } from "../../../utils/cn";
import CollectionsSheetHeader from "./CollectionsSheetHeader";
import SubCollectionsList from "./SubCollectionsList";
import CreateNewCollection from "./CreateNewCollection";
import CurrentCollectionItems from "./CurrentCollectionItems";
import { ListsProvider } from "@replyke/expo";
const CollectionsSheet = () => {
const { collectionsSheetRef } = useSheetManager();
const [isCreateCollectionView, setIsCreateCollectionView] = useState(false);
const [newCollectionName, setNewCollectionName] = useState("");
const snapPoints = useMemo(() => ["100%"], []);
const [sheetOpen, setSheetOpen] = useState(false);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<SafeAreaView
className={cn(
"flex-1 absolute inset-0",
sheetOpen ? "" : "pointer-events-none"
)}
>
<View className="flex-1 relative">
<BottomSheet
ref={collectionsSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={renderBackdrop}
onChange={(state) => {
setSheetOpen(state > -1);
if (state === -1) {
Keyboard.dismiss();
}
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<BottomSheetView className="flex-1">
<ListsProvider>
<CollectionsSheetHeader
newListName={newCollectionName}
setNewListName={setNewCollectionName}
isCreateListView={isCreateCollectionView}
setIsCreateListView={setIsCreateCollectionView}
/>
<CurrentCollectionItems />
<CreateNewCollection
isCreateCollectionView={isCreateCollectionView}
setIsCreateCollectionView={setIsCreateCollectionView}
newCollectionName={newCollectionName}
setNewCollectionName={setNewCollectionName}
/>
<SubCollectionsList />
</ListsProvider>
</BottomSheetView>
</KeyboardAvoidingView>
</BottomSheet>
</View>
</SafeAreaView>
);
};
export default CollectionsSheet;
Features Completed
- By passing an entity id when opening the sheet, our sheet shows us the correct action in regards to the entity and current collection.
- Users can view all items inside the collection.
- Users can create new collections.
- A list of sub-collections allows easy navigation.
- Users can rename collections (except the root collection).
With this, the collections feature is fully implemented!
Wrapping Up
Throughout this chapter, we’ve transformed our social feed from a static display into an interactive experience. We started by making user avatars clickable, allowing seamless navigation between profiles. Then, we brought likes to life, enabling users to express appreciation for posts with real-time feedback. Next, we integrated the comment section, where users can view and engage in discussions, complete with Replyke’s comment system for a smooth experience. Finally, we built the Collections Sheet, allowing users to save posts, navigate collections, and create new ones—all while keeping everything organized within an intuitive bottom sheet.
With these essential interactions in place, our app now feels much closer to a real social platform. But we’re just getting started! In the next part, we’ll focus on user profiles, adding details, customization, and interactions to make them truly engaging. Stay tuned as we continue building the core of our social experience! 🚀
Stay Updated
Don’t forget to join the Discord server where I post free boilerplate code repos for different types of social networks. Updates about the next article and additional resources will also be shared there. Lastly, follow me for updates here and on X/Twitter & BlueSky.
Top comments (0)