Skip to content

Commit

Permalink
Add mute expiration support when muting a channel (#3083)
Browse files Browse the repository at this point in the history
  • Loading branch information
testableapple authored Mar 14, 2024
1 parent ea09f4a commit 88e1c7a
Show file tree
Hide file tree
Showing 16 changed files with 234 additions and 26 deletions.
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 mute expiration support when muting a channel [#3083](https://github.com/GetStream/stream-chat-swift/pull/3083)

# [4.50.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.50.0)
_March 11, 2024_
Expand Down
13 changes: 13 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,19 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
.init(title: "Mute channel with expiration", isEnabled: canMuteChannel, handler: { [unowned self] _ in
self.rootViewController.presentAlert(title: "Enter expiration", textFieldPlaceholder: "Seconds") { expiration in
guard let expiration = Int(expiration ?? ""), expiration != 0 else {
self.rootViewController.presentAlert(title: "Expiration is not valid")
return
}
channelController.muteChannel(expiration: expiration * 1000) { error in
if let error = error {
self.rootViewController.presentAlert(title: "Couldn't mute channel \(cid)", message: "\(error)")
}
}
}
}),
.init(title: "Cool channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
channelController.partialChannelUpdate(extraData: ["is_cool": true]) { error in
if let error = error {
Expand Down
14 changes: 10 additions & 4 deletions Sources/StreamChat/APIClient/Endpoints/ChannelEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,20 @@ extension Endpoint {
body: body
)
}

static func muteChannel(cid: ChannelId, mute: Bool) -> Endpoint<EmptyResponse> {
.init(

static func muteChannel(cid: ChannelId, mute: Bool, expiration: Int? = nil) -> Endpoint<EmptyResponse> {
var body: [String: AnyEncodable] = ["channel_cid": AnyEncodable(cid)]

if let expiration = expiration {
body["expiration"] = AnyEncodable(expiration)
}

return .init(
path: .muteChannel(mute),
method: .post,
queryItems: nil,
requiresConnectionId: true,
body: ["channel_cid": cid]
body: body
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,34 @@

import Foundation

/// An object describing the incoming muted-user JSON payload.
/// An object describing the incoming muted-channel JSON payload.
struct MutedChannelPayload: Decodable {
private enum CodingKeys: String, CodingKey {
case mutedChannel = "channel"
case user
case createdAt = "created_at"
case updatedAt = "updated_at"
case expiresAt = "expires"
}

let mutedChannel: ChannelDetailPayload
let user: UserPayload
let createdAt: Date
let updatedAt: Date
let expiresAt: Date?

init(
mutedChannel: ChannelDetailPayload,
user: UserPayload,
createdAt: Date,
updatedAt: Date
updatedAt: Date,
expiresAt: Date? = nil
) {
self.mutedChannel = mutedChannel
self.user = user
self.createdAt = createdAt
self.updatedAt = updatedAt
self.expiresAt = expiresAt
}

init(from decoder: Decoder) throws {
Expand All @@ -36,5 +40,6 @@ struct MutedChannelPayload: Decodable {
user = try container.decode(UserPayload.self, forKey: .user)
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .createdAt)
expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -306,17 +306,19 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP

/// Mutes the channel this controller manages.
///
/// - 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.
/// - Parameters:
/// - expiration: Duration of mute in milliseconds.
/// - 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 muteChannel(completion: ((Error?) -> Void)? = nil) {
public func muteChannel(expiration: Int? = nil, completion: ((Error?) -> Void)? = nil) {
/// Perform action only if channel is already created on backend side and have a valid `cid`.
guard let cid = cid, isChannelAlreadyCreated else {
channelModificationFailed(completion)
return
}

updater.muteChannel(cid: cid, mute: true) { error in
updater.muteChannel(cid: cid, mute: true, expiration: expiration) { error in
self.callback {
completion?(error)
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ extension ChatChannel {
return .noUnread
}
}

let fetchMessages: () -> [ChatMessage] = {
guard dto.isValid else { return [] }
return MessageDTO
Expand Down Expand Up @@ -494,7 +494,8 @@ extension ChatChannel {

return .init(
createdAt: mute.createdAt.bridgeDate,
updatedAt: mute.updatedAt.bridgeDate
updatedAt: mute.updatedAt.bridgeDate,
expiresAt: mute.expiresAt?.bridgeDate
)
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChat/Database/DTOs/ChannelMuteDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
final class ChannelMuteDTO: NSManagedObject {
@NSManaged var createdAt: DBDate
@NSManaged var updatedAt: DBDate
@NSManaged var expiresAt: DBDate?
@NSManaged var channel: ChannelDTO
@NSManaged var currentUser: CurrentUserDTO

Expand Down Expand Up @@ -49,6 +50,7 @@ extension NSManagedObjectContext {
dto.currentUser = currentUser
dto.createdAt = payload.createdAt.bridgeDate
dto.updatedAt = payload.updatedAt.bridgeDate
dto.expiresAt = payload.expiresAt?.bridgeDate

return dto
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23C71" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23A339" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AttachmentDTO" representedClassName="AttachmentDTO" syncable="YES">
<attribute name="data" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
Expand Down Expand Up @@ -110,6 +110,7 @@
</entity>
<entity name="ChannelMuteDTO" representedClassName="ChannelMuteDTO" syncable="YES">
<attribute name="createdAt" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<relationship name="channel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ChannelDTO" inverseName="mute" inverseEntity="ChannelDTO"/>
<relationship name="currentUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CurrentUserDTO" inverseName="channelMutes" inverseEntity="CurrentUserDTO"/>
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChat/Models/MuteDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public struct MuteDetails: Equatable {
public let createdAt: Date
/// The time when the mute was updated.
public let updatedAt: Date?
/// The expiration date of the pinning. Infinite expiration in case it is `nil`.
public let expiresAt: Date?
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,10 @@ struct ChannelReadUpdaterMiddleware: EventMiddleware {
channelRead: ChannelReadDTO,
message: MessagePayload
) -> UnreadSkippingReason? {
if channelRead.channel.mute != nil {
return .channelIsMuted
if let mute = channelRead.channel.mute {
guard let expiredDate = mute.expiresAt, expiredDate.bridgeDate <= Date() else {
return .channelIsMuted
}
}

if message.user.id == currentUser.user.id {
Expand Down
5 changes: 3 additions & 2 deletions Sources/StreamChat/Workers/ChannelUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,10 @@ class ChannelUpdater: Worker {
/// - Parameters:
/// - cid: The channel identifier.
/// - mute: Defines if the channel with the specified **cid** should be muted.
/// - expiration: Duration of mute in milliseconds.
/// - completion: Called when the API call is finished. Called with `Error` if the remote update fails.
func muteChannel(cid: ChannelId, mute: Bool, completion: ((Error?) -> Void)? = nil) {
apiClient.request(endpoint: .muteChannel(cid: cid, mute: mute)) {
func muteChannel(cid: ChannelId, mute: Bool, expiration: Int? = nil, completion: ((Error?) -> Void)? = nil) {
apiClient.request(endpoint: .muteChannel(cid: cid, mute: mute, expiration: expiration)) {
completion?($0.error)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class ChannelUpdater_Mock: ChannelUpdater {

@Atomic var muteChannel_cid: ChannelId?
@Atomic var muteChannel_mute: Bool?
@Atomic var muteChannel_expiration: Int?
@Atomic var muteChannel_completion: ((Error?) -> Void)?

@Atomic var deleteChannel_cid: ChannelId?
Expand Down Expand Up @@ -239,9 +240,10 @@ final class ChannelUpdater_Mock: ChannelUpdater {
partialChannelUpdate_completion = completion
}

override func muteChannel(cid: ChannelId, mute: Bool, completion: ((Error?) -> Void)? = nil) {
override func muteChannel(cid: ChannelId, mute: Bool, expiration: Int? = nil, completion: ((Error?) -> Void)? = nil) {
muteChannel_cid = cid
muteChannel_mute = mute
muteChannel_expiration = expiration
muteChannel_completion = completion
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,35 @@ final class ChannelEndpoints_Tests: XCTestCase {
XCTAssertEqual(mute ? "moderation/mute/channel" : "moderation/unmute/channel", endpoint.path.value)
}
}

func test_muteChannelWithExpiration_buildsCorrectly() {
let testCases = [true, false]
let expiration = 1_000_000

for mute in testCases {
let channelID = ChannelId.unique

let body: [String: AnyEncodable] = [
"channel_cid": AnyEncodable(channelID),
"expiration": AnyEncodable(expiration)
]

let expectedEndpoint = Endpoint<EmptyResponse>(
path: .muteChannel(mute),
method: .post,
queryItems: nil,
requiresConnectionId: true,
body: body
)

// Build endpoint
let endpoint: Endpoint<EmptyResponse> = .muteChannel(cid: channelID, mute: mute, expiration: expiration)

// Assert endpoint is built correctly
XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
XCTAssertEqual(mute ? "moderation/mute/channel" : "moderation/unmute/channel", endpoint.path.value)
}
}

func test_showChannel_buildsCorrectly() {
let cid = ChannelId.unique
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,37 @@ final class ChannelController_Tests: XCTestCase {

XCTAssertNil(error)
}

func test_muteChannelWithExpiration_failsForNewChannels() throws {
let expiration = 1_000_000

// Create `ChannelController` for new channel
let query = ChannelQuery(channelPayload: .unique)
setupControllerForNewChannel(query: query)

// Simulate `muteChannel` call and assert error is returned
var error: Error? = try waitFor { [callbackQueueID] completion in
controller.muteChannel(expiration: expiration) { error in
AssertTestQueue(withId: callbackQueueID)
completion(error)
}
}
XCTAssert(error is ClientError.ChannelNotCreatedYet)

// Simulate successful backend channel creation
env.channelUpdater!.update_onChannelCreated?(query.cid!)

// Simulate `muteChannel` call and assert no error is returned
error = try waitFor { [callbackQueueID] completion in
controller.muteChannel(expiration: expiration) { error in
AssertTestQueue(withId: callbackQueueID)
completion(error)
}
env.channelUpdater!.muteChannel_completion?(nil)
}

XCTAssertNil(error)
}

func test_muteChannel_callsChannelUpdater() {
// Simulate `muteChannel` call and catch the completion
Expand Down Expand Up @@ -1958,6 +1989,41 @@ final class ChannelController_Tests: XCTestCase {
// `weakController` should be deallocated too
AssertAsync.canBeReleased(&weakController)
}

func test_muteChannelWithExpiration_callsChannelUpdater() {
let expiration = 1_000_000

// Simulate `muteChannel` call and catch the completion
var completionCalled = false
controller.muteChannel(expiration: expiration) { [callbackQueueID] error in
AssertTestQueue(withId: callbackQueueID)
XCTAssertNil(error)
completionCalled = true
}

// Keep a weak ref so we can check if it's actually deallocated
weak var weakController = controller

// (Try to) deallocate the controller
// by not keeping any references to it
controller = nil

// Assert cid, muted state and expiration are passed to `channelUpdater`, completion is not called yet
XCTAssertEqual(env.channelUpdater!.muteChannel_cid, channelId)
XCTAssertEqual(env.channelUpdater!.muteChannel_mute, true)
XCTAssertEqual(env.channelUpdater!.muteChannel_expiration, expiration)
XCTAssertFalse(completionCalled)

// Simulate successful update
env.channelUpdater!.muteChannel_completion?(nil)
// Release reference of completion so we can deallocate stuff
env.channelUpdater!.muteChannel_completion = nil

// Assert completion is called
AssertAsync.willBeTrue(completionCalled)
// `weakController` should be deallocated too
AssertAsync.canBeReleased(&weakController)
}

func test_muteChannel_propagatesErrorFromUpdater() {
// Simulate `muteChannel` call and catch the completion
Expand All @@ -1974,6 +2040,24 @@ final class ChannelController_Tests: XCTestCase {
// Completion should be called with the error
AssertAsync.willBeEqual(completionCalledError as? TestError, testError)
}

func test_muteChannelWithExpiration_propagatesErrorFromUpdater() {
let expiration = 1_000_000

// Simulate `muteChannel` call and catch the completion
var completionCalledError: Error?
controller.muteChannel(expiration: expiration) { [callbackQueueID] in
AssertTestQueue(withId: callbackQueueID)
completionCalledError = $0
}

// Simulate failed update
let testError = TestError()
env.channelUpdater!.muteChannel_completion?(testError)

// Completion should be called with the error
AssertAsync.willBeEqual(completionCalledError as? TestError, testError)
}

// MARK: - Unmuting channel

Expand Down
Loading

0 comments on commit 88e1c7a

Please sign in to comment.