sendbird-uikit-ios/Sources/Module/MessageThread/SBUMessageThreadModule.List...

1021 lines
46 KiB
Swift

//
// SBUMessageThreadModule.List.swift
// SendbirdUIKit
//
// Created by Tez Park on 2022/11/01.
// Copyright © 2022 Sendbird, Inc. All rights reserved.
//
import UIKit
import SendbirdChatSDK
import AVFAudio
/// Event methods for the views updates and performing actions from the list component in a message thread.
public protocol SBUMessageThreadModuleListDelegate: SBUBaseChannelModuleListDelegate {
/// Called when tapped emoji in the cell.
/// - Parameters:
/// - emojiKey: emoji key
/// - messageCell: Message cell object
func messageThreadModule(_ listComponent: SBUMessageThreadModule.List, didTapEmoji emojiKey: String, messageCell: SBUBaseMessageCell)
/// Called when long tapped emoji in the cell.
/// - Parameters:
/// - emojiKey: emoji key
/// - messageCell: Message cell object
func messageThreadModule(_ listComponent: SBUMessageThreadModule.List, didLongTapEmoji emojiKey: String, messageCell: SBUBaseMessageCell)
/// Called when tapped the cell to get more emoji
/// - Parameters:
/// - messageCell: Message cell object
func messageThreadModule(_ listComponent: SBUMessageThreadModule.List, didTapMoreEmojiForCell messageCell: SBUBaseMessageCell)
/// Called when tapped the mentioned nickname in the cell.
/// - Parameters:
/// - user: The`SBUUser` object from the tapped mention.
func messageThreadModule(_ listComponent: SBUMessageThreadModule.List, didTapMentionUser user: SBUUser)
}
/// Methods to get data source for list component in a message thread.
public protocol SBUMessageThreadModuleListDataSource: SBUBaseChannelModuleListDataSource { }
extension SBUMessageThreadModule {
/// A module component that represent the list of `SBUMessageThreadModule`.
/// - Since: 3.3.0
@objc(SBUMessageThreadModuleList)
@objcMembers open class List: SBUBaseChannelModule.List, SBUParentMessageInfoViewDelegate, SBUVoicePlayerDelegate {
// MARK: - UI properties (Public)
/// A view that shows parent message info on the message thread.
public var parentMessageInfoView = SBUParentMessageInfoView()
public var tempMarginView = UIView()
/// The message cell for `AdminMessage` object. Use `register(adminMessageCell:nib:)` to update.
public private(set) var adminMessageCell: SBUBaseMessageCell?
/// The message cell for `UserMessage` object. Use `register(userMessageCell:nib:)` to update.
public private(set) var userMessageCell: SBUBaseMessageCell?
/// The message cell for `FileMessage` object. Use `register(fileMessageCell:nib:)` to update.
public private(set) var fileMessageCell: SBUBaseMessageCell?
/// The message cell for some unknown message which is not a type of `AdminMessage` | `UserMessage` | ` FileMessage`. Use `register(unknownMessageCell:nib:)` to update.
public private(set) var unknownMessageCell: SBUBaseMessageCell?
/// The custom message cell for some `BaseMessage`. Use `register(customMessageCell:nib:)` to update.
public private(set) var customMessageCell: SBUBaseMessageCell?
// MARK: - Logic properties (Public)
/// The object that acts as the delegate of the list component. The delegate must adopt the `SBUMessageThreadModuleListDelegate`.
public weak var delegate: SBUMessageThreadModuleListDelegate? {
get { self.baseDelegate as? SBUMessageThreadModuleListDelegate }
set { self.baseDelegate = newValue }
}
/// The object that acts as the data source of the list component. The data source must adopt the `SBUMessageThreadModuleDataSource`.
public weak var dataSource: SBUMessageThreadModuleListDataSource? {
get { self.baseDataSource as? SBUMessageThreadModuleListDataSource }
set { self.baseDataSource = newValue }
}
/// The current *group* channel object casted from `baseChannel`
public var channel: GroupChannel? {
self.baseChannel as? GroupChannel
}
public var parentMessage: BaseMessage?
var voicePlayer: SBUVoicePlayer?
var voiceFileInfos: [String: SBUVoiceFileInfo] = [:]
var currentVoiceFileInfo: SBUVoiceFileInfo?
var currentVoiceContentView: SBUVoiceContentView?
var currentVoiceContentIndexPath: IndexPath?
// MARK: - LifeCycle
required public init?(coder: NSCoder) { super.init(coder: coder) }
public override init(frame: CGRect) { super.init(frame: frame) }
/// Configures component with parameters.
/// - Parameters:
/// - delegate: `SBUMessageThreadModuleListDelegate` type listener
/// - dataSource: The data source that is type of `SBUMessageThreadModuleListDataSource`
/// - theme: `SBUChannelTheme` object
/// - voiceFileInfos: If you have voiceFileInfos, set this value. so the default value of Voice Messages are applied based on the voiceFileInfos.
/// - Since: 3.4.0
open func configure(
delegate: SBUMessageThreadModuleListDelegate,
dataSource: SBUMessageThreadModuleListDataSource,
theme: SBUChannelTheme,
voiceFileInfos: [String: SBUVoiceFileInfo]? = nil) {
self.delegate = delegate
self.dataSource = dataSource
self.theme = theme
self.isTransformedList = false
if let voiceFileInfos = voiceFileInfos {
self.voiceFileInfos = voiceFileInfos
}
self.setupViews()
self.setupLayouts()
self.setupStyles()
}
// MARK: - LifeCycle
open override func setupViews() {
self.tableView = UITableView(frame: CGRect.zero, style: .grouped)
super.setupViews()
self.tableView.tableFooterView =
UIView(frame: CGRect(origin: .zero,
size: CGSize(width: CGFloat.leastNormalMagnitude,
height: CGFloat.leastNormalMagnitude)))
self.emptyView?.transform = CGAffineTransform(scaleX: 1, y: 1)
self.tableView.transform = CGAffineTransform(scaleX: 1, y: 1)
// register cell (MessageThread)
if self.adminMessageCell == nil {
self.register(adminMessageCell: SBUAdminMessageCell())
}
if self.userMessageCell == nil {
self.register(userMessageCell: SBUUserMessageCell())
}
if self.fileMessageCell == nil {
self.register(fileMessageCell: SBUFileMessageCell())
}
if self.unknownMessageCell == nil {
self.register(unknownMessageCell: SBUUnknownMessageCell())
}
self.newMessageInfoView = nil
self.scrollBottomView = nil
self.voicePlayer = SBUVoicePlayer(delegate: self)
}
open override func setupLayouts() {
super.setupLayouts()
self.channelStateBanner?
.sbu_constraint(equalTo: self, leading: 8, trailing: -8, top: 8)
.sbu_constraint(height: 24)
}
/// Sets up style with theme. If the `theme` is `nil`, it uses the stored theme.
/// - Parameter theme: `SBUChannelTheme` object
open override func setupStyles(theme: SBUChannelTheme? = nil) {
if let theme = theme {
self.theme = theme
}
if let channelStateBanner = channelStateBanner as? UILabel {
channelStateBanner.textColor = theme?.channelStateBannerTextColor
channelStateBanner.font = theme?.channelStateBannerFont
channelStateBanner.backgroundColor = theme?.channelStateBannerBackgroundColor
}
self.tableView.backgroundColor = self.theme?.backgroundColor
(self.emptyView as? SBUEmptyView)?.setupStyles()
self.parentMessageInfoView.setupStyles()
}
/// Updates styles of the views in the list component with the `theme`.
/// - Parameters:
/// - theme: The object that is used as the theme of the list component. The theme must adopt the `SBUChannelTheme` class. The default value is `nil` to use the stored value.
/// - componentTheme: The object that is used as the theme of some UI component in the list component such as `scrollBottomView`. The theme must adopt the `SBUComponentTheme` class. The default value is `SBUTheme.componentTheme`
open override func updateStyles(
theme: SBUChannelTheme? = nil,
componentTheme: SBUComponentTheme = SBUTheme.componentTheme
) {
super.updateStyles(theme: theme, componentTheme: componentTheme)
(self.emptyView as? SBUEmptyView)?.setupStyles()
}
// MARK: - Parent info view
public func updateParentInfoView() {
self.updateParentInfoView(parentMessage: self.parentMessage)
}
public func updateParentInfoView(parentMessage: BaseMessage?) {
if let parentMessage = parentMessage {
self.parentMessage = parentMessage
}
let useReaction = SBUEmojiManager.useReaction(channel: self.channel)
var voiceFileInfo: SBUVoiceFileInfo?
if let requestId = parentMessage?.requestId {
voiceFileInfo = self.voiceFileInfos[requestId]
}
self.parentMessageInfoView.configure(
message: self.parentMessage,
delegate: self,
useReaction: useReaction,
voiceFileInfo: voiceFileInfo
)
self.reloadTableView()
if let parentMessage = self.parentMessage {
self.setParentMessageInfoViewGestures(message: parentMessage)
}
}
open func setParentMessageInfoViewGestures(message: BaseMessage) {
self.parentMessageInfoView.tapHandlerToContent = { [weak self] in
guard let self = self else { return }
self.setTapGesture(UITableViewCell(), message: message, indexPath: IndexPath())
}
self.parentMessageInfoView.moreButtonTapHandlerToContent = { [weak self] in
guard let self = self else { return }
let cell = SBUBaseMessageCell()
self.showMessageMenuSheet(for: message, cell: cell)
}
self.parentMessageInfoView.userProfileTapHandler = { [ weak self] in
guard let self = self else { return }
guard let sender = message.sender else { return }
self.setUserProfileTapGesture(SBUUser(sender: sender))
}
self.parentMessageInfoView.emojiTapHandler = { [weak self] emojiKey in
guard let self = self else { return }
let cell = SBUBaseMessageCell()
cell.message = message
self.delegate?.messageThreadModule(self, didTapEmoji: emojiKey, messageCell: cell)
}
self.parentMessageInfoView.emojiLongPressHandler = { [weak self] emojiKey in
guard let self = self else { return }
let cell = SBUBaseMessageCell()
cell.message = message
self.delegate?.messageThreadModule(self, didLongTapEmoji: emojiKey, messageCell: cell)
}
self.parentMessageInfoView.moreEmojiTapHandler = { [weak self] in
guard let self = self else { return }
let cell = SBUBaseMessageCell()
cell.message = message
self.delegate?.messageThreadModule(self, didTapMoreEmojiForCell: cell)
}
self.parentMessageInfoView.mentionTapHandler = { [weak self] user in
guard let self = self else { return }
self.delegate?.messageThreadModule(self, didTapMentionUser: user)
}
}
// MARK: - EmptyView
// MARK: - Menu
/// Calculates the `CGPoint` value that indicates where to draw the message menu in the message thread screen.
/// - Parameters:
/// - indexPath: The index path of the selected message cell
/// - position: Message position
/// - Returns: `CGPoint` value
open func calculateMessageMenuCGPoint(
indexPath: IndexPath,
position: MessagePosition
) -> CGPoint {
let rowRect = self.tableView.rectForRow(at: indexPath)
let rowRectInSuperview = self.tableView.convert(
rowRect,
to: UIApplication.shared.currentWindow
)
let originX = (position == .right) ? rowRectInSuperview.width : rowRectInSuperview.origin.x
let menuPoint = CGPoint(x: originX, y: rowRectInSuperview.origin.y)
return menuPoint
}
open override func createMessageMenuItems(for message: BaseMessage) -> [SBUMenuItem] {
let items = super.createMessageMenuItems(for: message)
return items
}
open override func showMessageContextMenu(for message: BaseMessage, cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let messageMenuItems = self.createMessageMenuItems(for: message)
guard !messageMenuItems.isEmpty,
let cell = cell as? SBUBaseMessageCell else {
cell.isSelected = false
return
}
let menuPoint = self.calculateMessageMenuCGPoint(indexPath: indexPath, position: cell.position)
SBUMenuView.show(items: messageMenuItems, point: menuPoint) {
cell.isSelected = false
}
}
// MARK: - Actions
/// Sets gestures in message cell.
/// - Parameters:
/// - cell: The message cell
/// - message: message object
/// - indexPath: Cell's indexPath
open func setMessageCellGestures(_ cell: SBUBaseMessageCell, message: BaseMessage, indexPath: IndexPath) {
cell.tapHandlerToContent = { [weak self] in
guard let self = self else { return }
self.setTapGesture(cell, message: message, indexPath: indexPath)
}
cell.longPressHandlerToContent = { [weak self] in
guard let self = self else { return }
self.setLongTapGesture(cell, message: message, indexPath: indexPath)
}
cell.userProfileTapHandler = { [weak self] in
guard let self = self else { return }
guard let sender = cell.message?.sender else { return }
self.setUserProfileTapGesture(SBUUser(sender: sender))
}
cell.emojiTapHandler = { [weak self] emojiKey in
guard let self = self else { return }
self.delegate?.messageThreadModule(self, didTapEmoji: emojiKey, messageCell: cell)
}
cell.emojiLongPressHandler = { [weak self] emojiKey in
guard let self = self else { return }
self.delegate?.messageThreadModule(self, didLongTapEmoji: emojiKey, messageCell: cell)
}
cell.moreEmojiTapHandler = { [weak self] in
guard let self = self else { return }
self.delegate?.messageThreadModule(self, didTapMoreEmojiForCell: cell)
}
cell.mentionTapHandler = { [weak self] user in
guard let self = self else { return }
self.delegate?.messageThreadModule(self, didTapMentionUser: user)
}
}
// MARK: - TableView
/// Reloads table view. This method corresponds to `UITableView reloadData()`.
public override func reloadTableView() {
if Thread.isMainThread {
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
} else {
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
self?.tableView.layoutIfNeeded()
}
}
}
// MARK: - TableView: Cell
/// Register the message cell to the table view.
public func register(messageCell: SBUBaseMessageCell, nib: UINib? = nil) {
if let nib = nib {
self.tableView.register(
nib,
forCellReuseIdentifier: messageCell.sbu_className
)
} else {
self.tableView.register(
type(of: messageCell), forCellReuseIdentifier: messageCell.sbu_className)
}
}
/// Registers a custom cell as a admin message cell based on `SBUBaseMessageCell`.
/// - Parameters:
/// - adminMessageCell: Customized admin message cell
/// - nib: nib information. If the value is nil, the nib file is not used.
/// - Important: To register custom message cell, please use this function before calling `configure(delegate:dataSource:theme:)`
/// ```swift
/// listComponent.register(adminMessageCell: MyAdminMessageCell)
/// listComponent.configure(delegate: self, dataSource: self, theme: theme)
/// ```
open func register(adminMessageCell: SBUBaseMessageCell, nib: UINib? = nil) {
self.adminMessageCell = adminMessageCell
self.register(messageCell: adminMessageCell, nib: nib)
}
/// Registers a custom cell as a user message cell based on `SBUBaseMessageCell`.
/// - Parameters:
/// - userMessageCell: Customized user message cell
/// - nib: nib information. If the value is nil, the nib file is not used.
/// - Important: To register custom message cell, please use this function before calling `configure(delegate:dataSource:theme:)`
/// ```swift
/// listComponent.register(userMessageCell: MyUserMessageCell)
/// listComponent.configure(delegate: self, dataSource: self, theme: theme)
/// ```
open func register(userMessageCell: SBUBaseMessageCell, nib: UINib? = nil) {
self.userMessageCell = userMessageCell
self.register(messageCell: userMessageCell, nib: nib)
}
/// Registers a custom cell as a file message cell based on `SBUBaseMessageCell`.
/// - Parameters:
/// - fileMessageCell: Customized file message cell
/// - nib: nib information. If the value is nil, the nib file is not used.
/// - Important: To register custom message cell, please use this function before calling `configure(delegate:dataSource:theme:)`
/// ```swift
/// listComponent.register(fileMessageCell: MyFileMessageCell)
/// listComponent.configure(delegate: self, dataSource: self, theme: theme)
/// ```
open func register(fileMessageCell: SBUBaseMessageCell, nib: UINib? = nil) {
self.fileMessageCell = fileMessageCell
self.register(messageCell: fileMessageCell, nib: nib)
}
/// Registers a custom cell as a unknown message cell based on `SBUBaseMessageCell`.
/// - Parameters:
/// - unknownMessageCell: Customized unknown message cell
/// - nib: nib information. If the value is nil, the nib file is not used.
/// - Important: To register custom message cell, please use this function before calling `configure(delegate:dataSource:theme:)`
/// ```swift
/// listComponent.register(unknownMessageCell: MyUnknownMessageCell)
/// listComponent.configure(delegate: self, dataSource: self, theme: theme)
/// ```
open func register(unknownMessageCell: SBUBaseMessageCell, nib: UINib? = nil) {
self.unknownMessageCell = unknownMessageCell
self.register(messageCell: unknownMessageCell, nib: nib)
}
/// Registers a custom cell as a additional message cell based on `SBUBaseMessageCell`.
/// - Parameters:
/// - customMessageCell: Customized message cell
/// - nib: nib information. If the value is nil, the nib file is not used.
/// - Important: To register custom message cell, please use this function before calling `configure(delegate:dataSource:theme:)`
/// ```swift
/// listComponent.register(customMessageCell: MyCustomMessageCell)
/// listComponent.configure(delegate: self, dataSource: self, theme: theme)
/// ```
open func register(customMessageCell: SBUBaseMessageCell, nib: UINib? = nil) {
self.customMessageCell = customMessageCell
self.register(messageCell: customMessageCell, nib: nib)
}
/// Configures cell with message for a particular row.
/// - Parameters:
/// - messageCell: `SBUBaseMessageCell` object.
/// - message: The message for `messageCell`.
/// - indexPath: An index path representing the `messageCell`
open func configureCell(
_ messageCell: SBUBaseMessageCell,
message: BaseMessage,
forRowAt indexPath: IndexPath
) {
guard self.channel != nil else {
SBULog.error("Channel must exist!")
return
}
// NOTE: to disable unwanted animation while configuring cells
UIView.setAnimationsEnabled(false)
let isSameDay = self.checkSameDayAsPrevMessage(
currentIndex: indexPath.row,
fullMessageList: fullMessageList
)
let useReaction = SBUEmojiManager.useReaction(channel: self.channel)
switch (message, messageCell) {
// Admin message
case let (adminMessage, adminMessageCell) as (AdminMessage, SBUAdminMessageCell):
let configuration = SBUAdminMessageCellParams(
message: adminMessage,
hideDateView: isSameDay
)
adminMessageCell.configure(with: configuration)
self.setMessageCellGestures(adminMessageCell, message: adminMessage, indexPath: indexPath)
// Unknown message
case let (unknownMessage, unknownMessageCell) as (BaseMessage, SBUUnknownMessageCell):
let configuration = SBUUnknownMessageCellParams(
message: unknownMessage,
hideDateView: isSameDay,
groupPosition: self.getMessageGroupingPosition(currentIndex: indexPath.row),
receiptState: .notUsed,
useReaction: useReaction,
isThreadMessage: true
)
unknownMessageCell.configure(with: configuration)
self.setMessageCellGestures(unknownMessageCell, message: unknownMessage, indexPath: indexPath)
// User message
case let (userMessage, userMessageCell) as (UserMessage, SBUUserMessageCell):
let configuration = SBUUserMessageCellParams(
message: userMessage,
hideDateView: isSameDay,
useMessagePosition: true,
groupPosition: self.getMessageGroupingPosition(currentIndex: indexPath.row),
receiptState: .notUsed,
useReaction: useReaction,
withTextView: true,
isThreadMessage: true
)
userMessageCell.configure(with: configuration)
self.setMessageCellGestures(userMessageCell, message: userMessage, indexPath: indexPath)
// File message
case let (fileMessage, fileMessageCell) as (FileMessage, SBUFileMessageCell):
let voiceFileInfo = self.voiceFileInfos[fileMessage.requestId] ?? nil
let configuration = SBUFileMessageCellParams(
message: fileMessage,
hideDateView: isSameDay,
useMessagePosition: true,
groupPosition: self.getMessageGroupingPosition(currentIndex: indexPath.row),
receiptState: .notUsed,
useReaction: useReaction,
isThreadMessage: true,
voiceFileInfo: voiceFileInfo
)
if voiceFileInfo != nil,
self.parentMessageInfoView.baseFileContentView != self.currentVoiceContentView {
self.currentVoiceFileInfo = nil
self.currentVoiceContentView = nil
}
fileMessageCell.configure(with: configuration)
self.setMessageCellGestures(fileMessageCell, message: fileMessage, indexPath: indexPath)
self.setFileMessageCellImage(fileMessageCell, fileMessage: fileMessage)
if let voiceFileInfo = voiceFileInfo,
voiceFileInfo.isPlaying == true,
let voiceContentView = fileMessageCell.baseFileContentView as? SBUVoiceContentView {
self.currentVoiceContentIndexPath = indexPath
self.currentVoiceFileInfo = voiceFileInfo
self.currentVoiceContentView = voiceContentView
}
default:
let configuration = SBUBaseMessageCellParams(
message: message,
hideDateView: isSameDay,
messagePosition: .center,
groupPosition: .none,
receiptState: .notUsed,
isThreadMessage: true
)
messageCell.configure(with: configuration)
}
UIView.setAnimationsEnabled(true)
}
// MARK: - UITableViewDelegate, UITableViewDataSource
open override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
return self.parentMessageInfoView
}
open override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let headerView = self.parentMessageInfoView
let height = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
var headerFrame = headerView.frame
// Comparison necessary to avoid infinite loop
if height != headerFrame.size.height {
headerFrame.size.height = height
headerView.frame = headerFrame
self.parentMessageInfoView = headerView
}
return headerFrame.height
}
open func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let headerView = UIView()
headerView.backgroundColor = UIColor.clear
return headerView
}
open func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return CGFloat.leastNormalMagnitude
}
open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard indexPath.row < self.fullMessageList.count else {
SBULog.error("The index is out of range.")
return .init()
}
let message = fullMessageList[indexPath.row]
let cell = tableView.dequeueReusableCell(
withIdentifier: self.generateCellIdentifier(by: message)
) ?? UITableViewCell()
cell.selectionStyle = .none
guard let messageCell = cell as? SBUBaseMessageCell else {
SBULog.error("There are no message cells!")
return cell
}
self.configureCell(messageCell, message: message, forRowAt: indexPath)
return cell
}
open override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let fileMessageCell = cell as? SBUFileMessageCell,
let _ = fileMessageCell.baseFileContentView as? SBUVoiceContentView else { return }
}
/// Generates identifier of message cell.
/// - Parameter message: Message object
/// - Returns: The identifier of message cell.
open func generateCellIdentifier(by message: BaseMessage) -> String {
switch message {
case is FileMessage:
return fileMessageCell?.sbu_className ?? SBUFileMessageCell.sbu_className
case is UserMessage:
return userMessageCell?.sbu_className ?? SBUUserMessageCell.sbu_className
case is AdminMessage:
return adminMessageCell?.sbu_className ?? SBUAdminMessageCell.sbu_className
default:
return unknownMessageCell?.sbu_className ?? SBUUnknownMessageCell.sbu_className
}
}
// MARK: - Scroll View
open override func scrollViewDidScroll(_ scrollView: UIScrollView) {
super.scrollViewDidScroll(scrollView)
}
// MARK: - SBUParentMessageInfoViewDelegate
open func parentMessageInfoViewBoundsDidChanged(_ view: SBUParentMessageInfoView) {
if let emptyView = self.emptyView as? SBUEmptyView {
emptyView.updateTopAnchorConstraint(constant: view.frame.height)
}
}
open func parentMessageInfoViewBoundsWillChanged(_ view: SBUParentMessageInfoView) {
if let emptyView = self.emptyView as? SBUEmptyView {
emptyView.updateTopAnchorConstraint(constant: view.frame.height)
}
}
}
}
// MARK: - Scroll related
extension SBUMessageThreadModule.List {
public override var isScrollNearByBottom: Bool {
(tableView.contentOffset.y + (tableView.visibleCells.last?.frame.height ?? 0)) >= (tableView.contentSize.height - tableView.frame.size.height) - 20
}
/// To keep track of which scrolls tableview.
override func scrollTableView(
to row: Int,
at position: UITableView.ScrollPosition = .top,
animated: Bool = false) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.tableView.numberOfRows(inSection: 0) <= row ||
row < 0 {
return
}
let isScrollable = !self.fullMessageList.isEmpty
&& row >= 0
&& row < self.fullMessageList.count
if isScrollable {
if row+1 == self.fullMessageList.count {
let indexPath = IndexPath(item: self.fullMessageList.count - 1, section: 0)
self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: false)
} else {
self.tableView.scrollToRow(
at: IndexPath(row: row, section: 0),
at: position,
animated: animated
)
}
} else {
let indexPath = IndexPath(item: self.fullMessageList.count - 1, section: 0)
self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: false)
}
}
}
/// This function keeps the current scroll position with upserted messages.
/// - Note: Only newly added messages are used for processing.
/// - Parameter upsertedMessages: upserted messages
override func keepCurrentScroll(for upsertedMessages: [BaseMessage]) -> IndexPath {
let firstVisibleIndexPath = tableView
.indexPathsForVisibleRows?.last ?? IndexPath(row: 0, section: 0)
var nextInsertedCount = 0
if let newestMessage = sentMessages.last {
// only filter out messages inserted at the bottom (newer) of current visible item
nextInsertedCount = upsertedMessages
.filter({ $0.createdAt > newestMessage.createdAt })
.filter({ !SBUUtils.contains(messageId: $0.messageId, in: sentMessages) }).count
}
SBULog.info("New messages inserted : \(nextInsertedCount)")
return IndexPath(
row: firstVisibleIndexPath.row + nextInsertedCount,
section: 0
)
}
/// Scrolls tableview to initial position.
/// If starting point is set, scroll to the starting point at `.middle`.
override func scrollToInitialPosition() {
if let startingPoint = self.baseDataSource?.baseChannelModule(self, startingPointIn: self.tableView) {
if startingPoint != 0 {
if let index = fullMessageList.firstIndex(where: { $0.createdAt >= startingPoint }) {
// from quotedMessage
self.scrollTableView(to: index, at: .middle)
} else {
// from select reply thread on parent message menu
self.scrollTableView(to: fullMessageList.count - 1, at: .bottom)
}
} else {
// from threadInfo
self.scrollTableView(to: 0)
}
} else {
// from send message
self.scrollTableView(to: self.fullMessageList.count - 1)
}
}
}
// MARK: - Voice message
extension SBUMessageThreadModule.List {
func pauseVoicePlayer() {
self.currentVoiceFileInfo?.isPlaying = false
self.voicePlayer?.pause()
}
func pauseVoicePlayer(requestId: String) {
if let voiceFileInfo = self.voiceFileInfos[requestId],
voiceFileInfo.isPlaying == true {
voiceFileInfo.isPlaying = false
self.voicePlayer?.pause()
}
}
func pauseAllVoicePlayer() {
self.currentVoiceFileInfo?.isPlaying = false
self.voicePlayer?.pause()
for (_, value) in self.voiceFileInfos {
value.isPlaying = false
}
}
/// Updates voice message
/// - Note: As a default, it's called from `baseChannelModule(_:didTapVoiceMessage:cell:forRowAt:)` delegate method.
/// - Parameters:
/// - cell: The message cell
/// - message: message object
/// - indexPath: Cell's indexPath
///
/// - Since: 3.4.0
func updateVoiceMessage(_ cell: SBUBaseMessageCell, message: BaseMessage, indexPath: IndexPath) {
guard let fileMessageCell = cell as? SBUFileMessageCell,
let fileMessage = message as? FileMessage,
let voiceContentView = fileMessageCell.baseFileContentView as? SBUVoiceContentView,
SBUUtils.getFileType(by: fileMessage) == .voice else { return }
if self.voiceFileInfos[fileMessage.requestId] == nil {
voiceContentView.updateVoiceContentStatus(.loading)
}
SBUCacheManager.File.loadFile(
urlString: fileMessage.url,
cacheKey: fileMessage.requestId,
fileName: fileMessage.name
) { [weak self] filePath, _ in
if voiceContentView.status == .loading || voiceContentView.status == .none {
voiceContentView.updateVoiceContentStatus(.prepared)
}
var voicefileInfo: SBUVoiceFileInfo?
if self?.voiceFileInfos[fileMessage.requestId] == nil {
var playtime: Double = 0
let metaArrays = message.metaArrays(keys: [SBUConstant.voiceMessageDurationKey])
if metaArrays.count > 0 {
let value = metaArrays[0].value[0]
playtime = Double(value) ?? 0
}
voicefileInfo = SBUVoiceFileInfo(
fileName: fileMessage.name,
filePath: filePath,
playtime: playtime,
currentPlayTime: 0
)
self?.voiceFileInfos[fileMessage.requestId] = voicefileInfo
} else {
voicefileInfo = self?.voiceFileInfos[fileMessage.requestId]
}
var actionInSameView = false
if let voicefileInfo = voicefileInfo {
if self?.currentVoiceFileInfo?.isPlaying == true {
// updated status of previously contentView
let currentPlayTime = self?.currentVoiceFileInfo?.currentPlayTime ?? 0
self?.currentVoiceFileInfo?.isPlaying = false
self?.currentVoiceContentView?.updateVoiceContentStatus(.pause, time: currentPlayTime)
if self?.currentVoiceContentView == voiceContentView {
actionInSameView = true
}
}
self?.voicePlayer?.configure(voiceFileInfo: voicefileInfo)
}
if let voicefileInfo = voicefileInfo {
self?.voicePlayer?.configure(voiceFileInfo: voicefileInfo)
self?.currentVoiceContentIndexPath = indexPath
}
if self?.currentVoiceFileInfo != voicefileInfo {
self?.pauseAllVoicePlayer()
}
self?.currentVoiceFileInfo = voicefileInfo
self?.currentVoiceContentView = voiceContentView
switch voiceContentView.status {
case .none:
break
case .loading:
break
case .prepared:
self?.voicePlayer?.play()
case .playing:
self?.voicePlayer?.pause()
case .pause:
if actionInSameView == true { break }
let currentPlayTime = self?.currentVoiceFileInfo?.currentPlayTime ?? 0
self?.voicePlayer?.play(fromTime: currentPlayTime)
case .finishPlaying:
self?.voicePlayer?.play()
}
}
}
func updateParentInfoVoiceMessage(_ fileMessage: FileMessage) {
guard let voiceContentView = self.parentMessageInfoView.baseFileContentView as? SBUVoiceContentView,
SBUUtils.getFileType(by: fileMessage) == .voice else { return }
SBUCacheManager.File.loadFile(
urlString: fileMessage.url,
cacheKey: fileMessage.requestId,
fileName: fileMessage.name
) { [weak self] filePath, _ in
if voiceContentView.status == .loading || voiceContentView.status == .none {
voiceContentView.updateVoiceContentStatus(.prepared)
}
var voicefileInfo: SBUVoiceFileInfo?
if self?.voiceFileInfos[fileMessage.requestId] == nil {
var playtime: Double = 0
let metaArrays = fileMessage.metaArrays(keys: [SBUConstant.voiceMessageDurationKey])
if metaArrays.count > 0 {
let value = metaArrays[0].value[0]
playtime = Double(value) ?? 0
}
voicefileInfo = SBUVoiceFileInfo(
fileName: fileMessage.name,
filePath: filePath,
playtime: playtime,
currentPlayTime: 0
)
self?.voiceFileInfos[fileMessage.requestId] = voicefileInfo
} else {
voicefileInfo = self?.voiceFileInfos[fileMessage.requestId]
}
var actionInSameView = false
if let voicefileInfo = voicefileInfo {
if self?.currentVoiceFileInfo?.isPlaying == true {
// updated status of previously contentView
let currentPlayTime = self?.currentVoiceFileInfo?.currentPlayTime ?? 0
self?.currentVoiceFileInfo?.isPlaying = false
self?.currentVoiceContentView?.updateVoiceContentStatus(.pause, time: currentPlayTime)
if self?.currentVoiceContentView == voiceContentView {
actionInSameView = true
}
}
self?.voicePlayer?.configure(voiceFileInfo: voicefileInfo)
}
if let voicefileInfo = voicefileInfo {
self?.voicePlayer?.configure(voiceFileInfo: voicefileInfo)
self?.currentVoiceContentIndexPath = nil
}
if self?.currentVoiceFileInfo != voicefileInfo {
self?.pauseAllVoicePlayer()
}
self?.currentVoiceFileInfo = voicefileInfo
self?.currentVoiceContentView = voiceContentView
switch voiceContentView.status {
case .none:
break
case .loading:
break
case .prepared:
self?.voicePlayer?.play()
case .playing:
self?.voicePlayer?.pause()
case .pause:
if actionInSameView == true { break }
let currentPlayTime = self?.currentVoiceFileInfo?.currentPlayTime ?? 0
self?.voicePlayer?.play(fromTime: currentPlayTime)
case .finishPlaying:
self?.voicePlayer?.play()
}
}
}
// MARK: - SBUVoicePlayerDelegate
public func voicePlayerDidReceiveError(_ player: SBUVoicePlayer, errorStatus: SBUVoicePlayerErrorStatus) {}
public func voicePlayerDidStart(_ player: SBUVoicePlayer) {
let currentPlayTime = self.currentVoiceFileInfo?.currentPlayTime ?? 0
self.currentVoiceFileInfo?.isPlaying = true
var voiceContentView: SBUVoiceContentView?
if let indexPath = self.currentVoiceContentIndexPath,
let cell = self.tableView.cellForRow(at: indexPath) as? SBUFileMessageCell {
voiceContentView = cell.baseFileContentView as? SBUVoiceContentView
} else if parentMessageInfoView.baseFileContentView == self.currentVoiceContentView {
voiceContentView = parentMessageInfoView.baseFileContentView as? SBUVoiceContentView
}
voiceContentView?.updateVoiceContentStatus(.playing, time: currentPlayTime)
}
public func voicePlayerDidPause(_ player: SBUVoicePlayer, voiceFileInfo: SBUVoiceFileInfo?) {
let currentPlayTime = self.currentVoiceFileInfo?.currentPlayTime ?? 0
self.currentVoiceFileInfo?.isPlaying = false
var voiceContentView: SBUVoiceContentView?
if let indexPath = self.currentVoiceContentIndexPath,
let cell = self.tableView.cellForRow(at: indexPath) as? SBUFileMessageCell {
voiceContentView = cell.baseFileContentView as? SBUVoiceContentView
} else if parentMessageInfoView.baseFileContentView == self.currentVoiceContentView {
voiceContentView = parentMessageInfoView.baseFileContentView as? SBUVoiceContentView
}
voiceContentView?.updateVoiceContentStatus(.pause, time: currentPlayTime)
}
public func voicePlayerDidStop(_ player: SBUVoicePlayer) {
let time = self.currentVoiceFileInfo?.playtime ?? 0
self.currentVoiceFileInfo?.isPlaying = false
var voiceContentView: SBUVoiceContentView?
if let indexPath = self.currentVoiceContentIndexPath,
let cell = self.tableView.cellForRow(at: indexPath) as? SBUFileMessageCell {
voiceContentView = cell.baseFileContentView as? SBUVoiceContentView
} else if parentMessageInfoView.baseFileContentView == self.currentVoiceContentView {
voiceContentView = parentMessageInfoView.baseFileContentView as? SBUVoiceContentView
}
voiceContentView?.updateVoiceContentStatus(.finishPlaying, time: time)
}
public func voicePlayerDidReset(_ player: SBUVoicePlayer) {}
public func voicePlayerDidUpdatePlayTime(_ player: SBUVoicePlayer, time: TimeInterval) {
self.currentVoiceFileInfo?.currentPlayTime = time
self.currentVoiceFileInfo?.isPlaying = true
var voiceContentView: SBUVoiceContentView?
if let indexPath = self.currentVoiceContentIndexPath,
let cell = self.tableView.cellForRow(at: indexPath) as? SBUFileMessageCell {
voiceContentView = cell.baseFileContentView as? SBUVoiceContentView
} else if parentMessageInfoView.baseFileContentView == self.currentVoiceContentView {
voiceContentView = parentMessageInfoView.baseFileContentView as? SBUVoiceContentView
}
voiceContentView?.updateVoiceContentStatus(.playing, time: time)
}
}