Skip to content

Commit

Permalink
Pinning channels for the current user (#3518)
Browse files Browse the repository at this point in the history
* Add support for pinning channels

* Add tests for channel list filtering and sorting

* Add MemberUpdatePayload for handling partial updates

* Add scope parameter to the channel pinning action
  • Loading branch information
laevandus authored Dec 5, 2024
1 parent 76e373f commit 8fd696a
Show file tree
Hide file tree
Showing 42 changed files with 674 additions and 99 deletions.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🔄 Changed
## StreamChat
### ✅ Added
- Pinning channels for the current user [#3518](https://github.com/GetStream/stream-chat-swift/pull/3518)
- Add `Chat.pin(scope:)` and `Chat.unpin(scope:)`
- Add `ChatChannelController.pin(scope:completion:)` and `ChatChannelController.unpin(scope:completion:)`
- Add `FilterKey.pinned` for filtering channel lists
- Add `ChannelListSortingKey.pinnedAt`
- Add `ChatChannel.membership.pinnedAt`
- Add `ChatChannel.isPinned`

# [4.68.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.68.0)
_December 03, 2024_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ struct DemoAppConfig {
var isAtlantisEnabled: Bool
/// A Boolean value to define if an additional message debugger action will be added.
var isMessageDebuggerEnabled: Bool
/// A Boolean value to define if channel pinning example is enabled.
var isChannelPinningEnabled: Bool
/// A Boolean value to define if custom location attachments are enabled.
var isLocationAttachmentsEnabled: Bool
/// Set this value to define if we should mimic token refresh scenarios.
Expand Down Expand Up @@ -49,7 +47,6 @@ class AppConfig {
isHardDeleteEnabled: false,
isAtlantisEnabled: false,
isMessageDebuggerEnabled: false,
isChannelPinningEnabled: false,
isLocationAttachmentsEnabled: false,
tokenRefreshDetails: nil,
shouldShowConnectionBanner: false,
Expand Down Expand Up @@ -172,7 +169,6 @@ class AppConfigViewController: UITableViewController {
case isHardDeleteEnabled
case isAtlantisEnabled
case isMessageDebuggerEnabled
case isChannelPinningEnabled
case isLocationAttachmentsEnabled
case tokenRefreshDetails
case shouldShowConnectionBanner
Expand Down Expand Up @@ -322,10 +318,6 @@ class AppConfigViewController: UITableViewController {
cell.accessoryView = makeSwitchButton(demoAppConfig.isMessageDebuggerEnabled) { [weak self] newValue in
self?.demoAppConfig.isMessageDebuggerEnabled = newValue
}
case .isChannelPinningEnabled:
cell.accessoryView = makeSwitchButton(demoAppConfig.isChannelPinningEnabled) { [weak self] newValue in
self?.demoAppConfig.isChannelPinningEnabled = newValue
}
case .isLocationAttachmentsEnabled:
cell.accessoryView = makeSwitchButton(demoAppConfig.isLocationAttachmentsEnabled) { [weak self] newValue in
self?.demoAppConfig.isLocationAttachmentsEnabled = newValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ final class DemoChatChannelListItemView: ChatChannelListItemView {
if content?.searchResult?.message != nil {
return super.contentBackgroundColor
}
if AppConfig.shared.demoAppConfig.isChannelPinningEnabled && content?.channel.isPinned == true {
if content?.channel.isPinned == true {
return appearance.colorPalette.pinnedMessageBackground
}
return super.contentBackgroundColor
Expand Down
22 changes: 8 additions & 14 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,22 +451,16 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
.init(title: "Pin channel", isEnabled: AppConfig.shared.demoAppConfig.isChannelPinningEnabled, handler: { [unowned self] _ in
let userId = channelController.channel?.membership?.id ?? ""
let pinnedKey = ChatChannel.isPinnedBy(keyForUserId: userId)
channelController.partialChannelUpdate(extraData: [pinnedKey: true]) { error in
if let error = error {
self.rootViewController.presentAlert(title: "Couldn't pin channel \(cid)", message: "\(error)")
}
.init(title: "Pin channel", isEnabled: true, handler: { [unowned self] _ in
channelController.pin { error in
guard let error else { return }
self.rootViewController.presentAlert(title: "Couldn't pin channel \(cid)", message: "\(error)")
}
}),
.init(title: "Unpin channel", isEnabled: AppConfig.shared.demoAppConfig.isChannelPinningEnabled, handler: { [unowned self] _ in
let userId = channelController.channel?.membership?.id ?? ""
let pinnedKey = ChatChannel.isPinnedBy(keyForUserId: userId)
channelController.partialChannelUpdate(extraData: [pinnedKey: false]) { error in
if let error = error {
self.rootViewController.presentAlert(title: "Couldn't unpin channel \(cid)", message: "\(error)")
}
.init(title: "Unpin channel", isEnabled: true, handler: { [unowned self] _ in
channelController.unpin { error in
guard let error else { return }
self.rootViewController.presentAlert(title: "Couldn't unpin channel \(cid)", message: "\(error)")
}
}),
.init(title: "Enable slow mode", isEnabled: canSetChannelCooldown, handler: { [unowned self] _ in
Expand Down
22 changes: 20 additions & 2 deletions DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ final class DemoChatChannelListVC: ChatChannelListVC {
.containMembers(userIds: [currentUserId]),
.equal("is_cool", to: true)
]))

lazy var pinnedChannelsQuery: ChannelListQuery = .init(filter: .and([
.containMembers(userIds: [currentUserId]),
.equal(.pinned, to: true)
]))

var demoRouter: DemoChatChannelListRouter? {
router as? DemoChatChannelListRouter
Expand Down Expand Up @@ -144,6 +149,14 @@ final class DemoChatChannelListVC: ChatChannelListVC {
self?.setMutedChannelsQuery()
}
)

let pinnedChannelsAction = UIAlertAction(
title: "Pinned Channels",
style: .default
) { [weak self] _ in
self?.title = "Pinned Channels"
self?.setPinnedChannelsQuery()
}

presentAlert(
title: "Filter Channels",
Expand All @@ -152,8 +165,9 @@ final class DemoChatChannelListVC: ChatChannelListVC {
unreadChannelsAction,
hiddenChannelsAction,
mutedChannelsAction,
coolChannelsAction
],
coolChannelsAction,
pinnedChannelsAction
].sorted(by: { $0.title ?? "" < $1.title ?? "" }),
preferredStyle: .actionSheet,
sourceView: filterChannelsButton
)
Expand All @@ -180,6 +194,10 @@ final class DemoChatChannelListVC: ChatChannelListVC {
)
replaceChannelListController(controller)
}

func setPinnedChannelsQuery() {
replaceQuery(pinnedChannelsQuery)
}

func setInitialChannelsQuery() {
replaceQuery(initialQuery)
Expand Down
30 changes: 3 additions & 27 deletions DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,9 @@ extension DemoAppCoordinator {
let channelListQuery: ChannelListQuery
switch user {
case let .credentials(userCredentials):
if AppConfig.shared.demoAppConfig.isChannelPinningEnabled {
let pinnedByKey = ChatChannel.isPinnedBy(keyForUserId: userCredentials.id)
channelListQuery = .init(
filter: .containMembers(userIds: [userCredentials.id]),
sort: [
.init(key: .custom(keyPath: \.isPinned, key: pinnedByKey), isAscending: true),
.init(key: .lastMessageAt),
.init(key: .updatedAt)
]
)
} else {
channelListQuery = .init(
filter: .containMembers(userIds: [userCredentials.id])
)
}
channelListQuery = .init(
filter: .containMembers(userIds: [userCredentials.id])
)
case let .custom(userCredentials):
guard let userId = userCredentials?.id else {
fallthrough
Expand Down Expand Up @@ -216,18 +204,6 @@ private extension DemoAppCoordinator {
}
}

extension ChatChannel {
static func isPinnedBy(keyForUserId userId: UserId) -> String {
"is_pinned_by_\(userId)"
}

var isPinned: Bool {
guard let userId = membership?.id else { return false }
let key = Self.isPinnedBy(keyForUserId: userId)
return extraData[key]?.boolValue ?? false
}
}

private extension DemoUserType {
var staticUserId: UserId? {
guard case let .credentials(user) = self else { return nil }
Expand Down
3 changes: 3 additions & 0 deletions DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ extension StreamChatWrapper {
LogConfig.formatters = [
PrefixLogFormatter(prefixes: [.info: "ℹ️", .debug: "🛠", .warning: "⚠️", .error: "🚨"])
]
if let subsystems = StreamRuntimeCheck.subsystems {
LogConfig.subsystems = subsystems
}

// Create Client
if client == nil {
Expand Down
6 changes: 6 additions & 0 deletions DemoApp/StreamRuntimeCheck+StreamInternal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ extension StreamRuntimeCheck {
guard let intValue = Int(value) else { return nil }
return LogLevel(rawValue: intValue)
}

static var subsystems: LogSubsystem? {
guard let value = ProcessInfo.processInfo.environment["STREAM_LOG_SUBSYSTEM"] else { return nil }
guard let intValue = Int(value) else { return nil }
return LogSubsystem(rawValue: intValue)
}
}
6 changes: 3 additions & 3 deletions Sources/StreamChat/APIClient/Endpoints/MemberEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ extension Endpoint {
static func partialMemberUpdate(
userId: UserId,
cid: ChannelId,
extraData: [String: RawJSON]?,
updates: MemberUpdatePayload?,
unset: [String]?
) -> Endpoint<PartialMemberUpdateResponse> {
var body: [String: AnyEncodable] = [:]
if let extraData {
body["set"] = AnyEncodable(extraData)
if let updates {
body["set"] = AnyEncodable(updates)
}
if let unset {
body["unset"] = AnyEncodable(unset)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct MemberPayload: Decodable {
case inviteAcceptedAt = "invite_accepted_at"
case inviteRejectedAt = "invite_rejected_at"
case notificationsMuted = "notifications_muted"
case pinnedAt = "pinned_at"
}

let userId: String
Expand All @@ -63,6 +64,8 @@ struct MemberPayload: Decodable {
let inviteAcceptedAt: Date?
/// A date when an invited was rejected.
let inviteRejectedAt: Date?
/// A date when the channel was pinned.
let pinnedAt: Date?

/// A boolean value that returns whether the user has muted the channel or not.
let notificationsMuted: Bool
Expand All @@ -82,6 +85,7 @@ struct MemberPayload: Decodable {
isInvited: Bool? = nil,
inviteAcceptedAt: Date? = nil,
inviteRejectedAt: Date? = nil,
pinnedAt: Date? = nil,
notificationsMuted: Bool = false,
extraData: [String: RawJSON]? = nil
) {
Expand All @@ -96,6 +100,7 @@ struct MemberPayload: Decodable {
self.isInvited = isInvited
self.inviteAcceptedAt = inviteAcceptedAt
self.inviteRejectedAt = inviteRejectedAt
self.pinnedAt = pinnedAt
self.notificationsMuted = notificationsMuted
self.extraData = extraData
}
Expand All @@ -112,6 +117,7 @@ struct MemberPayload: Decodable {
isInvited = try container.decodeIfPresent(Bool.self, forKey: .isInvited)
inviteAcceptedAt = try container.decodeIfPresent(Date.self, forKey: .inviteAcceptedAt)
inviteRejectedAt = try container.decodeIfPresent(Date.self, forKey: .inviteRejectedAt)
pinnedAt = try container.decodeIfPresent(Date.self, forKey: .pinnedAt)
notificationsMuted = try container.decodeIfPresent(Bool.self, forKey: .notificationsMuted) ?? false

if let user = user {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation

struct MemberUpdatePayload: Encodable, Equatable {
let pinned: Bool?
let extraData: [String: RawJSON]?

init(
pinned: Bool? = nil,
extraData: [String: RawJSON]? = nil
) {
self.pinned = pinned
self.extraData = extraData
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(pinned, forKey: .pinned)
if let extraData, !extraData.isEmpty {
try extraData.encode(to: encoder)
}
}
}

extension MemberUpdatePayload {
enum CodingKeys: String, CodingKey {
case pinned
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
/// The worker used to fetch the remote data and communicate with servers.
private let updater: ChannelUpdater

private let channelMemberUpdater: ChannelMemberUpdater

private lazy var eventSender: TypingEventsSender = self.environment.eventSenderBuilder(
client.databaseContainer,
client.apiClient
Expand Down Expand Up @@ -225,6 +227,10 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
client.databaseContainer,
client.apiClient
)
channelMemberUpdater = self.environment.memberUpdaterBuilder(
client.databaseContainer,
client.apiClient
)
pollsRepository = client.pollsRepository

super.init()
Expand Down Expand Up @@ -1181,6 +1187,51 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
}
}
}

/// Pins the channel with the specified scope.
///
/// - Important: Only pinning the channel for me is supported.
/// - SeeAlso: You can retrieve the list of pinned channels with ``FilterKey/pinned`` filter and sort by ``ChannelListSortingKey/pinnedAt`` key.
///
/// - Parameters:
/// - scope: The scope of the pinning action. Default is pinning for the current user only.
/// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
/// If request fails, the completion will be called with an error.
public func pin(scope: ChannelPinningScope = .me, completion: ((Error?) -> Void)? = nil) {
guard let cid, isChannelAlreadyCreated, let userId = client.currentUserId else {
channelModificationFailed(completion)
return
}
switch scope {
case .me:
channelMemberUpdater.pinMemberChannel(true, userId: userId, cid: cid) { error in
self.callback {
completion?(error)
}
}
}
}

/// Unpins the channel with the specified scope.
///
/// - Parameters:
/// - scope: The scope of the unpinning action. The default scope is unpinned only for me.
/// - completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
/// If request fails, the completion will be called with an error.
public func unpin(scope: ChannelPinningScope = .me, completion: ((Error?) -> Void)? = nil) {
guard let cid, isChannelAlreadyCreated, let userId = client.currentUserId else {
channelModificationFailed(completion)
return
}
switch scope {
case .me:
channelMemberUpdater.pinMemberChannel(false, userId: userId, cid: cid) { error in
self.callback {
completion?(error)
}
}
}
}

/// Uploads the given file to CDN and returns an attachment and the remote url.
/// - Parameters:
Expand Down Expand Up @@ -1388,6 +1439,11 @@ extension ChatChannelController {
_ database: DatabaseContainer,
_ apiClient: APIClient
) -> ChannelUpdater = ChannelUpdater.init

var memberUpdaterBuilder: (
_ database: DatabaseContainer,
_ apiClient: APIClient
) -> ChannelMemberUpdater = ChannelMemberUpdater.init

var eventSenderBuilder: (
_ database: DatabaseContainer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ public extension CurrentChatUserController {
currentMemberUpdater.partialUpdate(
userId: currentUserId,
in: channelId,
extraData: extraData,
updates: MemberUpdatePayload(extraData: extraData),
unset: unsetProperties
) { result in
self.callback {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public extension ChatChannelMemberController {
memberUpdater.partialUpdate(
userId: userId,
in: cid,
extraData: extraData,
updates: MemberUpdatePayload(extraData: extraData),
unset: unsetProperties
) { result in
self.callback {
Expand Down
Loading

0 comments on commit 8fd696a

Please sign in to comment.