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

[Reactions v2] New reactions query endpoint #3167

Merged
merged 13 commits into from
Apr 30, 2024
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🔄 Changed
## StreamChat
### ✅ Added
- Add `ChatReactionListController` to query and filter reactions from a message [#3167](https://github.com/GetStream/stream-chat-swift/pull/3167)
- Add `ChatClient.reactionListController()` to create an instance of `ChatReactionListController` [#3167](https://github.com/GetStream/stream-chat-swift/pull/3167)

# [4.52.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.52.0)
_April 09, 2024_
Expand Down
104 changes: 104 additions & 0 deletions Examples/SlackClone/SlackChatMessageListViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,108 @@ final class SlackChatMessageListViewController: ChatMessageListVC {
override func cellContentClassForMessage(at indexPath: IndexPath) -> ChatMessageContentView.Type {
SlackChatMessageContentView.self
}

override func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
let location = gesture.location(in: listView)
guard gesture.state == .began,
let indexPath = listView.indexPathForRow(at: location),
let cell = listView.cellForRow(at: indexPath) as? ChatMessageCell,
let messageContentView = cell.messageContentView as? SlackChatMessageContentView,
let message = messageContentView.content else {
return super.handleLongPress(gesture)
}

let reactionsLocation = gesture.location(in: messageContentView.slackReactionsView.collectionView)
guard let reactionsIndexPath = messageContentView.slackReactionsView.collectionView.indexPathForItem(at: reactionsLocation) else {
return super.handleLongPress(gesture)
}

let reaction = messageContentView.slackReactionsView.reactions[reactionsIndexPath.row]
openReactionsSheet(for: message, with: reaction.type)
}

func openReactionsSheet(for message: ChatMessage, with reactionType: MessageReactionType) {
let reactionListController = client.reactionListController(
query: .init(
messageId: message.id,
filter: .equal(.reactionType, to: reactionType)
)
)
let slackReactionListView = SlackReactionListViewController(
reactionListController: reactionListController
)
present(slackReactionListView, animated: true)
}
}

/// Displays the reaction authors for a specific reaction type.
class SlackReactionListViewController: UITableViewController, ChatReactionListControllerDelegate {
let reactionListController: ChatReactionListController

init(reactionListController: ChatReactionListController) {
self.reactionListController = reactionListController
super.init(nibName: nil, bundle: nil)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

var reactions: [ChatMessageReaction] = []

private var isLoadingReactions: Bool = false

let prefetchThreshold: Int = 10

override func viewDidLoad() {
super.viewDidLoad()

if let sheetController = presentationController as? UISheetPresentationController {
sheetController.detents = [.medium(), .large()]
sheetController.prefersGrabberVisible = true
}

reactionListController.delegate = self
isLoadingReactions = true
reactionListController.synchronize { [weak self] _ in
self?.isLoadingReactions = false
}
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
reactions.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let reaction = reactions[indexPath.row]
let cell = UITableViewCell(style: .value1, reuseIdentifier: "slack-reaction-cell")
Copy link
Contributor

@laevandus laevandus Apr 29, 2024

Choose a reason for hiding this comment

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

It is a sample app, but just wondering why it was not using dequeueReusableCell(withIdentifier:for:). Not that it makes a huge difference here. I guess because there are only a couple of cells so it does not matter. Just thinking out aloud, feel free to ignore.

cell.detailTextLabel?.text = reaction.author.name ?? "Unknown"
cell.textLabel?.text = reaction.type.toEmoji()
return cell
}

override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
prefetchNextReactions(currentIndexPath: indexPath)
}

func prefetchNextReactions(currentIndexPath indexPath: IndexPath) {
if isLoadingReactions {
return
}

if indexPath.row > reactions.count - prefetchThreshold {
return
}

isLoadingReactions = true
reactionListController.loadMoreReactions { [weak self] _ in
self?.isLoadingReactions = false
}
}

func controller(_ controller: ChatReactionListController, didChangeReactions changes: [ListChange<ChatMessageReaction>]) {
reactions = Array(controller.reactions)
tableView.reloadData()
}
}
28 changes: 22 additions & 6 deletions Examples/SlackClone/SlackReactionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import StreamChat
import StreamChatUI
import UIKit

extension MessageReactionType {
func toEmoji() -> String {
let emojis: [String: String] = [
"love": "❤️",
"haha": "😂",
"like": "👍",
"sad": "😔",
"wow": "🤯"
]
return emojis[rawValue] ?? "❓"
}
}

final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource, UICollectionViewDelegate {
var content: ChatMessage? {
didSet { updateContent() }
Expand All @@ -25,8 +38,10 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource

let reactionWidth: CGFloat = 40
let reactionRowHeight: CGFloat = 30
let reactionsMarginLeft: CGFloat = 40
let reactionInterSpacing: CGFloat = 4

private lazy var collectionView: UICollectionView = {
lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.dataSource = self
Expand All @@ -48,9 +63,9 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource
),
subitems: [item]
)
group.interItemSpacing = .fixed(4)
group.interItemSpacing = .fixed(reactionInterSpacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 4
section.interGroupSpacing = reactionInterSpacing

return UICollectionViewCompositionalLayout(section: section)
}
Expand All @@ -62,8 +77,8 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource

addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: topAnchor),
collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
collectionView.topAnchor.constraint(equalTo: topAnchor, constant: -4),
collectionView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: reactionsMarginLeft),
collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
Expand All @@ -89,7 +104,8 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource

collectionView.reloadData()

let numberOfRows = Double(reactions.count) * reactionWidth / UIScreen.main.bounds.width
let screenWidth = UIScreen.main.bounds.width
let numberOfRows = Double(reactions.count) * (reactionWidth + reactionInterSpacing) / (screenWidth - reactionsMarginLeft)
heightConstraint?.constant = ceil(numberOfRows) * reactionRowHeight
}

Expand Down
11 changes: 1 addition & 10 deletions Examples/SlackClone/SlackReactonsItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,13 @@ import StreamChatUI
import UIKit

final class SlackReactionsItemView: UICollectionViewCell {
var emojis: [String: String] = [
"love": "❤️",
"haha": "😂",
"like": "👍",
"sad": "😔",
"wow": "🤯"
]

var reaction: ChatMessageReactionData? {
didSet {
guard let reaction = reaction else {
return
}

let emoji = emojis[reaction.type.rawValue] ?? "🙂"

let emoji = reaction.type.toEmoji()
textLabel.text = "\(emoji) \(reaction.score)"
textLabel.textColor = reaction.isChosenByCurrentUser ? .blue : .gray
}
Expand Down
44 changes: 0 additions & 44 deletions Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,50 +50,6 @@ extension Endpoint {
)
}

static func loadReactions(messageId: MessageId, pagination: Pagination) -> Endpoint<MessageReactionsPayload> {
.init(
path: .reactions(messageId),
method: .get,
queryItems: nil,
requiresConnectionId: false,
body: pagination
)
}

static func addReaction(
_ type: MessageReactionType,
score: Int,
enforceUnique: Bool,
extraData: [String: RawJSON],
messageId: MessageId
) -> Endpoint<EmptyResponse> {
let body = MessageReactionRequestPayload(
enforceUnique: enforceUnique,
reaction: ReactionRequestPayload(
type: type,
score: score,
extraData: extraData
)
)
return .init(
path: .addReaction(messageId),
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: body
)
}

static func deleteReaction(_ type: MessageReactionType, messageId: MessageId) -> Endpoint<EmptyResponse> {
.init(
path: .deleteReaction(messageId, type),
method: .delete,
queryItems: nil,
requiresConnectionId: false,
body: nil
)
}

static func dispatchEphemeralMessageAction(
cid: ChannelId,
messageId: MessageId,
Expand Down
61 changes: 61 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/ReactionEndpoints.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation

extension Endpoint {
static func loadReactions(messageId: MessageId, pagination: Pagination) -> Endpoint<MessageReactionsPayload> {
.init(
path: .reactions(messageId),
method: .get,
queryItems: nil,
requiresConnectionId: false,
body: pagination
)
}

static func loadReactionsV2(query: ReactionListQuery) -> Endpoint<MessageReactionsPayload> {
.init(
path: .reactions(query.messageId),
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: query
)
}

static func addReaction(
_ type: MessageReactionType,
score: Int,
enforceUnique: Bool,
extraData: [String: RawJSON],
messageId: MessageId
) -> Endpoint<EmptyResponse> {
let body = MessageReactionRequestPayload(
enforceUnique: enforceUnique,
reaction: ReactionRequestPayload(
type: type,
score: score,
extraData: extraData
)
)
return .init(
path: .addReaction(messageId),
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: body
)
}

static func deleteReaction(_ type: MessageReactionType, messageId: MessageId) -> Endpoint<EmptyResponse> {
.init(
path: .deleteReaction(messageId, type),
method: .delete,
queryItems: nil,
requiresConnectionId: false,
body: nil
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation

extension ChatClient {
/// Creates a new `ChatReactionListController` with the provided reaction list query.
///
/// - Parameter query: The query to specify which reactions should be fetch from the message.
/// - Returns: A new instance of `ChatReactionListController`.
public func reactionListController(query: ReactionListQuery) -> ChatReactionListController {
.init(query: query, client: self)
}

/// Creates a new `ChatReactionListController` with the default query.
/// It loads all reactions from the message.
///
/// - Parameter messageId: The message id of the reactions to fetch.
/// - Returns: A new instance of `ChatReactionListController`.
public func reactionListController(for messageId: MessageId) -> ChatReactionListController {
.init(
query: ReactionListQuery(
messageId: messageId,
pagination: .init(pageSize: 25, offset: 0)
),
client: self
)
}
}