DEV Community

Cover image for How to Build a Social Network in 1 Day: Part 7 - Adding Interactivity
Tsabary
Tsabary

Posted on

How to Build a Social Network in 1 Day: Part 7 - Adding Interactivity

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:

  1. 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.
  2. 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.
  3. Opening the Comment Section:

    • Clicking the chat bubble icon will open a comment section sheet, allowing users to view and leave comments.
  4. 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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. If the user is not logged in, an alert prompts them to log in or create an account.

  2. If the user has already liked the post, clicking the button removes the like.

  3. 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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a context provider that manages the opening and closing of these sheets.

  2. Wrap our app with this provider so any screen can trigger a sheet.

  3. 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

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

Explanation

Now that everything is set up, here’s what we’ve done:

  1. Installed dependencies: We installed @gorhom/bottom-sheet for managing sheets, along with its dependencies.
  2. Set up GestureHandlerRootView: This ensures smooth gesture handling, which is required for bottom sheets.
  3. 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.
  4. Wrapped the app in SheetManagerProvider: This makes the sheet context available throughout the app.
  5. 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Required Dependency

Run the following command to install the Replyke comment section package:

npx expo install @replyke/comments-social-react-native
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

To customize specific styles:

const customStyles = useMemo<Partial<UseSocialStyleProps>>(
  () => ({
    newCommentFormProps: {
      verticalPadding: 16,
      paddingLeft: 24,
      paddingRight: 24,
    },
  }),
  []
);
const styleConfig = useSocialStyle(customStyles);
Enter fullscreen mode Exit fullscreen mode

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."
      );
    },
  }),
  []
);
Enter fullscreen mode Exit fullscreen mode

Step 4: Retrieve the Comment Section Components

const { CommentSectionProvider, CommentsFeed, NewCommentForm, SortByButton } =
  useSocialComments({
    entityId: commmentsEntityId,
    styleConfig,
    callbacks,
  });
Enter fullscreen mode Exit fullscreen mode

Step 5: Replace Placeholder with the Comment Section

Replace:

<Text>Comment Section Sheet</Text>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

Pass the ref to NewCommentForm:

<NewCommentForm ref={commentFormRef} />
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Finalizing the Collections Sheet Component

Step 1: Organizing the Files

To keep the CollectionsSheet component structured, we:

  1. Move CollectionsSheet.tsx into a new CollectionsSheet/ folder
  2. Create an index.ts file inside CollectionsSheet/ with the following content:
import CollectionsSheet from "./CollectionsSheet";
export default CollectionsSheet;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Features Completed

  1. By passing an entity id when opening the sheet, our sheet shows us the correct action in regards to the entity and current collection.
  2. Users can view all items inside the collection.
  3. Users can create new collections.
  4. A list of sub-collections allows easy navigation.
  5. 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)