diff --git a/CHANGELOG.md b/CHANGELOG.md index 175c35e6..aa59b4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log -### 2.2.7 +### 2.3.0 +#### Update +* Added live typing indicator +* Raised min os version to 9.0 + +### 2.2.6 #### Bug fixes * Fixed scopes for objective-c * Fixed symbol error for iOS 8 diff --git a/CHPlugin.podspec b/CHPlugin.podspec index 61515a26..244aada0 100644 --- a/CHPlugin.podspec +++ b/CHPlugin.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'CHPlugin' - s.version = '2.2.7' + s.version = '2.3.0' s.summary = 'Channel plugin for iOS' # This description is used to generate tags and improve search results. # * Think: What does it do? Why did you write it? What is the focus? @@ -22,7 +22,7 @@ Pod::Spec.new do |s| s.license = { :type => 'SDK', :file => 'LICENSE' } s.author = { 'ZOYI' => 'eng@zoyi.co' } s.source = { :git => 'https://github.com/zoyi/channel-plugin-ios.git', :tag => s.version.to_s } - s.ios.deployment_target = '8.0' + s.ios.deployment_target = '9.0' s.source_files = 'CHPlugin/Source/**/*' s.resources = 'CHPlugin/Assets/*' diff --git a/CHPlugin.xcodeproj/project.pbxproj b/CHPlugin.xcodeproj/project.pbxproj index 33174983..538be95a 100644 --- a/CHPlugin.xcodeproj/project.pbxproj +++ b/CHPlugin.xcodeproj/project.pbxproj @@ -17,6 +17,10 @@ 14D2AA831E24AE07006FEE22 /* CHPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D2AA821E24AE07006FEE22 /* CHPluginTests.swift */; }; 14D2AA851E24AE07006FEE22 /* CHPlugin.h in Headers */ = {isa = PBXBuildFile; fileRef = 14D2AA771E24AE06006FEE22 /* CHPlugin.h */; settings = {ATTRIBUTES = (Public, ); }; }; 14DFAC681E4A189D00130119 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 14DFAC671E4A189D00130119 /* Images.xcassets */; }; + 222086B81FBAAD37002CFA88 /* CHAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222086B71FBAAD37002CFA88 /* CHAnimations.swift */; }; + 222086BA1FBAB28D002CFA88 /* CHTypingEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 222086B91FBAB28D002CFA88 /* CHTypingEntity.swift */; }; + 22448B061FBA9C1900EDE528 /* TypingIndicatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22448B051FBA9C1900EDE528 /* TypingIndicatorCell.swift */; }; + 22448B081FBA9D5800EDE528 /* CHMultiAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22448B071FBA9D5800EDE528 /* CHMultiAvatarView.swift */; }; 225B05F81E4ED913001DE109 /* WsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 225B05F71E4ED913001DE109 /* WsServiceTests.swift */; }; 225B05FA1E4ED9F5001DE109 /* ChannelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 225B05F91E4ED9F5001DE109 /* ChannelTests.swift */; }; 225B05FE1E4EDA14001DE109 /* GuestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 225B05FD1E4EDA14001DE109 /* GuestTests.swift */; }; @@ -227,6 +231,10 @@ 14D2AA821E24AE07006FEE22 /* CHPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CHPluginTests.swift; sourceTree = ""; }; 14D2AA841E24AE07006FEE22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 14DFAC671E4A189D00130119 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Assets/Images.xcassets; sourceTree = ""; }; + 222086B71FBAAD37002CFA88 /* CHAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CHAnimations.swift; sourceTree = ""; }; + 222086B91FBAB28D002CFA88 /* CHTypingEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CHTypingEntity.swift; sourceTree = ""; }; + 22448B051FBA9C1900EDE528 /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = ""; }; + 22448B071FBA9D5800EDE528 /* CHMultiAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CHMultiAvatarView.swift; sourceTree = ""; }; 225B05F71E4ED913001DE109 /* WsServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WsServiceTests.swift; sourceTree = ""; }; 225B05F91E4ED9F5001DE109 /* ChannelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelTests.swift; sourceTree = ""; }; 225B05FB1E4EDA00001DE109 /* MessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageTests.swift; sourceTree = ""; }; @@ -634,6 +642,7 @@ 659792B61FA7167A00840F6A /* CustomTransform.swift */, 659792B71FA7167A00840F6A /* LocalMessageFactory.swift */, 659792B81FA7167A00840F6A /* PrefStore.swift */, + 222086B71FBAAD37002CFA88 /* CHAnimations.swift */, ); name = Utils; path = Source/Utils; @@ -714,6 +723,7 @@ 659792EB1FA7167B00840F6A /* DataTransferObject.swift */, 659792EC1FA7167B00840F6A /* ModelType.swift */, 6556A29B1FA821D300B80F4E /* CHError.swift */, + 222086B91FBAB28D002CFA88 /* CHTypingEntity.swift */, ); name = Models; path = Source/Models; @@ -778,7 +788,6 @@ 6597930B1FA7167C00840F6A /* Cells */, 6597931B1FA7167C00840F6A /* ChatBannerView.swift */, 6597931C1FA7167C00840F6A /* ChatNotificationView */, - 6597931E1FA7167C00840F6A /* ChatNotificationView.swift */, 6597931F1FA7167C00840F6A /* CHAvatar.swift */, 659793201FA7167C00840F6A /* CHMBubbleView.swift */, 659793211FA7167C00840F6A /* CHMFileView.swift */, @@ -787,11 +796,9 @@ 659793241FA7167C00840F6A /* CHPhoneField.swift */, 659793251FA7167C00840F6A /* CHTextField.swift */, 659793261FA7167C00840F6A /* CountryCodePickerView.swift */, - 659793271FA7167C00840F6A /* DialogActionView.swift */, 659793281FA7167C00840F6A /* DialogView */, 6597932B1FA7167C00840F6A /* ErrorToastView.swift */, 6597932C1FA7167C00840F6A /* LauncherView */, - 6597932F1FA7167C00840F6A /* MessageViews */, 659793301FA7167C00840F6A /* MultiAvatarView.swift */, 659793311FA7167C00840F6A /* NavigationItem.swift */, 659793321FA7167C00840F6A /* NewChatView.swift */, @@ -799,6 +806,7 @@ 659793341FA7167C00840F6A /* ProfileView */, 6597933A1FA7167C00840F6A /* TextActionView.swift */, 6597933B1FA7167C00840F6A /* UserChatsEmptyView.swift */, + 22448B071FBA9D5800EDE528 /* CHMultiAvatarView.swift */, ); name = Views; path = Source/Views; @@ -824,6 +832,7 @@ 6597930F1FA7167C00840F6A /* MessageCell */, 659793121FA7167C00840F6A /* NewMessageDividerCell.swift */, 659793131FA7167C00840F6A /* SatisfactionCompleteCell.swift */, + 22448B051FBA9C1900EDE528 /* TypingIndicatorCell.swift */, 659793141FA7167C00840F6A /* SatisfactionFeedbackCell.swift */, 659793151FA7167C00840F6A /* SwitchCell.swift */, 659793161FA7167C00840F6A /* TextInputCell.swift */, @@ -854,6 +863,7 @@ 6597931C1FA7167C00840F6A /* ChatNotificationView */ = { isa = PBXGroup; children = ( + 6597931E1FA7167C00840F6A /* ChatNotificationView.swift */, 6597931D1FA7167C00840F6A /* ChatNotificationViewModel.swift */, ); path = ChatNotificationView; @@ -863,6 +873,7 @@ isa = PBXGroup; children = ( 659793291FA7167C00840F6A /* DialogView.swift */, + 659793271FA7167C00840F6A /* DialogActionView.swift */, 6597932A1FA7167C00840F6A /* DialogViewModel.swift */, ); path = DialogView; @@ -877,13 +888,6 @@ path = LauncherView; sourceTree = ""; }; - 6597932F1FA7167C00840F6A /* MessageViews */ = { - isa = PBXGroup; - children = ( - ); - path = MessageViews; - sourceTree = ""; - }; 659793341FA7167C00840F6A /* ProfileView */ = { isa = PBXGroup; children = ( @@ -1242,6 +1246,7 @@ 6597935E1FA716CB00840F6A /* CRToast+Extensions.swift in Sources */, 6597935F1FA716CB00840F6A /* Date+Extensions.swift in Sources */, 659793601FA716CB00840F6A /* String+BoundingRect.swift in Sources */, + 22448B081FBA9D5800EDE528 /* CHMultiAvatarView.swift in Sources */, 659793611FA716CB00840F6A /* String+Utils.swift in Sources */, 659793621FA716CB00840F6A /* UIButton+Extensions.swift in Sources */, 659793631FA716CB00840F6A /* UIDevice+Extenions.swift in Sources */, @@ -1280,6 +1285,7 @@ 659793831FA716CB00840F6A /* DataTransferObject.swift in Sources */, 659793841FA716CB00840F6A /* ModelType.swift in Sources */, 659793861FA716CB00840F6A /* EventPromise.swift in Sources */, + 222086B81FBAAD37002CFA88 /* CHAnimations.swift in Sources */, 659793871FA716CB00840F6A /* GuestPromise.swift in Sources */, 659793881FA716CB00840F6A /* PluginPromise.swift in Sources */, 659793891FA716CB00840F6A /* ScriptPromise.swift in Sources */, @@ -1292,6 +1298,7 @@ 659793901FA716CB00840F6A /* MessagesReducer.swift in Sources */, 659793911FA716CB00840F6A /* PluginReducer.swift in Sources */, 659793921FA716CB00840F6A /* PushReducer.swift in Sources */, + 222086BA1FBAB28D002CFA88 /* CHTypingEntity.swift in Sources */, 659793931FA716CB00840F6A /* ScriptsReducer.swift in Sources */, 659793941FA716CB00840F6A /* SessionsReducer.swift in Sources */, 659793951FA716CB00840F6A /* UIReducer.swift in Sources */, @@ -1310,6 +1317,7 @@ 659793A21FA716CB00840F6A /* AvatarView.swift in Sources */, 659793A31FA716CB00840F6A /* Badge.swift in Sources */, 659793A41FA716CB00840F6A /* BaseButton.swift in Sources */, + 22448B061FBA9C1900EDE528 /* TypingIndicatorCell.swift in Sources */, 659793A51FA716CB00840F6A /* BaseTableViewCell.swift in Sources */, 659793A61FA716CB00840F6A /* BaseView.swift in Sources */, 659793A71FA716CB00840F6A /* NeverClearView.swift in Sources */, diff --git a/CHPlugin.xcodeproj/xcuserdata/R3alFr3e.xcuserdatad/xcschemes/xcschememanagement.plist b/CHPlugin.xcodeproj/xcuserdata/R3alFr3e.xcuserdatad/xcschemes/xcschememanagement.plist index 9d54cd28..b1eafc12 100644 --- a/CHPlugin.xcodeproj/xcuserdata/R3alFr3e.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/CHPlugin.xcodeproj/xcuserdata/R3alFr3e.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CHPlugin.xcscheme orderHint - 32 + 31 diff --git a/CHPlugin.xcworkspace/xcuserdata/R3alFr3e.xcuserdatad/UserInterfaceState.xcuserstate b/CHPlugin.xcworkspace/xcuserdata/R3alFr3e.xcuserdatad/UserInterfaceState.xcuserstate index 7e6773c0..660f906f 100644 Binary files a/CHPlugin.xcworkspace/xcuserdata/R3alFr3e.xcuserdatad/UserInterfaceState.xcuserstate and b/CHPlugin.xcworkspace/xcuserdata/R3alFr3e.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/CHPlugin/Assets/Images.xcassets/typing.imageset/Contents.json b/CHPlugin/Assets/Images.xcassets/typing.imageset/Contents.json new file mode 100644 index 00000000..d0809f18 --- /dev/null +++ b/CHPlugin/Assets/Images.xcassets/typing.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "typing.gif", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "typing@2x.gif", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "typing@3x.gif", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/CHPlugin/Assets/Images.xcassets/typing.imageset/typing.gif b/CHPlugin/Assets/Images.xcassets/typing.imageset/typing.gif new file mode 100644 index 00000000..bc969d7c Binary files /dev/null and b/CHPlugin/Assets/Images.xcassets/typing.imageset/typing.gif differ diff --git a/CHPlugin/Assets/Images.xcassets/typing.imageset/typing@2x.gif b/CHPlugin/Assets/Images.xcassets/typing.imageset/typing@2x.gif new file mode 100644 index 00000000..bc969d7c Binary files /dev/null and b/CHPlugin/Assets/Images.xcassets/typing.imageset/typing@2x.gif differ diff --git a/CHPlugin/Assets/Images.xcassets/typing.imageset/typing@3x.gif b/CHPlugin/Assets/Images.xcassets/typing.imageset/typing@3x.gif new file mode 100644 index 00000000..89add703 Binary files /dev/null and b/CHPlugin/Assets/Images.xcassets/typing.imageset/typing@3x.gif differ diff --git a/CHPlugin/Info.plist b/CHPlugin/Info.plist index 02085881..352f4c72 100644 --- a/CHPlugin/Info.plist +++ b/CHPlugin/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.2.2 + 2.3.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSAppTransportSecurity diff --git a/CHPlugin/Source/ChannelPlugin/ChannelPlugin.swift b/CHPlugin/Source/ChannelPlugin/ChannelPlugin.swift index 1e49177b..83eb413a 100644 --- a/CHPlugin/Source/ChannelPlugin/ChannelPlugin.swift +++ b/CHPlugin/Source/ChannelPlugin/ChannelPlugin.swift @@ -213,7 +213,7 @@ public final class ChannelPlugin : NSObject { }).disposed(by: disposeBeg) - WsService.sharedService.disconnect() + WsService.shared.disconnect() mainStore.dispatch(CheckOutSuccess()) ChannelPlugin.isCheckedIn = false } @@ -460,7 +460,7 @@ public final class ChannelPlugin : NSObject { return } - WsService.sharedService.connect() + WsService.shared.connect() mainStore.dispatch(CheckInSuccess(payload: data)) ChannelPlugin.isCheckedIn = true @@ -468,7 +468,7 @@ public final class ChannelPlugin : NSObject { ChannelPlugin.track(name: "Checkin", properties: nil) } - WsService.sharedService.ready() + WsService.shared.ready() .subscribe(onNext: { _ in subscriber.onNext(data) subscriber.onCompleted() @@ -629,13 +629,13 @@ extension ChannelPlugin { } @objc private class func disconnectWebsocket() { - WsService.sharedService.disconnect() + WsService.shared.disconnect() } @objc private class func connectWebsocket() { guard ChannelPlugin.isCheckedIn == true else { return } - WsService.sharedService.connect() + WsService.shared.connect() } } diff --git a/CHPlugin/Source/Controllers/UserChatViewController.swift b/CHPlugin/Source/Controllers/UserChatViewController.swift index c882835d..da70e078 100644 --- a/CHPlugin/Source/Controllers/UserChatViewController.swift +++ b/CHPlugin/Source/Controllers/UserChatViewController.swift @@ -47,10 +47,10 @@ final class UserChatViewController: BaseSLKTextViewController { var isFetching = false var isRequstingReadAll = false var photoUrls = [String]() - var newMessageView = ChatBannerView().then { - $0.isHidden = true - } + var typingManagers = [CHManager]() + var timeStorage = [String: Timer]() + var diffCalculator: SingleSectionTableViewDiffCalculator? var messages = [CHMessage]() { didSet { @@ -58,14 +58,18 @@ final class UserChatViewController: BaseSLKTextViewController { } } + var createdFeedback = false + var createdFeedbackComplete = false + var disposeBag = DisposeBag() var photoBrowser : MWPhotoBrowser? = nil var errorToastView = ErrorToastView().then { $0.isHidden = true } + var newMessageView = ChatBannerView().then { + $0.isHidden = true + } - var createdFeedback = false - var createdFeedbackComplete = false var newChatSubject = PublishSubject() var profileSubject = PublishSubject() @@ -89,9 +93,10 @@ final class UserChatViewController: BaseSLKTextViewController { chNavigation.chDelegate = self self.initSLKTextView() - self.initInputViews() self.initTableView() + self.initInputViews() self.initViews() + self.initLiveTyping() self.shouldShowGuide = (mainStore.state.guest.ghost == true || mainStore.state.guest.mobileNumber == nil) && @@ -112,16 +117,19 @@ final class UserChatViewController: BaseSLKTextViewController { mainStore.subscribe(self) if let userChatId = self.userChatId { self.state = .ChatJoining - WsService.sharedService.join(chatId: userChatId) + WsService.shared.join(chatId: userChatId) } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) mainStore.unsubscribe(self) + + self.sendTyping(isStop: true) + if let userChatId = self.userChatId { //self.loaded = false - WsService.sharedService.leave(chatId: userChatId) + WsService.shared.leave(chatId: userChatId) } } @@ -171,6 +179,7 @@ final class UserChatViewController: BaseSLKTextViewController { self.tableView.register(cellType: SatisfactionFeedbackCell.self) self.tableView.register(cellType: SatisfactionCompleteCell.self) self.tableView.register(cellType: LogCell.self) + self.tableView.register(cellType: TypingIndicatorCell.self) self.tableView.clipsToBounds = true self.tableView.separatorStyle = .none @@ -183,7 +192,9 @@ final class UserChatViewController: BaseSLKTextViewController { self.tableView.reloadData() //self.tableView.scrollToBottom(false) self.diffCalculator = SingleSectionTableViewDiffCalculator( - tableView: self.tableView, initialRows: self.messages + tableView: self.tableView, + initialRows: self.messages, + sectionIndex: 1 ) self.diffCalculator?.forceOffAnimationEnabled = true self.diffCalculator?.insertionAnimation = UITableViewRowAnimation.none @@ -213,7 +224,7 @@ final class UserChatViewController: BaseSLKTextViewController { self.errorToastView.refreshImageView.signalForClick() .subscribe(onNext: { [weak self] _ in - WsService.sharedService.connect() + WsService.shared.connect() self?.resetUserChat() self?.fetchMessages() }).disposed(by: self.disposeBag) @@ -445,7 +456,6 @@ extension UserChatViewController: StoreSubscriber { self.nextSeq = "" CHUserChat.get(userChatId: self.userChatId ?? "") - .subscribe(onNext: { [weak self] (response) in mainStore.dispatch(GetUserChat(payload: response)) self?.fetchMessages() @@ -468,9 +478,6 @@ extension UserChatViewController: StoreSubscriber { } self.requestReadAll() - //let diff = UIScreen.main.bounds.height - self.tableView.contentSize.height - 80 - //self.tableView.contentInset.top = diff > 0 ? diff : 10.f - //self.tableView.contentInset.bottom = 10.f } func configureInputField(_ userChat: CHUserChat?) { @@ -681,11 +688,12 @@ extension UserChatViewController { private func sendMessage(userChatId: String, text: String) { let me = mainStore.state.guest var message = CHMessage(chatId: userChatId, guest: me, message: text) - self.scrollToBottom(false) + mainStore.dispatch(CreateMessage(payload: message)) self.scrollToBottom(false) message.send().subscribe(onNext: { [weak self] (updated) in + self?.sendTyping(isStop: true) mainStore.dispatch(CreateMessage(payload: updated)) self?.showUserInfoGuideIfNeeded() }, onError: { (error) in @@ -702,7 +710,7 @@ extension UserChatViewController { self?.userChatId = userChat.id mainStore.dispatch(CreateUserChat(payload: userChat)) mainStore.dispatch(CreateSession(payload: session)) - WsService.sharedService.join(chatId: userChat.id) + WsService.shared.join(chatId: userChat.id) completion(userChat.id) }, onError: { [weak self] (error) in self?.errorToastView.show(animated: true) @@ -717,12 +725,15 @@ extension UserChatViewController { self.photoBrowser?.reloadData() } + + override func textViewDidChange(_ textView: UITextView) { + self.sendTyping(isStop: textView.text == "") + } } // MARK: - UIScrollViewDelegate extension UserChatViewController { - override func scrollViewDidScroll(_ scrollView: UIScrollView) { let yOffset = scrollView.contentOffset.y if yOffset + UIScreen.main.bounds.height > scrollView.contentHeight && @@ -737,20 +748,74 @@ extension UserChatViewController { self.newMessageView.hide(animated: false) } } - } -// MARK: - UITableViewDataSource +// MARK: - UITableView extension UserChatViewController { - + override func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.messages.count + if section == 0 { + return 1 + } else if section == 1 { + return self.messages.count + } + return 0 + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + if indexPath.section == 0 { + return 40 + } + + let message = self.messages[indexPath.row] + let previousMessage: CHMessage? = + indexPath.row == self.messages.count - 1 ? + self.messages[indexPath.row] : + self.messages[indexPath.row + 1] + let viewModel = MessageCellModel(message: message, previous: previousMessage) + switch message.messageType { + case .DateDivider: + return 40 + case .NewAlertMessage: + return 54 + case .SatisfactionFeedback: + return 158 + 16 + case .SatisfactionCompleted: + return 104 + 16 + case .Log: + return 46 + case .UserInfoDialog: + let model = DialogViewModel.model(type: message.userGuideDialogType) + return UserInfoDialogCell.measureHeight(fits: Constant.messageCellMaxWidth, viewModel: model) + default: + let calSize = MessageCell.measureHeight(fits: Constant.messageCellMaxWidth, viewModel: viewModel) + return calSize + } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let section = indexPath.section + if section == 0 { + return self.cellForTyping(tableView, cellForRowAt: indexPath) + } else { + return self.cellForMessage(tableView, cellForRowAt: indexPath) + } + } + + func cellForTyping(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TypingIndicatorCell = tableView.dequeueReusableCell(for: indexPath) + cell.transform = tableView.transform + cell.configure(typingUsers: self.typingManagers) + return cell + } + + func cellForMessage(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let message = self.messages[indexPath.row] - + switch message.messageType { case .ChannelClosed: let cell: MessageCell = tableView.dequeueReusableCell(for: indexPath) @@ -798,18 +863,18 @@ extension UserChatViewController { cell.configure(viewModel: model) cell.dialogView.signalForCountryCode() .subscribe(onNext: { [weak self] (code) in - self?.dismissKeyboard(true) - - let pickerView = CountryCodePickerView(frame: (self?.view.frame)!) - pickerView.pickedCode = code - pickerView.showPicker(onView: (self?.navigationController?.view)!,animated: true) + self?.dismissKeyboard(true) - pickerView.signalForSubmit() - .subscribe(onNext: { (code) in - cell.dialogView.setCountryCodeText(code: code) - cell.dialogView.phoneFieldView.phoneField.becomeFirstResponder() - }).disposed(by: (self?.disposeBag)!) - }).disposed(by: self.disposeBag) + let pickerView = CountryCodePickerView(frame: (self?.view.frame)!) + pickerView.pickedCode = code + pickerView.showPicker(onView: (self?.navigationController?.view)!,animated: true) + + pickerView.signalForSubmit() + .subscribe(onNext: { (code) in + cell.dialogView.setCountryCodeText(code: code) + cell.dialogView.phoneFieldView.phoneField.becomeFirstResponder() + }).disposed(by: (self?.disposeBag)!) + }).disposed(by: self.disposeBag) cell.transform = self.tableView.transform return cell case .SatisfactionFeedback: @@ -823,7 +888,7 @@ extension UserChatViewController { mainStore.dispatch(GetUserChat(payload: response)) }).disposed(by: (self?.disposeBag)!) } - }).disposed(by: self.disposeBag) + }).disposed(by: self.disposeBag) cell.transform = self.tableView.transform return cell case .SatisfactionCompleted: @@ -855,16 +920,16 @@ extension UserChatViewController { cell.clipImageView.signalForClick() .subscribe { [weak self] _ in - self?.didImageTapped(message: message) - }.disposed(by: self.disposeBag) + self?.didImageTapped(message: message) + }.disposed(by: self.disposeBag) cell.clipWebpageView.signalForClick() .subscribe{ [weak self] _ in - self?.didWebPageTapped(message: message) - }.disposed(by: self.disposeBag) + self?.didWebPageTapped(message: message) + }.disposed(by: self.disposeBag) cell.clipFileView.signalForClick() .subscribe { [weak self] _ in - self?.didFileTapped(message: message) - }.disposed(by: self.disposeBag) + self?.didFileTapped(message: message) + }.disposed(by: self.disposeBag) cell.transform = self.tableView.transform return cell @@ -875,7 +940,6 @@ extension UserChatViewController { // MARK: MWPhotoBrowser extension UserChatViewController: MWPhotoBrowserDelegate { - func numberOfPhotos(in photoBrowser: MWPhotoBrowser!) -> UInt { return UInt(self.photoUrls.count) } @@ -883,45 +947,8 @@ extension UserChatViewController: MWPhotoBrowserDelegate { func photoBrowser(_ photoBrowser: MWPhotoBrowser!, photoAt index: UInt) -> MWPhotoProtocol! { return MWPhoto(url: URL(string: self.photoUrls[Int(index)])) } - } -// MARK: - UITableViewDelegate - -extension UserChatViewController { - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let message = self.messages[indexPath.row] - let previousMessage: CHMessage? = - indexPath.row == self.messages.count - 1 ? - self.messages[indexPath.row] : - self.messages[indexPath.row + 1] - let viewModel = MessageCellModel(message: message, previous: previousMessage) - switch message.messageType { - case .DateDivider: - return 40 - case .NewAlertMessage: - return 54 - case .SatisfactionFeedback: - return 158 + 16 - case .SatisfactionCompleted: - return 104 + 16 - case .Log: - return 46 - case .UserInfoDialog: - let model = DialogViewModel.model(type: message.userGuideDialogType) - return UserInfoDialogCell.measureHeight(fits: Constant.messageCellMaxWidth, viewModel: model) - default: - let calSize = MessageCell.measureHeight(fits: Constant.messageCellMaxWidth, viewModel: viewModel) - return calSize - } - } - - override func tableView(_ tableView: UITableView, - didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - } -} // MARK: Clip handlers @@ -1004,14 +1031,12 @@ extension UserChatViewController { // MARK: UIDocumentInteractionControllerDelegate methods extension UserChatViewController : UIDocumentInteractionControllerDelegate { - func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { if let controller = CHUtils.getTopController() { return controller } return UIViewController() } - } extension UserChatViewController : CHNavigationDelegate { @@ -1020,6 +1045,7 @@ extension UserChatViewController : CHNavigationDelegate { self.requestReadAll() } if !willShow.isKind(of: UserChatViewController.self) { + self.resetTypingInfo() mainStore.dispatch(RemoveMessages(payload: self.userChatId)) } } @@ -1045,3 +1071,100 @@ extension UserChatViewController : SLKInputBarViewDelegate { } } } + +extension UserChatViewController { + func initLiveTyping() { + WsService.shared.typingSubject + .observeOn(MainScheduler.instance) + .subscribe(onNext: { [weak self] (typingEntity) in + guard let s = self else { return } + if typingEntity.action == "stop" { + if let index = s.getTypingIndex(of: typingEntity) { + let person = s.typingManagers.remove(at: index) + s.removeTimer(with: person) + } + } else if typingEntity.action == "start" { + if s.getTypingIndex(of: typingEntity) == nil, + let manager = personSelector( + state: mainStore.state, + personType: typingEntity.personType ?? "", + personId: typingEntity.personId) as? CHManager { + s.typingManagers.append(manager) + s.addTimer(with: manager, delay: 15) + } + } + + s.tableView.reloadSections(IndexSet(integer: 0), with: UITableViewRowAnimation.none) + }).disposed(by: self.disposeBag) + + WsService.shared.mOnCreate() + .observeOn(MainScheduler.instance) + .subscribe(onNext: { [weak self] (message) in + guard let s = self else { return } + + let typing = CHTypingEntity.transform(from: message) + if let index = s.getTypingIndex(of: typing) { + let person = s.typingManagers.remove(at: index) + s.removeTimer(with: person) + s.tableView.reloadSections(IndexSet(integer: 0), with: UITableViewRowAnimation.none) + } + }).disposed(by: self.disposeBag) + } + + func sendTyping(isStop: Bool) { + WsService.shared.sendTyping( + chat: self.userChat, isStop: isStop + ) + } + + func addTimer(with manager: CHManager, delay: TimeInterval) { + let timer = Timer.scheduledTimer( + timeInterval: delay, + target: self, + selector: #selector(self.expired(_:)), + userInfo: [manager], + repeats: false + ) + + if let t = self.timeStorage[manager.key] { + t.invalidate() + } + + self.timeStorage[manager.key] = timer + } + + func removeTimer(with manager: CHManager?) { + guard let manager = manager else { return } + if let t = self.timeStorage.removeValue(forKey: manager.key) { + t.invalidate() + } + } + + func resetTypingInfo() { + self.timeStorage.forEach { (k, t) in + t.invalidate() + } + self.typingManagers = [] + self.timeStorage = [:] + } + + @objc func expired(_ timer: Timer) { + guard let params = timer.userInfo as? [Any] else { return } + guard let manager = params[0] as? CHManager else { return } + + timer.invalidate() + if let index = self.typingManagers.index(where: { (m) in + return m.id == manager.id + }) { + self.typingManagers.remove(at: index) + self.timeStorage.removeValue(forKey: manager.key) + self.tableView.reloadSections(IndexSet(integer: 0), with: UITableViewRowAnimation.none) + } + } + + func getTypingIndex(of typingEntity: CHTypingEntity) -> Int? { + return self.typingManagers.index(where: { + $0.id == typingEntity.personId + }) + } +} diff --git a/CHPlugin/Source/Controllers/UserChatsViewController.swift b/CHPlugin/Source/Controllers/UserChatsViewController.swift index d757485a..6da33f35 100644 --- a/CHPlugin/Source/Controllers/UserChatsViewController.swift +++ b/CHPlugin/Source/Controllers/UserChatsViewController.swift @@ -84,7 +84,7 @@ class UserChatsViewController: BaseViewController { .subscribe(onNext: { [weak self] _ in self?.nextSeq = nil self?.fetchUserChats() - WsService.sharedService.connect() + WsService.shared.connect() }).disposed(by: self.disposeBag) self.plusButton.signalForClick() diff --git a/CHPlugin/Source/Extensions/String+Utils.swift b/CHPlugin/Source/Extensions/String+Utils.swift index 55bfa26a..53fd9551 100644 --- a/CHPlugin/Source/Extensions/String+Utils.swift +++ b/CHPlugin/Source/Extensions/String+Utils.swift @@ -48,7 +48,8 @@ extension String { .documentType: NSAttributedString.DocumentType.html, //iOS 8 symbol error //https://stackoverflow.com/questions/46484650/documentreadingoptionkey-key-corrupt-after-swift4-migration - NSAttributedString.DocumentReadingOptionKey("CharacterEncodi‌​ng"): String.Encoding.utf8.rawValue + //NSAttributedString.DocumentReadingOptionKey("CharacterEncoding"): String.Encoding.utf8.rawValue + .characterEncoding: String.Encoding.utf8.rawValue ], documentAttributes: nil).string } diff --git a/CHPlugin/Source/Models/CHAssets.swift b/CHPlugin/Source/Models/CHAssets.swift index 39ab8550..071d99a8 100644 --- a/CHPlugin/Source/Models/CHAssets.swift +++ b/CHPlugin/Source/Models/CHAssets.swift @@ -19,6 +19,22 @@ class CHAssets { return UIImage(named: named, in: bundle, compatibleWith: nil) } + class func getData(named: String) -> Data? { + let bundle = Bundle(for: self) + if #available(iOS 9.0, *) { + return NSDataAsset(name: named, bundle: bundle)?.data + } else { + do { + guard let url = try bundle.path(forResource: "typing", ofType: "gif")?.asURL() else { + return nil + } + return try Data(contentsOf: url) + } catch { + return nil + } + } + } + class func localized(_ key: String) -> String { let bundle = Bundle(for: self) return NSLocalizedString(key, tableName: nil, bundle: bundle, value: "", comment: "") diff --git a/CHPlugin/Source/Models/CHManager.swift b/CHPlugin/Source/Models/CHManager.swift index d060a40e..503b7799 100644 --- a/CHPlugin/Source/Models/CHManager.swift +++ b/CHPlugin/Source/Models/CHManager.swift @@ -20,6 +20,12 @@ struct CHManager: CHEntity { var color = "" // Manager var username = "" + + var key: String { + get { + return "Manager:\(self.id)" + } + } } extension CHManager: Mappable { diff --git a/CHPlugin/Source/Models/CHTypingEntity.swift b/CHPlugin/Source/Models/CHTypingEntity.swift new file mode 100644 index 00000000..afb0aa4d --- /dev/null +++ b/CHPlugin/Source/Models/CHTypingEntity.swift @@ -0,0 +1,58 @@ +// +// CHTypingEntity.swift +// CHPlugin +// +// Created by R3alFr3e on 11/14/17. +// Copyright © 2017 ZOYI. All rights reserved. +// + +import Foundation +import SocketIO +import ObjectMapper + +struct CHTypingEntity: SocketData { + var action = "" + var chatId = "" + var chatType = "" + var personId: String? = nil + var personType: String? = nil + + init(action: String, chatId: String, chatType: String, + personId: String? = nil, personType: String? = nil) { + self.action = action + self.chatId = chatId + self.chatType = chatType + self.personId = personId + self.personType = personType + } + + func socketRepresentation() -> SocketData { + return [ + "action": self.action, + "chatId": self.chatId, + "chatType": self.chatType, + ] + } + + static func transform(from message: CHMessage) -> CHTypingEntity { + return CHTypingEntity( + action: "stop", + chatId: message.chatId, + chatType: message.chatType, + personId: message.personId, + personType: message.personType + ) + } +} + +extension CHTypingEntity: Mappable { + init?(map: Map) { } + + mutating func mapping(map: Map) { + action <- map["action"] + chatId <- map["channelId"] + chatType <- map["chatType"] + personId <- map["personId"] + personType <- map["personType"] + } +} diff --git a/CHPlugin/Source/Models/CHi18n.swift b/CHPlugin/Source/Models/CHi18n.swift index f05e76fb..1f4a1e0d 100644 --- a/CHPlugin/Source/Models/CHi18n.swift +++ b/CHPlugin/Source/Models/CHi18n.swift @@ -18,8 +18,7 @@ struct CHi18n { guard let str = NSLocale.preferredLanguages.get(index: 0) else { return nil } let start = str.startIndex let end = str.index(str.startIndex, offsetBy: 2) - let range = start.. CHEntity? { +func personSelector(state: AppState, personType: String?, personId: String?) -> CHEntity? { + guard let personType = personType else { return nil } + guard let personId = personId else { return nil } + if personType == "Manager" { return state.managersState.findBy(id: personId) } else if personType == "User" || personType == "Veil" { diff --git a/CHPlugin/Source/Services/WsService.swift b/CHPlugin/Source/Services/WsService.swift index 4320cdde..43911b04 100644 --- a/CHPlugin/Source/Services/WsService.swift +++ b/CHPlugin/Source/Services/WsService.swift @@ -44,6 +44,7 @@ enum CHSocketResponse : String { case disconnect = "disconnect" case push = "push" case error = "error" + case typing = "typing" var value: String { return self.rawValue @@ -90,9 +91,11 @@ struct WsServiceType: OptionSet { class WsService { //MARK: Share Singleton Instance - static let sharedService = WsService() + static let shared = WsService() let eventSubject = PublishSubject() let readySubject = PublishSubject() + let typingSubject = PublishSubject() + let messageOnCreateSubject = PublishSubject() //MARK: Private properties fileprivate var socket: SocketIOClient! @@ -105,7 +108,10 @@ class WsService { //move these properties into state fileprivate var currentChatId: String? fileprivate var currentChat: CHUserChat? - fileprivate var heartbeatTimer: Foundation.Timer? + fileprivate var heartbeatTimer: Timer? + + private var stopTypingThrottleFnc: ((CHUserChat?) -> Void)? + private var startTypingThrottleFnc: ((CHUserChat?) -> Void)? init() { if let staging = CHUtils.getCurrentStage() { @@ -119,6 +125,16 @@ class WsService { // error } } + + self.stopTypingThrottleFnc = throttle( + delay: 1.0, + queue: DispatchQueue.global(qos: .background), + action: self.stopTyping) + + self.startTypingThrottleFnc = throttle( + delay: 1.0, + queue: DispatchQueue.global(qos: .background), + action: self.startTyping) } //MARK: Signals @@ -132,6 +148,13 @@ class WsService { return self.readySubject } + func typing() -> PublishSubject { + return self.typingSubject + } + + func mOnCreate() -> PublishSubject { + return self.messageOnCreateSubject + } //MARK: Socket functionalities func connect() { @@ -190,6 +213,40 @@ class WsService { } } + func sendTyping(chat: CHUserChat?, isStop: Bool) { + guard let socket = self.socket, socket.status == .connected else { return } + guard let chat = chat else { return } + + if isStop { + self.stopTypingThrottleFnc?(chat) + } else { + self.startTypingThrottleFnc?(chat) + } + } + + func startTyping(chat: CHUserChat?) { + guard let socket = self.socket, socket.status == .connected else { return } + guard let chat = chat else { return } + socket.emit("typing", CHTypingEntity( + action: "start", + chatId: chat.id, + chatType: "UserChat") + ) + } + + func stopTyping(chat: CHUserChat?) { + guard let socket = self.socket, socket.status == .connected else { return } + guard let chat = chat else { return } + + let entity = CHTypingEntity( + action: "stop", + chatId: chat.id, + chatType: "UserChat") + + socket.emit("typing", entity) + self.typingSubject.onNext(entity) + } + @objc func heartbeat() { dlog("heartbeat") if self.socket != nil { @@ -218,6 +275,7 @@ fileprivate extension WsService { self.onJoined() self.onLeaved() self.onPush() + self.onTyping() self.onAuthenticated() self.onUnauthorized() self.onReconnectAttempt() @@ -274,6 +332,7 @@ fileprivate extension WsService { case WsServiceType.Message: guard let message = Mapper() .map(JSONObject: json["entity"].object) else { return } + self?.messageOnCreateSubject.onNext(message) mainStore.dispatch(CreateMessage(payload: message)) break default: @@ -395,11 +454,19 @@ fileprivate extension WsService { } } + fileprivate func onTyping() { + self.socket.on(CHSocketResponse.typing.value) { [weak self] (data, ack) in + guard let entity = data.get(index: 0) else { return } + guard let json = JSON(rawValue: entity) else { return } + guard let typing = Mapper().map(JSONObject: json.object) else { return } + self?.typingSubject.onNext(typing) + } + } + fileprivate func onPush() { self.socket.on(CHSocketResponse.push.value) { [weak self] (data, ack) in self?.eventSubject.onNext(CHSocketResponse.push.value) //dlog("socket pushed: \(data)") - guard let entity = data.get(index: 0) else { return } guard let json = JSON(rawValue: entity) else { return } guard let push = Mapper().map(JSONObject: json.object) else { return } @@ -412,7 +479,6 @@ fileprivate extension WsService { } } - fileprivate func onAuthenticated() { self.socket.on(CHSocketResponse.authenticated.value) { [weak self] (data, ack) in self?.eventSubject.onNext(CHSocketResponse.authenticated.value) @@ -423,11 +489,13 @@ fileprivate extension WsService { } if let s = self { - self?.heartbeatTimer = Foundation.Timer.scheduledTimer( - timeInterval: 30, - target: s, - selector: #selector(WsService.heartbeat), - userInfo: nil, repeats: true) + dispatch { + self?.heartbeatTimer = Foundation.Timer.scheduledTimer( + timeInterval: 30, + target: s, + selector: #selector(WsService.heartbeat), + userInfo: nil, repeats: true) + } } } } diff --git a/CHPlugin/Source/Utils/CHAnimations.swift b/CHPlugin/Source/Utils/CHAnimations.swift new file mode 100644 index 00000000..0ba7d38b --- /dev/null +++ b/CHPlugin/Source/Utils/CHAnimations.swift @@ -0,0 +1,76 @@ +// +// CHAnimations.swift +// CHPlugin +// +// Created by R3alFr3e on 11/14/17. +// Copyright © 2017 ZOYI. All rights reserved. +// + +import Foundation +import UIKit + +final private class ViewAnimationStep { + fileprivate var completed: (() -> Void) = { } + fileprivate let animations: (() -> Void) + fileprivate let duration: TimeInterval + + init(withAnimations animations: @escaping (() -> Void), duration: TimeInterval = 0.0) { + self.animations = animations + self.duration = duration + } + + func onCompleted(completed: @escaping (() -> Void)) -> Self { + self.completed = completed + + return self + } + + func execute() { + UIView.animate(withDuration: duration, animations: animations) { (_) in + self.completed() + } + } +} + +class AnimationSequence { + fileprivate var completion: (() -> Void) = { } + fileprivate var sequence = [ViewAnimationStep]() + fileprivate var stepDuration: TimeInterval + + init(withStepDuration stepDuration: TimeInterval = 0.0) { + self.stepDuration = stepDuration + } + + @discardableResult + func doStep(_ animations: @escaping (() -> Void)) -> Self { + let step = ViewAnimationStep(withAnimations: animations, duration: stepDuration) + sequence.append(step) + + return self + } + + @discardableResult + func onCompletion(_ sequenceCompletion: @escaping (() -> Void)) -> Self { + completion = sequenceCompletion + + return self + } + + func execute() { + executeSteps() + } + + fileprivate func executeSteps() { + if sequence.isEmpty == false { + let step = sequence.removeFirst() + step + .onCompleted { + self.executeSteps() + } + .execute() + } + else { + completion() + } + } +} diff --git a/CHPlugin/Source/Utils/CHUtils.swift b/CHPlugin/Source/Utils/CHUtils.swift index cc0a402e..341ecc42 100644 --- a/CHPlugin/Source/Utils/CHUtils.swift +++ b/CHPlugin/Source/Utils/CHUtils.swift @@ -91,8 +91,7 @@ class CHUtils { guard let str = NSLocale.preferredLanguages.get(index: 0) else { return nil } let start = str.startIndex let end = str.index(str.startIndex, offsetBy: 2) - let range = start.. Bool { + return Date().timeIntervalSinceReferenceDate - self > since + } + +} +/** + Wraps a function in a new function that will throttle the execution to once in every `delay` seconds. + + - Parameter delay: A `TimeInterval` specifying the number of seconds that needst to pass between each execution of `action`. + - Parameter queue: The queue to perform the action on. Defaults to the main queue. + - Parameter action: A function to throttle. + + - Returns: A new function that will only call `action` once every `delay` seconds, regardless of how often it is called. + */ +func throttle(delay: TimeInterval, queue: DispatchQueue = .main, action: @escaping (() -> Void)) -> () -> Void { + var currentWorkItem: DispatchWorkItem? + var lastFire: TimeInterval = 0 + return { + guard currentWorkItem == nil else { return } + currentWorkItem = DispatchWorkItem { + action() + lastFire = Date().timeIntervalSinceReferenceDate + currentWorkItem = nil + } + if delay.hasPassed(since: lastFire) { + queue.async(execute: currentWorkItem!) + } else { + currentWorkItem = nil + } + } +} + +func throttle(delay: TimeInterval, queue: DispatchQueue = .main, action: @escaping ((T) -> Void)) -> (T) -> Void { + var currentWorkItem: DispatchWorkItem? + var lastFire: TimeInterval = 0 + return { (p1: T) in + guard currentWorkItem == nil else { return } + currentWorkItem = DispatchWorkItem { + action(p1) + lastFire = Date().timeIntervalSinceReferenceDate + currentWorkItem = nil + } + //if time has passed, execute workitem. Otherwise, abandon work + if delay.hasPassed(since: lastFire) { + queue.async(execute: currentWorkItem!) + } else { + currentWorkItem = nil + } + } +} + diff --git a/CHPlugin/Source/Views/CHMultiAvatarView.swift b/CHPlugin/Source/Views/CHMultiAvatarView.swift new file mode 100644 index 00000000..4ea90cdd --- /dev/null +++ b/CHPlugin/Source/Views/CHMultiAvatarView.swift @@ -0,0 +1,137 @@ +// +// CHMultiAvatarView.swift +// CHPlugin +// +// Created by R3alFr3e on 11/14/17. +// Copyright © 2017 ZOYI. All rights reserved. +// + +import Foundation +import UIKit + +class CHMultiAvatarView: BaseView { + let firstAvatarView = AvatarView().then { + $0.showBorder = false + } + let secondAvatarView = AvatarView().then { + $0.showBorder = false + } + let thirdAvatarView = AvatarView().then { + $0.showBorder = false + } + + var persons = [CHEntity]() + + override func initialize() { + super.initialize() + + self.addSubview(self.thirdAvatarView) + self.addSubview(self.secondAvatarView) + self.addSubview(self.firstAvatarView) + } + + override func setLayouts() { + super.setLayouts() + + self.firstAvatarView.snp.remakeConstraints { (make) in + make.size.equalTo(CGSize(width:22, height:22)) + make.top.equalToSuperview() + make.bottom.equalToSuperview() + make.leading.equalToSuperview() + } + + self.secondAvatarView.snp.remakeConstraints { (make) in + make.size.equalTo(CGSize(width:22, height:22)) + make.top.equalToSuperview() + make.bottom.equalToSuperview() + make.leading.equalToSuperview().inset(18) + } + + self.thirdAvatarView.snp.remakeConstraints { (make) in + make.size.equalTo(CGSize(width:22, height:22)) + make.top.equalToSuperview() + make.bottom.equalToSuperview() + make.leading.equalToSuperview().inset(36) + } + } + + func configure(persons: [CHEntity]) { + guard self.isIdentical(persons: persons) == false else { return } + + if persons.count == 1 { + self.firstAvatarView.configure(persons[0]) + self.layoutOneAvatar() + } else if persons.count == 2 { + self.firstAvatarView.configure(persons[0]) + self.secondAvatarView.configure(persons[1]) + self.layoutTwoAvatars() + } else if persons.count == 3 { + self.firstAvatarView.configure(persons[0]) + self.secondAvatarView.configure(persons[1]) + self.thirdAvatarView.configure(persons[2]) + self.layoutThreeAvatars() + } else { + self.firstAvatarView.configure(persons[0]) + self.secondAvatarView.configure(persons[1]) + self.layoutTwoAvatars() + } + } + + func isIdentical(persons: [CHEntity]) -> Bool { + for person in persons { + if self.persons.index(where: { (p) in + return p.avatarUrl == person.avatarUrl && p.name == person.name + }) != nil { + continue + } else { + return false + } + } + + return true + } + + func layoutOneAvatar() { + AnimationSequence(withStepDuration: 0.2).doStep { [weak self] in + self?.firstAvatarView.alpha = 1 + }.execute() + + if self.secondAvatarView.alpha == 1 { + AnimationSequence(withStepDuration: 0.2).doStep { [weak self] in + self?.secondAvatarView.alpha = 0 + }.execute() + } + + if self.thirdAvatarView.alpha == 1 { + AnimationSequence(withStepDuration: 0.2).doStep { [weak self] in + self?.thirdAvatarView.alpha = 0 + }.execute() + } + } + + func layoutTwoAvatars() { + if self.secondAvatarView.alpha == 0 { + AnimationSequence(withStepDuration: 0.2).doStep { [weak self] in + self?.secondAvatarView.alpha = 1 + }.execute() + } + + if self.thirdAvatarView.alpha == 1 { + AnimationSequence(withStepDuration: 0.2).doStep { [weak self] in + self?.thirdAvatarView.alpha = 0 + }.execute() + } + } + + func layoutThreeAvatars() { + if self.secondAvatarView.alpha == 0 { + let seq = AnimationSequence(withStepDuration: 0.4) + seq.doStep { [weak self] in + self?.secondAvatarView.alpha = 1 + } + .doStep { [weak self] in + self?.thirdAvatarView.alpha = 1 + }.execute() + } + } +} diff --git a/CHPlugin/Source/Views/Cells/TypingIndicatorCell.swift b/CHPlugin/Source/Views/Cells/TypingIndicatorCell.swift new file mode 100644 index 00000000..4e06fef8 --- /dev/null +++ b/CHPlugin/Source/Views/Cells/TypingIndicatorCell.swift @@ -0,0 +1,92 @@ +// +// TypingIndicatorCell.swift +// +// +// Created by R3alFr3e on 11/14/17. +// + +import Foundation +import UIKit +import SnapKit +import Reusable + +final class TypingIndicatorCell: BaseTableViewCell, Reusable { + let multiAvatarView = CHMultiAvatarView() + let personCountLabel = UILabel().then { + $0.font = UIFont.systemFont(ofSize: 12) + $0.textColor = CHColors.blueyGrey + } + let typingImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + } + + var avatarViewWidthConstraint: Constraint? = nil + + override func initialize() { + super.initialize() + + if let data = CHAssets.getData(named: "typing") { + self.typingImageView.image = UIImage.sd_animatedGIF(with: data) + } + + self.contentView.addSubview(self.multiAvatarView) + self.contentView.addSubview(self.personCountLabel) + self.contentView.addSubview(self.typingImageView) + } + + override func prepareForReuse() { + self.typingImageView.layoutIfNeeded() + } + + override func setLayouts() { + super.setLayouts() + + self.multiAvatarView.snp.remakeConstraints { [weak self] (make) in + make.leading.equalToSuperview().inset(10) + make.height.equalTo(22) + make.centerY.equalToSuperview() + self?.avatarViewWidthConstraint = make.width.equalTo(22).constraint + } + + self.personCountLabel.snp.remakeConstraints { [weak self] (make) in + make.leading.equalTo((self?.multiAvatarView.snp.trailing)!).offset(2) + make.centerY.equalToSuperview() + } + + self.typingImageView.snp.remakeConstraints { [weak self] (make) in + make.centerY.equalToSuperview() + make.height.equalTo(6) + make.width.equalTo(22) + make.leading.equalTo((self?.personCountLabel.snp.trailing)!).offset(6) + } + } + + func configure(typingUsers: [CHEntity]) { + guard typingUsers.count > 0 else { + self.multiAvatarView.isHidden = true + self.typingImageView.isHidden = true + self.personCountLabel.isHidden = true + return + } + + self.typingImageView.isHidden = false + self.multiAvatarView.isHidden = false + UIView.animate(withDuration: 0.2) { + self.personCountLabel.isHidden = typingUsers.count < 4 + self.personCountLabel.text = typingUsers.count < 4 ? "" : "+\(typingUsers.count)" + + if typingUsers.count == 1 { + self.avatarViewWidthConstraint?.update(offset: 22) + } else if typingUsers.count == 2 { + self.avatarViewWidthConstraint?.update(offset: 40) + } else if typingUsers.count == 3 { + self.avatarViewWidthConstraint?.update(offset: 58) + } else { + self.avatarViewWidthConstraint?.update(offset: 40) + } + } + + self.multiAvatarView.configure(persons: typingUsers) + } +} + diff --git a/CHPlugin/Source/Views/ChatNotificationView.swift b/CHPlugin/Source/Views/ChatNotificationView/ChatNotificationView.swift similarity index 100% rename from CHPlugin/Source/Views/ChatNotificationView.swift rename to CHPlugin/Source/Views/ChatNotificationView/ChatNotificationView.swift diff --git a/CHPlugin/Source/Views/DialogActionView.swift b/CHPlugin/Source/Views/DialogView/DialogActionView.swift similarity index 100% rename from CHPlugin/Source/Views/DialogActionView.swift rename to CHPlugin/Source/Views/DialogView/DialogActionView.swift diff --git a/Example/Swift Example/Channel Plugin Sample.xcodeproj/project.pbxproj b/Example/Swift Example/Channel Plugin Sample.xcodeproj/project.pbxproj index fd4759fb..9861258c 100644 --- a/Example/Swift Example/Channel Plugin Sample.xcodeproj/project.pbxproj +++ b/Example/Swift Example/Channel Plugin Sample.xcodeproj/project.pbxproj @@ -185,16 +185,16 @@ "${SRCROOT}/Pods/Target Support Files/Pods-Channel Plugin Sample/Pods-Channel Plugin Sample-frameworks.sh", "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework", "${BUILT_PRODUCTS_DIR}/CGFloatLiteral/CGFloatLiteral.framework", + "${BUILT_PRODUCTS_DIR}/CHDwifft/CHDwifft.framework", + "${BUILT_PRODUCTS_DIR}/CHPhotoBrowser/CHPhotoBrowser.framework", "${BUILT_PRODUCTS_DIR}/CHPlugin/CHPlugin.framework", "${BUILT_PRODUCTS_DIR}/CHSlackTextViewController/CHSlackTextViewController.framework", "${BUILT_PRODUCTS_DIR}/CRToast/CRToast.framework", "${BUILT_PRODUCTS_DIR}/DACircularProgress/DACircularProgress.framework", "${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework", - "${BUILT_PRODUCTS_DIR}/Dwifft/Dwifft.framework", "${BUILT_PRODUCTS_DIR}/M13ProgressSuite/M13ProgressSuite.framework", "${BUILT_PRODUCTS_DIR}/MBProgressHUD/MBProgressHUD.framework", "${BUILT_PRODUCTS_DIR}/MGSwipeTableCell/MGSwipeTableCell.framework", - "${BUILT_PRODUCTS_DIR}/MWPhotoBrowser/MWPhotoBrowser.framework", "${BUILT_PRODUCTS_DIR}/ManualLayout/ManualLayout.framework", "${BUILT_PRODUCTS_DIR}/NVActivityIndicatorView/NVActivityIndicatorView.framework", "${BUILT_PRODUCTS_DIR}/ObjectMapper/ObjectMapper.framework", @@ -216,16 +216,16 @@ outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Alamofire.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CGFloatLiteral.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CHDwifft.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CHPhotoBrowser.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CHPlugin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CHSlackTextViewController.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CRToast.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DACircularProgress.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Dwifft.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/M13ProgressSuite.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MBProgressHUD.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MGSwipeTableCell.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MWPhotoBrowser.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ManualLayout.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/NVActivityIndicatorView.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ObjectMapper.framework", diff --git a/README.md b/README.md index 0cfe6142..1f851fe8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## Prerequisite -* iOS 8 or above +* iOS 9 or above ## Documentation