Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

Adding Stream Feeds to the Timeline

In this second part of the Build Your Own Twitter tutorial series we’ll add Stream feeds to enable users to send, receive, subscribe, or unsubscribe from others' timelines.

Scaling activity feeds is hard when you have many users. Stream’s API leverages Go, RocksDB & Raft to ensure great performance (<10ms) and scalability. It hides the complexity of push, pull, fanout on read, and fanout on write behind an easy-to-use API. Apps such as Soundcloud, eToro, Under Armour, and Dana rely on Stream for their activity feeds.

In this second part of the Twitter Clone tutorial series we’ll add Stream feeds to enable users to send, receive, subscribe, or unsubscribe from others' timelines. Refer to the first part of the this tutorial, Building the Timeline, to learn about the overview of the project's architecture and the setup process.

💡 You must have a Stream account and API key for Feeds before continuing. You can create a free account on Stream here.

Find the project demo and download the source code from GitHub.

Overview of Stream Feeds

Using the Stream's Activity Feeds components, you can build timeline feeds with real-time updates that support likes and comments, similar to the timelines of Instagram or Twitter.

Feeds Setup

The main component of the app is a Twitter-like user experience where users can post messages, and other people can subscribe to receive those messages. It may sound like something perfectly suited for Stream’s feed product. The Stream Feeds App will handle all the heavy lifting as a backend to ensure users can create activities, subscribe/unsubscribe to other users’ updates, and more.

We created three feed groups in Stream Feeds’ backend to get the functionality. In the application, we need to have a:

  • "timeline" feed group for activities made by users that a user follows
  • "user" feed group for activities by a user
  • "notification" feed group for notification activities originating from follow or reaction actions

The biggest thing to understand is that a user follows another user by subscribing to their timeline feed to another user’s user feed.

https://getstream.io/blog/static/0815a3c6d53cee09923d72cea0a3534c/b074b/4.-Adding-a-feed-group.png

In the timeline and user group, we use a flat feed type and a notification group with a notification feed type.

In our codebase, everything directly related to Feeds is in the TimelineUI and the Feeds Packages. In your downloaded TwitterClone project, find the TimelineUI in the Project Navigator. Open the following Swift files to explore the code samples related to feeds. Below are a few examples

FeedsView.swift

swift
            import SwiftUI

import Feeds

public struct FeedsView: View {
    @EnvironmentObject var feedClient: FeedsClient

    @State private var selection = 0

    public init() {}

    public var body: some View {
        VStack {
            ForYouFeedsView()
        }
    }
}
        

ProfileInfoAndTweets.swift

swift
            import SwiftUI

import TwitterCloneUI
import Feeds
import Profile

struct ProfileInfoAndTweets: View {
    var userId: String
    var profilePicture: String?

    @EnvironmentObject var feedsClient: FeedsClient
    @State private var selection = 0

    @StateObject var profileInfoViewModel = ProfileInfoViewModel()

    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                HStack {
                    ProfileImage(imageUrl: profilePicture, action: {})
                        .scaleEffect(1.2)

                    Spacer()

                    Button {
                        print("receives notifications from this user")
                    } label: {
                        Image(systemName: "bell.badge.circle.fill")
                            .symbolRenderingMode(.hierarchical)
                            .font(.title)
                    }

                    Button {
                        print("")
                    } label: {
                        Image(systemName: "message.circle.fill")
                            .symbolRenderingMode(.hierarchical)
                            .font(.title)
                    }

                    Button {
                        print("")
                    } label: {
                        Text("Following")
                            .font(.subheadline)
                            .fontWeight(.bold)
                    }
                    .buttonStyle(.bordered)
                }

                ProfileInfoView(viewModel: profileInfoViewModel)

                ForYouFeedsView(feedType: .user(userId: userId))
                    .frame(height: UIScreen.main.bounds.height)
            }.padding()
        }
        .task {
            profileInfoViewModel.feedUser = try? await feedsClient.user(id: userId)
        }
    }
}
        

MyProfileInfoAndTweets.swift

swift
            import SwiftUI
import TwitterCloneUI
import Feeds
import Auth
import Profile

public struct MyProfileInfoAndTweets: View {
    private var feedsClient: FeedsClient

    @State private var isShowingEditProfile = false

    @EnvironmentObject var profileInfoViewModel: ProfileInfoViewModel

    @State private var selection = 0

    public init(feedsClient: FeedsClient) {
        self.feedsClient = feedsClient
    }

    public var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                HStack {
                    ProfileImage(imageUrl: profileInfoViewModel.feedUser?.profilePicture, action: {})
                            .scaleEffect(1.2)

                    Spacer()

                    Button {
                        isShowingEditProfile.toggle()
                        print("Navigate to edit profile page")
                    } label: {
                        Text("Edit profile")
                            .font(.subheadline)
                            .fontWeight(.bold)
                    }
                    .sheet(isPresented: $isShowingEditProfile, content: {
                        if let feedUser = profileInfoViewModel.feedUser {
                            EditProfileView(feedsClient: feedsClient, currentUser: feedUser)
                        }
                    })
                    .buttonStyle(.borderedProminent)
                }

                ProfileInfoView(viewModel: profileInfoViewModel)

                ForYouFeedsView(feedType: .user(userId: feedsClient.authUser.userId))
                    .frame(height: UIScreen.main.bounds.height)
            }.padding()
        }
    }
}
        

From our dependency graph below, the app's targets depend on the HomeUI package. The HomeUI package is the central hub of our app. Everything starts from the HomeUI. Additionally, the HomeUI is only invoked by the app class if the user has logged in. Otherwise, we send the user to the signup/login flow.

NodeJS Server

Next to the Stream backend, we need a way to authenticate users and fetch Tokens on their behalf. To facilitate this, we created a small NodeJS backend, which we deployed to Digital Ocean. It is not much more than a Database with a user table and some code to isolate this database from the internet. In a previous blog post, Minimal Node Integration to Get You Started with Stream, we wrote about this integration layer.

To have convenient persistent availability of our NodeJS backend, we opted to deploy to Digital Ocean using a managed PostgreSQL instance combined with their “App” offering. It brings us two benefits. First, we get access to a scalable infrastructure with the convenience of Git push deployment. Check Digital Ocean’s offerings to see a lot of powerful components at several levels of abstraction.

As mentioned, we use the NodeJS server described as described in our previous blog post. But we did extend the its functionality a lot. Most important, the version in the blog post uses a local SQLite database for convenience. Since we are deploying to Digital Ocean and are using their PostgreSQL offering, we migrated the code of the NodeJS server to work on this infrastructure. On top of that we extended the NodeJS codebase significantly to facilitate in several backend related tasks we need to have for our implementation. Things like feeding Stream Feed Webhook calls into our search API provider Algolia. All code related to our additional work is being kept on a separate branch to make sure the basic SQLite implementation remains untouched. To have a look at our actual backend code deployed to Digital Ocean, have a look at the “postgresql” branch of the GetStream/stream-node-simple-integration-sample repository.

Build the Add a New Tweet View

You can add a new tweet to the timeline using the floating Add a new tweet button at the bottom right of the home view. Tapping the button launches a new screen to compose your tweet. When you send the tweet, it appears as the most recent timeline item. Check out the code below from the HomeUI in the Project Navigator and explore how we build the add a new tweet view.

The code below uses hard-coded data, but we will update this to use dynamic data once we implement user profiles.

AddNewTweetView.swift

swift
            import SwiftUI
import TwitterCloneUI
import Profile
import Auth
import PhotosUI
import os.log

import Feeds

let logger = Logger(subsystem: "AddNewTweetView", category: "main")

public struct AddNewTweetView: View {
    @EnvironmentObject var feedsClient: FeedsClient
    @Environment(\.presentationMode) var presentationMode

    @State private var isShowingComposeArea = ""
    @State private var isRecording = false

    @State var selectedItems: [PhotosPickerItem] = []
    @State var selectedPhotosData = [Data]()

    var profileInfoViewModel: ProfileInfoViewModel

    public init(profileInfoViewModel: ProfileInfoViewModel) {
        self.profileInfoViewModel = profileInfoViewModel
    }

    public var body: some View {
        NavigationStack {
            VStack {
                HStack(alignment: .top) {

                    ProfileImage(imageUrl: profileInfoViewModel.feedUser?.profilePicture, action: {})
                    TextField("What's happening?", text: $isShowingComposeArea, axis: .vertical)
                        .textFieldStyle(.roundedBorder)
                        .lineLimit(3, reservesSpace: true)
                        .font(.caption)
                        .keyboardType(.twitter)
                }
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button("Cancel") {
                            presentationMode.wrappedValue.dismiss()
                        }
                    }

                    ToolbarItem(placement: .navigationBarTrailing) {
                        AsyncButton("Tweet") {
                            do {
                                var tweetPhotoUrlString: String?
                                logger.debug("add tweet photo identifier: \(selectedItems.first?.itemIdentifier ?? "", privacy: .public)")

                                if let item = selectedItems.first, let mimeType = item.supportedContentTypes.first?.preferredMIMEType, let imageData = selectedPhotosData.first {

                                    tweetPhotoUrlString = try await feedsClient.uploadImage(fileName: item.itemIdentifier ?? "filename", mimeType: mimeType, imageData: imageData).absoluteString
                                    logger.debug("add tweet photo url: \(tweetPhotoUrlString ?? "", privacy: .public)")

                                }

                                let activity = PostActivity(actor: feedsClient.authUser.userId, object: isShowingComposeArea, tweetPhotoUrlString: tweetPhotoUrlString)
                                try await feedsClient.addActivity(activity)
                                presentationMode.wrappedValue.dismiss()
                            } catch {
                                print(error)
                            }

                            print("tap to send tweet")
                        }
                        .font(.subheadline)
                        .fontWeight(.bold)
                        .buttonStyle(.borderedProminent)
                        .disabled(isShowingComposeArea.isEmpty)
                    }

                    // Photo picker view
                    ToolbarItem(placement: .keyboard) {
                        Button {
                            print("tap to upload an image")
                        } label: {
                            VStack {
                                PhotosPicker(
                                    selection: $selectedItems,
                                    maxSelectionCount: 1,
                                    matching: .any(of: [.images, .not(.livePhotos)])
                                ) {
                                    Image(systemName: "photo.on.rectangle.angled")
                                        .accessibilityLabel("Photo picker")
                                        .accessibilityAddTraits(.isButton)
                                }
                                .onChange(of: selectedItems) { newItems in
                                    selectedPhotosData.removeAll()
                                    for newItem in newItems {
                                        Task {
                                            if let data = try? await newItem.loadTransferable(type: Data.self) {
                                                selectedPhotosData.append(data)
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }

                    ToolbarItem(placement: .keyboard) {
                        Button {
                            print("tap to initiate a new Space")
                        } label: {
                            Image(systemName: "mic.badge.plus")
                                .font(.subheadline)
                                .fontWeight(.bold)
                        }

                    }

                    ToolbarItem(placement: .keyboard) {
                        Button {
                            self.isRecording.toggle()
                        } label: {
                            Image(systemName: "waveform")
                                .font(.subheadline)
                                .fontWeight(.bold)
                        }
                        .fullScreenCover(isPresented: $isRecording) {
                            RecordAudioView(profileInfoViewModel: profileInfoViewModel)
                        }
                    }

                    ToolbarItem(placement: .keyboard) {
                        Button {
                            print("tap to record audio")
                        } label: {
                            Image(systemName: "bolt.square")
                                .font(.subheadline)
                                .fontWeight(.bold)
                        }
                    }

                    // For the sake of keeping the 4 above icons on the left of the keyboard
                    ToolbarItem(placement: .keyboard) {
                        Button {
                            print("tap to record audio")
                        } label: {
                            Image(systemName: "")
                                .font(.subheadline)
                                .fontWeight(.bold)
                        }
                    }
                }
                ForEach(selectedPhotosData, id: \.self) { photoData in
                    if let image = UIImage(data: photoData) {
                        Image(uiImage: image)
                            .resizable()
                            .scaledToFit()
                            .cornerRadius(10.0)
                            .padding(.horizontal)
                    }
                }

                Spacer()
            }
            .padding()
        }
    }
}
        

Build the For You Feeds View

In the For You Feeds View, we fetch two feed categories from our feed client. Here, we display user-specific feeds and timeline feeds. Open ForYouFeedsView.swift from the Sources folder under HomeUI to explore the code.

ForYouFeedsView.swift

swift
            import Feeds

import SwiftUI

public enum FeedType {
    case user(userId: String?)
    case timeline
}
@MainActor
private class ForYouFeedsViewModel: ObservableObject {
    internal var type: FeedType = .timeline
    internal var feedClient: FeedsClient?

    @Published public var activities: [EnrichedPostActivity] = []

    func refreshActivities() {
        Task {
            do {
                if let feedClient = self.feedClient {
                    activities.removeAll()
                    switch type {
                    case .timeline:
                        activities = try await feedClient.getTimelineActivities()
                    case .user(let userId):
                        if let userId {
                            activities = try await feedClient.getUserActivities(userId: userId)
                        }
                    }
                }
            } catch {
                print(error)
            }
        }
    }
}

public struct ForYouFeedsView: View {
    @EnvironmentObject var feedClient: FeedsClient
    @StateObject private var viewModel = ForYouFeedsViewModel()

    private let feedType: FeedType

    public init(feedType: FeedType = .timeline) {
        self.feedType = feedType
    }

    public var body: some View {
        List(viewModel.activities) { item in
            let model = PostRowViewViewModel(item: item)
            PostRowView(model: model).onReceive(model.$liked) { liked in
                if liked {
                    Task {
                        try await feedClient.addLike(item.id)
                    }
                }
            }
        } // LIST STYLES
        .listStyle(.plain)
        .task {
            viewModel.type = feedType
            viewModel.feedClient = feedClient
            viewModel.refreshActivities()
        }
        .refreshable {
            viewModel.refreshActivities()
        }
    }
}
        

As you can see from the code above, the UI is displayed using a list and the content from PostRowView.swift. The list of items gets updated by fetching data about the updated activities from the feed client.

Create the Post Row View

The Post Row View shows data about a single tweet, like user name, profile image, number of likes, and retweets. The PostRowViewViewModel contains the data used to populate the post-row UI. To create this view, refer to PostRowView.swift in the HomeUI of the Project Navigator.

swift
            import Feeds

import SwiftUI
import TwitterCloneUI
import Profile

class PostRowViewViewModel: ObservableObject {
    var item: EnrichedPostActivity

    @Published
    var liked: Bool

    init(item: EnrichedPostActivity) {
        liked = false
        self.item = item
    }
}

struct PostRowView: View {

    var model: PostRowViewViewModel

    @State private var isShowingActivityProfile = false

    var body: some View {
        HStack(alignment: .top) {
            ProfileImage(imageUrl: model.item.actor.profilePicture, action: {
                self.isShowingActivityProfile.toggle()
            })
            .accessibilityLabel("Profile of \(model.item.actor.fullname)")
            .accessibilityAddTraits(.isButton)
            .sheet(isPresented: $isShowingActivityProfile, content: {
                ProfileFollower(contentView: {
                    AnyView(ProfileInfoAndTweets(userId: model.item.actor.userId, profilePicture: model.item.actor.profilePicture))
                })
            })

            VStack(alignment: .leading) {
                HStack(alignment: .top) {
                    Text(model.item.actor.fullname)
                        .fontWeight(.bold)
                        .lineLimit(1)
                        .layoutPriority(1)

                    Text(model.item.actor.username)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                        .lineLimit(1)
                        .layoutPriority(1)

                    Text("* " + model.item.postAge)
                        .font(.subheadline)
                        .lineLimit(1)
                        .foregroundColor(.secondary)

                    Spacer()

                    Image(systemName: "ellipsis.circle")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                        .buttonStyle(.borderless)
                        .contextMenu {
                            Button(role: .destructive) {
                                // TODO implement activity deletion
                                print("Delete")
                            } label: {
                                Label("Delete activity", systemImage: "trash")
                            }
                        }
                }

                HStack(alignment: .bottom) {
                    Text(model.item.object)
                        .layoutPriority(2)
                }.font(.subheadline)

                if let tweetPhoto = model.item.tweetPhoto {
                    AsyncImage(url: URL(string: tweetPhoto)) { loading in
                        if let image = loading.image {
                            image
                                .resizable()
                                .scaledToFit()
                        } else if loading.error != nil {
                            Image(systemName: "exclamationmark.icloud")
                                .resizable()
                                .scaledToFit()
                        } else {
                            ProgressView()
                        }
                    }
                    .frame(width: nil, height: 180)
                    .cornerRadius(16)
                    .accessibilityLabel("Tweet with photo")
                    .accessibilityAddTraits(.isButton)
                }

                HStack {
                    Image(systemName: "message")
                    Text("\(model.item.numberOfComments ?? "x")")
                    Spacer()
                    Image(systemName: "arrow.2.squarepath")
                    Spacer()
                    Button {
                        model.liked.toggle()
                    } label: {
                        HStack {
                            Image(systemName: "heart")
                            Text("\(model.item.numberOfLikes ?? "0")")
                        }
                    }
                    Spacer()
                    Image(systemName: "square.and.arrow.up.on.square")
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
            }
        }
    }
}
        

Conclusion

This tutorial introduced you to adding Stream Feeds to the timeline to build a twitter-like home page feeds experience. To get the best out of the TwitterClone tutorial series, we encourage you to read the following tutorial about Enabling Support For Media Tweets and Video Playback.