Skip to content

Commit

Permalink
Archiving channels for the current user (#3524)
Browse files Browse the repository at this point in the history
  • Loading branch information
laevandus authored Dec 6, 2024
1 parent 01b961f commit 40ac335
Show file tree
Hide file tree
Showing 24 changed files with 503 additions and 3 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## StreamChat
### ✅ Added
- Archiving channels for the current user [#3524](https://github.com/GetStream/stream-chat-swift/pull/3524)
- Add `Chat.archive(scope:)` and `Chat.unarchive(scope:)`
- Add `ChatChannelController.archive(scope:completion:)` and `ChatChannelController.unarchive(scope:completion:)`
- Add `FilterKey.archive` for filtering channel lists
- Add `ChatChannel.membership.archivedAt`
- Add `ChatChannel.isArchived`
- 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:)`
Expand Down
12 changes: 12 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,18 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
self.rootViewController.presentAlert(title: "Couldn't unpin channel \(cid)", message: "\(error)")
}
}),
.init(title: "Archive channel", isEnabled: true, handler: { [unowned self] _ in
channelController.archive { error in
guard let error else { return }
self.rootViewController.presentAlert(title: "Couldn't archive channel \(cid)", message: "\(error)")
}
}),
.init(title: "Unarchive channel", isEnabled: true, handler: { [unowned self] _ in
channelController.unarchive { error in
guard let error else { return }
self.rootViewController.presentAlert(title: "Couldn't unarchive channel \(cid)", message: "\(error)")
}
}),
.init(title: "Enable slow mode", isEnabled: canSetChannelCooldown, handler: { [unowned self] _ in
self.rootViewController
.presentAlert(title: "Enter cooldown", textFieldPlaceholder: "Cooldown duration, 0-120") { cooldown in
Expand Down
20 changes: 19 additions & 1 deletion DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ final class DemoChatChannelListVC: ChatChannelListVC {
.equal("is_cool", to: true)
]))

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

lazy var pinnedChannelsQuery: ChannelListQuery = .init(filter: .and([
.containMembers(userIds: [currentUserId]),
.equal(.pinned, to: true)
Expand Down Expand Up @@ -150,6 +155,14 @@ final class DemoChatChannelListVC: ChatChannelListVC {
}
)

let archivedChannelsAction = UIAlertAction(
title: "Archived Channels",
style: .default
) { [weak self] _ in
self?.title = "Archived Channels"
self?.setArchivedChannelsQuery()
}

let pinnedChannelsAction = UIAlertAction(
title: "Pinned Channels",
style: .default
Expand All @@ -166,7 +179,8 @@ final class DemoChatChannelListVC: ChatChannelListVC {
hiddenChannelsAction,
mutedChannelsAction,
coolChannelsAction,
pinnedChannelsAction
pinnedChannelsAction,
archivedChannelsAction
].sorted(by: { $0.title ?? "" < $1.title ?? "" }),
preferredStyle: .actionSheet,
sourceView: filterChannelsButton
Expand Down Expand Up @@ -195,6 +209,10 @@ final class DemoChatChannelListVC: ChatChannelListVC {
replaceChannelListController(controller)
}

func setArchivedChannelsQuery() {
replaceQuery(archivedChannelsQuery)
}

func setPinnedChannelsQuery() {
replaceQuery(pinnedChannelsQuery)
}
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 archivedAt = "archived_at"
case pinnedAt = "pinned_at"
}

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

Expand All @@ -85,6 +88,7 @@ struct MemberPayload: Decodable {
isInvited: Bool? = nil,
inviteAcceptedAt: Date? = nil,
inviteRejectedAt: Date? = nil,
archivedAt: Date? = nil,
pinnedAt: Date? = nil,
notificationsMuted: Bool = false,
extraData: [String: RawJSON]? = nil
Expand All @@ -100,6 +104,7 @@ struct MemberPayload: Decodable {
self.isInvited = isInvited
self.inviteAcceptedAt = inviteAcceptedAt
self.inviteRejectedAt = inviteRejectedAt
self.archivedAt = archivedAt
self.pinnedAt = pinnedAt
self.notificationsMuted = notificationsMuted
self.extraData = extraData
Expand All @@ -117,6 +122,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)
archivedAt = try container.decodeIfPresent(Date.self, forKey: .archivedAt)
pinnedAt = try container.decodeIfPresent(Date.self, forKey: .pinnedAt)
notificationsMuted = try container.decodeIfPresent(Bool.self, forKey: .notificationsMuted) ?? false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@
import Foundation

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

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

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(archived, forKey: .archived)
try container.encodeIfPresent(pinned, forKey: .pinned)
if let extraData, !extraData.isEmpty {
try extraData.encode(to: encoder)
Expand All @@ -27,6 +31,7 @@ struct MemberUpdatePayload: Encodable, Equatable {

extension MemberUpdatePayload {
enum CodingKeys: String, CodingKey {
case archived
case pinned
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,51 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
}
}
}

/// Archives the channel with the specified scope.
///
/// - Important: Only archiving the channel for me is supported.
/// - SeeAlso: You can retrieve the list of archived channels with ``FilterKey/archived`` filter.
///
/// - Parameters:
/// - scope: The scope of the archiving action. Default is archiving 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 archive(scope: ChannelArchivingScope = .me, completion: ((Error?) -> Void)? = nil) {
guard let cid, isChannelAlreadyCreated, let userId = client.currentUserId else {
channelModificationFailed(completion)
return
}
switch scope {
case .me:
channelMemberUpdater.archiveMemberChannel(true, userId: userId, cid: cid) { error in
self.callback {
completion?(error)
}
}
}
}

/// Unarchives the channel with the specified scope.
///
/// - Parameters:
/// - scope: The scope of the unarchiving action. The default scope is unarchived 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 unarchive(scope: ChannelArchivingScope = .me, completion: ((Error?) -> Void)? = nil) {
guard let cid, isChannelAlreadyCreated, let userId = client.currentUserId else {
channelModificationFailed(completion)
return
}
switch scope {
case .me:
channelMemberUpdater.archiveMemberChannel(false, userId: userId, cid: cid) { error in
self.callback {
completion?(error)
}
}
}
}

/// Delete the channel this controller manages.
/// - Parameters:
Expand Down
3 changes: 3 additions & 0 deletions Sources/StreamChat/Database/DTOs/MemberModelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class MemberDTO: NSManagedObject {
@NSManaged var inviteRejectedAt: DBDate?
@NSManaged var isInvited: Bool

@NSManaged var archivedAt: DBDate?
@NSManaged var pinnedAt: DBDate?

@NSManaged var extraData: Data?
Expand Down Expand Up @@ -132,6 +133,7 @@ extension NSManagedObjectContext {
dto.isInvited = payload.isInvited ?? false
dto.inviteAcceptedAt = payload.inviteAcceptedAt?.bridgeDate
dto.inviteRejectedAt = payload.inviteRejectedAt?.bridgeDate
dto.archivedAt = payload.archivedAt?.bridgeDate
dto.pinnedAt = payload.pinnedAt?.bridgeDate
dto.notificationsMuted = payload.notificationsMuted

Expand Down Expand Up @@ -229,6 +231,7 @@ extension ChatChannelMember {
isInvited: dto.isInvited,
inviteAcceptedAt: dto.inviteAcceptedAt?.bridgeDate,
inviteRejectedAt: dto.inviteRejectedAt?.bridgeDate,
archivedAt: dto.archivedAt?.bridgeDate,
pinnedAt: dto.pinnedAt?.bridgeDate,
isBannedFromChannel: dto.isBanned,
banExpiresAt: dto.banExpiresAt?.bridgeDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
</uniquenessConstraints>
</entity>
<entity name="MemberDTO" representedClassName="MemberDTO" syncable="YES">
<attribute name="archivedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="banExpiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="channelRoleRaw" optional="YES" attributeType="String"/>
<attribute name="extraData" optional="YES" attributeType="Binary"/>
Expand Down
5 changes: 5 additions & 0 deletions Sources/StreamChat/Models/Channel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ public struct ChatChannel {
/// If the current user is a member of the channel, this variable contains the details about the membership.
public let membership: ChatChannelMember?

/// Returns `true`, if the channel is archived.
public var isArchived: Bool {
membership?.archivedAt != nil
}

/// Returns `true`, if the channel is pinned.
public var isPinned: Bool {
membership?.pinnedAt != nil
Expand Down
11 changes: 11 additions & 0 deletions Sources/StreamChat/Models/ChannelArchivingScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation

/// The scope of the channel archiving action.
public enum ChannelArchivingScope: String {
/// Channel is archived only for the currently connected user. Other channel members do not see channels as archived.
case me
}
5 changes: 5 additions & 0 deletions Sources/StreamChat/Models/Member.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public class ChatChannelMember: ChatUser {
/// otherwise it's `nil`.
public let inviteRejectedAt: Date?

/// Returns the date if the member has archived the channel, otherwise nil.
public let archivedAt: Date?

/// Returns the date if the member has pinned the channel, otherwise nil.
public let pinnedAt: Date?

Expand Down Expand Up @@ -72,6 +75,7 @@ public class ChatChannelMember: ChatUser {
isInvited: Bool,
inviteAcceptedAt: Date?,
inviteRejectedAt: Date?,
archivedAt: Date?,
pinnedAt: Date?,
isBannedFromChannel: Bool,
banExpiresAt: Date?,
Expand All @@ -85,6 +89,7 @@ public class ChatChannelMember: ChatUser {
self.isInvited = isInvited
self.inviteAcceptedAt = inviteAcceptedAt
self.inviteRejectedAt = inviteRejectedAt
self.archivedAt = archivedAt
self.pinnedAt = pinnedAt
self.isBannedFromChannel = isBannedFromChannel
self.isShadowBannedFromChannel = isShadowBannedFromChannel
Expand Down
17 changes: 17 additions & 0 deletions Sources/StreamChat/Query/ChannelListQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ public extension FilterKey where Scope: AnyChannelListFilterScope {
/// Supported operators: `equal`
static var blocked: FilterKey<Scope, Bool> { .init(rawValue: "blocked", keyPathString: #keyPath(ChannelDTO.isBlocked)) }

/// A filter key for matching the `archived` value.
static var archived: FilterKey<Scope, Bool> {
.init(
rawValue: "archived",
keyPathString: #keyPath(ChannelDTO.membership.archivedAt),
predicateMapper: { op, archived in
switch op {
case .equal:
let key = #keyPath(ChannelDTO.membership.archivedAt)
return NSPredicate(format: archived ? "\(key) != nil" : "\(key) == nil")
default:
return nil
}
}
)
}

/// A filter key for matching the `pinned` value.
static var pinned: FilterKey<Scope, Bool> {
.init(
Expand Down
31 changes: 31 additions & 0 deletions Sources/StreamChat/StateLayer/Chat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,37 @@ public class Chat {
client.syncRepository.stopTrackingChat(self)
}

// MARK: - Archiving the Channel

/// Archives the channel with the specified scope.
///
/// - Important: Only archiving the channel for me is supported.
/// - SeeAlso: You can retrieve the list of archived channels with ``FilterKey/archived`` filter.
///
/// - Parameter scope: The scope of the archiving action. The default scope is archived only for me.
///
/// - Throws: An error while communicating with the Stream API.
public func archive(scope: ChannelArchivingScope = .me) async throws {
switch scope {
case .me:
guard let currentUserId = client.authenticationRepository.currentUserId else { throw ClientError.CurrentUserDoesNotExist() }
try await memberUpdater.archiveMemberChannel(true, userId: currentUserId, cid: cid)
}
}

/// Unarchives the channel with the specified scope.
///
/// - Parameter scope: The scope of the unarchiving action. The default scope is unarchived only for me.
///
/// - Throws: An error while communicating with the Stream API.
public func unarchive(scope: ChannelArchivingScope = .me) async throws {
switch scope {
case .me:
guard let currentUserId = client.authenticationRepository.currentUserId else { throw ClientError.CurrentUserDoesNotExist() }
try await memberUpdater.archiveMemberChannel(false, userId: currentUserId, cid: cid)
}
}

// MARK: - Deleting the Channel

/// Deletes the channel.
Expand Down
27 changes: 27 additions & 0 deletions Sources/StreamChat/Workers/ChannelMemberUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ class ChannelMemberUpdater: Worker {
}
}
}

func archiveMemberChannel(
_ isArchived: Bool,
userId: UserId,
cid: ChannelId,
completion: @escaping (Error?) -> Void
) {
partialUpdate(
userId: userId,
in: cid,
updates: isArchived ? MemberUpdatePayload(archived: true) : nil,
unset: isArchived ? nil : [MemberUpdatePayload.CodingKeys.archived.rawValue],
completion: { completion($0.error) }
)
}

func archiveMemberChannel(
_ isArchived: Bool,
userId: UserId,
cid: ChannelId
) async throws {
try await withCheckedThrowingContinuation { continuation in
archiveMemberChannel(isArchived, userId: userId, cid: cid) { error in
continuation.resume(with: error)
}
}
}

/// Bans the user in the channel.
/// - Parameters:
Expand Down
Loading

0 comments on commit 40ac335

Please sign in to comment.