Skip to content

Commit

Permalink
Implement user block feature (#3223)
Browse files Browse the repository at this point in the history
  • Loading branch information
testableapple authored Jun 28, 2024
1 parent 9aa1b92 commit 1d2660f
Show file tree
Hide file tree
Showing 54 changed files with 892 additions and 20 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 support for user blocking [#3223](https://github.com/GetStream/stream-chat-swift/pull/3223)

# [4.58.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.58.0)
_June 26, 2024_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ class AppConfigViewController: UITableViewController {
case isUnreadMessageSeparatorEnabled
case isJumpToUnreadEnabled
case mentionAllAppUsers
case isBlockingUsersEnabled
}

enum ChatClientConfigOption: String, CaseIterable {
Expand Down Expand Up @@ -459,6 +460,10 @@ class AppConfigViewController: UITableViewController {
cell.accessoryView = makeSwitchButton(Components.default.mentionAllAppUsers) { newValue in
Components.default.mentionAllAppUsers = newValue
}
case .isBlockingUsersEnabled:
cell.accessoryView = makeSwitchButton(Components.default.isBlockingUsersEnabled) { newValue in
Components.default.isBlockingUsersEnabled = newValue
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,21 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
)
), animated: true)
}),
.init(title: "Show Blocked Users", handler: { [unowned self] _ in
guard let cid = channelController.channel?.cid else { return }
let client = channelController.client
client.currentUserController().loadBlockedUsers { result in
guard let blockedUsers = try? result.get() else { return }
self.rootViewController.present(MembersViewController(
membersController: client.memberListController(
query: .init(
cid: cid,
filter: .in(.id, values: blockedUsers.map(\.userId))
)
)
), animated: true)
}
}),
.init(title: "Truncate channel w/o message", isEnabled: canUpdateChannel, handler: { _ in
channelController.truncateChannel { [unowned self] error in
if let error = error {
Expand Down
70 changes: 70 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/BlockedUserPayload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation

/// An object describing the incoming blocking user JSON payload.
struct BlockingUserPayload: Decodable, Equatable {
private enum CodingKeys: String, CodingKey {
case blockedUserId = "blocked_user_id"
case blockedByUserId = "blocked_by_user_id"
case createdAt = "created_at"
}

let blockedUserId: String
let blockedByUserId: String
let createdAt: Date
}

/// An object describing the incoming blocked users JSON payload.
struct BlocksPayload: Decodable {
private enum CodingKeys: String, CodingKey {
case blockedUsers = "blocks"
}

/// The blocked users.
let blockedUsers: [BlockPayload]
}

/// An object describing the incoming block JSON payload.
struct BlockPayload: Decodable {
private enum CodingKeys: String, CodingKey {
case blockedUserId = "blocked_user_id"
case userId = "user_id"
case createdAt = "created_at"
case blockedUser = "blocked_user"
}

let blockedUserId: String
let userId: String
let createdAt: Date
let blockedUser: BlockedUserPayload
}

extension BlockPayload: Equatable {
static func == (lhs: BlockPayload, rhs: BlockPayload) -> Bool {
lhs.blockedUserId == rhs.blockedUserId
&& lhs.userId == rhs.userId
&& lhs.createdAt == rhs.createdAt
}
}

/// An object describing the incoming blocked-user JSON payload.
struct BlockedUserPayload: Decodable {
private enum CodingKeys: String, CodingKey {
case id
case anon
case name
case role
case teams
case username
}

let id: String
let anon: Bool?
let name: String?
let role: UserRole
let teams: [TeamId]
let username: String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ extension EndpointPath {
return true
case .createChannel, .connect, .sync, .users, .guest, .members, .search, .devices, .channels, .updateChannel,
.deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread,
.markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadAttachment, .message,
.replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage,
.callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread, .polls, .pollsQuery,
.poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote:
.markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadAttachment, .message, .replies,
.reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .blockUser, .unblockUser, .callToken,
.createCall, .translateMessage, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead,
.markThreadUnread, .polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote:
return false
}
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ enum EndpointPath: Codable {
case flagUser(Bool)
case flagMessage(Bool)
case muteUser(Bool)
case blockUser
case unblockUser

case callToken(String)
case createCall(String)
Expand Down Expand Up @@ -123,6 +125,8 @@ enum EndpointPath: Codable {
case let .flagUser(flag): return "moderation/\(flag ? "flag" : "unflag")"
case let .flagMessage(flag): return "moderation/\(flag ? "flag" : "unflag")"
case let .muteUser(mute): return "moderation/\(mute ? "mute" : "unmute")"
case .blockUser: return "users/block"
case .unblockUser: return "users/unblock"
case let .callToken(callId): return "calls/\(callId)"
case let .createCall(queryString): return "channels/\(queryString)/call"
case let .deleteFile(channelId): return "channels/\(channelId)/file"
Expand Down
33 changes: 33 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/ModerationEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,39 @@ extension Endpoint {
}
}

// MARK: - User blocking

extension Endpoint {
static func blockUser(_ userId: UserId) -> Endpoint<BlockingUserPayload> {
.init(
path: .blockUser,
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: ["blocked_user_id": userId]
)
}

static func unblockUser(_ userId: UserId) -> Endpoint<EmptyResponse> {
.init(
path: .unblockUser,
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: ["blocked_user_id": userId]
)
}

static func loadBlockedUsers() -> Endpoint<BlocksPayload> {
.init(
path: .blockUser,
method: .get,
queryItems: nil,
requiresConnectionId: false
)
}
}

// MARK: - User banning

extension Endpoint {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ public enum ChannelCodingKeys: String, CodingKey, CaseIterable {
/// Cooldown duration for the channel, if it's in slow mode.
/// This value will be 0 if the channel is not in slow mode.
case cooldownDuration = "cooldown"
/// Blocked flag.
case blocked
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ struct ChannelDetailPayload {
let ownCapabilities: [String]?
/// Checks if the channel is frozen.
let isFrozen: Bool
/// Checks if the channel is blocked.
let isBlocked: Bool?

/// Checks if the channel is hidden.
/// Backend only sends this field for `QueryChannel` and `QueryChannels` API calls,
Expand Down Expand Up @@ -168,6 +170,7 @@ extension ChannelDetailPayload: Decodable {
config: try container.decode(ChannelConfig.self, forKey: .config),
ownCapabilities: try container.decodeIfPresent([String].self, forKey: .ownCapabilities),
isFrozen: try container.decode(Bool.self, forKey: .frozen),
isBlocked: try container.decodeIfPresent(Bool.self, forKey: .blocked),
// For `hidden`, we don't fallback to `false`
// since this field is not sent for all API calls and for events
// We can't assume anything regarding this flag when it's absent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class CurrentUserPayload: UserPayload {
let unreadCount: UnreadCount?
/// The current privacy settings of the user.
let privacySettings: UserPrivacySettingsPayload?
/// Blocked user ids.
let blockedUserIds: Set<UserId>

init(
id: String,
Expand All @@ -36,13 +38,15 @@ class CurrentUserPayload: UserPayload {
mutedUsers: [MutedUserPayload] = [],
mutedChannels: [MutedChannelPayload] = [],
unreadCount: UnreadCount? = nil,
privacySettings: UserPrivacySettingsPayload? = nil
privacySettings: UserPrivacySettingsPayload? = nil,
blockedUserIds: Set<UserId> = []
) {
self.devices = devices
self.mutedUsers = mutedUsers
self.mutedChannels = mutedChannels
self.unreadCount = unreadCount
self.privacySettings = privacySettings
self.blockedUserIds = blockedUserIds

super.init(
id: id,
Expand All @@ -69,6 +73,7 @@ class CurrentUserPayload: UserPayload {
mutedChannels = try container.decodeIfPresent([MutedChannelPayload].self, forKey: .mutedChannels) ?? []
unreadCount = try? UnreadCount(from: decoder)
privacySettings = try container.decodeIfPresent(UserPrivacySettingsPayload.self, forKey: .privacySettings)
blockedUserIds = try container.decodeIfPresent(Set<UserId>.self, forKey: .blockedUserIds) ?? []

try super.init(from: decoder)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum UserPayloadsCodingKeys: String, CodingKey, CaseIterable {
case unreadCount = "unread_count"
case language
case privacySettings = "privacy_settings"
case blockedUserIds = "blocked_user_ids"
}

// MARK: - GET users
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,18 @@ public extension CurrentChatUserController {
}
}
}

/// Get all blocked users.
///
/// - Parameter completion: Called when the API call is finished. Called with `Error` if the remote update fails.
///
func loadBlockedUsers(completion: @escaping (Result<[BlockedUserDetails], Error>) -> Void) {
currentUserUpdater.loadBlockedUsers { result in
self.callback {
completion(result)
}
}
}
}

// MARK: - Environment
Expand Down
22 changes: 22 additions & 0 deletions Sources/StreamChat/Controllers/UserController/UserController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,28 @@ public extension ChatUserController {
}
}
}

/// Blocks the user 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.
func block(completion: ((Error?) -> Void)? = nil) {
userUpdater.blockUser(userId) { error in
self.callback {
completion?(error)
}
}
}

/// Unblocks the user this controller manages.
/// - Parameter completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
///
func unblock(completion: ((Error?) -> Void)? = nil) {
userUpdater.unblockUser(userId) { error in
self.callback {
completion?(error)
}
}
}

/// Flags the user this controller manages.
/// - Parameter completion: The completion. Will be called on a **callbackQueue** when the network request is finished.
Expand Down
23 changes: 23 additions & 0 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class ChannelDTO: NSManagedObject {
@NSManaged var isFrozen: Bool
@NSManaged var cooldownDuration: Int
@NSManaged var team: String?

@NSManaged var isBlocked: Bool

// MARK: - Queries

Expand Down Expand Up @@ -224,6 +226,14 @@ extension NSManagedObjectContext {
}

dto.isFrozen = payload.isFrozen

// Backend only returns a boolean
// for blocked 1:1 channels on channel list query
if let isBlocked = payload.isBlocked {
dto.isBlocked = isBlocked
} else {
dto.isBlocked = false
}

// Backend only returns a boolean for hidden state
// on channel query and channel list query
Expand Down Expand Up @@ -373,6 +383,18 @@ extension ChannelDTO {
request.fetchBatchSize = query.pagination.pageSize
return request
}

static func directMessageChannel(participantId: UserId, context: NSManagedObjectContext) -> ChannelDTO? {
let request = NSFetchRequest<ChannelDTO>(entityName: ChannelDTO.entityName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \ChannelDTO.updatedAt, ascending: false)]
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "cid CONTAINS ':!members'"),
NSPredicate(format: "members.@count == 2"),
NSPredicate(format: "ANY members.user.id == %@", participantId)
])
request.fetchLimit = 1
return try? context.fetch(request).first
}
}

extension ChannelDTO {
Expand Down Expand Up @@ -514,6 +536,7 @@ extension ChatChannel {
config: dto.config.asModel(),
ownCapabilities: Set(dto.ownCapabilities.compactMap(ChannelCapability.init(rawValue:))),
isFrozen: dto.isFrozen,
isBlocked: dto.isBlocked,
lastActiveMembers: { fetchMembers() },
membership: dto.membership.map { try $0.asModel() },
currentlyTypingUsers: { Set(dto.currentlyTypingUsers.compactMap { try? $0.asModel() }) },
Expand Down
8 changes: 6 additions & 2 deletions Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class CurrentUserDTO: NSManagedObject {
/// Contains the timestamp when last sync process was finished.
/// The date later serves as reference date for the last event synced using `/sync` endpoint
@NSManaged var lastSynchedEventDate: DBDate?


@NSManaged var blockedUserIds: Set<String>
@NSManaged var flaggedUsers: Set<UserDTO>
@NSManaged var flaggedMessages: Set<MessageDTO>
@NSManaged var mutedUsers: Set<UserDTO>
Expand Down Expand Up @@ -91,6 +92,8 @@ extension NSManagedObjectContext: CurrentUserDatabaseSession {

let mutedUsers = try payload.mutedUsers.map { try saveUser(payload: $0.mutedUser) }
dto.mutedUsers = Set(mutedUsers)

dto.blockedUserIds = payload.blockedUserIds

let channelMutes = Set(
try payload.mutedChannels.map { try saveChannelMute(payload: $0) }
Expand Down Expand Up @@ -205,7 +208,7 @@ extension CurrentChatUser {
)
extraData = [:]
}

let mutedUsers: [ChatUser] = try dto.mutedUsers.map { try $0.asModel() }
let flaggedUsers: [ChatUser] = try dto.flaggedUsers.map { try $0.asModel() }
let flaggedMessagesIDs: [MessageId] = dto.flaggedMessages.map(\.id)
Expand Down Expand Up @@ -233,6 +236,7 @@ extension CurrentChatUser {
extraData: extraData,
devices: dto.devices.map { try $0.asModel() },
currentDevice: dto.currentDevice?.asModel(),
blockedUserIds: dto.blockedUserIds,
mutedUsers: Set(mutedUsers),
flaggedUsers: Set(flaggedUsers),
flaggedMessageIDs: Set(flaggedMessagesIDs),
Expand Down
Loading

0 comments on commit 1d2660f

Please sign in to comment.