Skip to content

Add CurrentUserUnreads.totalUnreadCountByTeam #3733

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🔄 Changed
## StreamChat
### ✅ Added
- Add `CurrentUserUnreads.totalUnreadCountByTeam` [#3733](https://github.com/GetStream/stream-chat-swift/pull/3733)

# [4.82.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.82.0)
_July 16, 2025_
Expand Down
266 changes: 266 additions & 0 deletions DemoApp/Screens/UserProfileViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//

import StreamChat
import SwiftUI
import UIKit

class UserProfileViewController: UITableViewController, CurrentChatUserControllerDelegate {
Expand All @@ -17,6 +18,7 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
case role
case typingIndicatorsEnabled
case readReceiptsEnabled
case detailedUnreadCounts
}

let currentUserController: CurrentChatUserController
Expand Down Expand Up @@ -105,6 +107,11 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
cell.accessoryView = makeSwitchButton(UserConfig.shared.typingIndicatorsEnabled ?? true) { newValue in
UserConfig.shared.typingIndicatorsEnabled = newValue
}
case .detailedUnreadCounts:
cell.textLabel?.text = "Detailed Unread Counts"
cell.accessoryView = makeButton(title: "View Details", action: { [weak self] in
self?.showDetailedUnreads()
})
}
return cell
}
Expand Down Expand Up @@ -133,6 +140,25 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
tableView.reloadData()
}

private func showDetailedUnreads() {
let unreadDetailsView = UnreadDetailsView(
onLoadData: { [weak self](completion: @escaping (Result<CurrentUserUnreads, Error>) -> Void) in
self?.currentUserController.loadAllUnreads { result in
DispatchQueue.main.async {
completion(result)
}
}
},
onDismiss: { [weak self] in
self?.dismiss(animated: true)
}
)
let hostingController = UIHostingController(rootView: unreadDetailsView)
hostingController.title = "Unread Details"

present(hostingController, animated: true)
}

@objc private func didTapUpdateButton() {
currentUserController.updateUserData(
name: name,
Expand Down Expand Up @@ -163,3 +189,243 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
return button
}
}

// MARK: - SwiftUI Views

struct UnreadDetailsView: View {
let onLoadData: (@escaping (Result<CurrentUserUnreads, Error>) -> Void) -> Void
let onDismiss: () -> Void

@State private var unreads: CurrentUserUnreads?
@State private var isLoading = false
@State private var errorMessage: String?

private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()

var body: some View {
NavigationView {
Group {
if isLoading {
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text("Loading unread data...")
.font(.headline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = errorMessage {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.orange)
Text("Error")
.font(.headline)
Text(errorMessage)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Retry") {
loadData()
}
.font(.headline)
.foregroundColor(.blue)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let unreads = unreads {
List {
// Summary Section
Section(header: Text("Summary")) {
SummaryRow(title: "Total Unread Messages", value: "\(unreads.totalUnreadMessagesCount)")
SummaryRow(title: "Total Unread Channels", value: "\(unreads.totalUnreadChannelsCount)")
SummaryRow(title: "Total Unread Threads", value: "\(unreads.totalUnreadThreadsCount)")
}

// Unread Channels Section
Section(header: Text("Unread Channels (\(unreads.unreadChannels.count))")) {
ForEach(unreads.unreadChannels, id: \.channelId.rawValue) { channel in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(channel.channelId.id)
.font(.headline)
Spacer()
Text("\(channel.unreadMessagesCount)")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.red.opacity(0.1))
.cornerRadius(4)
}

if let lastRead = channel.lastRead {
Text("Last read: \(dateFormatter.string(from: lastRead))")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Never read")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 2)
}
}

// Unread Threads Section
Section(header: Text("Unread Threads (\(unreads.unreadThreads.count))")) {
ForEach(unreads.unreadThreads, id: \.parentMessageId) { thread in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Thread: \(thread.parentMessageId)")
.font(.headline)
.lineLimit(1)
Spacer()
Text("\(thread.unreadRepliesCount)")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.1))
.cornerRadius(4)
}

if let lastRead = thread.lastRead {
Text("Last read: \(dateFormatter.string(from: lastRead))")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Never read")
.font(.caption)
.foregroundColor(.secondary)
}

if let lastReadMessageId = thread.lastReadMessageId {
Text("Last read message: \(lastReadMessageId)")
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 2)
}
}

// Channel Types Section
Section(header: Text("Unread by Channel Type")) {
ForEach(unreads.unreadChannelsByType, id: \.channelType.rawValue) { typeInfo in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(typeInfo.channelType.rawValue.capitalized)
.font(.headline)
Text("\(typeInfo.unreadChannelCount) channels")
.font(.caption)
.foregroundColor(.secondary)
}

Spacer()

Text("\(typeInfo.unreadMessagesCount)")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
.padding(.vertical, 2)
}
}

// Unread by Team Section
let teamUnreads = unreads.totalUnreadCountByTeam ?? [:]
Section(header: Text("Unread by Team (\(teamUnreads.count))")) {
ForEach(Array(teamUnreads.keys).sorted(), id: \.self) { teamId in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Team: \(teamId)")
.font(.headline)
Text("Team ID: \(teamId)")
.font(.caption)
.foregroundColor(.secondary)
}

Spacer()

Text("\(teamUnreads[teamId] ?? 0)")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.purple.opacity(0.1))
.cornerRadius(4)
}
.padding(.vertical, 2)
}
}
}
}
}
.navigationTitle("Unread Details")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(
leading: Button(action: loadData) {
HStack {
if isLoading {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
Text("Refresh")
}
}
.disabled(isLoading),
trailing: Button("Done") {
onDismiss()
}
)
.onAppear {
loadData()
}
}
}

private func loadData() {
isLoading = true
errorMessage = nil

onLoadData { result in
isLoading = false

switch result {
case .success(let unreadData):
unreads = unreadData
errorMessage = nil
case .failure(let error):
errorMessage = error.localizedDescription
}
}
}
}

struct SummaryRow: View {
let title: String
let value: String

var body: some View {
HStack {
Text(title)
Spacer()
Text(value)
.font(.headline)
.foregroundColor(.primary)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,15 @@ struct CurrentUserUnreadsPayload: Decodable {
enum CodingKeys: String, CodingKey {
case totalUnreadCount = "total_unread_count"
case totalUnreadThreadsCount = "total_unread_threads_count"
case totalUnreadCountByTeam = "total_unread_count_by_team"
case channels
case channelType = "channel_type"
case threads
}

let totalUnreadCount: Int
let totalUnreadThreadsCount: Int
let totalUnreadCountByTeam: [TeamId: Int]?
let channels: [CurrentUserChannelUnreadPayload]
let channelType: [ChannelUnreadByTypePayload]
let threads: [CurrentUserThreadUnreadPayload]
Expand Down
3 changes: 3 additions & 0 deletions Sources/StreamChat/Models/CurrentUser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ public struct CurrentUserUnreads {
public let totalUnreadChannelsCount: Int
/// The total number of unread threads.
public let totalUnreadThreadsCount: Int
/// The total number of unread messages grouped by team.
public let totalUnreadCountByTeam: [TeamId: Int]?
/// The unread information per channel.
public let unreadChannels: [UnreadChannel]
/// The unread information per thread.
Expand Down Expand Up @@ -177,6 +179,7 @@ extension CurrentUserUnreadsPayload {
totalUnreadMessagesCount: totalUnreadCount,
totalUnreadChannelsCount: unreadChannels.count,
totalUnreadThreadsCount: totalUnreadThreadsCount,
totalUnreadCountByTeam: totalUnreadCountByTeam,
unreadChannels: unreadChannels,
unreadThreads: threads.map { .init(
parentMessageId: $0.parentMessageId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ final class CurrentUserController_Tests: XCTestCase {
totalUnreadMessagesCount: 10,
totalUnreadChannelsCount: 5,
totalUnreadThreadsCount: 3,
totalUnreadCountByTeam: ["Benfica": 3],
unreadChannels: [
UnreadChannel(
channelId: .init(type: .messaging, id: "channel1"),
Expand Down Expand Up @@ -832,6 +833,7 @@ final class CurrentUserController_Tests: XCTestCase {

// Assert the result is correct
XCTAssertEqual(receivedUnreads?.totalUnreadMessagesCount, expectedUnreads.totalUnreadMessagesCount)
XCTAssertEqual(receivedUnreads?.totalUnreadCountByTeam?["Benfica"], 3)
}

func test_loadAllUnreads_propagatesError() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,7 @@ final class CurrentUserUpdater_Tests: XCTestCase {
let payload = CurrentUserUnreadsPayload(
totalUnreadCount: 10,
totalUnreadThreadsCount: 3,
totalUnreadCountByTeam: ["Benfica": 3],
channels: [
CurrentUserChannelUnreadPayload(
channelId: .init(type: .messaging, id: "channel1"),
Expand Down Expand Up @@ -780,6 +781,7 @@ final class CurrentUserUpdater_Tests: XCTestCase {
XCTAssertEqual(receivedUnreads?.unreadChannels.count, payload.channels.count)
XCTAssertEqual(receivedUnreads?.unreadThreads.count, payload.threads.count)
XCTAssertEqual(receivedUnreads?.unreadChannelsByType.count, payload.channelType.count)
XCTAssertEqual(receivedUnreads?.totalUnreadCountByTeam?["Benfica"], 3)
}

func test_loadAllUnreads_propagatesNetworkError() {
Expand Down
Loading