Skip to content
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

Pinning channels for the current user #3518

Merged
merged 13 commits into from
Dec 5, 2024
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 [#3518](https://github.com/GetStream/stream-chat-swift/pull/3518)
- Add `Chat.pin()` and `Chat.unpin()`
- Add `ChatChannelController.unpin(completion:)` and `ChatChannelController.unpin(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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope (but not intended 😄 ) but in the future if we have global pinning, we might need to have more than 1 property to distinguish isPinned from local only or global only 🤔

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)
}
laevandus marked this conversation as resolved.
Show resolved Hide resolved
}
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
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,38 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
}
}
}

/// Pins the channel for the current user.
///
/// - Parameter 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(completion: ((Error?) -> Void)? = nil) {
guard let cid, isChannelAlreadyCreated, let userId = client.currentUserId else {
channelModificationFailed(completion)
return
}
channelMemberUpdater.pinMemberChannel(true, userId: userId, cid: cid) { error in
self.callback {
completion?(error)
}
}
}

/// Unpins the channel for the current user.
///
/// - Parameter 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(completion: ((Error?) -> Void)? = nil) {
guard let cid, isChannelAlreadyCreated, let userId = client.currentUserId else {
channelModificationFailed(completion)
return
}
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 +1426,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
4 changes: 2 additions & 2 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,8 @@ extension NSManagedObjectContext {

// Note: membership payload should be saved before all the members
if let membership = payload.membership {
let membership = try saveMember(payload: membership, channelId: payload.channel.cid, query: nil, cache: cache)
dto.membership = membership
let membershipDTO = try saveMember(payload: membership, channelId: payload.channel.cid, query: nil, cache: cache)
dto.membership = membershipDTO
} else {
dto.membership = nil
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession {
func invalidateCurrentUserCache() {
userInfo[Self.currentUserKey] = nil
}

func deleteCurrentUser() {
guard let currentUser else { return }
delete(currentUser)
}
laevandus marked this conversation as resolved.
Show resolved Hide resolved
}

extension CurrentUserDTO {
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChat/Database/DTOs/MemberModelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class MemberDTO: NSManagedObject {
@NSManaged var inviteAcceptedAt: DBDate?
@NSManaged var inviteRejectedAt: DBDate?
@NSManaged var isInvited: Bool

@NSManaged var pinnedAt: DBDate?

@NSManaged var extraData: Data?

Expand Down Expand Up @@ -130,6 +132,7 @@ extension NSManagedObjectContext {
dto.isInvited = payload.isInvited ?? false
dto.inviteAcceptedAt = payload.inviteAcceptedAt?.bridgeDate
dto.inviteRejectedAt = payload.inviteRejectedAt?.bridgeDate
dto.pinnedAt = payload.pinnedAt?.bridgeDate
dto.notificationsMuted = payload.notificationsMuted

if let extraData = payload.extraData {
Expand Down Expand Up @@ -226,6 +229,7 @@ extension ChatChannelMember {
isInvited: dto.isInvited,
inviteAcceptedAt: dto.inviteAcceptedAt?.bridgeDate,
inviteRejectedAt: dto.inviteRejectedAt?.bridgeDate,
pinnedAt: dto.pinnedAt?.bridgeDate,
isBannedFromChannel: dto.isBanned,
banExpiresAt: dto.banExpiresAt?.bridgeDate,
isShadowBannedFromChannel: dto.isShadowBanned,
Expand Down
3 changes: 3 additions & 0 deletions Sources/StreamChat/Database/DatabaseSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ protocol CurrentUserDatabaseSession {

/// Returns `CurrentUserDTO` from the DB. Returns `nil` if no `CurrentUserDTO` exists.
var currentUser: CurrentUserDTO? { get }

/// Removes the current user from DB.
func deleteCurrentUser()
}

extension CurrentUserDatabaseSession {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
<attribute name="memberCreatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="memberUpdatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="notificationsMuted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="pinnedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="channel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ChannelDTO" inverseName="members" inverseEntity="ChannelDTO"/>
<relationship name="membershipChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ChannelDTO" inverseName="membership" inverseEntity="ChannelDTO"/>
<relationship name="queries" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="ChannelMemberListQueryDTO" inverseName="members" inverseEntity="ChannelMemberListQueryDTO"/>
Expand Down
4 changes: 3 additions & 1 deletion Sources/StreamChat/Extensions/URLRequest+cURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ extension URLRequest {
.replacingOccurrences(of: "\"", with: "\\\"")
cURL.append("-d \"\(escapedBody)\"")
}
cURL.append("\"\(url.absoluteString)\"")
let urlString = url.absoluteString
.replacingOccurrences(of: "$", with: "%24") // encoded JSON payload
cURL.append("\"\(urlString)\"")
laevandus marked this conversation as resolved.
Show resolved Hide resolved
return cURL.joined(separator: " \\\n\t")
}
}
Loading
Loading