GetStream / stream-chat-swiftui

SwiftUI Chat SDK ➜ Stream Chat 💬
https://getstream.io/chat/sdk/swiftui/
Other
348 stars 87 forks source link

Custom ChatChannelListViewModel not publishing updates to ChannelList #283

Closed grandsir closed 1 year ago

grandsir commented 1 year ago

What did you do?

I was following the iMessage Clone tutorial made by Stefan Blos. After following that tutorial, synchronization between ChatChannelList and the ViewModel has been lost.

The things we've tried are those:

Further observation:

Streamchat-swiftui's ChatChannelListView() works just fine. Except the thing i've mentioned in Additional Context

GetStream Environment

GetStream Chat version: 4.27.1 GetStream Chat frameworks: StreamChat, StreamChatSwiftUI 4.27.0 iOS version:16.1 Swift version:5.7 Xcode version:14.2 Device:iPhone 13 pro max, iPhone 11, iPhone 6s, iPhone XR, iPhone 8, iPhone 14 Pro Max, iPhone 14

Additional context

We previously encountered a similar issue with GetStream's Default Components where the problem occurred while using a custom tab view (uses if statements to switch tabs). Specifically, when initially tapping on the Chat Tab, nothing was clickable (it was clickable, but navigationlink wasn't working). However, upon switching to a different tab and then returning to the Chat Tab, the issue appeared to resolve itself. It was also noted that in some cases, the issue did not resolve until the third switch. As of my recent check, this issue still persists.

Additional Additional context

I want to share a demo for you to test. but for the company's privacy purposes, we can not share it publicly, please do provide us an email so that we can send it privately.

Just an idea

I've been digging into your stream-chat-swiftui library for so long, and I must tell, the utils you're using is great, TopLeftView, KeyboardReadable conditional if viewmodifier, topSafeArea etc. is great, you can create a repo called getstream-swiftui-utils to keep these things in a repo, it would be great.

Code

For now, I can only share this portion:

ViewModel:

import Foundation
import StreamChatSwiftUI
import StreamChat

class ChatListViewModel: ChatChannelListViewModel {
    @Injected(\.chatClient) var chatClient

    func leaveTapped(_ channel: ChatChannel) {
        self.alertType = .leave(channel)
    }

    func leave(_ channel: ChatChannel) {

        // our api call
        Session.leaveRoom(channel: channel) { error in
            if error == nil {
                print("DEBUG: Successfully leaved room")
            }
            else {
                print("DEBUG: An error occured when trying to leave room: \(String(describing: error))")
            }
        }
        let controller = chatClient.channelController(for: channel.cid)

        guard let currentUser = chatClient.currentUserId else { return }

        controller.removeMembers(userIds: [currentUser])
    }

    @Published var alertType : ChatAlertType? {
        didSet {
            if alertType != nil {
                self.alertShown = true
            }
        }
    }

    func deleteChannelTapped(_ channel: ChatChannel) {
        self.alertType = .delete(channel)
    }

    func deleteChannel(_ channel: ChatChannel) {
        let roomId = channel.cid.id

        // Our API call
        Session.deleteChat(roomId: roomId) { [weak self] error in
            if let error = error {
                self?.channelAlertType = .error
            }

            else {
                self?.delete(channel: channel)
            }
        }

    }
}

enum ChatAlertType {
    case delete(ChatChannel)
    case leave(ChatChannel)
    case other
}

View:

struct ChannelListView: View {
    @StateObject var channelHeaderLoader = ChannelHeaderLoader()
    @StateObject private var channelViewModel: ChatListViewModel

    @EnvironmentObject var configurationViewModel: HMSConfigurationViewModel
    @EnvironmentObject var viewModel: CustomTabViewModel
    @State private var isActive = false

    let viewFactory: CustomFactory

    init(viewFactory: CustomFactory) {
        self.viewFactory = viewFactory
        _channelViewModel = StateObject(wrappedValue: ChatListViewModel())
    }

    var body: some View {
        ZStack {

            if channelViewModel.loading {
                ActivityIndicatorView()
            }

            if channelViewModel.channels.isEmpty {
                CreateYourFirstSpreeButton()
            }
            else {
                VStack {
                    HStack(spacing: 2) {
                        GetStreamSearchBar(text: $channelViewModel.searchText)

                        Button {
                            self.configurationViewModel.presentingCreateYourSpreeSheet = true
                        } label : {
                            Image(systemName: "plus")
                        }
                        .padding(.trailing, 12)
                    }
                    .background(
                        NavigationLink(isActive: $isActive) {
                            if let selectedChannel = channelViewModel.selectedChannel {
                                viewFactory.makeChannelDestination()(selectedChannel)
                            } else {
                                if let openedFromTab = viewModel.openedTabFromChannel {
                                    viewFactory.makeChannelDestination()(openedFromTab.channelSelectionInfo)
                                        .onAppear {
                                            viewModel.openedTabFromChannel = nil
                                        }
                                }
                            }
                        } label: {
                            EmptyView()
                        }
                    )
                    .alert(isPresented: $channelViewModel.alertShown) {
                        switch channelViewModel.alertType {
                        case let .delete(channel):
                            return Alert(
                                title: Text("Delete Channel"),
                                message: Text("Are you sure you want to delete this channel?"),
                                primaryButton: .destructive(Text("Delete")) {
                                    channelViewModel.deleteChannel(channel)
                                },
                                secondaryButton: .cancel())
                        case let .leave(channel):
                            return Alert(
                                title: Text("Leave Channel"),
                                message: Text("Are you sure you want to leave?"),
                                primaryButton: .destructive(Text("Leave")) {
                                    channelViewModel.leave(channel)
                                },
                                secondaryButton: .cancel())
                        default:
                            return Alert.defaultErrorAlert
                        }
                    }
                    if !channelViewModel.searchText.isEmpty {
                        //Mentioned in getstream-swiftui repo
                        EmptyView()
//                        SearchResultsView(
//                            factory: viewFactory,
//                            selectedChannel: $viewModel.selectedChannel,
//                            searchResults: viewModel.searchResults,
//                            loadingSearchResults: viewModel.loadingSearchResults,
//                            onlineIndicatorShown: viewModel.onlineIndicatorShown(for:),
//                            channelNaming: viewModel.name(forChannel:),
//                            imageLoader: channelHeaderLoader.image(for:),
//                            onSearchResultTap: { searchResult in
//                                viewModel.selectedChannel = searchResult
//                            },
//                            onItemAppear: viewModel.loadAdditionalSearchResults(index:)
//                        )
                    }
                    else {
                        let extractedExpr = ChannelList(
                            factory: viewFactory,
                            channels: channelViewModel.channels,
                            selectedChannel: $channelViewModel.selectedChannel,
                            swipedChannelId: $channelViewModel.swipedChannelId,
                            onlineIndicatorShown: channelViewModel.onlineIndicatorShown(for:),
                            imageLoader: channelHeaderLoader.image(for:),
                            onItemTap: {
                                channelViewModel.selectedChannel = $0.channelSelectionInfo
                                self.isActive = true
                                Session.decreaseNotification(count: $0.unreadCount.messages)
                            },
                            onItemAppear: { index in
                                channelViewModel.checkForChannels(index: index)
                            },
                            channelNaming: channelViewModel.name(forChannel:),
                            channelDestination: viewFactory.makeChannelDestination(),
                            trailingSwipeRightButtonTapped: channelViewModel.deleteChannelTapped(_:),
                            trailingSwipeLeftButtonTapped: channelViewModel.leaveTapped(_:),
                            leadingSwipeButtonTapped: { _ in }
                        )
                        .navigationBarTitle("Chat")
                        extractedExpr
                    }
                }
            }
        }

    }
}
martinmitrevski commented 1 year ago

Hey @grandsir,

It's hard to tell from the provided code what's the issue, and also I'm not that familiar with Stefan's tutorial.

Please send us the sample project at stefan.blos@getstream.io and martin.mitrevski@getstream.io, and we will have a look.

Best, Martin

grandsir commented 1 year ago

Hey @martinmitrevski,

I sent the demo to those emails, I'd be glad if you check that 🙌

Also, I'm not sure if that can be the case, but if you ever do not see the channels, please close the app, wait for 5 seconds, then launch the app again

martinmitrevski commented 1 year ago

Hey @grandsir,

I've already checked the project and replied to the email, but most likely the info is still not passed to you.

In any case, here it is: it seems the issue is in your custom implementation of the channel list. The SpreeChatListViewModel is kept in the channel list, and it’s being recreated whenever the tab view redraws, leaving a stale instance of the channel list controller.

My suggestion would be to move the SpreeChatListViewModel to your CustomTabView: @StateObject private var spreeChannelViewModel = SpreeChatListViewModel()

Then, in your channel list, just use it as an ObservedObject: @ObservedObject private var spreeChannelViewModel: SpreeChatListViewModel

This change made things work for me.

Hope that helps, Martin

grandsir commented 1 year ago

hey @martinmitrevski, unfortunately, that didn't help either. The issue still persists.

Actually, I've tried something similiar as well, this is from things we've tried section:

Keeping the ChatChannelListViewModel as a StateObject in the TabView, then passing it as EnvironmentObject, then passing that EnvironmentObject to ChannelList. Unlike the first one, this never works. UI always stays desynchronized

martinmitrevski commented 1 year ago

Hey @grandsir, I wasn't using an EnvironmentObject, I just passed it as a param to the channel list, which had it as an @ObservedObject. That made the UI responsive. If you like I can also send you the sample project back, although it's a minimal change.

grandsir commented 1 year ago

@martinmitrevski, yes i'll be glad if you send me the sample project back. I'll try to reproduce it in there and if i can, i'll record it

grandsir commented 1 year ago

@martinmitrevski I was able to reproduce, please check your email.

crayment commented 1 year ago

I'm not sure if my issue is related or not, but we were having an issue with the default UI where selecting channels would randomly stop working. Usually the first time we presented the chat UI. I narrowed the issue down to the ChatChannelListView having a different instance of ChatChannelListViewModel than the ChatChannelListContentView.

I forked and changed from @StateObject to @ObservedObject (see commit here) This resolved the issue for us.

I'm not 100% sure but I think it might be unsafe to use @StateObject anywhere you are using viewModel ?? ViewModelsFactory.make to optionally create the view model.

martinmitrevski commented 1 year ago

hey @crayment, thanks for providing the additional details. The issue here is not related to the ChatChannelListView, since they are using a completely custom channel list, not the one from the SDK. I'm analysing this case. I will also check your case - I think it's safe to use @StateObject in all cases, but need to analyse this further.

crayment commented 1 year ago

@martinmitrevski Thanks! Sorry this felt a bit related, should have just opened a new issue. Let me know if you would like me to open one.

martinmitrevski commented 1 year ago

hey @crayment, yes, please open a new one. We've resolved this one, the custom implementation was using different navigation while not reseting the selectedChannel in our view model.