From 28fe10f6ac356e39ff606833518c67c12543c220 Mon Sep 17 00:00:00 2001 From: eddardliu Date: Wed, 26 Aug 2026 15:23:32 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90Chat=E3=80=91Chat=20Demo=20=E5=92=8C?= =?UTF-8?q?=20UI=20=E7=BB=84=E4=BB=B6=20=E9=87=8D=E6=9E=84=E4=B8=BA?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=BC=8F=EF=BC=88Swift=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Recents/RecentCallsViewController.swift | 6 +- .../Recents/RecentCallsViewModel.swift | 2 +- chat/demo/ChatDemo.xcodeproj/project.pbxproj | 101 +- ...{TUIKitDemo.xcscheme => ChatDemo.xcscheme} | 18 +- .../UserInterfaceState.xcuserstate | Bin 0 -> 18470 bytes chat/demo/ChatDemo/AppDelegate.swift | 56 +- .../Contents.json | 21 + .../demo_ic_login_arrow_down@2x.png | Bin 0 -> 235 bytes .../demo_ic_login_arrow_down@3x.png | Bin 0 -> 295 bytes .../Contents.json | 21 + .../demo_ic_login_language@2x.png | Bin 0 -> 874 bytes .../demo_ic_login_language@3x.png | Bin 0 -> 1443 bytes .../Contents.json | 21 + .../demo_ic_login_theme@2x.png | Bin 0 -> 863 bytes .../demo_ic_login_theme@3x.png | Bin 0 -> 1511 bytes .../Contents.json | 18 + .../demo_ic_security_close@2x.png | Bin 0 -> 538 bytes .../demo_ic_security_close@3x.png | Bin 0 -> 699 bytes .../Contents.json | 18 + .../demo_ic_security_warning@2x.png | Bin 0 -> 1036 bytes .../demo_ic_security_warning@3x.png | Bin 0 -> 1336 bytes .../Contents.json | 12 + .../demo_login_brand_logo.png | Bin 0 -> 8477 bytes .../demo_login_hero_bg.imageset/Contents.json | 12 + .../demo_login_hero_bg.png | Bin 0 -> 171087 bytes .../tab_calls.imageset/Contents.json | 16 + .../tab_calls.imageset/tab_calls.svg | 1 + .../tab_chat.imageset/Contents.json | 20 +- .../tab_chat.imageset/tab_chat.svg | 1 + .../tab_chat_lines.imageset/Contents.json | 16 + .../tab_chat_lines.svg | 1 + .../tab_contact.imageset/Contents.json | 20 +- .../tab_contact.imageset/contacts.svg | 3 - .../tab_contact.imageset/tab_contact.svg | 1 + .../tab_setting.imageset/Contents.json | 20 +- .../tab_setting.imageset/setting.svg | 3 - .../tab_setting.imageset/tab_setting.svg | 1 + .../ChatDemo/ChatDemo-AppStore.entitlements | 5 + .../demo/ChatDemo/ChatDemo-Debug.entitlements | 5 + .../ChatDemo/ChatDemo-Release.entitlements | 5 + chat/demo/ChatDemo/ContentView.swift | 91 - chat/demo/ChatDemo/GenerateTestUserSig.swift | 3 +- chat/demo/ChatDemo/Info.plist | 10 +- .../Navigation/ChatFlowCoordinator.swift | 481 ++++ chat/demo/ChatDemo/Pages/ChatNavPage.swift | 386 --- chat/demo/ChatDemo/Pages/HomePage.swift | 668 +++-- chat/demo/ChatDemo/Pages/LoginPage.swift | 196 +- chat/demo/ChatDemo/Pages/LoginSubviews.swift | 180 ++ chat/demo/ChatDemo/Pages/SettingsPage.swift | 2478 +++++++++++------ .../Pages/VoiceMessageSettingPages.swift | 786 ++++++ chat/demo/ChatDemo/RootViewController.swift | 162 ++ chat/demo/ChatDemo/SceneDelegate.swift | 21 +- chat/demo/ChatDemo/StyleSettings.swift | 7 - chat/demo/ChatDemo/TUIKitDemoApp.swift | 10 - chat/demo/ChatDemo/appConfig.json | 2 +- chat/demo/ChatDemo/telephone_area_code.json | 1556 +++++++++++ chat/demo/ChatDemo/verification.html | 69 + chat/demo/Podfile | 31 +- chat/demo/Podfile.lock | 121 +- chat/demo/build.sh | 603 ++-- chat/demo/buildServer.json | 19 - chat/uikit/ChatPage.swift | 583 +++- chat/uikit/ChatUIKit.podspec | 26 +- chat/uikit/ContactsPage.swift | 202 +- chat/uikit/ConversationsPage.swift | 515 ++-- chat/uikit/LICENSE | 21 + .../components/AudioPlayer/AudioPlayer.swift | 138 + .../AudioRecorder/AudioRecorder.swift | 35 + .../AudioRecordSignatureChecker.h | 24 + .../AudioRecordSignatureChecker.m | 262 ++ .../AudioRecorderCore/AudioRecorderImpl.swift | 194 ++ .../AudioRecorderInternalProtocol.swift | 14 + .../AudioRecorderSystem.swift | 132 + .../AudioRecorderTXUGC.swift | 138 + .../TXUGCAudioRecorderReflector.swift | 127 + .../basiccontrols/AlertDialog.swift | 325 +++ .../BaseComponent/basiccontrols/Toast.swift | 329 +++ .../BaseComponent/basiccontrols/readme.md | 464 +++ .../BaseComponent/theme/Colors.swift | 497 ++++ .../BaseComponent/theme/Fonts.swift | 118 + .../BaseComponent/theme/Radius.swift | 27 + .../BaseComponent/theme/Spacing.swift | 40 + .../BaseComponent/theme/ThemeState.swift | 605 ++++ .../utils/AppBuilderHelper.swift | 112 + .../BaseComponent/utils/BundleHelper.swift | 86 + .../BaseComponent/utils/DateHelper.swift | 132 + .../BaseComponent/utils/LanguageHelper.swift | 74 + .../components/VideoPlayer/VideoPlayer.swift | 149 + .../components/ai/AiMediaProcessManager.swift | 260 ++ .../components/ai/tts/TtsPlaybackHelper.swift | 113 + .../components/ai/tts/TtsTextSanitizer.swift | 83 + chat/uikit/components/ai/tts/VoiceItem.swift | 57 + .../ai/tts/VoiceMessageConfig.swift | 41 + .../AddContactViewController.swift | 494 ++++ .../AvatarPickerSheetController.swift | 211 ++ .../AvatarSelectorViewController.swift | 203 ++ .../C2CChatSettingViewController.swift | 490 ++++ .../ChatBackgroundPickerViewController.swift | 235 ++ .../chatsetting/ChatBackgroundStore.swift | 67 + .../chatsetting/ChatSettingViews.swift | 403 +++ .../GroupChatSettingViewController.swift | 969 +++++++ .../GroupManagementViewController.swift | 485 ++++ .../GroupMemberListViewController.swift | 303 ++ .../GroupMemberPickerViewController.swift | 297 ++ .../chatsetting/GroupPermissionManager.swift | 430 +++ .../TextInputDialogViewController.swift | 292 ++ .../chatsetting/UserProfileRouter.swift | 158 ++ .../common/BubbleMenuViewController.swift | 313 +++ .../components/common/CallMessageParser.swift | 286 ++ .../components/common/ChatAvatarView.swift | 117 + .../common/ChatUIKitLayoutDirection.swift | 23 + .../common/ContactSearchBarView.swift | 168 ++ .../common/CustomMessageSummaryRegistry.swift | 23 + .../common/MessageInfo+Content.swift | 34 + .../components/common/MessageListHelper.swift | 283 ++ .../uikit/components/common/RTCBaseView.swift | 37 + .../components/common/SelectionCheckBox.swift | 76 + .../common/SystemNavigationBarPage.swift | 3 + .../common/UIViewController+ClosePage.swift | 13 + .../components/common/UserPickerItem.swift | 23 + .../components/common/UserPickerView.swift | 347 +++ .../azorderedlist/AZOrderedListCell.swift | 141 + .../azorderedlist/AZOrderedListSorter.swift | 95 + .../azorderedlist/AZOrderedListView.swift | 275 ++ .../azorderedlist/AtomicIndexBarView.swift | 145 + .../common/theme/ChatUIKitTheme.swift | 96 + .../common/uicustom/CustomEditor.swift | 88 + .../contactlist/AddFriendViewController.swift | 32 + .../contactlist/ContactListView.swift | 141 + .../contactlist/JoinGroupViewController.swift | 42 + .../utils/ContactDisplayNameFormatter.swift | 39 + .../view/AddFriendViewControllerImpl.swift | 181 ++ .../contactlist/view/ContactAddSupport.swift | 137 + .../contactlist/view/ContactEntryIcons.swift | 0 .../view/ContactListViewImpl.swift | 228 ++ .../view/ContactNavigationHeaderView.swift | 183 ++ .../view/JoinGroupDetailViewController.swift | 246 ++ .../view/JoinGroupViewControllerImpl.swift | 149 + .../subpage/BlackListViewController.swift | 135 + .../view/subpage/FriendApplicationCell.swift | 162 ++ .../FriendApplicationListViewController.swift | 144 + .../view/subpage/GroupApplicationCell.swift | 258 ++ .../GroupApplicationListViewController.swift | 144 + .../subpage/GroupListViewController.swift | 126 + .../view/subpage/SubPageNavigationBar.swift | 66 + .../viewmodel/ContactListViewModel.swift | 74 + .../viewmodel/ContactSubPageViewModels.swift | 117 + .../ConversationListView.swift | 142 + .../CreateC2CConversationViewController.swift | 53 + ...reateGroupConversationViewController.swift | 65 + .../utils/ConversationCellFormatter.swift | 146 + .../view/ConfigGroupInfoViewController.swift | 864 ++++++ .../view/ConversationCell.swift | 398 +++ .../view/ConversationCreateSupport.swift | 32 + .../view/ConversationListViewImpl.swift | 298 ++ .../ConversationPopupMenuController.swift | 274 ++ ...ateC2CConversationViewControllerImpl.swift | 72 + ...eGroupConversationViewControllerImpl.swift | 194 ++ .../viewmodel/ConversationListViewModel.swift | 102 + chat/uikit/components/emoji/EmojiHelper.swift | 301 ++ .../uikit/components/emoji/EmojiManager.swift | 266 ++ .../messageinput/MessageInputView.swift | 323 +++ ...lbumPickerMediaSendCoordinator+Media.swift | 165 ++ .../AlbumPickerMediaSendCoordinator.swift | 365 +++ .../AlbumPickerPlaceholderImpl.swift | 154 + .../typing/TypingIndicatorController.swift | 164 ++ .../messageinput/view/EmojiPickerView.swift | 520 ++++ .../GroupCallMemberPickerViewController.swift | 241 ++ .../messageinput/view/MentionMemberCell.swift | 80 + .../MentionMemberPickerViewController.swift | 280 ++ .../view/MessageInputAudioRecorderView.swift | 587 ++++ .../view/MessageInputMediaCoordinator.swift | 404 +++ .../view/MessageInputMorePanel.swift | 240 ++ .../view/MessageInputQuotePreviewBar.swift | 105 + .../MessageInputQuoteSummaryFormatter.swift | 33 + .../view/MessageInputTextView.swift | 371 +++ .../view/MessageInputViewImpl.swift | 1072 +++++++ .../view/VoiceTranscriptionOverlayView.swift | 1125 ++++++++ .../messageinput/viewmodel/MentionInfo.swift | 42 + .../viewmodel/MessageInputViewModel.swift | 300 ++ .../messagelist/MessageListView.swift | 273 ++ .../cell/AuxiliaryTextDisplayState.swift | 7 + .../cell/ChatAudioPlaybackCoordinator.swift | 42 + .../cell/MessageAudioContentView.swift | 366 +++ .../cell/MessageAuxiliaryTextBubbleView.swift | 217 ++ .../messagelist/cell/MessageBaseCell.swift | 908 ++++++ .../cell/MessageBubbleStyler.swift | 122 + .../cell/MessageCallContentView.swift | 175 ++ .../cell/MessageCellRegistry.swift | 193 ++ .../cell/MessageCenteredTextCell.swift | 88 + .../messagelist/cell/MessageContentView.swift | 28 + .../cell/MessageFallbackContentView.swift | 85 + .../cell/MessageFileContentView.swift | 229 ++ .../cell/MessageImageContentView.swift | 158 ++ .../cell/MessageMergedContentView.swift | 189 ++ .../cell/MessageQuoteBubbleView.swift | 345 +++ .../cell/MessageTextContentView.swift | 117 + .../cell/MessageVideoContentView.swift | 223 ++ .../config/MessageListConfig.swift | 116 + .../forward/ForwardBottomBar.swift | 157 ++ .../ForwardContactPickerViewController.swift | 120 + .../forward/ForwardConversationCell.swift | 125 + .../ForwardTargetSelectorViewController.swift | 377 +++ .../imageviewer/ImageElement.swift | 13 + .../imageviewer/ImageViewerController.swift | 370 +++ .../imageviewer/ImageViewerDataProvider.swift | 161 ++ .../imageviewer/MediaZoomCell.swift | 172 ++ .../listen/ListenFromHereController.swift | 250 ++ .../listen/ListenPlaybackBar.swift | 175 ++ .../menu/MessageActionMenuAction.swift | 278 ++ .../menu/MessageActionMenuController.swift | 775 ++++++ .../reaction/MessageReactionBarView.swift | 282 ++ .../ReactionDetailViewController.swift | 312 +++ .../reaction/ReactionEmojiPanelView.swift | 252 ++ .../ReactionEmojiPickerViewController.swift | 396 +++ .../reaction/ReactionEmojiRenderer.swift | 44 + .../MessageReadReceiptHelper.swift | 54 + .../MessageReadReceiptViewController.swift | 313 +++ .../tongue/MessageListFloatingEntry.swift | 206 ++ .../tongue/MessageTongueView.swift | 137 + .../MergedMessageDetailViewController.swift | 499 ++++ .../view/MessageListMultiSelectBar.swift | 164 ++ .../view/MessageListViewImpl.swift | 1604 +++++++++++ .../viewmodel/MessageListViewModel.swift | 580 ++++ .../search/SearchEntryBarView.swift | 102 + .../search/utils/SearchDisplayHelper.swift | 20 + .../search/utils/SearchHighlightBuilder.swift | 67 + .../utils/SearchResultCellConfigurator.swift | 80 + .../search/view/SearchFieldBar.swift | 169 ++ .../SearchMessageDetailViewController.swift | 338 +++ .../search/view/SearchResultCell.swift | 118 + .../view/SearchResultMoreViewController.swift | 229 ++ .../view/SearchResultViewController.swift | 420 +++ .../search/view/SearchStatusView.swift | 135 + .../search/viewmodel/SearchViewModel.swift | 145 + .../Common/VideoRecorderCommon.h | 29 + .../Common/VideoRecorderCommon.m | 222 ++ .../Common/VideoRecorderImageUtil.h | 14 + .../Common/VideoRecorderImageUtil.m | 68 + .../Common/VideoRecorderNSArray+Functional.h | 13 + .../Common/VideoRecorderNSArray+Functional.m | 15 + .../videorecorder/Core/ReflectUtil.h | 11 + .../videorecorder/Core/ReflectUtil.m | 86 + .../Core/SystemVideoRecordCore.h | 11 + .../Core/SystemVideoRecordCore.m | 447 +++ .../Core/UGCReflectVideoRecordCore.h | 11 + .../Core/UGCReflectVideoRecordCore.m | 180 ++ .../videorecorder/Core/VideoRecordCore.h | 60 + .../videorecorder/Core/VideoRecordCore.m | 106 + .../Core/VideoRecordSignatureChecker.h | 22 + .../Core/VideoRecordSignatureChecker.m | 204 ++ .../Core/VideoRecorderBeautyManager.h | 33 + .../Core/VideoRecorderBeautyManager.m | 50 + .../Core/VideoRecorderConfigInternal.h | 24 + .../Core/VideoRecorderConfigInternal.m | 142 + .../Core/VideoRecorderEncodeConfig.h | 18 + .../Core/VideoRecorderEncodeConfig.m | 61 + .../Core/VideoRecorderReflectUtil.h | 14 + .../Core/VideoRecorderReflectUtil.m | 129 + .../videorecorder/Core/VideoRecorderTypeDef.h | 68 + chat/uikit/components/videorecorder/README.md | 72 + .../videorecorder/VideoRecorder.swift | 34 + .../VideoRecorderCircleProgressView.h | 20 + .../VideoRecorderCircleProgressView.m | 100 + .../VideoRecorderIconLabelButtonView.h | 13 + .../VideoRecorderIconLabelButtonView.m | 40 + .../VideoRecorderRecordButtonView.h | 29 + .../VideoRecorderRecordButtonView.m | 126 + .../VideoRecorderSplitterView.h | 15 + .../VideoRecorderSplitterView.m | 76 + .../VideoRecorderTabPanelView.h | 50 + .../VideoRecorderTabPanelView.m | 285 ++ .../VideoRecorderBeautifyEffectItem.h | 28 + .../VideoRecorderBeautifyEffectItem.m | 79 + .../VideoRecorderBeautifyEffectPanelView.h | 23 + .../VideoRecorderBeautifyEffectPanelView.m | 110 + .../VideoRecorderBeautifyEffectViewCell.h | 18 + .../VideoRecorderBeautifyEffectViewCell.m | 119 + .../VideoRecorderBeautifySettings.h | 29 + .../VideoRecorderBeautifySettings.m | 19 + .../BeautyView/VideoRecorderBeautifyView.h | 29 + .../BeautyView/VideoRecorderBeautifyView.m | 256 ++ ...oRecorderAuthorizationPrompterController.h | 20 + ...oRecorderAuthorizationPrompterController.m | 121 + .../View/VideoRecorderControlView.h | 47 + .../View/VideoRecorderControlView.m | 410 +++ .../View/VideoRecorderController.h | 17 + .../View/VideoRecorderController.m | 430 +++ .../View/VideoRecorderPhotoEditBridge.swift | 63 + .../View/VideoRecorderPreviewView.h | 21 + .../View/VideoRecorderPreviewView.m | 292 ++ .../config/video_recorder_config.json | 11 + .../assets/chat/chat.xcassets/Contents.json | 6 + .../chat/chat.xcassets/icon/Contents.json | 6 + .../avatar-contact.imageset/Contents.json | 24 + .../avatar-contact.png | Bin 0 -> 941 bytes .../Contents.json | 21 + .../avatar-default-contact.png | Bin 0 -> 1542 bytes .../Contents.json | 21 + .../avatar-default-group.png | Bin 0 -> 1684 bytes .../Contents.json | 21 + .../check-all-highlight.png | Bin 0 -> 745 bytes .../icon/check-all.imageset/Contents.json | 21 + .../icon/check-all.imageset/check-all.png | Bin 0 -> 696 bytes .../Contents.json | 24 + .../check-circle-filled.png | Bin 0 -> 881 bytes .../icon/check.imageset/Contents.json | 21 + .../icon/check.imageset/check.png | Bin 0 -> 699 bytes .../contact_add_circle.imageset/Contents.json | 25 + .../contact_add_circle@2x.png | Bin 0 -> 1252 bytes .../contact_add_circle@3x.png | Bin 0 -> 2061 bytes .../contact_add_friend.imageset/Contents.json | 25 + .../contact_add_friend@2x.png | Bin 0 -> 1016 bytes .../contact_add_friend@3x.png | Bin 0 -> 1800 bytes .../contact_add_group.imageset/Contents.json | 25 + .../contact_add_group@2x.png | Bin 0 -> 806 bytes .../contact_add_group@3x.png | Bin 0 -> 1057 bytes .../contact_blacklist.imageset/Contents.json | 22 + .../contact_blacklist@2x.png | Bin 0 -> 2084 bytes .../contact_blacklist@3x.png | Bin 0 -> 3667 bytes .../Contents.json | 22 + .../contact_group_notification@2x.png | Bin 0 -> 2025 bytes .../contact_group_notification@3x.png | Bin 0 -> 3276 bytes .../Contents.json | 25 + .../contact_info_arrow_right@2x.png | Bin 0 -> 375 bytes .../contact_info_arrow_right@3x.png | Bin 0 -> 598 bytes .../contact_info_back.imageset/Contents.json | 25 + .../contact_info_back@2x.png | Bin 0 -> 385 bytes .../contact_info_back@3x.png | Bin 0 -> 589 bytes .../contact_my_group.imageset/Contents.json | 22 + .../contact_my_group@2x.png | Bin 0 -> 2685 bytes .../contact_my_group@3x.png | Bin 0 -> 4506 bytes .../Contents.json | 22 + .../contact_new_contacts@2x.png | Bin 0 -> 2623 bytes .../contact_new_contacts@3x.png | Bin 0 -> 4285 bytes .../contact_search.imageset/Contents.json | 25 + .../contact_search@2x.png | Bin 0 -> 889 bytes .../contact_search@3x.png | Bin 0 -> 1493 bytes .../Contents.json | 21 + .../contact_search_clear@2x.png | Bin 0 -> 287 bytes .../contact_search_clear@3x.png | Bin 0 -> 385 bytes .../Contents.json | 24 + .../error-circle-filled.png | Bin 0 -> 830 bytes .../Contents.json | 25 + .../group_setting_edit_icon@2x.png | Bin 0 -> 1410 bytes .../group_setting_edit_icon@3x.png | Bin 0 -> 2239 bytes .../Contents.json | 18 + .../group_type_community@2x.png | Bin 0 -> 2445 bytes .../group_type_community@3x.png | Bin 0 -> 3896 bytes .../group_type_meeting.imageset/Contents.json | 18 + .../group_type_meeting@2x.png | Bin 0 -> 2427 bytes .../group_type_meeting@3x.png | Bin 0 -> 3596 bytes .../group_type_public.imageset/Contents.json | 18 + .../group_type_public@2x.png | Bin 0 -> 1643 bytes .../group_type_public@3x.png | Bin 0 -> 2549 bytes .../group_type_work.imageset/Contents.json | 18 + .../group_type_work@2x.png | Bin 0 -> 2562 bytes .../group_type_work@3x.png | Bin 0 -> 4012 bytes .../help-circle-filled.imageset/Contents.json | 24 + .../help-circle-filled.png | Bin 0 -> 955 bytes .../Contents.json | 22 + .../image_viewer_close_icon@2x.png | Bin 0 -> 346 bytes .../image_viewer_close_icon@3x.png | Bin 0 -> 571 bytes .../Contents.json | 22 + .../image_viewer_save_button@2x.png | Bin 0 -> 2092 bytes .../image_viewer_save_button@3x.png | Bin 0 -> 3048 bytes .../info-circle-filled.imageset/Contents.json | 24 + .../info-circle-filled.png | Bin 0 -> 830 bytes .../icon/info-circle.imageset/Contents.json | 24 + .../icon/info-circle.imageset/info-circle.png | Bin 0 -> 901 bytes .../icon/input_audio.imageset/Contents.json | 22 + .../input_audio.imageset/input_audio@2x.png | Bin 0 -> 3527 bytes .../input_audio.imageset/input_audio@3x.png | Bin 0 -> 5426 bytes .../icon/input_camera.imageset/Contents.json | 21 + .../input_camera.imageset/input_camera.png | Bin 0 -> 924 bytes .../icon/input_emoji.imageset/Contents.json | 22 + .../input_emoji.imageset/input_emoji@2x.png | Bin 0 -> 3252 bytes .../input_emoji.imageset/input_emoji@3x.png | Bin 0 -> 4842 bytes .../input_keyboard.imageset/Contents.json | 21 + .../input_keyboard@3x.png | Bin 0 -> 2333 bytes .../icon/input_more.imageset/Contents.json | 22 + .../input_more.imageset/input_more@2x.png | Bin 0 -> 2880 bytes .../input_more.imageset/input_more@3x.png | Bin 0 -> 4227 bytes .../icon/loading-blue.imageset/Contents.json | 24 + .../loading-blue.imageset/loading-blue.png | Bin 0 -> 1737 bytes .../icon/loading.imageset/Contents.json | 24 + .../icon/loading.imageset/loading.png | Bin 0 -> 812 bytes .../message_call_audio.imageset/Contents.json | 25 + .../message_call_audio@2x.png | Bin 0 -> 575 bytes .../message_call_audio@3x.png | Bin 0 -> 828 bytes .../message_call_video.imageset/Contents.json | 25 + .../message_call_video@2x.png | Bin 0 -> 537 bytes .../message_call_video@3x.png | Bin 0 -> 827 bytes .../Contents.json | 18 + .../message_chevron_down@2x.png | Bin 0 -> 385 bytes .../message_chevron_down@3x.png | Bin 0 -> 537 bytes .../message_convert.imageset/Contents.json | 18 + .../message_convert.png | Bin 0 -> 1154 bytes .../message_convert@2x.png | Bin 0 -> 328 bytes .../message_convert@3x.png | Bin 0 -> 408 bytes .../icon/message_copy.imageset/Contents.json | 18 + .../message_copy.imageset/message_copy.png | Bin 0 -> 553 bytes .../message_copy.imageset/message_copy@2x.png | Bin 0 -> 473 bytes .../message_copy.imageset/message_copy@3x.png | Bin 0 -> 546 bytes .../message_delete.imageset/Contents.json | 18 + .../message_delete.png | Bin 0 -> 706 bytes .../message_delete@2x.png | Bin 0 -> 525 bytes .../message_delete@3x.png | Bin 0 -> 691 bytes .../Contents.json | 18 + .../message_file_download@2x.png | Bin 0 -> 1927 bytes .../message_file_download@3x.png | Bin 0 -> 2957 bytes .../Contents.json | 18 + .../message_file_type_excel@2x.png | Bin 0 -> 2767 bytes .../message_file_type_excel@3x.png | Bin 0 -> 4594 bytes .../Contents.json | 18 + .../message_file_type_img@2x.png | Bin 0 -> 2373 bytes .../message_file_type_img@3x.png | Bin 0 -> 3940 bytes .../Contents.json | 18 + .../message_file_type_pdf@2x.png | Bin 0 -> 3389 bytes .../message_file_type_pdf@3x.png | Bin 0 -> 5723 bytes .../Contents.json | 18 + .../message_file_type_ppt@2x.png | Bin 0 -> 1389 bytes .../message_file_type_ppt@3x.png | Bin 0 -> 2161 bytes .../Contents.json | 18 + .../message_file_type_txt@2x.png | Bin 0 -> 1288 bytes .../message_file_type_txt@3x.png | Bin 0 -> 2175 bytes .../Contents.json | 18 + .../message_file_type_unknown@2x.png | Bin 0 -> 1558 bytes .../message_file_type_unknown@3x.png | Bin 0 -> 2672 bytes .../Contents.json | 18 + .../message_file_type_word@2x.png | Bin 0 -> 2814 bytes .../message_file_type_word@3x.png | Bin 0 -> 4712 bytes .../Contents.json | 18 + .../message_file_type_zip@2x.png | Bin 0 -> 1592 bytes .../message_file_type_zip@3x.png | Bin 0 -> 2544 bytes .../message_forward.imageset/Contents.json | 18 + .../message_forward.png | Bin 0 -> 1254 bytes .../message_forward@2x.png | Bin 0 -> 679 bytes .../message_forward@3x.png | Bin 0 -> 1061 bytes .../icon/message_info.imageset/Contents.json | 21 + .../message_info.imageset/message_info.png | Bin 0 -> 1269 bytes .../message_listen.imageset/Contents.json | 25 + .../message_listen@2x.png | Bin 0 -> 495 bytes .../message_listen@3x.png | Bin 0 -> 649 bytes .../Contents.json | 25 + .../message_listen_close@2x.png | Bin 0 -> 378 bytes .../message_listen_close@3x.png | Bin 0 -> 465 bytes .../Contents.json | 18 + .../message_listen_from_here@2x.png | Bin 0 -> 706 bytes .../message_listen_from_here@3x.png | Bin 0 -> 1092 bytes .../icon/message_more.imageset/Contents.json | 18 + .../message_more.imageset/message_more@2x.png | Bin 0 -> 333 bytes .../message_more.imageset/message_more@3x.png | Bin 0 -> 500 bytes .../Contents.json | 23 + .../message_multi_delete@1x.png | Bin 0 -> 892 bytes .../message_multi_delete@2x.png | Bin 0 -> 1934 bytes .../message_multi_delete@3x.png | Bin 0 -> 3183 bytes .../Contents.json | 23 + .../message_multi_forward_merge@1x.png | Bin 0 -> 1193 bytes .../message_multi_forward_merge@2x.png | Bin 0 -> 2638 bytes .../message_multi_forward_merge@3x.png | Bin 0 -> 4281 bytes .../Contents.json | 23 + .../message_multi_forward_separate@1x.png | Bin 0 -> 1174 bytes .../message_multi_forward_separate@2x.png | Bin 0 -> 2641 bytes .../message_multi_forward_separate@3x.png | Bin 0 -> 4248 bytes .../Contents.json | 18 + .../message_multiselect.png | Bin 0 -> 582 bytes .../message_multiselect@2x.png | Bin 0 -> 378 bytes .../message_multiselect@3x.png | Bin 0 -> 599 bytes .../icon/message_quote.imageset/Contents.json | 18 + .../message_quote.imageset/message_quote.png | Bin 0 -> 831 bytes .../message_quote@2x.png | Bin 0 -> 573 bytes .../message_quote@3x.png | Bin 0 -> 772 bytes .../message_reaction.imageset/Contents.json | 18 + .../message_reaction@2x.png | Bin 0 -> 851 bytes .../message_reaction@3x.png | Bin 0 -> 1446 bytes .../Contents.json | 18 + .../message_reaction_collapse@2x.png | Bin 0 -> 435 bytes .../message_reaction_collapse@3x.png | Bin 0 -> 595 bytes .../message_recall.imageset/Contents.json | 18 + .../message_recall.png | Bin 0 -> 886 bytes .../message_recall@2x.png | Bin 0 -> 463 bytes .../message_recall@3x.png | Bin 0 -> 686 bytes .../icon/message_reply.imageset/Contents.json | 21 + .../message_reply.imageset/message_reply.png | Bin 0 -> 1261 bytes .../Contents.json | 18 + .../message_toast_error@2x.png | Bin 0 -> 1569 bytes .../message_toast_error@3x.png | Bin 0 -> 1194 bytes .../message_translate.imageset/Contents.json | 18 + .../message_translate.png | Bin 0 -> 986 bytes .../message_translate@2x.png | Bin 0 -> 855 bytes .../message_translate@3x.png | Bin 0 -> 1285 bytes .../Contents.json | 22 + .../message_video_play_icon@2x.png | Bin 0 -> 2002 bytes .../message_video_play_icon@3x.png | Bin 0 -> 3686 bytes .../icon/more_camera.imageset/Contents.json | 21 + .../more_camera.imageset/more_camera@2x.png | Bin 0 -> 1366 bytes .../more_camera.imageset/more_camera@3x.png | Bin 0 -> 2063 bytes .../icon/more_file.imageset/Contents.json | 21 + .../icon/more_file.imageset/more_file@2x.png | Bin 0 -> 652 bytes .../icon/more_file.imageset/more_file@3x.png | Bin 0 -> 862 bytes .../icon/more_picture.imageset/Contents.json | 21 + .../more_picture.imageset/more_picture@2x.png | Bin 0 -> 1121 bytes .../more_picture.imageset/more_picture@3x.png | Bin 0 -> 1410 bytes .../icon/more_video.imageset/Contents.json | 21 + .../more_video.imageset/more_video@2x.png | Bin 0 -> 794 bytes .../more_video.imageset/more_video@3x.png | Bin 0 -> 1091 bytes .../more_video_call.imageset/Contents.json | 21 + .../more_video_call@2x.png | Bin 0 -> 808 bytes .../more_video_call@3x.png | Bin 0 -> 1064 bytes .../more_voice_call.imageset/Contents.json | 21 + .../more_voice_call@2x.png | Bin 0 -> 1513 bytes .../more_voice_call@3x.png | Bin 0 -> 2442 bytes .../setting_sendmsg.imageset/Contents.json | 16 + .../icon/setting_sendmsg.imageset}/chats.svg | 0 .../Contents.json | 18 + .../uikit_empty_illustration@2x.png | Bin 0 -> 15536 bytes .../uikit_empty_illustration@3x.png | Bin 0 -> 27214 bytes .../chat/videorecorder.xcassets/Contents.json | 6 + .../videorecorder.xcassets/icon/Contents.json | 6 + .../icon/beauty/Contents.json | 6 + .../beauty/natural.imageset/Contents.json | 21 + .../icon/beauty/natural.imageset/natural.png | Bin 0 -> 9842 bytes .../icon/beauty/none.imageset/Contents.json | 21 + .../icon/beauty/none.imageset/none.png | Bin 0 -> 1711 bytes .../icon/beauty/pitu.imageset/Contents.json | 21 + .../icon/beauty/pitu.imageset/pitu.png | Bin 0 -> 8163 bytes .../icon/beauty/ruddy.imageset/Contents.json | 21 + .../icon/beauty/ruddy.imageset/ruddy.png | Bin 0 -> 1166 bytes .../icon/beauty/smooth.imageset/Contents.json | 21 + .../icon/beauty/smooth.imageset/smooth.png | Bin 0 -> 1845 bytes .../beauty/whitness.imageset/Contents.json | 21 + .../beauty/whitness.imageset/whitness.png | Bin 0 -> 1998 bytes .../icon/beauty_record.imageset/Contents.json | 21 + .../beauty_record.imageset/beauty_record.png | Bin 0 -> 1487 bytes .../icon/camera_switch.imageset/Contents.json | 21 + .../camera_switch.imageset/camera_switch.png | Bin 0 -> 2836 bytes .../icon/cross.imageset/Contents.json | 21 + .../icon/cross.imageset/cross.png | Bin 0 -> 553 bytes .../effect_compare.imageset/Contents.json | 21 + .../effect_compare.png | Bin 0 -> 9093 bytes .../icon/filter/Contents.json | 6 + .../icon/filter/icon/Contents.json | 6 + .../icon/icon_bailan.imageset/Contents.json | 21 + .../icon/icon_bailan.imageset/bailan.png | Bin 0 -> 104920 bytes .../icon/icon_biaozhun.imageset/Contents.json | 21 + .../icon/icon_biaozhun.imageset/biaozhun.png | Bin 0 -> 108023 bytes .../icon/icon_chaotuo.imageset/Contents.json | 21 + .../icon/icon_chaotuo.imageset/chaotuo.png | Bin 0 -> 108100 bytes .../icon/icon_chunzhen.imageset/Contents.json | 21 + .../icon/icon_chunzhen.imageset/chunzhen.png | Bin 0 -> 106596 bytes .../icon/icon_fennen.imageset/Contents.json | 21 + .../icon/icon_fennen.imageset/fennen.png | Bin 0 -> 105092 bytes .../icon/icon_huaijiu.imageset/Contents.json | 21 + .../icon/icon_huaijiu.imageset/huaijiu.png | Bin 0 -> 106387 bytes .../icon/icon_landiao.imageset/Contents.json | 21 + .../icon/icon_landiao.imageset/landiao.png | Bin 0 -> 106591 bytes .../icon/icon_langman.imageset/Contents.json | 21 + .../icon/icon_langman.imageset/langman.png | Bin 0 -> 106485 bytes .../icon/icon_none.imageset/Contents.json | 21 + .../filter/icon/icon_none.imageset/none.png | Bin 0 -> 1711 bytes .../icon/icon_orginal.imageset/Contents.json | 21 + .../icon/icon_orginal.imageset/orginal.png | Bin 0 -> 57907 bytes .../icon_qingliang.imageset/Contents.json | 21 + .../icon_qingliang.imageset/qingliang.png | Bin 0 -> 107227 bytes .../icon/icon_qingxin.imageset/Contents.json | 21 + .../icon/icon_qingxin.imageset/qingxin.png | Bin 0 -> 106421 bytes .../icon/icon_rixi.imageset/Contents.json | 21 + .../filter/icon/icon_rixi.imageset/rixi.png | Bin 0 -> 104135 bytes .../icon/icon_weimei.imageset/Contents.json | 21 + .../icon/icon_weimei.imageset/weimei.png | Bin 0 -> 102165 bytes .../icon/icon_xiangfen.imageset/Contents.json | 21 + .../icon/icon_xiangfen.imageset/xiangfen.png | Bin 0 -> 107979 bytes .../icon/icon_yinghong.imageset/Contents.json | 21 + .../icon/icon_yinghong.imageset/yinghong.png | Bin 0 -> 108494 bytes .../icon/icon_yuanqi.imageset/Contents.json | 21 + .../icon/icon_yuanqi.imageset/yuanqi.png | Bin 0 -> 106402 bytes .../icon/icon_yunshang.imageset/Contents.json | 21 + .../icon/icon_yunshang.imageset/yunshang.png | Bin 0 -> 108371 bytes .../icon/filter/map/Contents.json | 6 + .../map/filter_bailan.imageset/Contents.json | 21 + .../map/filter_bailan.imageset/bailan.png | Bin 0 -> 246211 bytes .../map/filter_baixi.imageset/Contents.json | 21 + .../map/filter_baixi.imageset/baixi.png | Bin 0 -> 242508 bytes .../filter_biaozhun.imageset/Contents.json | 21 + .../map/filter_biaozhun.imageset/biaozhun.png | Bin 0 -> 123822 bytes .../map/filter_chaotuo.imageset/Contents.json | 21 + .../map/filter_chaotuo.imageset/chaotuo.png | Bin 0 -> 171670 bytes .../filter_chunzhen.imageset/Contents.json | 21 + .../map/filter_chunzhen.imageset/chunzhen.png | Bin 0 -> 309860 bytes .../map/filter_fennen.imageset/Contents.json | 21 + .../map/filter_fennen.imageset/fennen.png | Bin 0 -> 3062 bytes .../map/filter_huaijiu.imageset/Contents.json | 21 + .../map/filter_huaijiu.imageset/huaijiu.png | Bin 0 -> 3285 bytes .../map/filter_landiao.imageset/Contents.json | 21 + .../map/filter_landiao.imageset/landiao.png | Bin 0 -> 3198 bytes .../map/filter_langman.imageset/Contents.json | 21 + .../map/filter_langman.imageset/langman.png | Bin 0 -> 3175 bytes .../filter_qingliang.imageset/Contents.json | 21 + .../filter_qingliang.imageset/qingliang.png | Bin 0 -> 3016 bytes .../map/filter_qingxin.imageset/Contents.json | 21 + .../map/filter_qingxin.imageset/qingxin.png | Bin 0 -> 3112 bytes .../map/filter_rixi.imageset/Contents.json | 21 + .../filter/map/filter_rixi.imageset/rixi.png | Bin 0 -> 3224 bytes .../map/filter_weimei.imageset/Contents.json | 21 + .../map/filter_weimei.imageset/weimei.png | Bin 0 -> 2886 bytes .../map/filter_white.imageset/Contents.json | 21 + .../map/filter_white.imageset/white.png | Bin 0 -> 3085 bytes .../filter_xiangfen.imageset/Contents.json | 21 + .../map/filter_xiangfen.imageset/xiangfen.png | Bin 0 -> 206520 bytes .../filter_yinghong.imageset/Contents.json | 21 + .../map/filter_yinghong.imageset/yinghong.png | Bin 0 -> 169371 bytes .../map/filter_yuanqi.imageset/Contents.json | 21 + .../map/filter_yuanqi.imageset/yuanqi.png | Bin 0 -> 241405 bytes .../filter_yunshang.imageset/Contents.json | 21 + .../map/filter_yunshang.imageset/yunshang.png | Bin 0 -> 199953 bytes .../map/filter_ziran.imageset/Contents.json | 21 + .../map/filter_ziran.imageset/ziran.png | Bin 0 -> 280824 bytes .../icon/flash_close.imageset/Contents.json | 21 + .../icon/flash_close.imageset/flash_close.png | Bin 0 -> 976 bytes .../icon/flash_open.imageset/Contents.json | 21 + .../icon/flash_open.imageset/flash_open.png | Bin 0 -> 820 bytes .../icon/marker.imageset/Contents.json | 21 + .../icon/marker.imageset/marker.png | Bin 0 -> 653 bytes .../icon/ok.imageset/Contents.json | 21 + .../icon/ok.imageset/ok.png | Bin 0 -> 444 bytes .../record_aspect_3_4.imageset/Contents.json | 21 + .../record_aspect_3_4.png | Bin 0 -> 784 bytes .../record_aspect_9_16.imageset/Contents.json | 21 + .../record_aspect_9_16.png | Bin 0 -> 970 bytes .../icon/return_arrow.imageset/Contents.json | 21 + .../return_arrow.imageset/return_arrow.png | Bin 0 -> 570 bytes .../icon/slider_thumb.imageset/Contents.json | 21 + .../slider_thumb.imageset/slider_thumb.png | Bin 0 -> 1388 bytes .../Localizable/ar.lproj/Localizable.strings | 1008 +++++++ .../Localizable/en.lproj/Localizable.strings | 1008 +++++++ .../zh-Hans.lproj/Localizable.strings | 1009 +++++++ .../zh-Hant.lproj/Localizable.strings | 1008 +++++++ .../Localizable/ar.lproj/Localizable.strings | 62 + .../Localizable/en.lproj/Localizable.strings | 62 + .../zh-Hans.lproj/Localizable.strings | 62 + .../EmojiFace.bundle/del_normal@2x.png | Bin 0 -> 1266 bytes .../EmojiFace.bundle/del_pressed@2x.png | Bin 0 -> 1510 bytes .../EmojiFace.bundle/emoji/emoj_normal@2x.png | Bin 0 -> 2348 bytes .../emoji/emoj_pressed@2x.png | Bin 0 -> 2332 bytes .../EmojiFace.bundle/emoji/emoji.plist | 502 ++++ .../emoji/emojiRecentDefaultList.plist | 70 + .../EmojiFace.bundle/emoji/emoji_0@2x.png | Bin 0 -> 27901 bytes .../EmojiFace.bundle/emoji/emoji_10@2x.png | Bin 0 -> 26412 bytes .../EmojiFace.bundle/emoji/emoji_11@2x.png | Bin 0 -> 26589 bytes .../EmojiFace.bundle/emoji/emoji_12@2x.png | Bin 0 -> 30220 bytes .../EmojiFace.bundle/emoji/emoji_13@2x.png | Bin 0 -> 26317 bytes .../EmojiFace.bundle/emoji/emoji_14@2x.png | Bin 0 -> 31141 bytes .../EmojiFace.bundle/emoji/emoji_15@2x.png | Bin 0 -> 26937 bytes .../EmojiFace.bundle/emoji/emoji_16@2x.png | Bin 0 -> 27688 bytes .../EmojiFace.bundle/emoji/emoji_17@2x.png | Bin 0 -> 26747 bytes .../EmojiFace.bundle/emoji/emoji_18@2x.png | Bin 0 -> 26471 bytes .../EmojiFace.bundle/emoji/emoji_19@2x.png | Bin 0 -> 30065 bytes .../EmojiFace.bundle/emoji/emoji_1@2x.png | Bin 0 -> 29015 bytes .../EmojiFace.bundle/emoji/emoji_20@2x.png | Bin 0 -> 26832 bytes .../EmojiFace.bundle/emoji/emoji_21@2x.png | Bin 0 -> 30027 bytes .../EmojiFace.bundle/emoji/emoji_22@2x.png | Bin 0 -> 26535 bytes .../EmojiFace.bundle/emoji/emoji_23@2x.png | Bin 0 -> 26389 bytes .../EmojiFace.bundle/emoji/emoji_24@2x.png | Bin 0 -> 32967 bytes .../EmojiFace.bundle/emoji/emoji_25@2x.png | Bin 0 -> 28457 bytes .../EmojiFace.bundle/emoji/emoji_26@2x.png | Bin 0 -> 25598 bytes .../EmojiFace.bundle/emoji/emoji_27@2x.png | Bin 0 -> 27320 bytes .../EmojiFace.bundle/emoji/emoji_28@2x.png | Bin 0 -> 26646 bytes .../EmojiFace.bundle/emoji/emoji_29@2x.png | Bin 0 -> 25354 bytes .../EmojiFace.bundle/emoji/emoji_2@2x.png | Bin 0 -> 28589 bytes .../EmojiFace.bundle/emoji/emoji_30@2x.png | Bin 0 -> 25307 bytes .../EmojiFace.bundle/emoji/emoji_31@2x.png | Bin 0 -> 29128 bytes .../EmojiFace.bundle/emoji/emoji_32@2x.png | Bin 0 -> 11105 bytes .../EmojiFace.bundle/emoji/emoji_33@2x.png | Bin 0 -> 24337 bytes .../EmojiFace.bundle/emoji/emoji_34@2x.png | Bin 0 -> 28007 bytes .../EmojiFace.bundle/emoji/emoji_35@2x.png | Bin 0 -> 27913 bytes .../EmojiFace.bundle/emoji/emoji_36@2x.png | Bin 0 -> 24586 bytes .../EmojiFace.bundle/emoji/emoji_37@2x.png | Bin 0 -> 13295 bytes .../EmojiFace.bundle/emoji/emoji_38@2x.png | Bin 0 -> 21595 bytes .../EmojiFace.bundle/emoji/emoji_39@2x.png | Bin 0 -> 23536 bytes .../EmojiFace.bundle/emoji/emoji_3@2x.png | Bin 0 -> 28794 bytes .../EmojiFace.bundle/emoji/emoji_40@2x.png | Bin 0 -> 16114 bytes .../EmojiFace.bundle/emoji/emoji_41@2x.png | Bin 0 -> 22226 bytes .../EmojiFace.bundle/emoji/emoji_42@2x.png | Bin 0 -> 9600 bytes .../EmojiFace.bundle/emoji/emoji_43@2x.png | Bin 0 -> 14158 bytes .../EmojiFace.bundle/emoji/emoji_44@2x.png | Bin 0 -> 16098 bytes .../EmojiFace.bundle/emoji/emoji_45@2x.png | Bin 0 -> 29173 bytes .../EmojiFace.bundle/emoji/emoji_46@2x.png | Bin 0 -> 21639 bytes .../EmojiFace.bundle/emoji/emoji_47@2x.png | Bin 0 -> 28823 bytes .../EmojiFace.bundle/emoji/emoji_48@2x.png | Bin 0 -> 14016 bytes .../EmojiFace.bundle/emoji/emoji_49@2x.png | Bin 0 -> 24449 bytes .../EmojiFace.bundle/emoji/emoji_4@2x.png | Bin 0 -> 27558 bytes .../EmojiFace.bundle/emoji/emoji_50@2x.png | Bin 0 -> 21667 bytes .../EmojiFace.bundle/emoji/emoji_51@2x.png | Bin 0 -> 25282 bytes .../EmojiFace.bundle/emoji/emoji_52@2x.png | Bin 0 -> 20685 bytes .../EmojiFace.bundle/emoji/emoji_53@2x.png | Bin 0 -> 16999 bytes .../EmojiFace.bundle/emoji/emoji_54@2x.png | Bin 0 -> 21790 bytes .../EmojiFace.bundle/emoji/emoji_55@2x.png | Bin 0 -> 15568 bytes .../EmojiFace.bundle/emoji/emoji_56@2x.png | Bin 0 -> 15163 bytes .../EmojiFace.bundle/emoji/emoji_57@2x.png | Bin 0 -> 15187 bytes .../EmojiFace.bundle/emoji/emoji_58@2x.png | Bin 0 -> 15799 bytes .../EmojiFace.bundle/emoji/emoji_59@2x.png | Bin 0 -> 28142 bytes .../EmojiFace.bundle/emoji/emoji_5@2x.png | Bin 0 -> 27868 bytes .../EmojiFace.bundle/emoji/emoji_60@2x.png | Bin 0 -> 18420 bytes .../EmojiFace.bundle/emoji/emoji_61@2x.png | Bin 0 -> 23302 bytes .../EmojiFace.bundle/emoji/emoji_6@2x.png | Bin 0 -> 28127 bytes .../EmojiFace.bundle/emoji/emoji_7@2x.png | Bin 0 -> 26410 bytes .../EmojiFace.bundle/emoji/emoji_8@2x.png | Bin 0 -> 23987 bytes .../EmojiFace.bundle/emoji/emoji_9@2x.png | Bin 0 -> 27463 bytes .../EmojiFace.bundle/emoji/menu@2x.png | Bin 0 -> 1177 bytes .../EmojiFace.bundle/ic_unknown_image@2x.png | Bin 0 -> 16718 bytes .../ar.lproj/Localizable.strings | 126 + .../en.lproj/Localizable.strings | 130 + .../zh-Hans.lproj/Localizable.strings | 132 + .../zh-Hant.lproj/Localizable.strings | 130 + 715 files changed, 58076 insertions(+), 2840 deletions(-) rename chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/{TUIKitDemo.xcscheme => ChatDemo.xcscheme} (83%) create mode 100644 chat/demo/ChatDemo.xcworkspace/xcuserdata/lzmlsfe.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/demo_ic_login_arrow_down@2x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/demo_ic_login_arrow_down@3x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/demo_ic_login_language@2x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/demo_ic_login_language@3x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/demo_ic_login_theme@2x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/demo_ic_login_theme@3x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/demo_ic_security_close@2x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/demo_ic_security_close@3x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/demo_ic_security_warning@2x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/demo_ic_security_warning@3x.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_login_brand_logo.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_login_brand_logo.imageset/demo_login_brand_logo.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_login_hero_bg.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/demo_login_hero_bg.imageset/demo_login_hero_bg.png create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_calls.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_calls.imageset/tab_calls.svg create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/tab_chat.svg create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/Contents.json create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/tab_chat_lines.svg delete mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/contacts.svg create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/tab_contact.svg delete mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/setting.svg create mode 100644 chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/tab_setting.svg create mode 100644 chat/demo/ChatDemo/ChatDemo-AppStore.entitlements create mode 100644 chat/demo/ChatDemo/ChatDemo-Debug.entitlements create mode 100644 chat/demo/ChatDemo/ChatDemo-Release.entitlements delete mode 100644 chat/demo/ChatDemo/ContentView.swift create mode 100644 chat/demo/ChatDemo/Navigation/ChatFlowCoordinator.swift delete mode 100644 chat/demo/ChatDemo/Pages/ChatNavPage.swift create mode 100644 chat/demo/ChatDemo/Pages/LoginSubviews.swift create mode 100644 chat/demo/ChatDemo/Pages/VoiceMessageSettingPages.swift create mode 100644 chat/demo/ChatDemo/RootViewController.swift delete mode 100644 chat/demo/ChatDemo/StyleSettings.swift delete mode 100644 chat/demo/ChatDemo/TUIKitDemoApp.swift create mode 100644 chat/demo/ChatDemo/telephone_area_code.json create mode 100644 chat/demo/ChatDemo/verification.html delete mode 100644 chat/demo/buildServer.json create mode 100644 chat/uikit/LICENSE create mode 100644 chat/uikit/components/AudioPlayer/AudioPlayer.swift create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorder.swift create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.h create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.m create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderImpl.swift create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderInternalProtocol.swift create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderSystem.swift create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderTXUGC.swift create mode 100644 chat/uikit/components/AudioRecorder/AudioRecorderCore/TXUGCAudioRecorderReflector.swift create mode 100644 chat/uikit/components/BaseComponent/basiccontrols/AlertDialog.swift create mode 100644 chat/uikit/components/BaseComponent/basiccontrols/Toast.swift create mode 100644 chat/uikit/components/BaseComponent/basiccontrols/readme.md create mode 100644 chat/uikit/components/BaseComponent/theme/Colors.swift create mode 100644 chat/uikit/components/BaseComponent/theme/Fonts.swift create mode 100644 chat/uikit/components/BaseComponent/theme/Radius.swift create mode 100644 chat/uikit/components/BaseComponent/theme/Spacing.swift create mode 100644 chat/uikit/components/BaseComponent/theme/ThemeState.swift create mode 100644 chat/uikit/components/BaseComponent/utils/AppBuilderHelper.swift create mode 100644 chat/uikit/components/BaseComponent/utils/BundleHelper.swift create mode 100644 chat/uikit/components/BaseComponent/utils/DateHelper.swift create mode 100644 chat/uikit/components/BaseComponent/utils/LanguageHelper.swift create mode 100644 chat/uikit/components/VideoPlayer/VideoPlayer.swift create mode 100644 chat/uikit/components/ai/AiMediaProcessManager.swift create mode 100644 chat/uikit/components/ai/tts/TtsPlaybackHelper.swift create mode 100644 chat/uikit/components/ai/tts/TtsTextSanitizer.swift create mode 100644 chat/uikit/components/ai/tts/VoiceItem.swift create mode 100644 chat/uikit/components/ai/tts/VoiceMessageConfig.swift create mode 100644 chat/uikit/components/chatsetting/AddContactViewController.swift create mode 100644 chat/uikit/components/chatsetting/AvatarPickerSheetController.swift create mode 100644 chat/uikit/components/chatsetting/AvatarSelectorViewController.swift create mode 100644 chat/uikit/components/chatsetting/C2CChatSettingViewController.swift create mode 100644 chat/uikit/components/chatsetting/ChatBackgroundPickerViewController.swift create mode 100644 chat/uikit/components/chatsetting/ChatBackgroundStore.swift create mode 100644 chat/uikit/components/chatsetting/ChatSettingViews.swift create mode 100644 chat/uikit/components/chatsetting/GroupChatSettingViewController.swift create mode 100644 chat/uikit/components/chatsetting/GroupManagementViewController.swift create mode 100644 chat/uikit/components/chatsetting/GroupMemberListViewController.swift create mode 100644 chat/uikit/components/chatsetting/GroupMemberPickerViewController.swift create mode 100644 chat/uikit/components/chatsetting/GroupPermissionManager.swift create mode 100644 chat/uikit/components/chatsetting/TextInputDialogViewController.swift create mode 100644 chat/uikit/components/chatsetting/UserProfileRouter.swift create mode 100644 chat/uikit/components/common/BubbleMenuViewController.swift create mode 100644 chat/uikit/components/common/CallMessageParser.swift create mode 100644 chat/uikit/components/common/ChatAvatarView.swift create mode 100644 chat/uikit/components/common/ChatUIKitLayoutDirection.swift create mode 100644 chat/uikit/components/common/ContactSearchBarView.swift create mode 100644 chat/uikit/components/common/CustomMessageSummaryRegistry.swift create mode 100644 chat/uikit/components/common/MessageInfo+Content.swift create mode 100644 chat/uikit/components/common/MessageListHelper.swift create mode 100644 chat/uikit/components/common/RTCBaseView.swift create mode 100644 chat/uikit/components/common/SelectionCheckBox.swift create mode 100644 chat/uikit/components/common/SystemNavigationBarPage.swift create mode 100644 chat/uikit/components/common/UIViewController+ClosePage.swift create mode 100644 chat/uikit/components/common/UserPickerItem.swift create mode 100644 chat/uikit/components/common/UserPickerView.swift create mode 100644 chat/uikit/components/common/azorderedlist/AZOrderedListCell.swift create mode 100644 chat/uikit/components/common/azorderedlist/AZOrderedListSorter.swift create mode 100644 chat/uikit/components/common/azorderedlist/AZOrderedListView.swift create mode 100644 chat/uikit/components/common/azorderedlist/AtomicIndexBarView.swift create mode 100644 chat/uikit/components/common/theme/ChatUIKitTheme.swift create mode 100644 chat/uikit/components/common/uicustom/CustomEditor.swift create mode 100644 chat/uikit/components/contactlist/AddFriendViewController.swift create mode 100644 chat/uikit/components/contactlist/ContactListView.swift create mode 100644 chat/uikit/components/contactlist/JoinGroupViewController.swift create mode 100644 chat/uikit/components/contactlist/utils/ContactDisplayNameFormatter.swift create mode 100644 chat/uikit/components/contactlist/view/AddFriendViewControllerImpl.swift create mode 100644 chat/uikit/components/contactlist/view/ContactAddSupport.swift create mode 100644 chat/uikit/components/contactlist/view/ContactEntryIcons.swift create mode 100644 chat/uikit/components/contactlist/view/ContactListViewImpl.swift create mode 100644 chat/uikit/components/contactlist/view/ContactNavigationHeaderView.swift create mode 100644 chat/uikit/components/contactlist/view/JoinGroupDetailViewController.swift create mode 100644 chat/uikit/components/contactlist/view/JoinGroupViewControllerImpl.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/BlackListViewController.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/FriendApplicationCell.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/FriendApplicationListViewController.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/GroupApplicationCell.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/GroupApplicationListViewController.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/GroupListViewController.swift create mode 100644 chat/uikit/components/contactlist/view/subpage/SubPageNavigationBar.swift create mode 100644 chat/uikit/components/contactlist/viewmodel/ContactListViewModel.swift create mode 100644 chat/uikit/components/contactlist/viewmodel/ContactSubPageViewModels.swift create mode 100644 chat/uikit/components/conversationlist/ConversationListView.swift create mode 100644 chat/uikit/components/conversationlist/CreateC2CConversationViewController.swift create mode 100644 chat/uikit/components/conversationlist/CreateGroupConversationViewController.swift create mode 100644 chat/uikit/components/conversationlist/utils/ConversationCellFormatter.swift create mode 100644 chat/uikit/components/conversationlist/view/ConfigGroupInfoViewController.swift create mode 100644 chat/uikit/components/conversationlist/view/ConversationCell.swift create mode 100644 chat/uikit/components/conversationlist/view/ConversationCreateSupport.swift create mode 100644 chat/uikit/components/conversationlist/view/ConversationListViewImpl.swift create mode 100644 chat/uikit/components/conversationlist/view/ConversationPopupMenuController.swift create mode 100644 chat/uikit/components/conversationlist/view/CreateC2CConversationViewControllerImpl.swift create mode 100644 chat/uikit/components/conversationlist/view/CreateGroupConversationViewControllerImpl.swift create mode 100644 chat/uikit/components/conversationlist/viewmodel/ConversationListViewModel.swift create mode 100644 chat/uikit/components/emoji/EmojiHelper.swift create mode 100644 chat/uikit/components/emoji/EmojiManager.swift create mode 100644 chat/uikit/components/messageinput/MessageInputView.swift create mode 100644 chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator+Media.swift create mode 100644 chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator.swift create mode 100644 chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerPlaceholderImpl.swift create mode 100644 chat/uikit/components/messageinput/typing/TypingIndicatorController.swift create mode 100644 chat/uikit/components/messageinput/view/EmojiPickerView.swift create mode 100644 chat/uikit/components/messageinput/view/GroupCallMemberPickerViewController.swift create mode 100644 chat/uikit/components/messageinput/view/MentionMemberCell.swift create mode 100644 chat/uikit/components/messageinput/view/MentionMemberPickerViewController.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputAudioRecorderView.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputMediaCoordinator.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputMorePanel.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputQuotePreviewBar.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputQuoteSummaryFormatter.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputTextView.swift create mode 100644 chat/uikit/components/messageinput/view/MessageInputViewImpl.swift create mode 100644 chat/uikit/components/messageinput/view/VoiceTranscriptionOverlayView.swift create mode 100644 chat/uikit/components/messageinput/viewmodel/MentionInfo.swift create mode 100644 chat/uikit/components/messageinput/viewmodel/MessageInputViewModel.swift create mode 100644 chat/uikit/components/messagelist/MessageListView.swift create mode 100644 chat/uikit/components/messagelist/cell/AuxiliaryTextDisplayState.swift create mode 100644 chat/uikit/components/messagelist/cell/ChatAudioPlaybackCoordinator.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageAudioContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageAuxiliaryTextBubbleView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageBaseCell.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageBubbleStyler.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageCallContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageCellRegistry.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageCenteredTextCell.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageFallbackContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageFileContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageImageContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageMergedContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageQuoteBubbleView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageTextContentView.swift create mode 100644 chat/uikit/components/messagelist/cell/MessageVideoContentView.swift create mode 100644 chat/uikit/components/messagelist/config/MessageListConfig.swift create mode 100644 chat/uikit/components/messagelist/forward/ForwardBottomBar.swift create mode 100644 chat/uikit/components/messagelist/forward/ForwardContactPickerViewController.swift create mode 100644 chat/uikit/components/messagelist/forward/ForwardConversationCell.swift create mode 100644 chat/uikit/components/messagelist/forward/ForwardTargetSelectorViewController.swift create mode 100644 chat/uikit/components/messagelist/imageviewer/ImageElement.swift create mode 100644 chat/uikit/components/messagelist/imageviewer/ImageViewerController.swift create mode 100644 chat/uikit/components/messagelist/imageviewer/ImageViewerDataProvider.swift create mode 100644 chat/uikit/components/messagelist/imageviewer/MediaZoomCell.swift create mode 100644 chat/uikit/components/messagelist/listen/ListenFromHereController.swift create mode 100644 chat/uikit/components/messagelist/listen/ListenPlaybackBar.swift create mode 100644 chat/uikit/components/messagelist/menu/MessageActionMenuAction.swift create mode 100644 chat/uikit/components/messagelist/menu/MessageActionMenuController.swift create mode 100644 chat/uikit/components/messagelist/reaction/MessageReactionBarView.swift create mode 100644 chat/uikit/components/messagelist/reaction/ReactionDetailViewController.swift create mode 100644 chat/uikit/components/messagelist/reaction/ReactionEmojiPanelView.swift create mode 100644 chat/uikit/components/messagelist/reaction/ReactionEmojiPickerViewController.swift create mode 100644 chat/uikit/components/messagelist/reaction/ReactionEmojiRenderer.swift create mode 100644 chat/uikit/components/messagelist/readreceipt/MessageReadReceiptHelper.swift create mode 100644 chat/uikit/components/messagelist/readreceipt/MessageReadReceiptViewController.swift create mode 100644 chat/uikit/components/messagelist/tongue/MessageListFloatingEntry.swift create mode 100644 chat/uikit/components/messagelist/tongue/MessageTongueView.swift create mode 100644 chat/uikit/components/messagelist/view/MergedMessageDetailViewController.swift create mode 100644 chat/uikit/components/messagelist/view/MessageListMultiSelectBar.swift create mode 100644 chat/uikit/components/messagelist/view/MessageListViewImpl.swift create mode 100644 chat/uikit/components/messagelist/viewmodel/MessageListViewModel.swift create mode 100644 chat/uikit/components/search/SearchEntryBarView.swift create mode 100644 chat/uikit/components/search/utils/SearchDisplayHelper.swift create mode 100644 chat/uikit/components/search/utils/SearchHighlightBuilder.swift create mode 100644 chat/uikit/components/search/utils/SearchResultCellConfigurator.swift create mode 100644 chat/uikit/components/search/view/SearchFieldBar.swift create mode 100644 chat/uikit/components/search/view/SearchMessageDetailViewController.swift create mode 100644 chat/uikit/components/search/view/SearchResultCell.swift create mode 100644 chat/uikit/components/search/view/SearchResultMoreViewController.swift create mode 100644 chat/uikit/components/search/view/SearchResultViewController.swift create mode 100644 chat/uikit/components/search/view/SearchStatusView.swift create mode 100644 chat/uikit/components/search/viewmodel/SearchViewModel.swift create mode 100644 chat/uikit/components/videorecorder/Common/VideoRecorderCommon.h create mode 100644 chat/uikit/components/videorecorder/Common/VideoRecorderCommon.m create mode 100644 chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.h create mode 100644 chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.m create mode 100644 chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.h create mode 100644 chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.m create mode 100644 chat/uikit/components/videorecorder/Core/ReflectUtil.h create mode 100644 chat/uikit/components/videorecorder/Core/ReflectUtil.m create mode 100644 chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.h create mode 100644 chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.m create mode 100644 chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.h create mode 100644 chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecordCore.h create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecordCore.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.h create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.h create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.h create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.h create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.h create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.m create mode 100644 chat/uikit/components/videorecorder/Core/VideoRecorderTypeDef.h create mode 100644 chat/uikit/components/videorecorder/README.md create mode 100644 chat/uikit/components/videorecorder/VideoRecorder.swift create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.h create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.m create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.h create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.m create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.h create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.m create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.h create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.m create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.h create mode 100644 chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.m create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.h create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.m create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.h create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.m create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.h create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.m create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.h create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.m create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.h create mode 100644 chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.m create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.h create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.m create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderControlView.h create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderControlView.m create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderController.h create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderController.m create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderPhotoEditBridge.swift create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.h create mode 100644 chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.m create mode 100644 chat/uikit/resources/assets/chat/VideoRecorder.bundle/config/video_recorder_config.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/avatar-contact.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-default-contact.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-default-contact.imageset/avatar-default-contact.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-default-group.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-default-group.imageset/avatar-default-group.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all-highlight.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all-highlight.imageset/check-all-highlight.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/check-all.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/check-circle-filled.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/check.imageset/check.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/contact_add_circle@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/contact_add_circle@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/contact_add_friend@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/contact_add_friend@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_group.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_group.imageset/contact_add_group@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_group.imageset/contact_add_group@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_blacklist.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_blacklist.imageset/contact_blacklist@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_blacklist.imageset/contact_blacklist@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/contact_group_notification@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/contact_group_notification@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_arrow_right.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_arrow_right.imageset/contact_info_arrow_right@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_arrow_right.imageset/contact_info_arrow_right@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/contact_info_back@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/contact_info_back@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_my_group.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_my_group.imageset/contact_my_group@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_my_group.imageset/contact_my_group@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/contact_new_contacts@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/contact_new_contacts@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search.imageset/contact_search@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search.imageset/contact_search@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/contact_search_clear@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/contact_search_clear@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/error-circle-filled.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/error-circle-filled.imageset/error-circle-filled.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/group_setting_edit_icon@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/group_setting_edit_icon@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/group_type_community@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/group_type_community@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/group_type_meeting@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/group_type_meeting@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/group_type_public@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/group_type_public@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_work.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_work.imageset/group_type_work@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_work.imageset/group_type_work@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/help-circle-filled.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/image_viewer_close_icon@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/image_viewer_close_icon@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/image_viewer_save_button@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/image_viewer_save_button@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle-filled.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle-filled.imageset/info-circle-filled.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/info-circle.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_audio.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_audio.imageset/input_audio@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_audio.imageset/input_audio@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/input_camera.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_emoji.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_emoji.imageset/input_emoji@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_emoji.imageset/input_emoji@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/input_keyboard@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/input_more@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/input_more@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/loading-blue.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/loading-blue.imageset/loading-blue.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/loading.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/loading.imageset/loading.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/message_call_audio@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/message_call_audio@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_video.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_video.imageset/message_call_video@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_video.imageset/message_call_video@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_chevron_down.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_chevron_down.imageset/message_chevron_down@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_chevron_down.imageset/message_chevron_down@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_convert.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_convert.imageset/message_convert.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_convert.imageset/message_convert@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_convert.imageset/message_convert@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_copy.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_copy.imageset/message_copy.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_copy.imageset/message_copy@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_copy.imageset/message_copy@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_download.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_download.imageset/message_file_download@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_download.imageset/message_file_download@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_excel.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_excel.imageset/message_file_type_excel@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_excel.imageset/message_file_type_excel@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_img.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_img.imageset/message_file_type_img@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_img.imageset/message_file_type_img@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_pdf.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_pdf.imageset/message_file_type_pdf@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_pdf.imageset/message_file_type_pdf@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/message_file_type_ppt@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/message_file_type_ppt@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/message_file_type_txt@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/message_file_type_txt@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/message_file_type_unknown@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/message_file_type_unknown@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/message_file_type_word@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/message_file_type_word@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_zip.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_zip.imageset/message_file_type_zip@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_zip.imageset/message_file_type_zip@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/message_info.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/message_listen@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/message_listen@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_close.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_close.imageset/message_listen_close@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_close.imageset/message_listen_close@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/message_listen_from_here@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/message_listen_from_here@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/message_more@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/message_more@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_delete.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_delete.imageset/message_multi_delete@1x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_delete.imageset/message_multi_delete@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_delete.imageset/message_multi_delete@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@1x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@1x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multiselect.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multiselect.imageset/message_multiselect.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multiselect.imageset/message_multiselect@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multiselect.imageset/message_multiselect@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction.imageset/message_reaction@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction.imageset/message_reaction@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/message_reaction_collapse@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/message_reaction_collapse@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/message_recall.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/message_recall@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/message_recall@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/message_reply.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/message_toast_error@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/message_toast_error@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/message_video_play_icon@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/message_video_play_icon@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_camera.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_camera.imageset/more_camera@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_camera.imageset/more_camera@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_file.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_file.imageset/more_file@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_file.imageset/more_file@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/more_picture@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/more_picture@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/more_video@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/more_video@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/more_video_call@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/more_video_call@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_voice_call.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_voice_call.imageset/more_voice_call@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/more_voice_call.imageset/more_voice_call@3x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/setting_sendmsg.imageset/Contents.json rename chat/{demo/ChatDemo/Assets.xcassets/tab_chat.imageset => uikit/resources/assets/chat/chat.xcassets/icon/setting_sendmsg.imageset}/chats.svg (100%) create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/uikit_empty_illustration.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/uikit_empty_illustration.imageset/uikit_empty_illustration@2x.png create mode 100644 chat/uikit/resources/assets/chat/chat.xcassets/icon/uikit_empty_illustration.imageset/uikit_empty_illustration@3x.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/natural.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/none.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/pitu.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/pitu.imageset/pitu.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/ruddy.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/smooth.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/whitness.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/beauty_record.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/camera_switch.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/cross.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/cross.imageset/cross.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/effect_compare.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/effect_compare.imageset/effect_compare.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_bailan.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_bailan.imageset/bailan.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_biaozhun.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_biaozhun.imageset/biaozhun.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_chaotuo.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_chaotuo.imageset/chaotuo.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_chunzhen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_chunzhen.imageset/chunzhen.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_fennen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_fennen.imageset/fennen.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_huaijiu.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_huaijiu.imageset/huaijiu.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/landiao.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_langman.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_langman.imageset/langman.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_none.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_none.imageset/none.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_orginal.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_orginal.imageset/orginal.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_qingliang.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_qingliang.imageset/qingliang.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_qingxin.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_qingxin.imageset/qingxin.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_rixi.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_rixi.imageset/rixi.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_weimei.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_weimei.imageset/weimei.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_xiangfen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_xiangfen.imageset/xiangfen.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_yinghong.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_yinghong.imageset/yinghong.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_yuanqi.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_yuanqi.imageset/yuanqi.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_yunshang.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_yunshang.imageset/yunshang.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_bailan.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_bailan.imageset/bailan.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_baixi.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_baixi.imageset/baixi.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_biaozhun.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_biaozhun.imageset/biaozhun.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_chaotuo.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_chaotuo.imageset/chaotuo.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_chunzhen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_chunzhen.imageset/chunzhen.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_fennen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_fennen.imageset/fennen.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_huaijiu.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_huaijiu.imageset/huaijiu.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_landiao.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_landiao.imageset/landiao.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_langman.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_langman.imageset/langman.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_qingliang.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_qingliang.imageset/qingliang.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_qingxin.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_qingxin.imageset/qingxin.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_rixi.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_rixi.imageset/rixi.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_weimei.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_weimei.imageset/weimei.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_white.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_white.imageset/white.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_xiangfen.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_xiangfen.imageset/xiangfen.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_yinghong.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_yinghong.imageset/yinghong.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_yuanqi.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_yuanqi.imageset/yuanqi.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_yunshang.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_yunshang.imageset/yunshang.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_ziran.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/map/filter_ziran.imageset/ziran.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/flash_close.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/flash_close.imageset/flash_close.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/flash_open.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/flash_open.imageset/flash_open.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/marker.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/marker.imageset/marker.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/ok.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/ok.imageset/ok.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/record_aspect_3_4.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/record_aspect_3_4.imageset/record_aspect_3_4.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/record_aspect_9_16.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/record_aspect_9_16.imageset/record_aspect_9_16.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/return_arrow.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/return_arrow.imageset/return_arrow.png create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/slider_thumb.imageset/Contents.json create mode 100644 chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/slider_thumb.imageset/slider_thumb.png create mode 100644 chat/uikit/resources/strings/ChatLocalizable.bundle/Localizable/ar.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/ChatLocalizable.bundle/Localizable/en.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/ChatLocalizable.bundle/Localizable/zh-Hans.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/ChatLocalizable.bundle/Localizable/zh-Hant.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/Localizable/ar.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/Localizable/en.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/Localizable/zh-Hans.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/del_normal@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/del_pressed@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoj_normal@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoj_pressed@2x.png create mode 100755 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji.plist create mode 100755 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emojiRecentDefaultList.plist create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_0@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_10@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_11@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_12@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_13@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_14@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_15@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_16@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_17@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_18@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_19@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_1@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_20@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_21@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_22@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_23@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_24@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_25@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_26@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_27@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_28@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_29@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_2@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_30@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_31@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_32@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_33@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_34@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_35@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_36@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_37@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_38@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_39@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_3@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_40@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_41@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_42@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_43@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_44@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_45@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_46@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_47@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_48@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_49@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_4@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_50@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_51@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_52@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_53@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_54@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_55@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_56@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_57@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_58@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_59@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_5@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_60@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_61@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_6@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_7@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_8@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/emoji_9@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/emoji/menu@2x.png create mode 100644 chat/uikit/resources/strings/EmojiFace.bundle/ic_unknown_image@2x.png create mode 100644 chat/uikit/resources/strings/VideoRecorderLocalizable.bundle/ar.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/VideoRecorderLocalizable.bundle/en.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/VideoRecorderLocalizable.bundle/zh-Hans.lproj/Localizable.strings create mode 100644 chat/uikit/resources/strings/VideoRecorderLocalizable.bundle/zh-Hant.lproj/Localizable.strings diff --git a/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewController.swift b/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewController.swift index 28bf7e302..c86f0544c 100644 --- a/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewController.swift +++ b/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewController.swift @@ -16,7 +16,7 @@ import AtomicXCore private var cancellables = Set() - init(recordCallsUIStyle: RecentCallsUIStyle) { + public init(recordCallsUIStyle: RecentCallsUIStyle) { super.init(nibName: nil, bundle: nil) viewModel.recordCallsUIStyle = recordCallsUIStyle @@ -38,8 +38,8 @@ import AtomicXCore view.backgroundColor = TUICoreDefineConvert.getTUICoreDynamicColor(colorKey: "head_bg_gradient_start_color", defaultHex: "#EBF0F6") } else { - view.backgroundColor = TUICoreDefineConvert.getTUICoreDynamicColor(colorKey: "callkit_recents_bg_color", - defaultHex: "#FFFFFF") + view.backgroundColor = TUICoreDefineConvert.getTUICallKitDynamicColor(colorKey: "callkit_recents_bg_color", + defaultHex: "#FFFFFF") } return view }() diff --git a/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewModel.swift b/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewModel.swift index aa0fe0261..fc901e3da 100644 --- a/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewModel.swift +++ b/call/TUICallKit_Swift/View/Component/Recents/RecentCallsViewModel.swift @@ -18,7 +18,7 @@ enum RecentCallsType: Int { case missed } -enum RecentCallsUIStyle: Int { +public enum RecentCallsUIStyle: Int { case classic case minimalist } diff --git a/chat/demo/ChatDemo.xcodeproj/project.pbxproj b/chat/demo/ChatDemo.xcodeproj/project.pbxproj index 335fce6f0..8c60506e2 100644 --- a/chat/demo/ChatDemo.xcodeproj/project.pbxproj +++ b/chat/demo/ChatDemo.xcodeproj/project.pbxproj @@ -7,38 +7,60 @@ objects = { /* Begin PBXBuildFile section */ - 16EAAEE22EEC0CAE00286EA3 /* ChatNavPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16EAAEE12EEC0CAE00286EA3 /* ChatNavPage.swift */; }; + 161326BF2F693A6F00EBFF2E /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 161326BE2F693A6F00EBFF2E /* CoreTelephony.framework */; }; + 16CF01AA000000000000AA03 /* Navigation/ChatFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16CF01AA000000000000AA02 /* Navigation/ChatFlowCoordinator.swift */; }; + 16CF01AA000000000000BB03 /* VoiceMessageSettingPages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16CF01AA000000000000BB02 /* VoiceMessageSettingPages.swift */; }; 4AE6172335A14D917199DD62 /* Pods_ChatDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2341E1490CCF32C4F01AF72 /* Pods_ChatDemo.framework */; }; A32424E72E6EC0050075F614 /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424DB2E6EC0050075F614 /* SettingsPage.swift */; }; - A32424E82E6EC0050075F614 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424E32E6EC0050075F614 /* SceneDelegate.swift */; }; - A32424E92E6EC0050075F614 /* StyleSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424E42E6EC0050075F614 /* StyleSettings.swift */; }; A32424EB2E6EC0050075F614 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424DE2E6EC0050075F614 /* AppDelegate.swift */; }; A32424EC2E6EC0050075F614 /* HomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424D92E6EC0050075F614 /* HomePage.swift */; }; - A32424ED2E6EC0050075F614 /* TUIKitDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424E52E6EC0050075F614 /* TUIKitDemoApp.swift */; }; A32424EE2E6EC0050075F614 /* GenerateTestUserSig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424E12E6EC0050075F614 /* GenerateTestUserSig.swift */; }; A32424EF2E6EC0050075F614 /* LoginPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424DA2E6EC0050075F614 /* LoginPage.swift */; }; - A32424F02E6EC0050075F614 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424E02E6EC0050075F614 /* ContentView.swift */; }; A32424F12E6EC0050075F614 /* appConfig.json in Resources */ = {isa = PBXBuildFile; fileRef = A32424DD2E6EC0050075F614 /* appConfig.json */; }; + AB00000000000000000000AC /* LoginSubviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB00000000000000000000AD /* LoginSubviews.swift */; }; + AB00000000000000000000AE /* telephone_area_code.json in Resources */ = {isa = PBXBuildFile; fileRef = AB00000000000000000000AF /* telephone_area_code.json */; }; + AB00000000000000000000B1 /* verification.html in Resources */ = {isa = PBXBuildFile; fileRef = AB00000000000000000000B2 /* verification.html */; }; + AB00000000000000000000C1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB00000000000000000000C2 /* SceneDelegate.swift */; }; A32424F32E6EC0050075F614 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A32424DF2E6EC0050075F614 /* Assets.xcassets */; }; + A32424F52E6EC0050075F614 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A32424F42E6EC0050075F614 /* RootViewController.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + 161326E32F6A7E7600EBFF2E /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 0CB983487A575C830063379D /* Pods-TUIKitDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TUIKitDemo.debug.xcconfig"; path = "Target Support Files/Pods-TUIKitDemo/Pods-TUIKitDemo.debug.xcconfig"; sourceTree = ""; }; - 16EAAEE12EEC0CAE00286EA3 /* ChatNavPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatNavPage.swift; sourceTree = ""; }; + 161326BE2F693A6F00EBFF2E /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; }; + 161326D12F697B0000EBFF2E /* ChatDemo-Debug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ChatDemo-Debug.entitlements"; sourceTree = ""; }; + 161326D22F697B0100EBFF2E /* ChatDemo-Release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ChatDemo-Release.entitlements"; sourceTree = ""; }; + 16CF01AA000000000000AA02 /* Navigation/ChatFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigation/ChatFlowCoordinator.swift; sourceTree = ""; }; + 16CF01AA000000000000BB02 /* VoiceMessageSettingPages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageSettingPages.swift; sourceTree = ""; }; + 16E6A6AB2FDAE2A40032F383 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8D7295459315C15E4DEB5CB1 /* Pods-ChatDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatDemo.debug.xcconfig"; path = "Target Support Files/Pods-ChatDemo/Pods-ChatDemo.debug.xcconfig"; sourceTree = ""; }; A30527642E6EC1B700276146 /* ChatDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChatDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; A32424D92E6EC0050075F614 /* HomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePage.swift; sourceTree = ""; }; A32424DA2E6EC0050075F614 /* LoginPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginPage.swift; sourceTree = ""; }; A32424DB2E6EC0050075F614 /* SettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; }; A32424DD2E6EC0050075F614 /* appConfig.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = appConfig.json; sourceTree = ""; }; + AB00000000000000000000AD /* LoginSubviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSubviews.swift; sourceTree = ""; }; + AB00000000000000000000AF /* telephone_area_code.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = telephone_area_code.json; sourceTree = ""; }; + AB00000000000000000000B2 /* verification.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = verification.html; sourceTree = ""; }; + AB00000000000000000000C2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; A32424DE2E6EC0050075F614 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A32424DF2E6EC0050075F614 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - A32424E02E6EC0050075F614 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A32424E12E6EC0050075F614 /* GenerateTestUserSig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateTestUserSig.swift; sourceTree = ""; }; A32424E22E6EC0050075F614 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A32424E32E6EC0050075F614 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - A32424E42E6EC0050075F614 /* StyleSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleSettings.swift; sourceTree = ""; }; - A32424E52E6EC0050075F614 /* TUIKitDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUIKitDemoApp.swift; sourceTree = ""; }; + A32424F42E6EC0050075F614 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; ADA16EB7C9F14EC0FDEC6E5F /* Pods-ChatDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatDemo.release.xcconfig"; path = "Target Support Files/Pods-ChatDemo/Pods-ChatDemo.release.xcconfig"; sourceTree = ""; }; D13DF48F95F478A5C9C94A2B /* Pods-ChatApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatApp.release.xcconfig"; path = "Target Support Files/Pods-TUIKitDemo/Pods-TUIKitDemo.release.xcconfig"; sourceTree = ""; }; E89B2374B3E641FC68E9F381 /* Pods-TUIKitDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TUIKitDemo.release.xcconfig"; path = "Target Support Files/Pods-TUIKitDemo/Pods-TUIKitDemo.release.xcconfig"; sourceTree = ""; }; @@ -51,6 +73,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 161326BF2F693A6F00EBFF2E /* CoreTelephony.framework in Frameworks */, 4AE6172335A14D917199DD62 /* Pods_ChatDemo.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,6 +107,7 @@ 65D6509523A1AF803D6EEA96 /* Frameworks */ = { isa = PBXGroup; children = ( + 161326BE2F693A6F00EBFF2E /* CoreTelephony.framework */, F2341E1490CCF32C4F01AF72 /* Pods_ChatDemo.framework */, ); name = Frameworks; @@ -100,10 +124,11 @@ A32424DC2E6EC0050075F614 /* Pages */ = { isa = PBXGroup; children = ( - 16EAAEE12EEC0CAE00286EA3 /* ChatNavPage.swift */, A32424D92E6EC0050075F614 /* HomePage.swift */, A32424DA2E6EC0050075F614 /* LoginPage.swift */, A32424DB2E6EC0050075F614 /* SettingsPage.swift */, + 16CF01AA000000000000BB02 /* VoiceMessageSettingPages.swift */, + AB00000000000000000000AD /* LoginSubviews.swift */, ); path = Pages; sourceTree = ""; @@ -111,16 +136,19 @@ A32424E62E6EC0050075F614 /* ChatDemo */ = { isa = PBXGroup; children = ( + 161326D12F697B0000EBFF2E /* ChatDemo-Debug.entitlements */, + 161326D22F697B0100EBFF2E /* ChatDemo-Release.entitlements */, + 16CF01AA000000000000AA02 /* Navigation/ChatFlowCoordinator.swift */, A32424DC2E6EC0050075F614 /* Pages */, A32424DD2E6EC0050075F614 /* appConfig.json */, + AB00000000000000000000AF /* telephone_area_code.json */, + AB00000000000000000000B2 /* verification.html */, A32424DE2E6EC0050075F614 /* AppDelegate.swift */, + AB00000000000000000000C2 /* SceneDelegate.swift */, + A32424F42E6EC0050075F614 /* RootViewController.swift */, A32424DF2E6EC0050075F614 /* Assets.xcassets */, - A32424E02E6EC0050075F614 /* ContentView.swift */, A32424E12E6EC0050075F614 /* GenerateTestUserSig.swift */, A32424E22E6EC0050075F614 /* Info.plist */, - A32424E32E6EC0050075F614 /* SceneDelegate.swift */, - A32424E42E6EC0050075F614 /* StyleSettings.swift */, - A32424E52E6EC0050075F614 /* TUIKitDemoApp.swift */, ); path = ChatDemo; sourceTree = ""; @@ -138,6 +166,7 @@ 1658FB48197F6F185AF1F61E /* [CP] Embed Pods Frameworks */, 147293C1E5D25EB6974D2BC5 /* [CP] Copy Pods Resources */, CEAD06132DA9128900E92F24 /* Resources */, + 161326E32F6A7E7600EBFF2E /* Embed Foundation Extensions */, ); buildRules = ( ); @@ -155,6 +184,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1430; }; buildConfigurationList = 11AB62D2545EF2D0C5E3D465 /* Build configuration list for PBXProject "ChatDemo" */; @@ -182,6 +212,8 @@ files = ( A32424F12E6EC0050075F614 /* appConfig.json in Resources */, A32424F32E6EC0050075F614 /* Assets.xcassets in Resources */, + AB00000000000000000000AE /* telephone_area_code.json in Resources */, + AB00000000000000000000B1 /* verification.html in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -196,14 +228,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ChatDemo/Pods-ChatDemo-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ChatDemo/Pods-ChatDemo-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ChatDemo/Pods-ChatDemo-resources.sh\"\n"; @@ -217,14 +245,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ChatDemo/Pods-ChatDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ChatDemo/Pods-ChatDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ChatDemo/Pods-ChatDemo-frameworks.sh\"\n"; @@ -260,15 +284,15 @@ buildActionMask = 2147483647; files = ( A32424E72E6EC0050075F614 /* SettingsPage.swift in Sources */, - A32424E82E6EC0050075F614 /* SceneDelegate.swift in Sources */, - A32424E92E6EC0050075F614 /* StyleSettings.swift in Sources */, + 16CF01AA000000000000BB03 /* VoiceMessageSettingPages.swift in Sources */, A32424EB2E6EC0050075F614 /* AppDelegate.swift in Sources */, + A32424F52E6EC0050075F614 /* RootViewController.swift in Sources */, A32424EC2E6EC0050075F614 /* HomePage.swift in Sources */, - 16EAAEE22EEC0CAE00286EA3 /* ChatNavPage.swift in Sources */, - A32424ED2E6EC0050075F614 /* TUIKitDemoApp.swift in Sources */, + 16CF01AA000000000000AA03 /* Navigation/ChatFlowCoordinator.swift in Sources */, A32424EE2E6EC0050075F614 /* GenerateTestUserSig.swift in Sources */, A32424EF2E6EC0050075F614 /* LoginPage.swift in Sources */, - A32424F02E6EC0050075F614 /* ContentView.swift in Sources */, + AB00000000000000000000AC /* LoginSubviews.swift in Sources */, + AB00000000000000000000C1 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -280,18 +304,19 @@ baseConfigurationReference = ADA16EB7C9F14EC0FDEC6E5F /* Pods-ChatDemo.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = FN2V63AD2J; + CODE_SIGN_ENTITLEMENTS = "ChatDemo/ChatDemo-Release.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 8W4RR7HL62; INFOPLIST_FILE = ChatDemo/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.imchat.imchat; + PRODUCT_BUNDLE_IDENTIFIER = com.imchat.imchat.uikit; PRODUCT_NAME = ChatDemo; - PROVISIONING_PROFILE_SPECIFIER = com.imchat.imchat_Production_SignProvision; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -368,21 +393,19 @@ baseConfigurationReference = 8D7295459315C15E4DEB5CB1 /* Pods-ChatDemo.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = "ChatDemo/ChatDemo-Debug.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = F8A3GH6Q4W; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 8W4RR7HL62; INFOPLIST_FILE = ChatDemo/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.tencent.tuikit.demo.xa; + PRODUCT_BUNDLE_IDENTIFIER = com.tencent.tuikit.demo.xa.uikit; PRODUCT_NAME = ChatDemo; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = tuikitdemo_dev; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; diff --git a/chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/TUIKitDemo.xcscheme b/chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/ChatDemo.xcscheme similarity index 83% rename from chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/TUIKitDemo.xcscheme rename to chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/ChatDemo.xcscheme index 972347032..3987acb64 100644 --- a/chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/TUIKitDemo.xcscheme +++ b/chat/demo/ChatDemo.xcodeproj/xcshareddata/xcschemes/ChatDemo.xcscheme @@ -16,9 +16,9 @@ + BuildableName = "ChatDemo.app" + BlueprintName = "ChatDemo" + ReferencedContainer = "container:ChatDemo.xcodeproj"> @@ -45,9 +45,9 @@ + BuildableName = "ChatDemo.app" + BlueprintName = "ChatDemo" + ReferencedContainer = "container:ChatDemo.xcodeproj"> @@ -62,9 +62,9 @@ + BuildableName = "ChatDemo.app" + BlueprintName = "ChatDemo" + ReferencedContainer = "container:ChatDemo.xcodeproj"> diff --git a/chat/demo/ChatDemo.xcworkspace/xcuserdata/lzmlsfe.xcuserdatad/UserInterfaceState.xcuserstate b/chat/demo/ChatDemo.xcworkspace/xcuserdata/lzmlsfe.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..92af1c771c224dac0a0dbeca224280b005483eb6 GIT binary patch literal 18470 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 I_tgRa1D~7EtpET3 literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/AppDelegate.swift b/chat/demo/ChatDemo/AppDelegate.swift index eff7fd0fe..1d412f453 100644 --- a/chat/demo/ChatDemo/AppDelegate.swift +++ b/chat/demo/ChatDemo/AppDelegate.swift @@ -1,13 +1,48 @@ -import AtomicX +import AtomicXCore +import ChatUIKit +import Combine import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { + private static let administratorConversationID = "c2c_administrator" + + private static let welcomeMessageDelay: TimeInterval = 1 + + private var cancellables = Set() + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { setupAppConfiguration() + CustomLinkMessageManager.register() + setupLoginObserver() + return true } + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + private func setupLoginObserver() { + LoginStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \LoginState.loginStatus)) + .sink { [weak self] loginStatus in + guard let self = self else { return } + if loginStatus == .logined { + self.scheduleWelcomeMessage() + } + } + .store(in: &cancellables) + } + + private func scheduleWelcomeMessage() { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.welcomeMessageDelay) { + let inputStore = MessageInputStore.create(conversationID: Self.administratorConversationID) + let payload = TextSendMessagePayload(text: LocalizedChatString("DemoWelcomeMessage")) + inputStore.sendMessage(payload: .text(payload), option: nil, completion: nil) + } + } + private func setupAppConfiguration() { if let configPath = Bundle.main.path(forResource: "appConfig", ofType: "json") { print("appConfig.json existed: \(configPath)") @@ -15,25 +50,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } else { print("appConfig.json not found") } - - // Sync user settings from UserDefaults to AppBuilderConfig - // This ensures user preferences persist across app restarts + syncUserSettingsToAppConfig() } - + private func syncUserSettingsToAppConfig() { - // Sync enableReadReceipt + let readReceiptKey = "com.atomicx.enableReadReceipt" if UserDefaults.standard.object(forKey: readReceiptKey) != nil { AppBuilderConfig.shared.enableReadReceipt = UserDefaults.standard.bool(forKey: readReceiptKey) } - - // Sync translateTargetLanguage + let translateKey = "com.atomicx.translateTargetLanguage" if let saved = UserDefaults.standard.string(forKey: translateKey), !saved.isEmpty { AppBuilderConfig.shared.translateTargetLanguage = saved } else if AppBuilderConfig.shared.translateTargetLanguage.isEmpty { - // Use system language as default if not set, map to SDK language codes + var systemLanguage = LanguageHelper.getCurrentLanguage() if systemLanguage == "zh-Hans" { systemLanguage = "zh" @@ -43,10 +75,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AppBuilderConfig.shared.translateTargetLanguage = systemLanguage } } - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} } diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/Contents.json new file mode 100644 index 000000000..6f07fae5a --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "demo_ic_login_arrow_down@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "demo_ic_login_arrow_down@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/demo_ic_login_arrow_down@2x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/demo_ic_login_arrow_down@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d86366915f9c6a6a5dbd08356531bb8460f4746e GIT binary patch literal 235 zcmV6=>UKJA5x%FSrwA5gl4e; z3z+XyM*hrea!kd(J;@u%qwP0==3VQZY5K4?WxL@LGlD(0I)Uf zqE~eMa3|RY_pO~NU26Kd5Eq4vBoDU6$UOgN>qI1*iI>EaH`&wAgp@IlLY&oST~@ol l&7%-W?Tf*+j4KuV;st6%@%)3)TT%c3002ovPDHLkV1g3UW)}be literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/demo_ic_login_arrow_down@3x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_arrow_down.imageset/demo_ic_login_arrow_down@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..60ab09b62d666032548e84c9a6b2e23b22567a7f GIT binary patch literal 295 zcmV+?0oeYDP)ea|clPq=Zbta~<^-Deoyg zj6`%LqSkI`jCo2a`^|k;xaGMv<~T5Csp6A9xb9@87MbZ5b1S*l5mH%5%5KCZBoq5L zf?+@hLTOed8kg&XZY@n#4_-lF4kyRJzQpNR=W(WpbH?R3;wI tT08-1WgPCh``TXvzapY2ilWf@ya3Jd=-!;J(l-DA002ovPDHLkV1hWBd`bWS literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/Contents.json new file mode 100644 index 000000000..88e7a8ac5 --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "demo_ic_login_language@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "demo_ic_login_language@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/demo_ic_login_language@2x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/demo_ic_login_language@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5ebfb26e266e6c0db68828f262466ce57ef5685f GIT binary patch literal 874 zcmV-w1C{)VP)8P))?(IUg^O=K$S zZtN6X+9{Bf0YS0@K&xKsDW#PhaD{;t0z|UnKuC51xD4O|fb#&(0;o!K9>5HM4*=c+ zc#~3E%<9-RkxV2T%R}-6$%iCoSe&Lw-Y3~D9WBlBt|*wK{>>a9b+z@tTMzs!uz{@J z9fRQ?kDldt{)yyrcpp`BT9!|!Rw(gcPQ}3#On!;xvnSCRuM1*nz)u@v_Is{63O1+ zaj$ruPyJ!uqfDf3Yzr!^Z8yp9!RT`z*K;sLVI)V-NEjc?%zY8S&XPPHcXG4n*w&_U zVVdM=k~NY?)Fy(#TO_L_kE&J0bD9YAYSJ|=-9`_xQSxn_tdgqeeFSjQ*Z@`mSmmwg z-8?aw#xwv6dPYi;@q{&*4ZT}ow94MD_gMhvtav&Gk4~fXle`*2deG!Hu0#xe2Dz6P z)i;lRDMFj8D$mSAqcue@&iAO8Oq=>E(i69MyP|HqO8{PPiL{byJa}L`Et?<1Vf>zW z$MlSS8*>`Y(c`<==xwY=tzqrVqch^0N0TX~`3z{!AET-4c#xY4ubnx1CS{{thqKwX za*%w)1weB`?&uj#bt9`cNul>s-1|#Gu9~A~E&!^@b|XEu?*D&GEun8o{8N2H z-epUfQnJgHU$A264{N-x=Vsr=g4}$Lo-WydY?|!`wT7FV?V4`@`Xp>e#1Ab4v=mlT zocb(5wNvY^Pkqzjv>>^tr#}GtD%*{_c#5|d zT1p#pFY>4@7~PIKf)kOvdXGm8q?lY*AABqR?DcvI- ze_f{i`q@xZf!y=YhX3<-Or+BMuxes>-+$@-21TiX_n%m4rY07*qoM6N<$f`^Wy AcK`qY literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/demo_ic_login_language@3x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_language.imageset/demo_ic_login_language@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..82eda09263816f0221b354f2e87f894230890d60 GIT binary patch literal 1443 zcmV;U1zh@xP)(MK$`_JH49Kmsp8rM;Qavh0C*6< z0D$end|9 z3`XHV&sk`)Su^KAz2ZhOu9q)J4lCbDH}b}R!s$Da=|8D`Pm}yI05m%S@};JL(-bcC z2+4UjLuPD|)2^{up?**YC&fNJn@2dZj> zdd~(qW{3nmW86t z+L!}oy7w8619w1enr@r9)g=?>Ko80DPML+^+r}5h~v>0t>4Ln zUY69qUrJv+P2rV%@;QBpppY}}#nTf#L=WLB<*7+N?W-Zk@ ze*6GnuO25#Zqw?|Q(jMRP<2yFJ=&_p(`-h!)%WV}LjV5=;HlhcAcj+QM=j|cz_)5( zV>Y3yC#r8FDW!>c8mP5Jk)wxDMR{a|Oel9w5+0v=jen&xl{i+;$mR~JHo(SjzP$)w zBInS*lDB6|6ztcglB-wq%vdddl-<%GpU|lI9M5jcz~mXChdep?K5ETc%ZPlXqh$(` zTYql?kX?A8|K()-vE#{+^}Pn*VJCYTz=v)fnY`J49>BA%{W10V4~HT9K;Ewz$1;%6 zp&HJatYx>GT94J^53*xHc5(&&L%}zpYJ~ZnNLMC?<&FY6I%OT1i~7J6K^qYp%^f;h zo@SI^^w+xBvA&tmzFT%C0R9RyX0|VCo6TU^`J8gVY(eg{!O{8#fT@(ylE!ki-F}ml zOPcPWct-h!DGSW{NX`UV(X~CLG+o2ql={@;eB2o@WEZ&ICfUr0W7jf#=Eyc3HLq_G zjniU~Evrs%!_zev1x4_Anbw-Q+MY_8wuR6q;o6_!^PyS^BPe0gDD?!{it6+>JoURJ zR0=*Lx5iAdTxP~llLMrrVL4VTCl3jDRl4NDM`*y3N1Y9-^~q(bC(*T_@@50h_FyJd z&F3Nq%oNBCEkf2AN-3SG;o!7_-s5x(Nb*o8Yf-gNHp9XGWl0>hgh&o4AoI=p%z9g@ z^8n_OG)6&8yE1?e5RouDOj9OrgGg2Vgkv z?z?L2?#BbLcH9IPT7RR@xBu+!JNDR;I`yF0u~!5CpUa#kU%!pD&Gp+Ix-czjT$n!D z)C*&w=+Vw-syM#?f{{Xv*jRF{=dddI*002ovPDHLkV1iUO(&PXD literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/Contents.json new file mode 100644 index 000000000..02a0f125d --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "demo_ic_login_theme@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "demo_ic_login_theme@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/demo_ic_login_theme@2x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_login_theme.imageset/demo_ic_login_theme@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f7219944b433470f37f2cd79a6130b452dabbb22 GIT binary patch literal 863 zcmV-l1EBngP)lENZ?z!wDUf<&?K z)mSLRtghnj?0j<8bLDWEot>HNR{FtZ?#w;+-0z;peGKp)`4ChDN$opkI)WbhA|m=6 z9Y_kvaU)Yieos-^fWpEg$3>*;-VXyf4WI^K#y;-=_y}NAMBe+p0MemZjO3WJ&}EWq zB)_mfc1S)ZStofU*0UQT;>awJe4a6QCn_;Mk-TPB3f9^+nHwa#Hh@0wlWdTDW#3XO zv~LZQNgT1KBx{vOh2X`X`jRP3tkj*{tRof3%)0iv5&dQD??|31?LJ55x+!X#;_oD< zEqf+SMy(Ohuy2zlGi`n7NWCF>$R;gi$eeRl={jQbk^HJ7v>LU|yUB!jQ#>qeC1XB8 z@`Y2P~@{V(jA1G_ktoBvYp}J_gTO^P6CFRIyz-?Q{t&FUpnLQBBLh4)-XQk>4 zSd87~nT3cy17OTo=ITnqbNvP)PCwdJcEfGlo z%mLVIyZIo{WaqT6>hL-ZlSK%HjZz6*^;rX88Nh@MmbZq8$*AnI&q~z^plj{l9QkNE zMKIACSJkCW2J*gnUP zG>43jlM8uzE1#(|4rrYmQvR%S(gyBbm3*m0WWW(N&2e9F*Oa*= zL%H%)yauI4uI;S1FfuFkxhP&C`N%Dv9?wYb+fB?2<0^d|z(LysABf0TB~Kpobtt)Pu}>wq*B{G(>*^s8<-D@>gld}ufBS(>ebHx zBaAS@AQ1*BoMeb}c+NNvA^7i=ON>{E)BrTgG@9g?NM`*c8Tv@57D8wy?{5UK4#0W< z6Gj%mB>;Z{m<=J!C*|vwg%Gl#6C+uV36b0k;4uIX0kG#u0ON@(E&wSaA735z_M$B#%c8Y?53cd6{H` zWTwREIn%exDjRo(Js%`_x9gIt51dr17fDW%d@pHG96*VM@c?Qay!YFNuJIj;?Jj5X1nSf&5>t#hvW^ZrwoGB15tw)eBlR$)~MuPoW^{(VdZ{ z(izRI_jXQwbDd++xUodNTSCBOBr;>Ti=H8QdmKlrl5+PLk6h3jmcq#%AbGDs!NVk{ zbKJ_Gr&VTBbq?f|O|hu_wYcNEwbt&0l+RY4$M0SF`5Yp79YK{{Ox`yW9r6226WCfe zKwkUXnKDzQ=e54`6px9zWMukn4+ zVq?l*l?&LKE-M*WyG65Ppl>MuF^Yek<607nK9Z1sy9lgRQ6#Syjji^?UhxFX2DMIS<+DdV1qqZ*T%usZOVZ;C&PCrL==Hv=~%G9ropv$tg z;XhGK^!Fa;egLpZ77ghMSGzGbpPm7B2(0}=r$A5TpNfZbn+!v`Z$X8}AFLOAn(Q;QE&i(Jtwd@O4X>fn+E$@I5Y9dbn@ z^(}x$LkKgPFuFS>jy9TKCMQPyfUgV7;4hM|NBggOnfA9<@dapIGLU>r=kWcGQ$cac ztP*2UmlkHBBLF@OA^g&+$kt?0YP=UVHX59(HHl0`!Le6T=K$;na0tM~q>SmXCEJxy zBP;dqnkiadJ>KRprwaU(cWVjVHVnoFcC^i_1t|$3{B1D=fW?q{7vx;mX5Wx5%k@wXml9RS znBm1(=Fq|7YoxL<$tRu*B=3~0;ajc4#h(-GG;4Y%?2?>zv9xQ{s#Z|Ywm^9aH zlK@UgRxR2jHglFri1IXktqidEa=qQs%dPoSbdk3*TrovD0odOmNbV{ z`pd*%u3m@m2bP-vTmbNO(K*F+|Cb}S+4)|te-fjmL0W2r5k|nW;6E(UYW~`r!RY`1 N002ovPDHLkV1mpV*VO<3 literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/Contents.json new file mode 100644 index 000000000..c0806ec07 --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "demo_ic_security_close@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "demo_ic_security_close@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/demo_ic_security_close@2x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/demo_ic_security_close@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d05468feec56a2fe5eb0d4cf2e8e478c978d2aef GIT binary patch literal 538 zcmV+#0_FXQP)t z+h(DKz>c=rt$uNMG~~8{A463ll#hIciP&v+#kFh!mJ=7j>m9BSPMKg_c9>bs%llJ4 zO5iTcmx*gm;vRTxFu?gS+hX+ags+LaH3J4N$15j%GiL&v8q@@yh*$!-Oe~7aH)4c! zCgRG8XEV^4^F-*z%$xx=<7OtB;!WSgwbB+WT8TJiqU5t|%Vfoqy~66~E51?yxgZd! zGnhNiHD4L~7&Co#5nnlgKY}oxVljx{0!D?$anMD)Qvi!Wyc=?>HlDe~ldel1g7f0u zlrLSxW!XaXFU{7y1)k&| zV+3ru{0bE=r8{j$#0YDQ#FbvzmW<~clD9Yb5Lb1+N(z>Igo)TfL$dak?BX}2zRtFM cWB-Mn0lf@EM8lFf*Z=?k07*qoM6N<$f^tgv^8f$< literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/demo_ic_security_close@3x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_close.imageset/demo_ic_security_close@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..10fa8063865311f361871f0f4ce4ce0b16f58228 GIT binary patch literal 699 zcmV;s0!00ZP)MIzhAu35i^T2%@>8if98#&2_X?p@26);wX@K zfOIq@_y&z{jgFAJTia`iJNB1WvaI#||L+~oju#>#A|fIpDnLPOEKA&J{1H4Pnf@Is zxRKuhPck08+G5+ThmbZ6JajQErp3OB(@WUAISL#+MQ*&|p(uBO?_=S`z0bTi0bR)J zitj@HdW$=W@)+1q;qA8_4iBE8PMp!tM6!saeEOynuW;sE^^P(+CZNtrl;V#vIu^@u zof(I5EmZDnpb!h>$|rmC0_zAD0^=0+>zJ4ppTfSyaxvG9!?=7u0`dLG1oSc?%9=uw z!{GjiQ=fF2C7w&MO3qL#phj$gjlhVEu2Mf3Pr)&Kob*f ziLbq3R1swJ+Y;Ys$6I7VV6Z#l)aIUKbhHKWjkoN1CJ<7_JTE3DJVD7rix1LsTxCLp z3stTo{46FZ*F-qiz~X~7O{EY^6@;Fe35vHpDH6h1VNE6F95@`VpJ?7D6P*opz4R-(*v8@mm=*q{cgHfBw`*q{dL zc9m_KNZJtQ@_ZBR|;8#RJc`~+rW>e zE%B^$Qi}~6#^v=x>|>x@qOUs~+M$!cW?@R`H))BdO{ma!T${#WT+`eueYE)B9PX~f h@p?o=L`1~u;TJMkHL{Au&O-nI002ovPDHLkV1gy#NLc^? literal 0 HcmV?d00001 diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/Contents.json new file mode 100644 index 000000000..cfd426118 --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "demo_ic_security_warning@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "demo_ic_security_warning@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/demo_ic_security_warning@2x.png b/chat/demo/ChatDemo/Assets.xcassets/demo_ic_security_warning.imageset/demo_ic_security_warning@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7583b957e209fcb26788bc528400d0272f7e8c62 GIT binary patch literal 1036 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

N~pb*;BElHOzeB6(%Cm}(p_N_bFMFAtv{5e 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

j;j#a~HQ!R!O*CJ#R3C@3(h zkG8|aLoe065u^QX@<}P?XH(qdK&x2x3}jbX)BXBr0b`6aW7 \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/Contents.json index aab5790df..48507c618 100644 --- a/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/Contents.json +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/Contents.json @@ -1,16 +1,16 @@ { - "images" : [ + "images": [ { - "filename" : "chats.svg", - "idiom" : "universal" + "filename": "tab_chat.svg", + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" } -} +} \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/tab_chat.svg b/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/tab_chat.svg new file mode 100644 index 000000000..6d44b84e6 --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_chat.imageset/tab_chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/Contents.json new file mode 100644 index 000000000..dac0faeb4 --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "tab_chat_lines.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/tab_chat_lines.svg b/chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/tab_chat_lines.svg new file mode 100644 index 000000000..f640197fb --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_chat_lines.imageset/tab_chat_lines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/Contents.json index 383c2aa16..3af904cd6 100644 --- a/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/Contents.json +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/Contents.json @@ -1,16 +1,16 @@ { - "images" : [ + "images": [ { - "filename" : "contacts.svg", - "idiom" : "universal" + "filename": "tab_contact.svg", + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" } -} +} \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/contacts.svg b/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/contacts.svg deleted file mode 100644 index 501bd8938..000000000 --- a/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/contacts.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/tab_contact.svg b/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/tab_contact.svg new file mode 100644 index 000000000..a8469505f --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_contact.imageset/tab_contact.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/Contents.json b/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/Contents.json index 3989ed33c..658c2d6a9 100644 --- a/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/Contents.json +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/Contents.json @@ -1,16 +1,16 @@ { - "images" : [ + "images": [ { - "filename" : "setting.svg", - "idiom" : "universal" + "filename": "tab_setting.svg", + "idiom": "universal" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 }, - "properties" : { - "preserves-vector-representation" : true, - "template-rendering-intent" : "template" + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" } -} +} \ No newline at end of file diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/setting.svg b/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/setting.svg deleted file mode 100644 index 57be8f0e2..000000000 --- a/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/setting.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/tab_setting.svg b/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/tab_setting.svg new file mode 100644 index 000000000..c99f432e8 --- /dev/null +++ b/chat/demo/ChatDemo/Assets.xcassets/tab_setting.imageset/tab_setting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/chat/demo/ChatDemo/ChatDemo-AppStore.entitlements b/chat/demo/ChatDemo/ChatDemo-AppStore.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/chat/demo/ChatDemo/ChatDemo-AppStore.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/chat/demo/ChatDemo/ChatDemo-Debug.entitlements b/chat/demo/ChatDemo/ChatDemo-Debug.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/chat/demo/ChatDemo/ChatDemo-Debug.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/chat/demo/ChatDemo/ChatDemo-Release.entitlements b/chat/demo/ChatDemo/ChatDemo-Release.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/chat/demo/ChatDemo/ChatDemo-Release.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/chat/demo/ChatDemo/ContentView.swift b/chat/demo/ChatDemo/ContentView.swift deleted file mode 100644 index 69b08ff21..000000000 --- a/chat/demo/ChatDemo/ContentView.swift +++ /dev/null @@ -1,91 +0,0 @@ -import AtomicX -import AtomicXCore -import Combine -import SwiftUI - -public enum RootPage { - case login - case home -} - -struct ContentView: View { - @StateObject private var themeState = ThemeState.shared - @StateObject private var appStyleSettings = AppStyleSettings() - @StateObject private var languageState = LanguageState() - @State private var currentPage: RootPage = .login - @State private var cancellables = Set() - - var body: some View { - NavigationView { - Group { - switch currentPage { - case .login: - LoginPage() - case .home: - HomePage() - } - } - .navigationBarHidden(true) - } - .navigationViewStyle(StackNavigationViewStyle()) - .environmentObject(themeState) - .environmentObject(appStyleSettings) - .environmentObject(languageState) - .preferredColorScheme(getPreferredColorScheme()) - .environment(\.layoutDirection, getLayoutDirection()) - .onAppear { - languageState.setLanguage(languageState.currentLanguage) - setupLoginStatusObserver() - } - } - - private func getPreferredColorScheme() -> ColorScheme? { - switch themeState.currentMode { - case .light: - return .light - case .dark: - return .dark - case .system: - if themeState.isDarkMode { - return .dark - } else { - return .light - } - } - } - - private func getLayoutDirection() -> LayoutDirection { - // Right-to-left languages - let rtlLanguages = ["ar", "he", "fa", "ur"] - return rtlLanguages.contains(languageState.currentLanguage) ? .rightToLeft : .leftToRight - } - - private func setupLoginStatusObserver() { - let currentLoginStatus = LoginStore.shared.state.value.loginStatus - if currentLoginStatus == .logined { - currentPage = .home - } else { - currentPage = .login - } - - LoginStore.shared.state - .subscribe(StatePublisherSelector(keyPath: \LoginState.loginStatus)) - .sink { loginStatus in - DispatchQueue.main.async { - handleLoginStatusChange(loginStatus) - } - } - .store(in: &cancellables) - } - - private func handleLoginStatusChange(_ loginStatus: LoginStatus) { - switch loginStatus { - case .logined: - if currentPage == .login { - currentPage = .home - } - case .unlogin: - currentPage = .login - } - } -} diff --git a/chat/demo/ChatDemo/GenerateTestUserSig.swift b/chat/demo/ChatDemo/GenerateTestUserSig.swift index a1b29682f..a6310e8b1 100644 --- a/chat/demo/ChatDemo/GenerateTestUserSig.swift +++ b/chat/demo/ChatDemo/GenerateTestUserSig.swift @@ -13,8 +13,7 @@ public class GenerateTestUserSig: NSObject { @objc public class func genTestUserSig(userID: String, sdkAppID: Int, secretKey: String) -> String { - // Signature expiration time, it is recommended not to set it too short. - // Default time: 7 x 24 x 60 x 60 = 604800 = 7 days + let EXPIRETIME = 604_800 let current = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970 let TLSTime = CLong(floor(current)) diff --git a/chat/demo/ChatDemo/Info.plist b/chat/demo/ChatDemo/Info.plist index 47a4d2b43..c625438c5 100644 --- a/chat/demo/ChatDemo/Info.plist +++ b/chat/demo/ChatDemo/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.0.0 + 0.0.1 CFBundleVersion - 0.0.0 + 0.0.1 LSApplicationCategoryType NSCameraUsageDescription @@ -28,6 +28,8 @@ 需要访问麦克风以录制语音 NSPhotoLibraryUsageDescription 需要访问相册以保存照片 + NSPhotoLibraryAddUsageDescription + 需要访问相册以保存照片和视频 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -45,6 +47,10 @@ + UIBackgroundModes + + remote-notification + UILaunchStoryboardName LaunchScreen diff --git a/chat/demo/ChatDemo/Navigation/ChatFlowCoordinator.swift b/chat/demo/ChatDemo/Navigation/ChatFlowCoordinator.swift new file mode 100644 index 000000000..4b004945a --- /dev/null +++ b/chat/demo/ChatDemo/Navigation/ChatFlowCoordinator.swift @@ -0,0 +1,481 @@ +import ChatUIKit +import AtomicXCore +import SnapKit +import UIKit + +final class ChatFlowCoordinator { + weak var navigationController: UINavigationController? + + init() {} + + // MARK: - Chat + + func pushChat(_ conversation: ConversationInfo, locateMessage: MessageInfo? = nil, animated: Bool = true) { + let page = makeChatPage(conversation, locateMessage) + + page.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(page, animated: animated) + } + + // MARK: - C2C Setting + + func pushC2CSetting(userID: String, parentConversationID: String?) { + let setting = C2CChatSettingViewController( + userID: userID, + onSendMessageClick: { [weak self] in + self?.handleC2CSendMessage(userID: userID, parentConversationID: parentConversationID) + }, + onContactDelete: { [weak self] in + self?.navigationController?.popToRootViewController(animated: true) + } + ) + pushSettingPage(setting) + } + + // MARK: - Group Setting + + func pushGroupSetting(groupID: String, parentConversationID: String?) { + let setting = GroupChatSettingViewController( + groupID: groupID, + onSendMessageClick: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }, + onGroupDelete: { [weak self] in + self?.navigationController?.popToRootViewController(animated: true) + }, + onGroupMemberClick: { [weak self] memberUserID in + self?.pushUserProfile(userID: memberUserID, parentConversationID: parentConversationID) + } + ) + pushSettingPage(setting) + } + + // MARK: - Setting Push + + private func makeChatPage(_ conversation: ConversationInfo, _ locateMessage: MessageInfo?) -> ChatPage { + let page = ChatPage( + conversation: conversation, + locateMessage: locateMessage, + messageInputConfig: makeMessageInputConfig(conversationID: conversation.conversationID), + onBack: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }, + onUserAvatarClick: { [weak self] userID in + self?.pushC2CSetting(userID: userID, parentConversationID: conversation.conversationID) + }, + onNavigationAvatarClick: { [weak self] in + self?.handleNavigationAvatarClick(for: conversation) + } + ) + let warningView = ChatSecurityWarningView() + warningView.onClose = { [weak page] in + page?.topBannerView = nil + } + page.topBannerView = warningView + return page + } + + private func makeMessageInputConfig(conversationID: String) -> ChatMessageInputConfig { + return ChatMessageInputConfig(actionCustomizer: { editor in + editor.add(MessageInputMenuAction( + ID: "demo.messageInput.customLink", + title: LocalizedChatString("DemoChatCustomMessageMenuTitle"), + iconName: "", + icon: UIImage(systemName: "paperplane.fill")? + .withTintColor(ChatUIKitTheme.colors.textColorSecondary, renderingMode: .alwaysOriginal), + onClick: { + ChatFlowCoordinator.sendCustomLinkMessage(conversationID: conversationID) + } + )) + }) + } + + private static func sendCustomLinkMessage(conversationID: String) { + let text = LocalizedChatString("DemoChatCustomMessageContent") + let payload: [String: Any] = [ + "businessID": "text_link", + "text": text, + "link": "https://cloud.tencent.com/document/product/269/3794" + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + let customPayload = CustomSendMessagePayload(customData: json, description: text) + var option = SendMessageOption() + option.needReadReceipt = ChatMessageInputConfig().enableReadReceipt + MessageInputStore.create(conversationID: conversationID) + .sendMessage(payload: .custom(customPayload), option: option, completion: nil) + } + + private func handleNavigationAvatarClick(for conversation: ConversationInfo) { + if conversation.type == .c2c, let userID = ChatUtil.getUserID(conversation.conversationID) { + pushC2CSetting(userID: userID, parentConversationID: conversation.conversationID) + } else if conversation.type == .group, let groupID = ChatUtil.getGroupID(conversation.conversationID) { + pushGroupSetting(groupID: groupID, parentConversationID: conversation.conversationID) + } + } + + private func handleC2CSendMessage(userID: String, parentConversationID: String?) { + let targetConversationID = ChatUtil.getC2CConversationID(userID) + if parentConversationID == targetConversationID { + navigationController?.popViewController(animated: true) + return + } + ContactStore.shared.getContactInfo( + userIDList: [userID], + completion: CoordinatorContactInfoHandler( + onSuccess: { [weak self] list in + DispatchQueue.main.async { self?.pushC2CChat(userID: userID, contact: list.first) } + }, + onFailure: { [weak self] _, _ in + DispatchQueue.main.async { self?.pushC2CChat(userID: userID, contact: nil) } + } + ) + ) + } + + private func pushC2CChat(userID: String, contact: ContactInfo?) { + var conversation = ConversationInfo(conversationID: ChatUtil.getC2CConversationID(userID)) + conversation.type = .c2c + conversation.title = displayName(for: contact) ?? userID + conversation.avatarURL = contact?.avatarURL + pushChat(conversation) + } + + private func displayName(for contact: ContactInfo?) -> String? { + guard let contact = contact else { return nil } + if let remark = contact.friendRemark, !remark.isEmpty { return remark } + if let nickname = contact.nickname, !nickname.isEmpty { return nickname } + return nil + } + + private func pushUserProfile(userID: String, parentConversationID: String?) { + guard let source = navigationController?.topViewController else { return } + UserProfileRouter.open( + userID: userID, + from: source, + onSendMessageClick: { [weak self] in + self?.handleC2CSendMessage(userID: userID, parentConversationID: parentConversationID) + }, + onContactDelete: { [weak self] in + self?.navigationController?.popToRootViewController(animated: true) + } + ) + } + + private func pushSettingPage(_ viewController: UIViewController) { + viewController.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(viewController, animated: true) + } +} + +// MARK: - Security Warning Banner + +final class ChatSecurityWarningView: UIView { + var onClose: (() -> Void)? + + private static let leadingInset: CGFloat = 20 + + private static let trailingInset: CGFloat = 17 + + private static let verticalInset: CGFloat = 16 + + private static let iconSize: CGFloat = 16 + + private static let iconTextGap: CGFloat = 10 + + private static let textCloseGap: CGFloat = 7 + + private static let closeButtonSize: CGFloat = 22 + + private static let warningFontSize: CGFloat = 14 + + private static let reportURL = "https://cloud.tencent.com/act/event/report-platform" + + private let iconView = UIImageView() + + private let warningTextView = UITextView() + + private let closeButton = UIButton(type: .custom) + + override init(frame: CGRect) { + super.init(frame: frame) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var textHeightConstraint: Constraint? + + private var currentTextHeight: CGFloat = 0 + + override func layoutSubviews() { + super.layoutSubviews() + let textWidth = bounds.width - Self.leadingInset - Self.iconSize - Self.iconTextGap + - Self.textCloseGap - Self.closeButtonSize - Self.trailingInset + guard textWidth > 0 else { return } + let fitting = warningTextView.sizeThatFits(CGSize(width: textWidth, height: .greatestFiniteMagnitude)) + guard abs(fitting.height - currentTextHeight) > 0.5 else { return } + currentTextHeight = fitting.height + textHeightConstraint?.update(offset: fitting.height) + } + + private func constructViewHierarchy() { + addSubview(iconView) + addSubview(warningTextView) + addSubview(closeButton) + } + + private func activateConstraints() { + let firstLineHeight = UIFont.systemFont(ofSize: Self.warningFontSize).lineHeight + let iconTopOffset = Self.verticalInset + max(0, (firstLineHeight - Self.iconSize) / 2) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.leadingInset) + make.top.equalToSuperview().offset(iconTopOffset) + make.width.height.equalTo(Self.iconSize) + } + warningTextView.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(Self.iconTextGap) + make.top.equalToSuperview().offset(Self.verticalInset) + make.bottom.equalToSuperview().offset(-Self.verticalInset) + make.trailing.equalTo(closeButton.snp.leading).offset(-Self.textCloseGap) + textHeightConstraint = make.height.equalTo(0).constraint + } + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.trailingInset) + make.top.equalToSuperview().offset(Self.verticalInset) + make.width.height.equalTo(Self.closeButtonSize) + } + } + + private func setupViewStyle() { + let colors = ThemeState.shared.colors + backgroundColor = colors.toastColorWarning + iconView.image = UIImage(named: "demo_ic_security_warning")?.withRenderingMode(.alwaysTemplate) + iconView.tintColor = colors.textColorWarning + iconView.contentMode = .scaleAspectFit + closeButton.setImage( + UIImage(named: "demo_ic_security_close")?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + closeButton.tintColor = colors.textColorWarning + closeButton.imageView?.contentMode = .scaleAspectFit + + warningTextView.isEditable = false + warningTextView.isScrollEnabled = false + warningTextView.backgroundColor = .clear + warningTextView.textContainerInset = .zero + warningTextView.textContainer.lineFragmentPadding = 0 + warningTextView.dataDetectorTypes = [] + warningTextView.linkTextAttributes = [.foregroundColor: colors.textColorLink] + warningTextView.attributedText = makeWarningText() + } + + private func bindInteraction() { + warningTextView.delegate = self + closeButton.addTarget(self, action: #selector(handleCloseTapped), for: .touchUpInside) + } + + private func makeWarningText() -> NSAttributedString { + let colors = ThemeState.shared.colors + let warning = LocalizedChatString("ChatSecurityWarning") + let report = LocalizedChatString("ChatSecurityWarningReport") + let text = NSMutableAttributedString( + string: warning + " ", + attributes: [ + .font: UIFont.systemFont(ofSize: Self.warningFontSize), + .foregroundColor: colors.textColorWarning + ] + ) + text.append(NSAttributedString( + string: report, + attributes: [ + .font: UIFont.systemFont(ofSize: Self.warningFontSize), + .foregroundColor: colors.textColorLink, + .link: URL(string: Self.reportURL) as Any + ] + )) + return text + } + + @objc private func handleCloseTapped() { + onClose?() + } +} + +extension ChatSecurityWarningView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + UIApplication.shared.open(URL) + return false + } +} + +// MARK: - Custom Link Message + +struct CustomLinkMessage { + static let businessID = "text_link" + + let text: String? + + let link: String? + + static func from(customData: String?) -> CustomLinkMessage? { + guard let customData = customData, !customData.isEmpty, + let data = customData.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return CustomLinkMessage(text: dict["text"] as? String, link: dict["link"] as? String) + } + + static func from(message: MessageInfo) -> CustomLinkMessage? { + guard case .custom(let payload) = message.messagePayload else { return nil } + return from(customData: payload.customData) + } +} + +enum CustomLinkMessageManager { + static func register() { + MessageListView.registerCustomMessageCell( + businessID: CustomLinkMessage.businessID, + summaryProvider: { payload in + CustomLinkMessage.from(customData: payload.customData)?.text + }, + makeContentView: { + CustomLinkMessageContentView() + } + ) + } +} + +final class CustomLinkMessageContentView: UIView, MessageContentView { + private static let horizontalPadding: CGFloat = 16 + + private static let verticalPadding: CGFloat = 12 + + private static let linkTopSpacing: CGFloat = 8 + + private static let textFontSize: CGFloat = 16 + + private static let linkFontSize: CGFloat = 14 + + private static let textLineSpacingMultiplier: CGFloat = 0.3 + + private static let linkLineSpacingMultiplier: CGFloat = 0.2 + + private static let maxBubbleWidth: CGFloat = UIScreen.main.bounds.width * 0.72 + + private let textLabel = UILabel() + + private let linkLabel = UILabel() + + private var linkURL: URL? + + override init(frame: CGRect) { + super.init(frame: frame) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(message: MessageInfo, context: MessageContentContext) { + let linkMessage = CustomLinkMessage.from(message: message) + let colors = ChatUIKitTheme.colors + let textColor = message.isSentBySelf ? colors.textColorAntiPrimary : colors.textColorPrimary + textLabel.attributedText = makeAttributedText( + linkMessage?.text ?? "", + fontSize: Self.textFontSize, + color: textColor, + multiplier: Self.textLineSpacingMultiplier + ) + let link = linkMessage?.link?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + linkURL = URL(string: link) + linkLabel.isHidden = link.isEmpty + linkLabel.attributedText = makeAttributedText( + LocalizedChatString("DemoChatCustomMessageViewDetails"), + fontSize: Self.linkFontSize, + color: colors.textColorLink, + multiplier: Self.linkLineSpacingMultiplier + ) + isUserInteractionEnabled = !link.isEmpty && !context.isMultiSelectMode + } + + private func constructViewHierarchy() { + addSubview(textLabel) + addSubview(linkLabel) + } + + private func activateConstraints() { + textLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.verticalPadding) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalPadding) + make.width.lessThanOrEqualTo(Self.maxBubbleWidth - Self.horizontalPadding * 2) + } + linkLabel.snp.makeConstraints { make in + make.top.equalTo(textLabel.snp.bottom).offset(Self.linkTopSpacing) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + } + } + + private func setupViewStyle() { + textLabel.numberOfLines = 0 + textLabel.textAlignment = .natural + textLabel.preferredMaxLayoutWidth = Self.maxBubbleWidth - Self.horizontalPadding * 2 + linkLabel.numberOfLines = 1 + linkLabel.textAlignment = .natural + } + + private func bindInteraction() { + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) + } + + private func makeAttributedText(_ text: String, fontSize: CGFloat, color: UIColor, multiplier: CGFloat) -> NSAttributedString { + let font = UIFont.systemFont(ofSize: fontSize) + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .natural + paragraph.lineSpacing = font.lineHeight * multiplier + return NSAttributedString(string: text, attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph + ]) + } + + @objc private func handleTap() { + guard let url = linkURL else { return } + UIApplication.shared.open(url) + } +} + +// MARK: - Store Completion Handler + +private final class CoordinatorContactInfoHandler: GetContactInfoCompletionHandler { + private let onSuccessBlock: ([ContactInfo]) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping ([ContactInfo]) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(contactInfoList: [ContactInfo]) { + onSuccessBlock(contactInfoList) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/demo/ChatDemo/Pages/ChatNavPage.swift b/chat/demo/ChatDemo/Pages/ChatNavPage.swift deleted file mode 100644 index ab13ca0c0..000000000 --- a/chat/demo/ChatDemo/Pages/ChatNavPage.swift +++ /dev/null @@ -1,386 +0,0 @@ -import AtomicX -import AtomicXCore -import ChatUIKit -import SwiftUI - -struct ChatNavPage: View { - @EnvironmentObject var themeState: ThemeState - - // Essential parameters - let conversation: ConversationInfo - let locateMessage: MessageInfo? - let onBack: () -> Void - let onContactDelete: () -> Void - let onGroupDelete: () -> Void - let onNavigateToChat: ((ConversationInfo) -> Void)? - - // Internal navigation state - @State private var showC2CChatSetting: Bool = false - @State private var showGroupChatSetting: Bool = false - @State private var currentC2CUserID: String? = nil - @State private var currentGroupID: String? = nil - - init( - conversation: ConversationInfo, - locateMessage: MessageInfo? = nil, - onBack: @escaping () -> Void, - onContactDelete: @escaping () -> Void, - onGroupDelete: @escaping () -> Void, - onNavigateToChat: ((ConversationInfo) -> Void)? = nil - ) { - self.conversation = conversation - self.locateMessage = locateMessage - self.onBack = onBack - self.onContactDelete = onContactDelete - self.onGroupDelete = onGroupDelete - self.onNavigateToChat = onNavigateToChat - } - - var body: some View { - ZStack { - ChatPage( - conversation: conversation, - locateMessage: locateMessage, - onBack: onBack, - onUserAvatarClick: { userID in - handleUserAvatarClick(userID: userID) - }, - onNavigationAvatarClick: { - handleNavigationAvatarClick() - } - ) - - if let userID = currentC2CUserID { - NavigationLink( - destination: NestedC2CSetting( - userID: userID, - parentConversationID: conversation.conversationID, - onBack: { - dismissC2CChatSetting() - }, - onContactDelete: { - dismissC2CChatSetting() - onContactDelete() - } - ) - .id(userID) - .navigationBarTitle(LocalizedChatString("ProfileDetails"), displayMode: .inline) - .navigationBarItems( - leading: Button(action: { - dismissC2CChatSetting() - }) { - Image(systemName: "chevron.left") - .foregroundColor(themeState.colors.textColorLink) - } - ) - .navigationBarBackButtonHidden(true), - isActive: $showC2CChatSetting - ) { - EmptyView() - } - .hidden() - } - - if let groupID = currentGroupID { - NavigationLink( - destination: NestedGroupSetting( - groupID: groupID, - parentConversationID: conversation.conversationID, - onBack: { - dismissGroupChatSetting() - }, - onGroupDelete: { - dismissGroupChatSetting() - onGroupDelete() - } - ) - .id(groupID) - .navigationBarTitle(LocalizedChatString("GroupSettings"), displayMode: .inline) - .navigationBarItems( - leading: Button(action: { - dismissGroupChatSetting() - }) { - Image(systemName: "chevron.left") - .foregroundColor(themeState.colors.textColorLink) - } - ) - .navigationBarBackButtonHidden(true), - isActive: $showGroupChatSetting - ) { - EmptyView() - } - .hidden() - } - } - } - - // MARK: - Private Methods - - private func handleUserAvatarClick(userID: String) { - currentC2CUserID = userID - showC2CChatSetting = true - } - - private func handleNavigationAvatarClick() { - if conversation.type == .c2c { - if let userID = ChatUtil.getUserID(conversation.conversationID) { - currentC2CUserID = userID - showC2CChatSetting = true - } - } else if conversation.type == .group { - if let groupID = ChatUtil.getGroupID(conversation.conversationID) { - currentGroupID = groupID - showGroupChatSetting = true - } - } - } - - private func dismissC2CChatSetting() { - showC2CChatSetting = false - currentC2CUserID = nil - } - - private func dismissGroupChatSetting() { - showGroupChatSetting = false - currentGroupID = nil - } -} - -// MARK: - NestedC2CSetting -// A wrapper that handles nested navigation from C2CChatSetting to a new chat page. -// Note: This struct creates its own C2CSettingStore instance, separate from the one inside C2CChatSetting. -// This is intentional because we need to access user info (nickname, remark, avatarURL) for creating -// the nested conversation before navigating. The two store instances will fetch the same data independently. - -private struct NestedC2CSetting: View { - @EnvironmentObject var themeState: ThemeState - - let userID: String - let parentConversationID: String - let onBack: () -> Void - let onContactDelete: () -> Void - - @State private var showNestedChat: Bool = false - @State private var nestedConversation: ConversationInfo? = nil - - // User info from C2CSettingStore (updated via onReceive) - @State private var userRemark: String = "" - @State private var userNickname: String = "" - @State private var userAvatarURL: String = "" - @State private var settingStore: C2CSettingStore - - init( - userID: String, - parentConversationID: String, - onBack: @escaping () -> Void, - onContactDelete: @escaping () -> Void - ) { - self.userID = userID - self.parentConversationID = parentConversationID - self.onBack = onBack - self.onContactDelete = onContactDelete - self._settingStore = State(initialValue: C2CSettingStore.create(userID: userID)) - } - - private var displayName: String { - if !userRemark.isEmpty { - return userRemark - } else if !userNickname.isEmpty { - return userNickname - } else { - return userID - } - } - - var body: some View { - ZStack { - C2CChatSetting( - userID: userID, - onSendMessageClick: { - handleSendMessageClick() - }, - onContactDelete: onContactDelete - ) - - if let conversation = nestedConversation { - NavigationLink( - destination: NestedChat( - conversation: conversation, - userID: userID, - onBack: { - dismissNestedChat() - } - ) - .navigationBarHidden(true), - isActive: $showNestedChat - ) { - EmptyView() - } - .hidden() - } - } - .onReceive(settingStore.state.subscribe(StatePublisherSelector(keyPath: \C2CSettingState.remark))) { remark in - self.userRemark = remark - } - .onReceive(settingStore.state.subscribe(StatePublisherSelector(keyPath: \C2CSettingState.nickname))) { nickname in - self.userNickname = nickname - } - .onReceive(settingStore.state.subscribe(StatePublisherSelector(keyPath: \C2CSettingState.avatarURL))) { avatarURL in - self.userAvatarURL = avatarURL - } - .onAppear { - // Fetch user info to ensure displayName is available - settingStore.fetchUserInfo(completion: nil) - } - } - - private func handleSendMessageClick() { - let targetConversationID = ChatUtil.getC2CConversationID(userID) - - // If already in this user's C2C chat, just go back - if parentConversationID == targetConversationID { - onBack() - return - } - - var conversation = ConversationInfo(conversationID: targetConversationID) - conversation.type = .c2c - conversation.title = displayName - conversation.avatarURL = userAvatarURL - - nestedConversation = conversation - showNestedChat = true - } - - private func dismissNestedChat() { - showNestedChat = false - } -} - -// MARK: - NestedChat -// A chat page for nested navigation that supports avatar click to C2CChatSetting. -// This is the deepest level of nesting - clicking avatar here opens C2CChatSetting directly -// without further nesting capability to prevent infinite navigation depth. - -private struct NestedChat: View { - @EnvironmentObject var themeState: ThemeState - - let conversation: ConversationInfo - let userID: String - let onBack: () -> Void - - @State private var showC2CChatSetting: Bool = false - - var body: some View { - ZStack { - ChatPage( - conversation: conversation, - locateMessage: nil, - onBack: onBack, - onUserAvatarClick: { _ in - // In nested C2C chat, clicking avatar opens the same user's setting - showC2CChatSetting = true - }, - onNavigationAvatarClick: { - showC2CChatSetting = true - } - ) - - NavigationLink( - destination: C2CChatSetting( - userID: userID, - onSendMessageClick: { - // Already in this user's chat, just dismiss settings - showC2CChatSetting = false - }, - onContactDelete: { - showC2CChatSetting = false - onBack() - } - ) - .navigationBarTitle(LocalizedChatString("ProfileDetails"), displayMode: .inline) - .navigationBarItems( - leading: Button(action: { - showC2CChatSetting = false - }) { - Image(systemName: "chevron.left") - .foregroundColor(themeState.colors.textColorLink) - } - ) - .navigationBarBackButtonHidden(true), - isActive: $showC2CChatSetting - ) { - EmptyView() - } - .hidden() - } - } -} - -// MARK: - NestedGroupSetting -// A wrapper that handles nested navigation from GroupChatSetting to C2CChatSetting and chat pages. -// When a group member is clicked, it navigates to NestedC2CSetting for that member. - -private struct NestedGroupSetting: View { - @EnvironmentObject var themeState: ThemeState - - let groupID: String - let parentConversationID: String - let onBack: () -> Void - let onGroupDelete: () -> Void - - // Navigation state for member C2CChatSetting - @State private var showMemberSetting: Bool = false - @State private var selectedMemberUserID: String? = nil - - var body: some View { - ZStack { - GroupChatSetting( - groupID: groupID, - onSendMessageClick: { - // Already in group chat, just go back - onBack() - }, - onGroupDelete: onGroupDelete, - onGroupMemberClick: { userID in - selectedMemberUserID = userID - showMemberSetting = true - } - ) - - if let userID = selectedMemberUserID { - NavigationLink( - destination: NestedC2CSetting( - userID: userID, - parentConversationID: parentConversationID, - onBack: { - dismissMemberSetting() - }, - onContactDelete: { - dismissMemberSetting() - } - ) - .navigationBarTitle(LocalizedChatString("ProfileDetails"), displayMode: .inline) - .navigationBarItems( - leading: Button(action: { - dismissMemberSetting() - }) { - Image(systemName: "chevron.left") - .foregroundColor(themeState.colors.textColorLink) - } - ) - .navigationBarBackButtonHidden(true), - isActive: $showMemberSetting - ) { - EmptyView() - } - .hidden() - } - } - } - - private func dismissMemberSetting() { - showMemberSetting = false - selectedMemberUserID = nil - } -} diff --git a/chat/demo/ChatDemo/Pages/HomePage.swift b/chat/demo/ChatDemo/Pages/HomePage.swift index 8cb7dff75..53b9ecf93 100644 --- a/chat/demo/ChatDemo/Pages/HomePage.swift +++ b/chat/demo/ChatDemo/Pages/HomePage.swift @@ -1,218 +1,524 @@ -import AtomicX import AtomicXCore import ChatUIKit -import SwiftUI - -public struct HomePage: View { - @EnvironmentObject var themeState: ThemeState - @EnvironmentObject var appStyleSettings: AppStyleSettings - @EnvironmentObject var languageState: LanguageState - @StateObject private var homeToast = Toast() - @State private var selectedTab: Tab = .chats - private var conversationListStore: ConversationListStore - private var contactListStore: ContactListStore - @State private var totalUnreadCount: UInt = 0 - - @State private var showChatPage: Bool = false - @State private var showC2CChatSetting: Bool = false - - @State private var currentConversation: ConversationInfo? = nil - @State private var currentLocateMessage: MessageInfo? = nil - @State private var currentContactUser: AZOrderedListItem? = nil - - public init() { - self.conversationListStore = ConversationListStore.create() - self.contactListStore = ContactListStore.create() - } - - public var body: some View { - ZStack { - TabView(selection: self.$selectedTab) { - ConversationsPage( - onConversationClick: { navigationInfo in - showChatPage(conversation: navigationInfo.conversation, locateMessage: navigationInfo.locateMessage) - } - ) - .navigationTitle("") - .navigationBarHidden(true) - .tabItem { - Label(LocalizedChatString("TabChats"), image: "tab_chat") - } - .tag(Tab.chats) - .modifier(TabBadgeModifier(count: totalUnreadCount)) - - ContactsPage( - onContactClick: { user in - showC2CChatSetting(user) - }, - onGroupClick: { group in - let conversation = createConversationFromGroup(group) - showChatPage(conversation: conversation) - } - ) - .tabItem { - Label(LocalizedChatString("TabContacts"), image: "tab_contact") - } - .tag(Tab.contacts) - .navigationTitle("") - .navigationBarHidden(true) - - SettingsPage() - .tabItem { - Label(LocalizedChatString("TabSettings"), image: "tab_setting") - } - .tag(Tab.settings) - .navigationTitle("") - .navigationBarHidden(true) +import Combine +import TUICallKit_Swift +import UIKit + +final class HomeTabBarController: UITabBarController { + static var lastSelectedIndex = 0 + + private static let showCallsTabKey = "show_calls_tab" + + static var isCallsTabVisible: Bool { + guard UserDefaults.standard.object(forKey: showCallsTabKey) != nil else { return true } + return UserDefaults.standard.bool(forKey: showCallsTabKey) + } + + private let conversationListStore = ConversationListStore.create() + + private var cancellables = Set() + + private var chatsCoordinator: ChatFlowCoordinator? + + private var contactsCoordinator: ChatFlowCoordinator? + + private weak var chatsNavigationController: UINavigationController? + + private weak var contactsNavigationController: UINavigationController? + + private var friendApplicationUnreadCount = 0 + + private var groupApplicationUnreadCount = 0 + + private static let tabTitleFontSize: CGFloat = 10 + + private static let tabIconSize: CGFloat = 24 + + private static let tabIconGradientStart = CGPoint(x: 0.66, y: -0.33) + + private static let tabIconGradientEnd = CGPoint(x: -0.24, y: 0.6875) + + private static var tabIconBaseNames: [String] { + if isCallsTabVisible { + return ["tab_chat", "tab_calls", "tab_contact", "tab_setting"] + } + return ["tab_chat", "tab_contact", "tab_setting"] + } + + private static let badgeClearDragThreshold: CGFloat = 48 + + private static let badgeVerticalOffset: CGFloat = 4 + + private static let badgePressScale: CGFloat = 1.08 + + private static let badgeDragMaxScaleGain: CGFloat = 0.2 + + private static let badgeClearEndScale: CGFloat = 0.6 + + private static let badgeClearFlyDistanceFactor: CGFloat = 1.5 + + private static let badgePressAnimationDuration: TimeInterval = 0.12 + + private static let badgeDragAnimationDuration: TimeInterval = 0.18 + + private let messageUnreadBadge = TabUnreadBadgeView() + + private var isDraggingMessageBadge = false + + override func viewDidLoad() { + super.viewDidLoad() + setupTabs() + updateTabBarAppearance() + setupMessageBadgeDrag() + bindUnreadBadge() + bindContactsBadge() + bindTheme() + delegate = self + selectedIndex = Self.lastSelectedIndex + conversationListStore.loadConversations(option: nil, completion: nil) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if !isDraggingMessageBadge { + updateMessageBadgePosition() + } + } + + private func setupTabs() { + var tabs: [UIViewController] = [makeChatsTab()] + if Self.isCallsTabVisible { + tabs.append(makeCallsTab()) + } + tabs.append(contentsOf: [makeContactsTab(), makeSettingsTab()]) + viewControllers = tabs + } + + func setCallsTabVisible(_ visible: Bool) { + let previous = Self.isCallsTabVisible + UserDefaults.standard.set(visible, forKey: Self.showCallsTabKey) + guard visible != previous else { return } + let selectedWasSettings = selectedIndex == (viewControllers?.count ?? 1) - 1 + setupTabs() + updateTabBarAppearance() + if selectedWasSettings { + selectedIndex = (viewControllers?.count ?? 1) - 1 + } else { + selectedIndex = min(selectedIndex, (viewControllers?.count ?? 1) - 1) + } + } + + private func makeCallsTab() -> UIViewController { + let callsPage = RecentCallsViewController(recordCallsUIStyle: .minimalist) + callsPage.tabBarItem = UITabBarItem( + title: LocalizedChatString("TabCalls"), + image: UIImage(named: "tab_calls"), + tag: 1 + ) + return callsPage + } + + private func makeChatsTab() -> UIViewController { + let coordinator = ChatFlowCoordinator() + let conversationsPage = ConversationsPage(onConversationClick: { [weak coordinator] info in + coordinator?.pushChat(info.conversation, locateMessage: info.locateMessage) + }) + let nav = HiddenBarNavigationController(rootViewController: conversationsPage) + coordinator.navigationController = nav + nav.tabBarItem = UITabBarItem( + title: LocalizedChatString("TabChats"), + image: UIImage(named: "tab_chat"), + tag: 0 + ) + chatsCoordinator = coordinator + chatsNavigationController = nav + return nav + } + + private func makeContactsTab() -> UIViewController { + let coordinator = ChatFlowCoordinator() + let contactsPage = ContactsPage( + onContactClick: { [weak coordinator] user in + coordinator?.pushChat(Self.makeC2CConversation(from: user)) + }, + onGroupClick: { [weak coordinator] group in + coordinator?.pushChat(Self.makeGroupConversation(from: group)) } - .id("TabView-\(themeState.currentTheme.mode)") - .background(themeState.colors.bgColorOperate) - .onAppear { - updateTabBarAppearance() + ) + let nav = HiddenBarNavigationController(rootViewController: contactsPage) + coordinator.navigationController = nav + nav.tabBarItem = UITabBarItem( + title: LocalizedChatString("TabContacts"), + image: UIImage(named: "tab_contact"), + tag: 1 + ) + contactsCoordinator = coordinator + contactsNavigationController = nav + return nav + } + + private func makeSettingsTab() -> UIViewController { + let settingsPage = SettingsViewController() + let navigationController = HiddenBarNavigationController(rootViewController: settingsPage) + navigationController.tabBarItem = UITabBarItem( + title: LocalizedChatString("TabSettings"), + image: UIImage(named: "tab_setting"), + tag: 2 + ) + return navigationController + } + + private static func makeGroupConversation(from group: AZOrderedListItem) -> ConversationInfo { + var conversation = ConversationInfo(conversationID: ChatUtil.getGroupConversationID(group.userID)) + conversation.type = .group + conversation.title = group.title ?? group.userID + conversation.avatarURL = group.avatarURL + return conversation + } + + private static func makeC2CConversation(from user: AZOrderedListItem) -> ConversationInfo { + var conversation = ConversationInfo(conversationID: ChatUtil.getC2CConversationID(user.userID)) + conversation.type = .c2c + conversation.title = user.title ?? user.userID + conversation.avatarURL = user.avatarURL + return conversation + } + + private func setupMessageBadgeDrag() { + tabBar.clipsToBounds = false + messageUnreadBadge.isHidden = true + tabBar.addSubview(messageUnreadBadge) + let pan = UIPanGestureRecognizer(target: self, action: #selector(handleMessageBadgePan(_:))) + messageUnreadBadge.addGestureRecognizer(pan) + } + + private func bindUnreadBadge() { + conversationListStore.state + .subscribe(StatePublisherSelector(keyPath: \ConversationListState.totalUnreadCount)) + .receive(on: DispatchQueue.main) + .sink { [weak self] unreadCount in + self?.updateMessageUnreadBadge(unreadCount: Int(unreadCount)) } - .onChange(of: themeState.currentTheme.mode) { _ in - updateTabBarAppearance() + .store(in: &cancellables) + } + + private func updateMessageUnreadBadge(unreadCount: Int) { + if unreadCount <= 0 { + if !isDraggingMessageBadge { + messageUnreadBadge.isHidden = true } - .navigationBarHidden(true) - .navigationTitle("") - - ZStack { - if let conversation = currentConversation { - NavigationLink( - destination: ChatNavPage( - conversation: conversation, - locateMessage: currentLocateMessage, - onBack: { - dismissChatPage() - }, - onContactDelete: { - dismissChatPage() - }, - onGroupDelete: { - dismissChatPage() - }, - onNavigateToChat: { newConversation in - // First dismiss current chat page, then navigate to the new chat - dismissChatPage() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - showChatPage(conversation: newConversation) - } - } - ) - .navigationBarHidden(true), - isActive: $showChatPage - ) { - EmptyView() - } - .hidden() - } + return + } + if isDraggingMessageBadge { + return + } + messageUnreadBadge.setText(unreadCount > 99 ? "99+" : "\(unreadCount)") + messageUnreadBadge.transform = .identity + messageUnreadBadge.alpha = 1 + if messageUnreadBadge.isHidden { + messageUnreadBadge.isHidden = false + } + updateMessageBadgePosition() + } - if let contactUser = currentContactUser { - NavigationLink( - destination: C2CChatSetting( - userID: contactUser.userID, - onSendMessageClick: { - let newConversation = createConversationFromUser(contactUser) - dismissContactDetail() - showChatPage(conversation: newConversation) - } - ) - .navigationBarTitle(LocalizedChatString("ProfileDetails"), displayMode: .inline), - isActive: $showC2CChatSetting - ) { - EmptyView() - } - .hidden() - } + private func updateMessageBadgePosition() { + guard !messageUnreadBadge.isHidden else { return } + let tabButtons = tabBar.subviews + .filter { String(describing: type(of: $0)).contains("TabBarButton") } + .sorted { $0.frame.minX < $1.frame.minX } + let isRTL = tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft + guard let anchor = isRTL ? tabButtons.last : tabButtons.first, + let iconView = anchor.subviews.first(where: { $0 is UIImageView }) else { return } + tabBar.bringSubviewToFront(messageUnreadBadge) + let iconFrame = anchor.convert(iconView.frame, to: tabBar) + let badgeCenterX = isRTL ? iconFrame.minX : iconFrame.maxX + messageUnreadBadge.center = CGPoint(x: badgeCenterX, y: iconFrame.minY + Self.badgeVerticalOffset) + } + + @objc private func handleMessageBadgePan(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .began: + isDraggingMessageBadge = true + messageUnreadBadge.layer.removeAllAnimations() + UIView.animate(withDuration: Self.badgePressAnimationDuration) { + self.messageUnreadBadge.transform = CGAffineTransform(scaleX: Self.badgePressScale, y: Self.badgePressScale) + } + case .changed: + let translation = gesture.translation(in: tabBar) + let dragUpDistance = max(0, -translation.y) + let progress = min(dragUpDistance / Self.badgeClearDragThreshold, 1) + let scale = 1 + progress * Self.badgeDragMaxScaleGain + messageUnreadBadge.transform = CGAffineTransform(translationX: translation.x, y: -dragUpDistance) + .scaledBy(x: scale, y: scale) + case .ended: + let translation = gesture.translation(in: tabBar) + isDraggingMessageBadge = false + if -translation.y >= Self.badgeClearDragThreshold { + clearAllUnreadByBadgeDrag() + } else { + resetMessageBadgeDrag() } + case .cancelled, .failed: + isDraggingMessageBadge = false + resetMessageBadgeDrag() + default: + break } - .ignoresSafeArea(.keyboard, edges: .bottom) - .accentColor(themeState.colors.buttonColorPrimaryDefault) - .toast(homeToast) - .id("HomePage-\(languageState.currentLanguage)") - .onReceive(conversationListStore.state.subscribe(StatePublisherSelector(keyPath: \ConversationListState.totalUnreadCount))) { unreadCount in - self.totalUnreadCount = UInt(unreadCount) + } + + private func resetMessageBadgeDrag() { + messageUnreadBadge.layer.removeAllAnimations() + UIView.animate(withDuration: Self.badgeDragAnimationDuration) { + self.messageUnreadBadge.transform = .identity + self.messageUnreadBadge.alpha = 1 + } + } + + private func clearAllUnreadByBadgeDrag() { + let flyDistance = Self.badgeClearDragThreshold * Self.badgeClearFlyDistanceFactor + UIView.animate(withDuration: Self.badgeDragAnimationDuration, animations: { + self.messageUnreadBadge.transform = CGAffineTransform(translationX: 0, y: -flyDistance) + .scaledBy(x: Self.badgeClearEndScale, y: Self.badgeClearEndScale) + self.messageUnreadBadge.alpha = 0 + }) { _ in + self.messageUnreadBadge.isHidden = true + self.messageUnreadBadge.transform = .identity + self.messageUnreadBadge.alpha = 1 } - .onAppear { - // Initialize with current value - conversationListStore.getConversationTotalUnreadCount(completion: nil) + conversationListStore.clearConversationUnreadCount(conversationID: "") { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success: + WindowToastManager.shared.success(LocalizedChatString("ClearAllUnreadSuccess")) + case .failure: + self?.resetMessageBadgeDrag() + } + } } } - private func showChatPage(conversation: ConversationInfo, locateMessage: MessageInfo? = nil) { - currentConversation = conversation - currentLocateMessage = locateMessage - showChatPage = true + private func bindContactsBadge() { + ContactStore.shared.loadFriendApplications(completion: nil) + GroupStore.shared.loadApplications(completion: nil) + ContactStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendApplicationUnreadCount)) + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + self?.friendApplicationUnreadCount = count + self?.updateContactsBadge() + } + .store(in: &cancellables) + GroupStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.unreadApplicationCount)) + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + self?.groupApplicationUnreadCount = count + self?.updateContactsBadge() + } + .store(in: &cancellables) + } + + private func updateContactsBadge() { + let count = friendApplicationUnreadCount + groupApplicationUnreadCount + contactsNavigationController?.tabBarItem.badgeValue = count > 0 ? "\(count)" : nil + } + + private func bindTheme() { + ThemeState.shared.$currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateTabBarAppearance() + } + .store(in: &cancellables) } private func updateTabBarAppearance() { + let themeColors = ThemeState.shared.colors + let tertiaryColor = themeColors.textColorTertiary + let linkColor = themeColors.textColorLink + let titleFont = UIFont.systemFont(ofSize: Self.tabTitleFontSize) let appearance = UITabBarAppearance() appearance.configureWithTransparentBackground() - appearance.backgroundColor = UIColor(themeState.colors.bgColorOperate) - UITabBar.appearance().isTranslucent = false - UITabBar.appearance().standardAppearance = appearance - UITabBar.appearance().scrollEdgeAppearance = appearance + appearance.backgroundColor = themeColors.bgColorTopBar + appearance.shadowColor = .clear + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: tertiaryColor, .font: titleFont] + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: linkColor, .font: titleFont] + tabBar.isTranslucent = false + tabBar.standardAppearance = appearance + if #available(iOS 15.0, *) { + tabBar.scrollEdgeAppearance = appearance + } + tabBar.tintColor = linkColor + renderTabIcons() + if messageUnreadBadge.superview === tabBar { + tabBar.bringSubviewToFront(messageUnreadBadge) + } } - private func showC2CChatSetting(_ user: AZOrderedListItem) { - currentContactUser = user - showC2CChatSetting = true + private func renderTabIcons() { + for (index, viewController) in (viewControllers ?? []).enumerated() { + guard index < Self.tabIconBaseNames.count else { continue } + let cutoutName = index == 0 ? "tab_chat_lines" : nil + viewController.tabBarItem.image = renderTabIcon(baseName: Self.tabIconBaseNames[index], cutoutName: cutoutName, selected: false) + viewController.tabBarItem.selectedImage = renderTabIcon(baseName: Self.tabIconBaseNames[index], cutoutName: cutoutName, selected: true) + } } - private func dismissChatPage() { - showChatPage = false + private func renderTabIcon(baseName: String, cutoutName: String?, selected: Bool) -> UIImage? { + guard let base = UIImage(named: baseName)?.cgImage else { return nil } + let size = CGSize(width: Self.tabIconSize, height: Self.tabIconSize) + let colors = ThemeState.shared.colors + let fill: UIImage + if selected { + fill = gradientFillImage( + size: size, + colors: [colors.bgColorBubbleOwn.cgColor, colors.textColorLink.cgColor] + ) + } else { + fill = solidFillImage(size: size, color: colors.textColorTertiary) + } + var image = maskedImage(fill: fill, mask: base, size: size) + if let cutoutName = cutoutName, let cutout = UIImage(named: cutoutName)?.cgImage { + let cutoutFill = solidFillImage(size: size, color: colors.bgColorBottomBar) + let cutoutImage = maskedImage(fill: cutoutFill, mask: cutout, size: size) + image = compositeImage(bottom: image, top: cutoutImage, size: size) + } + return image.withRenderingMode(.alwaysOriginal) } - private func dismissContactDetail() { - showC2CChatSetting = false + private func solidFillImage(size: CGSize, color: UIColor) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } } - private func createConversationFromUser(_ user: AZOrderedListItem) -> ConversationInfo { - var conversation = ConversationInfo(conversationID: ChatUtil.getC2CConversationID(user.id)) - conversation.avatarURL = user.avatarURL - conversation.type = .c2c - conversation.title = user.title ?? user.id - return conversation + private func gradientFillImage(size: CGSize, colors: [CGColor]) -> UIImage { + let gradientLayer = CAGradientLayer() + gradientLayer.frame = CGRect(origin: .zero, size: size) + gradientLayer.colors = colors + gradientLayer.startPoint = Self.tabIconGradientStart + gradientLayer.endPoint = Self.tabIconGradientEnd + return UIGraphicsImageRenderer(size: size).image { context in + gradientLayer.render(in: context.cgContext) + } } - private func createConversationFromGroup(_ group: AZOrderedListItem) -> ConversationInfo { - var conversation = ConversationInfo(conversationID: ChatUtil.getGroupConversationID(group.id)) - conversation.avatarURL = group.avatarURL - conversation.type = .group - conversation.title = group.title ?? group.id - return conversation + private func maskedImage(fill: UIImage, mask: CGImage, size: CGSize) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { context in + let cgContext = context.cgContext + cgContext.translateBy(x: 0, y: size.height) + cgContext.scaleBy(x: 1, y: -1) + let rect = CGRect(origin: .zero, size: size) + cgContext.clip(to: rect, mask: mask) + if let fillImage = fill.cgImage { + cgContext.draw(fillImage, in: rect) + } + } } + + private func compositeImage(bottom: UIImage, top: UIImage, size: CGSize) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { _ in + bottom.draw(in: CGRect(origin: .zero, size: size)) + top.draw(in: CGRect(origin: .zero, size: size)) + } + } + } -enum Tab { - case chats, contacts, settings +extension HomeTabBarController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + Self.lastSelectedIndex = selectedIndex + } } -struct TabBadgeModifier: ViewModifier { - let count: UInt - func body(content: Content) -> some View { - let badgeValue = count > 0 ? (count > 99 ? "99+" : "\(count)") : nil - return content.badge(badgeValue) +final class HiddenBarNavigationController: UINavigationController, UIGestureRecognizerDelegate, UINavigationControllerDelegate { + + override func viewDidLoad() { + super.viewDidLoad() + setNavigationBarHidden(true, animated: false) + interactivePopGestureRecognizer?.delegate = self + delegate = self + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + viewControllers.count > 1 + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + setNavigationBarHidden(!(viewController is SystemNavigationBarPage), animated: animated) } } -// MARK: - FriendshipHelper - -extension HomePage { - static func checkIsFriend(userID: String, completion: @escaping (Bool) -> Void) { - let contactStore = ContactListStore.create() - - contactStore.fetchUserInfo(userID: userID, completion: { result in - switch result { - case .success: - // Check the result in the store's state - let contactInfo = contactStore.state.value.addFriendInfo - let isFriend = contactInfo?.isContact ?? false - completion(isFriend) - case .failure: - // If failed to get user info, assume not a friend - completion(false) - } - }) +private final class TabUnreadBadgeView: UIView { + private static let bubbleHeight: CGFloat = 16 + + private static let bubbleMinWidth: CGFloat = 16 + + private static let bubbleTextHorizontalPadding: CGFloat = 5 + + private static let textFontSize: CGFloat = 10 + + private static let touchAreaSize: CGFloat = 44 + + private static let touchSlop: CGFloat = 6 + + private let bubbleView = UIView() + + private let textLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: CGRect(x: 0, y: 0, width: Self.touchAreaSize, height: Self.touchAreaSize)) + bubbleView.backgroundColor = ThemeState.shared.colors.textColorError + bubbleView.layer.cornerRadius = Self.bubbleHeight / 2 + bubbleView.layer.masksToBounds = true + textLabel.textColor = .white + textLabel.font = UIFont.systemFont(ofSize: Self.textFontSize, weight: .medium) + textLabel.textAlignment = .center + addSubview(bubbleView) + bubbleView.addSubview(textLabel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard !isHidden, alpha > 0 else { return nil } + let hotFrame = bubbleView.frame.insetBy(dx: -Self.touchSlop, dy: -Self.touchSlop) + guard hotFrame.contains(point) else { return nil } + return super.hitTest(point, with: event) + } + + func setText(_ text: String) { + textLabel.text = text + textLabel.sizeToFit() + let bubbleWidth = max(Self.bubbleMinWidth, textLabel.bounds.width + Self.bubbleTextHorizontalPadding * 2) + bubbleView.frame = CGRect( + x: (bounds.width - bubbleWidth) / 2, + y: (bounds.height - Self.bubbleHeight) / 2, + width: bubbleWidth, + height: Self.bubbleHeight + ) + textLabel.frame = bubbleView.bounds + } +} + +private final class HomeConversationInfoHandler: GetConversationInfoCompletionHandler { + private let onSuccessBlock: (ConversationInfo) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (ConversationInfo) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(conversationInfo: ConversationInfo) { + onSuccessBlock(conversationInfo) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) } } diff --git a/chat/demo/ChatDemo/Pages/LoginPage.swift b/chat/demo/ChatDemo/Pages/LoginPage.swift index 6a79b0de5..2a8f5310c 100644 --- a/chat/demo/ChatDemo/Pages/LoginPage.swift +++ b/chat/demo/ChatDemo/Pages/LoginPage.swift @@ -1,134 +1,116 @@ -import AtomicX import AtomicXCore -import SwiftUI +import ChatUIKit +import RTCRoomEngine +import SnapKit +import TUICallKit_Swift +import UIKit -class LoginStatusManager: ObservableObject { - @Published var isLoggedIn: Bool = false - @Published var currentUserID: String = "" - @Published var isLoggingIn: Bool = false - @Published var loginError: String? = nil - static let shared = LoginStatusManager() +final class DemoLoginManager { + static let shared = DemoLoginManager() - private init() { - isLoggedIn = LoginStore.shared.state.value.loginStatus == .logined - if isLoggedIn, let userInfo = LoginStore.shared.state.value.loginUserInfo { - currentUserID = userInfo.userID + static let localSdkAppID = "" + + static let localSecretKey = "" + + private(set) var currentUserID = "" + + func login(sdkAppID: Int32, userID: String, userSig: String, completion: @escaping (Bool, String?) -> Void) { + LoginStore.shared.login(sdkAppID: sdkAppID, userID: userID, userSig: userSig) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success: + self?.currentUserID = userID + self?.initCallEngine(sdkAppID: sdkAppID, userID: userID, userSig: userSig) + completion(true, nil) + case .failure(let error): + completion(false, "\(LocalizedChatString("LoginFailed")): \(error.code), \(error.message)") + } + } } } - func login(sdkAppID: Int32, userID: String, userSig: String, completion: @escaping (Bool) -> Void) { - isLoggingIn = true - loginError = nil - LoginStore.shared.login(sdkAppID: sdkAppID, userID: userID, userSig: userSig, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - DispatchQueue.main.async { - self.isLoggedIn = true - self.currentUserID = userID - self.isLoggingIn = false - completion(true) - } - case .failure(let error): - DispatchQueue.main.async { - self.loginError = "\(LocalizedChatString("LoginFailed")): \(error.code), \(error.message)" - self.isLoggingIn = false - completion(false) - } + private func initCallEngine(sdkAppID: Int32, userID: String, userSig: String) { + TUICallEngine.createInstance().`init`(sdkAppID, userId: userID, userSig: userSig) { + TUICallEngine.createInstance().enableMultiDeviceAbility(enable: true) { + } fail: { code, message in + print("[Login] enableMultiDeviceAbility failed: \(code), \(message ?? "")") } - }) + TUICallKit.createInstance().enableIncomingBanner(enable: true) + } fail: { code, message in + print("[Login] initCallEngine failed: \(code), \(message ?? "")") + } } func logout(completion: @escaping (Bool) -> Void) { + UserDefaults.standard.set("", forKey: LoginPersist.loginUser) + UserDefaults.standard.set("", forKey: LoginPersist.loginType) LoginStore.shared.logout(completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - DispatchQueue.main.async { - self.isLoggedIn = false - self.currentUserID = "" - self.isLoggingIn = false + DispatchQueue.main.async { + switch result { + case .success: + self?.currentUserID = "" completion(true) + case .failure: + completion(false) } - case .failure(let error): - completion(false) } }) } } -struct LoginPage: View { - @EnvironmentObject var themeState: ThemeState - @EnvironmentObject var languageState: LanguageState - @StateObject private var loginManager = LoginStatusManager.shared - @State private var userID: String = "" - @State private var sdkAppID: String = "1400187352" - @State private var secretKey: String = "f442d0cca069bbcc8ced55f4f113b965999b928c78e3cd83495728133a06f4cb" - @State private var isShowingSettings: Bool = false +private enum LoginPersist { + static let loginUser = "LoginUser" + static let loginType = "LoginType" + static let loginTypeLocal = "local" +} - var body: some View { - VStack(spacing: 24) { - VStack(spacing: 16) { - Image(systemName: "bubble.left.and.bubble.right.fill") - .font(.system(size: 60)) - .foregroundColor(themeState.colors.buttonColorPrimaryDefault) - .padding(.bottom, 8) - Text(LocalizedChatString("AppTitle")) - .font(.largeTitle) - .fontWeight(.bold) - .foregroundColor(themeState.colors.textColorPrimary) - Text(LocalizedChatString("AppSubtitle")) - .font(.subheadline) - .foregroundColor(Colors.GrayLight6) - } - .padding(.top, 40) - .padding(.bottom, 20) - VStack(spacing: 16) { - TextField(LocalizedChatString("EnterUserID"), text: $userID) - .padding() - .background(themeState.colors.buttonColorSecondaryDefault) - .cornerRadius(8) - .foregroundColor(themeState.colors.textColorPrimary) - if loginManager.loginError != nil { - Text(loginManager.loginError!) - .font(.caption) - .foregroundColor(themeState.colors.textColorTertiary) - .padding(.vertical, 4) - } - Button(action: { - login() - }) { - if loginManager.isLoggingIn { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 14) - .frame(maxWidth: .infinity) - } else { - Text(LocalizedChatString("login")) - .fontWeight(.semibold) - .padding(.vertical, 14) - .frame(maxWidth: .infinity) - } - } - .background((userID.isEmpty || loginManager.isLoggingIn) ? themeState.colors.buttonColorPrimaryDisabled : themeState.colors.buttonColorPrimaryDefault) - .foregroundColor(themeState.colors.textColorButton) - .cornerRadius(8) - .disabled(userID.isEmpty || loginManager.isLoggingIn) - } - .padding(.horizontal, 32) - Spacer() +final class AutoLoginViewController: UIViewController { + static let didFailNotification = Notification.Name("DemoAutoLoginDidFailNotification") + + static var hasSavedCredentials: Bool { + let type = UserDefaults.standard.string(forKey: LoginPersist.loginType) ?? "" + let userID = UserDefaults.standard.string(forKey: LoginPersist.loginUser) ?? "" + return type == LoginPersist.loginTypeLocal && !userID.isEmpty + } + + private let loadingIndicator = UIActivityIndicatorView(style: .large) + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + view.addSubview(loadingIndicator) + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() } - .background(themeState.colors.bgColorOperate.ignoresSafeArea()) + loadingIndicator.startAnimating() + performAutoLogin() } - private func login() { - guard let appID = Int32(sdkAppID) else { - loginManager.loginError = LocalizedChatString("InvalidSDKAppID") + private func performAutoLogin() { + let type = UserDefaults.standard.string(forKey: LoginPersist.loginType) ?? "" + let userID = UserDefaults.standard.string(forKey: LoginPersist.loginUser) ?? "" + if type == LoginPersist.loginTypeLocal, !userID.isEmpty, let appID = Int32(DemoLoginManager.localSdkAppID) { + let userSig = GenerateTestUserSig.genTestUserSig(userID: userID, sdkAppID: Int(appID), secretKey: DemoLoginManager.localSecretKey) + performLogin(sdkAppID: appID, userID: userID, userSig: userSig, extraSave: nil) return } - let userSig = GenerateTestUserSig.genTestUserSig(userID: userID, sdkAppID: Int(appID), secretKey: secretKey) - loginManager.login(sdkAppID: appID, userID: userID, userSig: userSig) { _ in - loginManager.isLoggingIn = false + handleFailure() + } + + private func performLogin(sdkAppID: Int32, userID: String, userSig: String, extraSave: (() -> Void)?) { + DemoLoginManager.shared.login(sdkAppID: sdkAppID, userID: userID, userSig: userSig) { [weak self] success, _ in + guard let self = self else { return } + if success { + extraSave?() + } else { + self.handleFailure() + } } } + + private func handleFailure() { + UserDefaults.standard.set("", forKey: LoginPersist.loginUser) + UserDefaults.standard.set("", forKey: LoginPersist.loginType) + NotificationCenter.default.post(name: Self.didFailNotification, object: nil) + } } diff --git a/chat/demo/ChatDemo/Pages/LoginSubviews.swift b/chat/demo/ChatDemo/Pages/LoginSubviews.swift new file mode 100644 index 000000000..235b9fdb2 --- /dev/null +++ b/chat/demo/ChatDemo/Pages/LoginSubviews.swift @@ -0,0 +1,180 @@ +import ChatUIKit +import SnapKit +import UIKit + +final class LocalLoginViewController: UIViewController { + private static let iconSize: CGFloat = 60 + private static let fieldHeight: CGFloat = 48 + private static let fieldCornerRadius: CGFloat = 8 + private static let horizontalPadding: CGFloat = 32 + + private let logoImageView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let userIDField = UITextField() + private let errorLabel = UILabel() + private let loginButton = UIButton(type: .custom) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let closeButton = UIButton(type: .custom) + + private var isLoggingIn = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + closeButton.isHidden = presentingViewController == nil + } + + private func constructViewHierarchy() { + view.addSubview(closeButton) + view.addSubview(logoImageView) + view.addSubview(titleLabel) + view.addSubview(subtitleLabel) + view.addSubview(userIDField) + view.addSubview(errorLabel) + view.addSubview(loginButton) + loginButton.addSubview(loadingIndicator) + } + + private func activateConstraints() { + closeButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(8) + make.leading.equalToSuperview().offset(16) + make.width.height.equalTo(32) + } + logoImageView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(40) + make.centerX.equalToSuperview() + make.width.height.equalTo(Self.iconSize) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(logoImageView.snp.bottom).offset(24) + make.centerX.equalToSuperview() + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.centerX.equalToSuperview() + } + userIDField.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(36) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.height.equalTo(Self.fieldHeight) + } + errorLabel.snp.makeConstraints { make in + make.top.equalTo(userIDField.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + } + loginButton.snp.makeConstraints { make in + make.top.equalTo(userIDField.snp.bottom).offset(36) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.height.equalTo(Self.fieldHeight) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + logoImageView.image = UIImage(systemName: "bubble.left.and.bubble.right.fill") + logoImageView.tintColor = colors.buttonColorPrimaryDefault + logoImageView.contentMode = .scaleAspectFit + titleLabel.text = LocalizedChatString("AppTitle") + titleLabel.font = .systemFont(ofSize: 34, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + subtitleLabel.text = LocalizedChatString("AppSubtitle") + subtitleLabel.font = .systemFont(ofSize: 15) + subtitleLabel.textColor = colors.textColorTertiary + userIDField.placeholder = LocalizedChatString("EnterUserID") + userIDField.backgroundColor = colors.buttonColorSecondaryDefault + userIDField.textColor = colors.textColorPrimary + userIDField.layer.cornerRadius = Self.fieldCornerRadius + userIDField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 0)) + userIDField.leftViewMode = .always + userIDField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 0)) + userIDField.rightViewMode = .always + userIDField.autocapitalizationType = .none + userIDField.autocorrectionType = .no + userIDField.returnKeyType = .done + userIDField.delegate = self + errorLabel.font = .systemFont(ofSize: 12) + errorLabel.textColor = colors.textColorTertiary + errorLabel.numberOfLines = 0 + loginButton.setTitle(LocalizedChatString("login"), for: .normal) + loginButton.setTitleColor(colors.textColorButton, for: .normal) + loginButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) + loginButton.layer.cornerRadius = Self.fieldCornerRadius + loadingIndicator.color = colors.textColorButton + loadingIndicator.hidesWhenStopped = true + closeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + closeButton.tintColor = colors.textColorPrimary + refreshLoginButtonState() + } + + private func bindInteraction() { + loginButton.addTarget(self, action: #selector(handleLoginTapped), for: .touchUpInside) + closeButton.addTarget(self, action: #selector(handleCloseTapped), for: .touchUpInside) + userIDField.addTarget(self, action: #selector(handleUserIDChanged), for: .editingChanged) + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + backgroundTap.cancelsTouchesInView = false + view.addGestureRecognizer(backgroundTap) + } + + private func refreshLoginButtonState() { + let colors = ChatUIKitTheme.colors + let enabled = !(userIDField.text ?? "").isEmpty && !isLoggingIn + loginButton.isEnabled = enabled + loginButton.backgroundColor = enabled ? colors.buttonColorPrimaryDefault : colors.buttonColorPrimaryDisabled + } + + @objc private func handleUserIDChanged() { + errorLabel.text = nil + refreshLoginButtonState() + } + + @objc private func handleBackgroundTapped() { + view.endEditing(true) + } + + @objc private func handleCloseTapped() { + dismiss(animated: true) + } + + @objc private func handleLoginTapped() { + guard let appID = Int32(DemoLoginManager.localSdkAppID) else { + errorLabel.text = LocalizedChatString("InvalidSDKAppID") + return + } + let userID = userIDField.text ?? "" + let userSig = GenerateTestUserSig.genTestUserSig(userID: userID, sdkAppID: Int(appID), secretKey: DemoLoginManager.localSecretKey) + isLoggingIn = true + loginButton.setTitle(nil, for: .normal) + loadingIndicator.startAnimating() + refreshLoginButtonState() + DemoLoginManager.shared.login(sdkAppID: appID, userID: userID, userSig: userSig) { [weak self] success, errorMessage in + guard let self = self else { return } + self.isLoggingIn = false + self.loadingIndicator.stopAnimating() + self.loginButton.setTitle(LocalizedChatString("login"), for: .normal) + if success { + UserDefaults.standard.set(userID, forKey: "LoginUser") + UserDefaults.standard.set("local", forKey: "LoginType") + self.dismiss(animated: true) + } else { + self.errorLabel.text = errorMessage + } + self.refreshLoginButtonState() + } + } +} + +extension LocalLoginViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/chat/demo/ChatDemo/Pages/SettingsPage.swift b/chat/demo/ChatDemo/Pages/SettingsPage.swift index 9a5a84157..0cd99248b 100644 --- a/chat/demo/ChatDemo/Pages/SettingsPage.swift +++ b/chat/demo/ChatDemo/Pages/SettingsPage.swift @@ -1,988 +1,1736 @@ -import AtomicX import AtomicXCore -import SwiftUI - -struct ColorOption: Identifiable { - let id = UUID() - let name: String - let hex: String - let color: Color - init(name: String, hex: String) { - self.name = name - self.hex = hex - self.color = Color(hexString: hex) +import ChatUIKit +import Combine +import ImSDK_Plus +import SnapKit +import UIKit + +final class SettingsViewController: UIViewController { + private static let horizontalPadding: CGFloat = 16 + + private static let headerHeight: CGFloat = 44 + + private static let groupSpacerHeight: CGFloat = 10 + + private static let dividerHeight: CGFloat = 0.5 + + private static let rowVerticalPadding: CGFloat = 12 + + private static let arrowSpacing: CGFloat = 8 + + private static let logoutCornerRadius: CGFloat = 8 + + private static let entryRowMinHeight: CGFloat = 48 + + private static let primaryColorPreviewSize: CGFloat = 24 + + private let scrollView = UIScrollView() + + private let contentStack = UIStackView() + + private let headerLabel = UILabel() + + private let headerBackgroundView = UIView() + + private let profileRow = UIControl() + + private let avatarView = ChatAvatarView(size: .l, isRound: true) + + private let nicknameLabel = UILabel() + + private let userIDLabel = UILabel() + + private let signatureLabel = UILabel() + + private let themeValueLabel = UILabel() + + private let primaryColorPreview = UIView() + + private let languageValueLabel = UILabel() + + private let approveValueLabel = UILabel() + + private let translateValueLabel = UILabel() + + private let readReceiptSwitch = UISwitch() + + private let readReceiptDescLabel = UILabel() + + private let callsTabSwitch = UISwitch() + + private let logoutButton = UIButton(type: .custom) + + private var cancellables = Set() + + private var allowType: AllowType? + + private static let translateLanguageOptions: [(code: String, name: String)] = [ + ("zh", "简体中文"), ("zh-TW", "繁體中文"), ("en", "English"), ("ja", "日本語"), + ("ko", "한국어"), ("fr", "Français"), ("es", "Español"), ("it", "Italiano"), + ("de", "Deutsch"), ("tr", "Türkçe"), ("ru", "Русский"), ("pt", "Português"), + ("vi", "Tiếng Việt"), ("id", "Bahasa Indonesia"), ("th", "ภาษาไทย"), + ("ms", "Bahasa Melayu"), ("hi", "हिन्दी") + ] + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + bindLoginState() } -} -extension ThemeConfig { - var displayName: String { - let modeName: String - switch mode { - case .system: - modeName = LocalizedChatString("ThemeNameSystem") - case .light: - modeName = LocalizedChatString("ThemeNameLight") - case .dark: - modeName = LocalizedChatString("ThemeNameDark") + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + refreshStaticValues() + } + + // MARK: - Actions + + private func constructViewHierarchy() { + view.addSubview(headerBackgroundView) + view.addSubview(headerLabel) + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + contentStack.axis = .vertical + contentStack.spacing = 0 + + contentStack.addArrangedSubview(makeProfileSection()) + contentStack.addArrangedSubview(makeGroupSpacer()) + let groupOne = UIStackView() + groupOne.axis = .vertical + groupOne.spacing = 0 + groupOne.addArrangedSubview(makeEntryRow(title: LocalizedChatString("SelectThemeMode"), valueLabel: themeValueLabel, action: #selector(handleThemeTapped))) + groupOne.addArrangedSubview(makeRowDivider()) + groupOne.addArrangedSubview(makePrimaryColorRow()) + groupOne.addArrangedSubview(makeRowDivider()) + groupOne.addArrangedSubview(makeEntryRow(title: LocalizedChatString("SelectLanguage"), valueLabel: languageValueLabel, action: #selector(handleLanguageTapped))) + contentStack.addArrangedSubview(groupOne) + contentStack.addArrangedSubview(makeGroupSpacer()) + let groupTwo = UIStackView() + groupTwo.axis = .vertical + groupTwo.spacing = 0 + groupTwo.addArrangedSubview(makeEntryRow(title: LocalizedChatString("MeFriendRequest"), valueLabel: approveValueLabel, action: #selector(handleApproveTapped))) + groupTwo.addArrangedSubview(makeRowDivider()) + groupTwo.addArrangedSubview(makeReadReceiptRow()) + groupTwo.addArrangedSubview(makeRowDivider()) + groupTwo.addArrangedSubview(makeCallsTabRow()) + groupTwo.addArrangedSubview(makeRowDivider()) + groupTwo.addArrangedSubview(makeEntryRow(title: LocalizedChatString("TranslateMessage"), valueLabel: translateValueLabel, action: #selector(handleTranslateTapped))) + groupTwo.addArrangedSubview(makeRowDivider()) + groupTwo.addArrangedSubview(makeEntryRow(title: LocalizedChatString("VoiceMessageSettings"), valueLabel: nil, action: #selector(handleVoiceTapped))) + contentStack.addArrangedSubview(groupTwo) + contentStack.addArrangedSubview(makeGroupSpacer()) + let groupThree = UIStackView() + groupThree.axis = .vertical + groupThree.spacing = 0 + groupThree.addArrangedSubview(makeEntryRow(title: LocalizedChatString("AboutTencentIM"), valueLabel: nil, action: #selector(handleAboutTapped))) + contentStack.addArrangedSubview(groupThree) + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .vertical) + contentStack.addArrangedSubview(spacer) + contentStack.addArrangedSubview(makeLogoutSection()) + } + + private func activateConstraints() { + headerLabel.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.headerHeight) } - if let primaryColor = primaryColor { - return "\(modeName) + \(primaryColor)" - } else { - return modeName + headerBackgroundView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(headerLabel.snp.bottom) + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(headerLabel.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + make.height.greaterThanOrEqualTo(scrollView.frameLayoutGuide) } } -} -let presetColors = [ - ColorOption(name: LocalizedChatString("ColorRed"), hex: "#E54545"), - ColorOption(name: LocalizedChatString("ColorOrange"), hex: "#FF7200"), - ColorOption(name: LocalizedChatString("ColorYellow"), hex: "#7ff879"), - ColorOption(name: LocalizedChatString("ColorGreen"), hex: "#0ABF77"), - ColorOption(name: LocalizedChatString("ColorBlue"), hex: "#1C66E5"), - ColorOption(name: LocalizedChatString("ColorPurple"), hex: "#AF52DE") -] - -struct SettingsPage: View { - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var themeState: ThemeState - @EnvironmentObject var appStyleSettings: AppStyleSettings - @EnvironmentObject var languageState: LanguageState - @ObservedObject private var loginManager = LoginStatusManager.shared - @State private var loginStore = LoginStore.shared - @State private var allowType: AllowType? = nil - @State private var showStylePicker = false - @State private var themeListExpanded = false - @State private var primaryColorListExpanded = false - @State private var languageListExpanded = false - @State private var approveListExpanded = false - @State private var translateLanguageListExpanded = false - @State private var showProfileDetail = false - @AppStorage("com.atomicx.enableReadReceipt") private var enableReadReceipt: Bool = false - - // State variables for real-time updates - @State private var nickname: String? = nil - @State private var avatarURL: String? = nil - @State private var selfSignature: String? = nil - - private func getNickName() -> String { - if let nickname = nickname, !nickname.isEmpty { - return nickname - } - return loginManager.currentUserID - } - - private func getSelfSignature() -> String { - return selfSignature ?? LocalizedChatString("NoSelfSignature") - } - - var body: some View { - VStack { - headerView - List { - Section(header: Text(LocalizedChatString("PersonalInfo")).foregroundColor(themeState.colors.textColorSecondary)) { - Button(action: { - showProfileDetail = true - }) { - HStack { - Avatar(url: self.avatarURL, name: getNickName(), size: .xl) - VStack(alignment: .leading, spacing: 4) { - Text("\(getNickName())") - .font(.headline) - .foregroundColor(themeState.colors.textColorPrimary) - Text("\(LocalizedChatString("ProfileAccount")): \(loginManager.currentUserID)") - .font(.caption) - .foregroundColor(themeState.colors.textColorSecondary) - Text("\(LocalizedChatString("ProfileSignature")): \(getSelfSignature())") - .font(.caption) - .foregroundColor(themeState.colors.textColorSecondary) - } - .padding(.vertical, 10) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(themeState.colors.textColorSecondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(PlainButtonStyle()) - } - .listRowBackground(themeState.colors.bgColorTopBar) - Section(header: Text(LocalizedChatString("Style")).foregroundColor(themeState.colors.textColorSecondary)) { - themeSelectionView() - languageSelectionView() - } - .listRowBackground(themeState.colors.bgColorTopBar) - Section(header: Text(LocalizedChatString("Settings")).foregroundColor(themeState.colors.textColorSecondary)) { - AddMeApproveView() - readReceiptToggleView() - translateLanguageSelectionView() - } - .listRowBackground(themeState.colors.bgColorTopBar) - Section { - Button(action: { - loginManager.logout { success in - if success { - presentationMode.wrappedValue.dismiss() - } - } - }) { - Text(LocalizedChatString("logout")) - .foregroundColor(themeState.colors.textColorError) - .frame(maxWidth: .infinity, alignment: .center) - } - } - .listRowBackground(themeState.colors.bgColorTopBar) - } - .modifier(ListBackgroundModifier(backgroundColor: themeState.colors.bgColorOperate)) - .listStyle(InsetGroupedListStyle()) - } - .background(themeState.colors.bgColorOperate.ignoresSafeArea(edges: .top)) - .id("SettingsPage-\(languageState.currentLanguage)") - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.allowType))) { allowType in - self.allowType = allowType - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.nickname))) { nickname in - self.nickname = nickname - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.avatarURL))) { avatarURL in - self.avatarURL = avatarURL - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.selfSignature))) { selfSignature in - self.selfSignature = selfSignature - } - .onAppear { - // Initialize with current values - self.nickname = loginStore.state.value.loginUserInfo?.nickname - self.avatarURL = loginStore.state.value.loginUserInfo?.avatarURL - self.selfSignature = loginStore.state.value.loginUserInfo?.selfSignature - } - .fullScreenCover(isPresented: $showProfileDetail) { - NavigationView { - ProfileDetailView() - .environmentObject(themeState) - .environmentObject(languageState) - .navigationBarItems( - leading: Button(LocalizedChatString("Cancel")) { - showProfileDetail = false - } - .foregroundColor(themeState.colors.textColorLink) - ) + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorTopBar + headerBackgroundView.backgroundColor = colors.bgColorOperate + headerLabel.backgroundColor = colors.bgColorOperate + headerLabel.text = LocalizedChatString("TabSettings") + headerLabel.font = .systemFont(ofSize: 17, weight: .bold) + headerLabel.textColor = colors.textColorPrimary + headerLabel.textAlignment = .center + scrollView.backgroundColor = .clear + navigationItem.backButtonDisplayMode = .minimal + } + + private func bindInteraction() { + profileRow.addTarget(self, action: #selector(handleProfileTapped), for: .touchUpInside) + readReceiptSwitch.addTarget(self, action: #selector(handleReadReceiptChanged), for: .valueChanged) + callsTabSwitch.addTarget(self, action: #selector(handleCallsTabChanged), for: .valueChanged) + logoutButton.addTarget(self, action: #selector(handleLogoutTapped), for: .touchUpInside) + } + + private func bindLoginState() { + let state = LoginStore.shared.state + state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.nickname)) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refreshProfile() } + .store(in: &cancellables) + state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.avatarURL)) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refreshProfile() } + .store(in: &cancellables) + state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.selfSignature)) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refreshProfile() } + .store(in: &cancellables) + state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.allowType)) + .receive(on: DispatchQueue.main) + .sink { [weak self] allowType in + self?.allowType = allowType + self?.refreshApproveValue() } + .store(in: &cancellables) + refreshProfile() + allowType = LoginStore.shared.state.value.loginUserInfo?.allowType + refreshApproveValue() + } + + private func makeProfileSection() -> UIView { + profileRow.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + let textColumn = UIStackView() + textColumn.axis = .vertical + textColumn.spacing = 2 + nicknameLabel.font = .systemFont(ofSize: 18) + nicknameLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + nicknameLabel.lineBreakMode = .byTruncatingTail + userIDLabel.font = .systemFont(ofSize: 13) + userIDLabel.textColor = ChatUIKitTheme.colors.textColorTertiary + signatureLabel.font = .systemFont(ofSize: 13) + signatureLabel.textColor = ChatUIKitTheme.colors.textColorTertiary + signatureLabel.lineBreakMode = .byTruncatingTail + textColumn.addArrangedSubview(nicknameLabel) + textColumn.addArrangedSubview(userIDLabel) + textColumn.addArrangedSubview(signatureLabel) + avatarView.isUserInteractionEnabled = false + textColumn.isUserInteractionEnabled = false + profileRow.addSubview(avatarView) + profileRow.addSubview(textColumn) + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(ChatAvatarSize.l.size) } + textColumn.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) + } + return profileRow } - @ViewBuilder - private func AddMeApproveView() -> some View { - Button(action: { withAnimation { approveListExpanded.toggle() } }) { - HStack { - Text(LocalizedChatString("MeFriendRequest")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(getCurrentApproveDisplayName()) - .foregroundColor(themeState.colors.textColorSecondary) - .id("approve_display_\(allowType?.rawValue ?? -1)") - Image(systemName: approveListExpanded ? "chevron.up" : "chevron.down") - .foregroundColor(.gray) - } + private func makeEntryRow(title: String, valueLabel: UILabel?, action: Selector) -> UIView { + let colors = ChatUIKitTheme.colors + let row = UIControl() + row.backgroundColor = colors.bgColorOperate + row.addTarget(self, action: action, for: .touchUpInside) + row.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.entryRowMinHeight) } - .buttonStyle(PlainButtonStyle()) - if approveListExpanded { - Group { - createApproveOption(title: LocalizedChatString("AllowTypeAcceptOne"), allowType: .allowAny) - createApproveOption(title: LocalizedChatString("AllowTypeNeedConfirm"), allowType: .needConfirm) - createApproveOption(title: LocalizedChatString("AllowTypeDeclineAll"), allowType: .denyAny) + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let arrowImageView = UIImageView(image: AtomicXChatResources.image(named: "contact_info_arrow_right")?.withRenderingMode(.alwaysTemplate)) + arrowImageView.tintColor = colors.textColorTertiary + arrowImageView.contentMode = .scaleAspectFit + arrowImageView.setContentHuggingPriority(.required, for: .horizontal) + row.addSubview(titleLabel) + row.addSubview(arrowImageView) + if let valueLabel = valueLabel { + valueLabel.font = .systemFont(ofSize: 16) + valueLabel.textColor = colors.textColorPrimary + valueLabel.lineBreakMode = .byTruncatingTail + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + row.addSubview(valueLabel) + valueLabel.snp.makeConstraints { make in + make.trailing.equalTo(arrowImageView.snp.leading).offset(-Self.arrowSpacing) + make.centerY.equalToSuperview() } - .transition(.opacity.combined(with: .move(edge: .top))) } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) + } + arrowImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + return row } - @ViewBuilder - private func themeSelectionView() -> some View { - themeModeSelectionView() - primaryColorSelectionView() + private func makePrimaryColorRow() -> UIView { + let colors = ChatUIKitTheme.colors + let row = UIControl() + row.backgroundColor = colors.bgColorOperate + row.addTarget(self, action: #selector(handlePrimaryColorTapped), for: .touchUpInside) + row.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.entryRowMinHeight) + } + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("SelectThemeColor") + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let arrowImageView = UIImageView(image: AtomicXChatResources.image(named: "contact_info_arrow_right")?.withRenderingMode(.alwaysTemplate)) + arrowImageView.tintColor = colors.textColorTertiary + arrowImageView.contentMode = .scaleAspectFit + arrowImageView.setContentHuggingPriority(.required, for: .horizontal) + refreshPrimaryColorPreview() + row.addSubview(titleLabel) + row.addSubview(arrowImageView) + row.addSubview(primaryColorPreview) + primaryColorPreview.snp.makeConstraints { make in + make.trailing.equalTo(arrowImageView.snp.leading).offset(-Self.arrowSpacing) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.primaryColorPreviewSize) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) + } + arrowImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + return row } - @ViewBuilder - private func themeModeSelectionView() -> some View { - Button(action: { withAnimation { themeListExpanded.toggle() } }) { - HStack { - Text(LocalizedChatString("SelectThemeMode")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(getCurrentModeDisplayName()) - .foregroundColor(themeState.colors.textColorSecondary) - Image(systemName: themeListExpanded ? "chevron.up" : "chevron.down") - .foregroundColor(.gray) - } + private func makeReadReceiptRow() -> UIView { + let colors = ChatUIKitTheme.colors + let container = UIView() + container.backgroundColor = colors.bgColorOperate + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("MessageReadReceipt") + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + readReceiptSwitch.onTintColor = colors.switchColorOn + readReceiptSwitch.isOn = UserDefaults.standard.bool(forKey: "com.atomicx.enableReadReceipt") + readReceiptDescLabel.font = .systemFont(ofSize: 12) + readReceiptDescLabel.textColor = colors.textColorTertiary + readReceiptDescLabel.numberOfLines = 0 + refreshReadReceiptDesc() + container.addSubview(titleLabel) + container.addSubview(readReceiptSwitch) + container.addSubview(readReceiptDescLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.rowVerticalPadding) } - .buttonStyle(PlainButtonStyle()) - if themeListExpanded { - Group { - createThemeModeOption(title: LocalizedChatString("ThemeNameLight"), color: .gray.opacity(0.2), mode: .light) - createThemeModeOption(title: LocalizedChatString("ThemeNameDark"), color: .black, mode: .dark) - createThemeModeOption(title: LocalizedChatString("ThemeNameSystem"), color: .blue, mode: .system) - } - .transition(.opacity.combined(with: .move(edge: .top))) + readReceiptSwitch.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalTo(titleLabel) + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) + } + readReceiptDescLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) } + return container } - @ViewBuilder - private func primaryColorSelectionView() -> some View { - Button(action: { withAnimation { primaryColorListExpanded.toggle() } }) { - HStack { - Text(LocalizedChatString("SelectPrimaryColor")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(getCurrentPrimaryColorDisplayName()) - .foregroundColor(themeState.colors.textColorSecondary) - Image(systemName: primaryColorListExpanded ? "chevron.up" : "chevron.down") - .foregroundColor(.gray) - } + private func makeCallsTabRow() -> UIView { + let colors = ChatUIKitTheme.colors + let container = UIView() + container.backgroundColor = colors.bgColorOperate + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("SettingsShowCalls") + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + callsTabSwitch.onTintColor = colors.switchColorOn + callsTabSwitch.isOn = HomeTabBarController.isCallsTabVisible + container.addSubview(titleLabel) + container.addSubview(callsTabSwitch) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) } - .buttonStyle(PlainButtonStyle()) - if primaryColorListExpanded { - Group { - createDefaultColorOption() - if themeState.hasCustomPrimaryColor { - createClearColorOption() - } - ForEach(presetColors) { colorOption in - createCustomThemeOption(colorOption: colorOption) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) + callsTabSwitch.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalTo(titleLabel) + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) } + return container } - private func getCurrentApproveDisplayName() -> String { - guard let allowType = allowType else { - return "" + private func makeGroupSpacer() -> UIView { + let spacer = UIView() + spacer.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + spacer.snp.makeConstraints { make in + make.height.equalTo(Self.groupSpacerHeight) } - switch allowType { - case .allowAny: - return LocalizedChatString("AllowTypeAcceptOne") - case .needConfirm: - return LocalizedChatString("AllowTypeNeedConfirm") - case .denyAny: - return LocalizedChatString("AllowTypeDeclineAll") - } - } - - @ViewBuilder - private func createApproveOption(title: String, allowType: AllowType) -> some View { - Button(action: { - setAllowType(allowType) - approveListExpanded = false - }) { - HStack { - Text(title) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if allowType == self.allowType { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + return spacer + } + + private func makeRowDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary + divider.snp.makeConstraints { make in + make.height.equalTo(Self.dividerHeight) } - .buttonStyle(PlainButtonStyle()) - .id("approve_option_\(allowType.rawValue)") + return divider } - private func setAllowType(_ allowType: AllowType) { - if var info = loginStore.state.value.loginUserInfo { - info.allowType = allowType - LoginStore.shared.setSelfInfo(userProfile: info, completion: nil) + private func makeLogoutSection() -> UIView { + let container = UIView() + container.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + logoutButton.setTitle(LocalizedChatString("logout"), for: .normal) + logoutButton.setTitleColor(ChatUIKitTheme.colors.textColorError, for: .normal) + logoutButton.titleLabel?.font = .systemFont(ofSize: 16) + logoutButton.backgroundColor = ChatUIKitTheme.colors.bgColorInput + logoutButton.layer.cornerRadius = Self.logoutCornerRadius + container.addSubview(logoutButton) + logoutButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.top.equalToSuperview().offset(20) + make.bottom.equalToSuperview().offset(-20) + make.height.greaterThanOrEqualTo(44) } + return container + } + + private func refreshProfile() { + let userInfo = LoginStore.shared.state.value.loginUserInfo + let nickname = userInfo?.nickname ?? "" + let displayName = nickname.isEmpty ? DemoLoginManager.shared.currentUserID : nickname + nicknameLabel.text = displayName + let userID = userInfo?.userID ?? DemoLoginManager.shared.currentUserID + userIDLabel.text = "ID:\(userID)" + let signature = userInfo?.selfSignature ?? "" + signatureLabel.text = "\(LocalizedChatString("MeSignaturePrefix")):\(signature.isEmpty ? LocalizedChatString("NoSelfSignature") : signature)" + avatarView.configure(avatarURL: userInfo?.avatarURL, fallbackName: displayName) } - private func getCurrentModeDisplayName() -> String { - switch themeState.currentMode { + private func refreshStaticValues() { + switch ThemeState.shared.currentMode { case .system: - return LocalizedChatString("ThemeNameSystem") + themeValueLabel.text = LocalizedChatString("ThemeNameSystem") case .light: - return LocalizedChatString("ThemeNameLight") + themeValueLabel.text = LocalizedChatString("ThemeNameLight") case .dark: - return LocalizedChatString("ThemeNameDark") + themeValueLabel.text = LocalizedChatString("ThemeNameDark") } + languageValueLabel.text = DemoLanguageManager.shared.currentLanguageName() + refreshPrimaryColorPreview() + refreshApproveValue() + refreshTranslateValue() } - private func getCurrentPrimaryColorDisplayName() -> String { - if let primaryColor = themeState.currentPrimaryColor { - return primaryColor - } else { - return LocalizedChatString("DefaultColor") - } - } - - @ViewBuilder - private func createDefaultColorOption() -> some View { - Button(action: { - themeState.clearPrimaryColor() - primaryColorListExpanded = false - }) { - HStack { - Circle().fill(.blue).frame(width: 20, height: 20) - Text(LocalizedChatString("DefaultColor")) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if !themeState.hasCustomPrimaryColor { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } - - @ViewBuilder - private func createThemeModeOption(title: String, color: Color, mode: ThemeMode) -> some View { - Button(action: { - themeState.setThemeMode(mode) - themeListExpanded = false - }) { - HStack { - Circle().fill(color).frame(width: 20, height: 20) - Text(title) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if themeState.currentMode == mode && !themeState.hasCustomPrimaryColor { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } - - @ViewBuilder - private func createClearColorOption() -> some View { - Button(action: { - themeState.clearPrimaryColor() - primaryColorListExpanded = false - }) { - HStack { - Circle().fill(.gray).frame(width: 20, height: 20) - Text(LocalizedChatString("ClearCustomColor")) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if !themeState.hasCustomPrimaryColor { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } - - @ViewBuilder - private func createCustomThemeOption(colorOption: ColorOption) -> some View { - Button(action: { - themeState.setPrimaryColor(colorOption.hex) - primaryColorListExpanded = false - }) { - HStack { - Circle().fill(colorOption.color).frame(width: 20, height: 20) - Text(colorOption.name) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if themeState.currentPrimaryColor == colorOption.hex { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } - - @ViewBuilder - private func languageSelectionView() -> some View { - Button(action: { withAnimation { languageListExpanded.toggle() } }) { - HStack { - Text(LocalizedChatString("SelectLanguage")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(languageState.getCurrentLanguageName()) - .foregroundColor(themeState.colors.textColorSecondary) - Image(systemName: languageListExpanded ? "chevron.up" : "chevron.down") - .foregroundColor(.gray) - } - } - .buttonStyle(PlainButtonStyle()) - if languageListExpanded { - Group { - ForEach(languageState.supportedLanguages) { language in - createLanguageOption(language: language) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - @ViewBuilder - private func createLanguageOption(language: LanguageOption) -> some View { - Button(action: { - languageState.setLanguage(language.code) - languageListExpanded = false - }) { - HStack { - Text(language.nativeName) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if languageState.currentLanguage == language.code { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } - - @ViewBuilder - private func readReceiptToggleView() -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(LocalizedChatString("MessageReadReceipt")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Toggle("", isOn: $enableReadReceipt) - .labelsHidden() - .onChange(of: enableReadReceipt) { newValue in - AppBuilderConfig.shared.enableReadReceipt = newValue - } - } + private func currentPrimaryColorHex() -> String { + return ThemeState.shared.currentPrimaryColor ?? PrimaryColorPickerViewController.defaultPrimaryHex + } - Text(getReadReceiptDescription()) - .font(.caption) - .foregroundColor(themeState.colors.textColorSecondary) - .fixedSize(horizontal: false, vertical: true) + private func refreshPrimaryColorPreview() { + primaryColorPreview.backgroundColor = UIColor(demoHex: currentPrimaryColorHex()) + primaryColorPreview.layer.cornerRadius = Self.primaryColorPreviewSize / 2 + primaryColorPreview.layer.borderWidth = 1.5 + primaryColorPreview.layer.borderColor = ChatUIKitTheme.colors.strokeColorPrimary.cgColor + } + + @objc private func handlePrimaryColorTapped() { + let picker = PrimaryColorPickerViewController(selectedHex: currentPrimaryColorHex()) { [weak self] hex in + ThemeState.shared.setPrimaryColor(hex) + self?.refreshPrimaryColorPreview() } - .padding(.vertical, 4) + present(picker, animated: true) } - private func getReadReceiptDescription() -> String { - if enableReadReceipt { - return LocalizedChatString("MessageReadReceiptEnabledDesc") - } else { - return LocalizedChatString("MessageReadReceiptDisabledDesc") - } - } - - // MARK: - Translate Language Selection - - private let translateLanguageOptions: [(code: String, name: String)] = [ - ("zh", "简体中文"), - ("zh-TW", "繁體中文"), - ("en", "English"), - ("ja", "日本語"), - ("ko", "한국어"), - ("fr", "Français"), - ("es", "Español"), - ("it", "Italiano"), - ("de", "Deutsch"), - ("tr", "Türkçe"), - ("ru", "Русский"), - ("pt", "Português"), - ("vi", "Tiếng Việt"), - ("id", "Bahasa Indonesia"), - ("th", "ภาษาไทย"), - ("ms", "Bahasa Melayu"), - ("hi", "हिन्दी") - ] + private func refreshApproveValue() { + guard let allowType = allowType else { + approveValueLabel.text = "" + return + } + switch allowType { + case .allowAny: + approveValueLabel.text = LocalizedChatString("AllowTypeAcceptOne") + case .needConfirm: + approveValueLabel.text = LocalizedChatString("AllowTypeNeedConfirm") + case .denyAny: + approveValueLabel.text = LocalizedChatString("AllowTypeDeclineAll") + } + } - private func getCurrentTranslateLanguageDisplayName() -> String { + private func refreshTranslateValue() { var currentCode = AppBuilderConfig.shared.translateTargetLanguage - // Map system language codes to SDK language codes if currentCode == "zh-Hans" { currentCode = "zh" } else if currentCode == "zh-Hant" { currentCode = "zh-TW" } - return translateLanguageOptions.first { $0.code == currentCode }?.name ?? currentCode + translateValueLabel.text = Self.translateLanguageOptions.first { $0.code == currentCode }?.name ?? currentCode } - @ViewBuilder - private func translateLanguageSelectionView() -> some View { - Button(action: { withAnimation { translateLanguageListExpanded.toggle() } }) { - HStack { - Text(LocalizedChatString("TranslateMessage")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(getCurrentTranslateLanguageDisplayName()) - .foregroundColor(themeState.colors.textColorSecondary) - Image(systemName: translateLanguageListExpanded ? "chevron.up" : "chevron.down") - .foregroundColor(.gray) - } + private func refreshReadReceiptDesc() { + readReceiptDescLabel.text = readReceiptSwitch.isOn + ? LocalizedChatString("MessageReadReceiptEnabledDesc") + : LocalizedChatString("MessageReadReceiptDisabledDesc") + } + + @objc private func handleProfileTapped() { + let controller = ProfileDetailViewController() + controller.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(controller, animated: true) + } + + @objc private func handleThemeTapped() { + presentActionSheet(options: [ + (LocalizedChatString("ThemeNameSystem"), { ThemeState.shared.setThemeMode(.system) }), + (LocalizedChatString("ThemeNameLight"), { ThemeState.shared.setThemeMode(.light) }), + (LocalizedChatString("ThemeNameDark"), { ThemeState.shared.setThemeMode(.dark) }) + ]) { [weak self] in + self?.refreshStaticValues() } - .buttonStyle(PlainButtonStyle()) - if translateLanguageListExpanded { - Group { - ForEach(translateLanguageOptions, id: \.code) { option in - createTranslateLanguageOption(code: option.code, name: option.name) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - @ViewBuilder - private func createTranslateLanguageOption(code: String, name: String) -> some View { - Button(action: { - AppBuilderConfig.shared.translateTargetLanguage = code - UserDefaults.standard.set(code, forKey: "com.atomicx.translateTargetLanguage") - translateLanguageListExpanded = false - }) { - HStack { - Text(name) - .foregroundColor(themeState.colors.textColorSecondary) - Spacer() - if AppBuilderConfig.shared.translateTargetLanguage == code { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + } + + @objc private func handleLanguageTapped() { + presentActionSheet(options: DemoLanguageManager.shared.supportedLanguages.map { language in + (language.nativeName, { DemoLanguageManager.shared.setLanguage(language.code) }) + }) { [weak self] in + self?.refreshStaticValues() } - .buttonStyle(PlainButtonStyle()) } - private var headerView: some View { - HStack { - Text(LocalizedChatString("TabSettings")) - .font(.system(size: 34, weight: .semibold)) - .tracking(0.3) - .foregroundColor(themeState.colors.textColorPrimary) - .background(themeState.colors.clearColor) - .padding(.leading, 16) - Spacer() + @objc private func handleApproveTapped() { + presentActionSheet(options: [ + (LocalizedChatString("AllowTypeAcceptOne"), { self.setAllowType(.allowAny) }), + (LocalizedChatString("AllowTypeDeclineAll"), { self.setAllowType(.denyAny) }), + (LocalizedChatString("AllowTypeNeedConfirm"), { self.setAllowType(.needConfirm) }) + ]) + } + + @objc private func handleTranslateTapped() { + presentActionSheet(options: Self.translateLanguageOptions.map { option in + (option.name, { + AppBuilderConfig.shared.translateTargetLanguage = option.code + UserDefaults.standard.set(option.code, forKey: "com.atomicx.translateTargetLanguage") + }) + }) { [weak self] in + self?.refreshTranslateValue() } - .padding(.top, 12) - .padding(.bottom, 16) } -} -enum Theme: String, CaseIterable { - case light = "Light" - case dark = "Dark" - case custom = "Custom" -} + @objc private func handleVoiceTapped() { + let controller = VoiceMessageSettingViewController() + controller.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(controller, animated: true) + } -struct ThemePicker: View { - @Binding var selectedTheme: Theme - @EnvironmentObject var themeState: ThemeState - var body: some View { - NavigationView { - List { - ForEach(Theme.allCases, id: \.self) { theme in - Button(action: { - selectedTheme = theme - }) { - HStack { - Text(theme.rawValue) - if selectedTheme == theme { - Spacer() - Image(systemName: "checkmark") - .foregroundColor(themeState.colors.buttonColorPrimaryDefault) - } - } - } - } - } - .navigationBarTitle(LocalizedChatString("SelectTheme"), displayMode: .inline) + @objc private func handleAboutTapped() { + let controller = AboutViewController() + controller.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(controller, animated: true) + } + + @objc private func handleReadReceiptChanged() { + UserDefaults.standard.set(readReceiptSwitch.isOn, forKey: "com.atomicx.enableReadReceipt") + AppBuilderConfig.shared.enableReadReceipt = readReceiptSwitch.isOn + refreshReadReceiptDesc() + } + + @objc private func handleCallsTabChanged() { + (tabBarController as? HomeTabBarController)?.setCallsTabVisible(callsTabSwitch.isOn) + } + + @objc private func handleLogoutTapped() { + DemoLoginManager.shared.logout { _ in } + } + + private func setAllowType(_ allowType: AllowType) { + if var info = LoginStore.shared.state.value.loginUserInfo { + info.allowType = allowType + LoginStore.shared.setSelfInfo(userProfile: info, completion: nil) } } + + private func presentActionSheet(options: [(String, () -> Void)], completion: (() -> Void)? = nil) { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + for (title, handler) in options { + alert.addAction(UIAlertAction(title: title, style: .default) { _ in + handler() + completion?() + }) + } + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY, width: 0, height: 0) + popover.permittedArrowDirections = [] + } + present(alert, animated: true) + } } -struct ProfileDetailView: View { - @EnvironmentObject var themeState: ThemeState - @EnvironmentObject var languageState: LanguageState - @ObservedObject private var loginManager = LoginStatusManager.shared - @State private var loginStore = LoginStore.shared - @State private var showingAvatarPicker = false - @State private var showingNicknameSheet = false - @State private var showingSignatureSheet = false - @State private var showingGenderActionSheet = false - @State private var showingGenderSheet = false - @State private var showingDatePicker = false - @State private var gender: Gender? = nil - @State private var selectedDate = Date() - @State private var birthday: UInt32? = nil - @State private var nickname: String? - @State private var avatar: String? - @State private var selfSignature: String? - - private func getNickName() -> String { - if let nickname = loginStore.state.value.loginUserInfo?.nickname, !nickname.isEmpty { - return nickname - } - return loginManager.currentUserID - } - - private func getGenderText() -> String { - guard let gender = gender else { - return LocalizedChatString("Unsetted") - } - switch gender { - case .male: - return LocalizedChatString("Male") - case .female: - return LocalizedChatString("Female") - case .unknown: - return LocalizedChatString("Unknown") +// MARK: - Profile Detail + +final class ProfileDetailViewController: UIViewController, SystemNavigationBarPage { + private static let horizontalPadding: CGFloat = 16 + + private static let rowVerticalPadding: CGFloat = 12 + + private static let dividerHeight: CGFloat = 0.5 + + private static let headerTopMargin: CGFloat = 16 + + private static let entryTopMargin: CGFloat = 36 + + private static let entryRowMinHeight: CGFloat = 48 + + private static let defaultBirthdayText = "1970-01-01" + + private let scrollView = UIScrollView() + + private let contentStack = UIStackView() + + private let avatarButton = UIControl() + + private let avatarView = ChatAvatarView(size: .xxl, isRound: true) + + private let displayNameLabel = UILabel() + + private let accountValueLabel = UILabel() + + private let nicknameValueLabel = UILabel() + + private let signatureValueLabel = UILabel() + + private let genderValueLabel = UILabel() + + private let birthdayValueLabel = UILabel() + + private var cancellables = Set() + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + bindLoginState() + } + + private func constructViewHierarchy() { + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + contentStack.axis = .vertical + contentStack.spacing = 0 + contentStack.addArrangedSubview(makeHeaderSection()) + let entryContainer = UIStackView() + entryContainer.axis = .vertical + entryContainer.spacing = 0 + entryContainer.addArrangedSubview(makeInfoRow(title: LocalizedChatString("ProfileAccount"), valueLabel: accountValueLabel, showArrow: false, action: nil)) + entryContainer.addArrangedSubview(makeInfoRow(title: LocalizedChatString("ProfileNickname"), valueLabel: nicknameValueLabel, showArrow: true, action: #selector(handleNicknameTapped))) + entryContainer.addArrangedSubview(makeInfoRow(title: LocalizedChatString("ProfileStatus"), valueLabel: signatureValueLabel, showArrow: true, action: #selector(handleSignatureTapped))) + entryContainer.addArrangedSubview(makeInfoRow(title: LocalizedChatString("ProfileGender"), valueLabel: genderValueLabel, showArrow: true, action: #selector(handleGenderTapped))) + entryContainer.addArrangedSubview(makeInfoRow(title: LocalizedChatString("ProfileBirthday"), valueLabel: birthdayValueLabel, showArrow: true, action: #selector(handleBirthdayTapped), showDivider: false)) + let entryWrapper = UIView() + entryWrapper.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + entryWrapper.addSubview(entryContainer) + entryContainer.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.top.equalToSuperview().offset(Self.entryTopMargin) + make.bottom.equalToSuperview() } + contentStack.addArrangedSubview(entryWrapper) } - private func getBirthdayText() -> String { - let birthday = birthday ?? loginStore.state.value.loginUserInfo?.birthday - guard let birthday = birthday else { - return LocalizedChatString("Unsetted") + private func activateConstraints() { + scrollView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) } - let date = Date(timeIntervalSince1970: TimeInterval(birthday)) - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) } - var body: some View { - VStack(spacing: 0) { - VStack(spacing: 16) { - Button(action: { - showingAvatarPicker = true - }) { - Avatar(url: self.avatar, name: self.getNickName(), size: .xxl) - } + private func setupViewStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + title = LocalizedChatString("ProfileDetails") + } - Button(action: { - showingNicknameSheet = true - }) { - Text(self.getNickName()) - .font(.title2) - .fontWeight(.medium) - .foregroundColor(themeState.colors.textColorPrimary) - Image(systemName: "pencil") - .font(.system(size: 16)) - .foregroundColor(themeState.colors.textColorLink) - } + private func bindInteraction() { + avatarButton.addTarget(self, action: #selector(handleAvatarTapped), for: .touchUpInside) + } + + private func bindLoginState() { + let state = LoginStore.shared.state + state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo)) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refreshValues() } + .store(in: &cancellables) + refreshValues() + } + + private func makeHeaderSection() -> UIView { + let container = UIView() + avatarButton.addSubview(avatarView) + avatarView.isUserInteractionEnabled = false + avatarView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + displayNameLabel.font = .systemFont(ofSize: 16) + displayNameLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + displayNameLabel.textAlignment = .center + displayNameLabel.numberOfLines = 3 + container.addSubview(avatarButton) + container.addSubview(displayNameLabel) + avatarButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.headerTopMargin) + make.centerX.equalToSuperview() + make.width.height.equalTo(ChatAvatarSize.xxl.size) + } + displayNameLabel.snp.makeConstraints { make in + make.top.equalTo(avatarButton.snp.bottom).offset(Self.horizontalPadding) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.bottom.equalToSuperview() + } + return container + } + + private func makeInfoRow(title: String, valueLabel: UILabel, showArrow: Bool, action: Selector?, showDivider: Bool = true) -> UIView { + let colors = ChatUIKitTheme.colors + let row = UIControl() + row.backgroundColor = .clear + if let action = action { + row.addTarget(self, action: action, for: .touchUpInside) + } + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + titleLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabel.font = .systemFont(ofSize: 16) + valueLabel.textColor = colors.textColorPrimary + valueLabel.lineBreakMode = .byTruncatingTail + valueLabel.textAlignment = LanguageHelper.isRTL ? .left : .right + row.addSubview(titleLabel) + row.addSubview(valueLabel) + let divider = UIView() + divider.backgroundColor = colors.strokeColorPrimary + divider.isHidden = !showDivider + row.addSubview(divider) + divider.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(Self.dividerHeight) + } + row.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.entryRowMinHeight) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) + } + if showArrow { + let arrowImageView = UIImageView(image: UIImage(systemName: "chevron.right")) + arrowImageView.tintColor = colors.textColorTertiary + arrowImageView.contentMode = .scaleAspectFit + arrowImageView.setContentHuggingPriority(.required, for: .horizontal) + row.addSubview(arrowImageView) + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) + make.trailing.equalTo(arrowImageView.snp.leading).offset(-8) + make.centerY.equalToSuperview() } - .padding(.top, 40) - .padding(.bottom, 30) - - List { - Section { - HStack { - Text(LocalizedChatString("ProfileAccount")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(loginManager.currentUserID) - .foregroundColor(themeState.colors.textColorSecondary) - } - .listRowBackground(themeState.colors.bgColorTopBar) - - Button(action: { - showingSignatureSheet = true - }) { - HStack { - Text(LocalizedChatString("ProfileSignature")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(themeState.colors.textColorSecondary) - } - } - .buttonStyle(PlainButtonStyle()) - .listRowBackground(themeState.colors.bgColorTopBar) - - Button(action: { - showingGenderActionSheet = true - }) { - HStack { - Text(LocalizedChatString("ProfileGender")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(getGenderText()) - .foregroundColor(themeState.colors.textColorSecondary) - Image(systemName: "chevron.right") - .foregroundColor(themeState.colors.textColorSecondary) - } - } - .buttonStyle(PlainButtonStyle()) - .listRowBackground(themeState.colors.bgColorTopBar) - - Button(action: { - if let birthday = loginStore.state.value.loginUserInfo?.birthday { - selectedDate = Date(timeIntervalSince1970: TimeInterval(birthday)) - } - showingDatePicker = true - }) { - HStack { - Text(LocalizedChatString("ProfileBirthday")) - .foregroundColor(themeState.colors.textColorPrimary) - Spacer() - Text(getBirthdayText()) - .foregroundColor(themeState.colors.textColorSecondary) - Image(systemName: "chevron.right") - .foregroundColor(themeState.colors.textColorSecondary) - } - } - .buttonStyle(PlainButtonStyle()) - .listRowBackground(themeState.colors.bgColorTopBar) - } + arrowImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() } - .modifier(ListBackgroundModifier(backgroundColor: themeState.colors.bgColorOperate)) - .listStyle(InsetGroupedListStyle()) - } - .background( - themeState.colors.bgColorOperate - .ignoresSafeArea() - ) - .navigationBarTitle(LocalizedChatString("ProfileDetails"), displayMode: .inline) - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.avatarURL))) { avatarURL in - self.avatar = avatarURL - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.nickname))) { nickname in - self.nickname = nickname - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.selfSignature))) { selfSignature in - self.selfSignature = selfSignature - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.gender))) { gender in - self.gender = gender - } - .onReceive(loginStore.state.subscribe(StatePublisherSelector(keyPath: \LoginState.loginUserInfo?.birthday))) { birthday in - self.birthday = birthday - } - .onAppear { - // Initialize with current values - self.avatar = loginStore.state.value.loginUserInfo?.avatarURL - self.nickname = loginStore.state.value.loginUserInfo?.nickname - self.selfSignature = loginStore.state.value.loginUserInfo?.selfSignature - self.gender = loginStore.state.value.loginUserInfo?.gender - self.birthday = loginStore.state.value.loginUserInfo?.birthday - } - .modifier(GenderActionSheetModifier(isPresented: $showingGenderActionSheet, onGenderSelected: updateGender)) - .background( - NavigationLink( - destination: AvatarSelector( - imageUrlList: createUserAvatarUrlList(), - column: 4, - onComplete: { selectedImageUrl in - if let userID = loginStore.state.value.loginUserInfo?.userID { - let user = UserProfile(userID: userID, nickname: nil, avatarURL: selectedImageUrl) - loginStore.setSelfInfo(userProfile: user, completion: nil) - } - } - ), - isActive: $showingAvatarPicker - ) { - EmptyView() + } else { + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() } - .hidden() - ) - .sheet(isPresented: $showingNicknameSheet) { - nicknameEditSheet( - currentNickname: self.nickname ?? "", - onSave: { newNickname in - if let userID = loginStore.state.value.loginUserInfo?.userID { - let user = UserProfile(userID: userID, nickname: newNickname) - loginStore.setSelfInfo(userProfile: user, completion: nil) - } - } - ) - .modifier(SheetModifier()) - } - .sheet(isPresented: $showingSignatureSheet) { - selfSignatureEditSheet( - currentSignature: loginStore.state.value.loginUserInfo?.selfSignature ?? "", - onSave: { selfSignature in - if let userID = loginStore.state.value.loginUserInfo?.userID { - var user = UserProfile(userID: userID) - user.selfSignature = selfSignature - loginStore.setSelfInfo(userProfile: user, completion: nil) - } - } - ) - .modifier(SheetModifier()) - } - .sheet(isPresented: $showingDatePicker) { - birthdayEditSheet( - currentDate: selectedDate, - onSave: { newDate in - updateBirthday(newDate) - } - ) - .environmentObject(languageState) - .modifier(SheetModifier()) } + return row + } + + private func refreshValues() { + let userInfo = LoginStore.shared.state.value.loginUserInfo + let userID = userInfo?.userID ?? DemoLoginManager.shared.currentUserID + let nickname = userInfo?.nickname ?? "" + let displayName = nickname.isEmpty ? userID : nickname + displayNameLabel.text = displayName + avatarView.configure(avatarURL: userInfo?.avatarURL, fallbackName: displayName) + accountValueLabel.text = userID + nicknameValueLabel.text = nickname + signatureValueLabel.text = userInfo?.selfSignature ?? "" + switch userInfo?.gender { + case .male: + genderValueLabel.text = LocalizedChatString("Male") + case .female: + genderValueLabel.text = LocalizedChatString("Female") + default: + genderValueLabel.text = LocalizedChatString("GenderSecret") + } + birthdayValueLabel.text = Self.birthdayDisplayText(userInfo?.birthday) + } + + private static func birthdayDisplayText(_ birthday: UInt32?) -> String { + guard let birthday = birthday, birthday > 0 else { return defaultBirthdayText } + let raw = String(birthday) + guard raw.count == 8 else { return defaultBirthdayText } + let year = raw.prefix(4) + let month = raw.dropFirst(4).prefix(2) + let day = raw.dropFirst(6).prefix(2) + return "\(year)-\(month)-\(day)" } - private func createUserAvatarUrlList() -> [String] { - return (1 ... 27).map { index in - "https://im.sdk.qcloud.com/download/tuikit-resource/avatar/avatar_\(index).png" + private static func birthdayDate(_ birthday: UInt32?) -> Date { + guard let birthday = birthday, birthday > 0 else { return Date() } + let raw = String(birthday) + guard raw.count == 8, + let year = Int(raw.prefix(4)), + let month = Int(raw.dropFirst(4).prefix(2)), + let day = Int(raw.dropFirst(6).prefix(2)) else { return Date() } + var components = DateComponents() + components.year = year + components.month = month + components.day = day + return Calendar.current.date(from: components) ?? Date() + } + + @objc private func handleAvatarTapped() { + let imageUrlList = (1 ... 26).map { "https://im.sdk.qcloud.com/download/tuikit-resource/avatar/avatar_\($0).png" } + let picker = AvatarPickerPanel(imageUrlList: imageUrlList) { selectedImageUrl in + guard let userID = LoginStore.shared.state.value.loginUserInfo?.userID else { return } + let user = UserProfile(userID: userID, nickname: nil, avatarURL: selectedImageUrl) + LoginStore.shared.setSelfInfo(userProfile: user, completion: nil) } + picker.modalPresentationStyle = .overFullScreen + present(picker, animated: false) } - private func updateGender(_ genderRawValue: Int) { - let gender: Gender - switch genderRawValue { - case 1: - gender = .male - case 2: - gender = .female - default: - gender = .unknown + @objc private func handleNicknameTapped() { + presentTextEditDialog( + title: LocalizedChatString("ProfileEditName"), + currentText: LoginStore.shared.state.value.loginUserInfo?.nickname ?? "" + ) { newNickname in + let trimmed = newNickname.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + let userID = LoginStore.shared.state.value.loginUserInfo?.userID else { return } + let user = UserProfile(userID: userID, nickname: newNickname) + LoginStore.shared.setSelfInfo(userProfile: user, completion: nil) } + } - if let userID = loginStore.state.value.loginUserInfo?.userID { + @objc private func handleSignatureTapped() { + presentTextEditDialog( + title: LocalizedChatString("ProfileEditSignture"), + currentText: LoginStore.shared.state.value.loginUserInfo?.selfSignature ?? "" + ) { newSignature in + guard let userID = LoginStore.shared.state.value.loginUserInfo?.userID else { return } var user = UserProfile(userID: userID) - user.gender = gender - loginStore.setSelfInfo(userProfile: user, completion: nil) + user.selfSignature = newSignature + LoginStore.shared.setSelfInfo(userProfile: user, completion: nil) + } + } + + @objc private func handleGenderTapped() { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: LocalizedChatString("Male"), style: .default) { _ in + self.updateGender(.male) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Female"), style: .default) { _ in + self.updateGender(.female) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("GenderSecret"), style: .default) { _ in + self.updateGender(.unknown) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = view + popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY, width: 0, height: 0) + popover.permittedArrowDirections = [] } + present(alert, animated: true) + } + + @objc private func handleBirthdayTapped() { + let initialDate = Self.birthdayDate(LoginStore.shared.state.value.loginUserInfo?.birthday) + let panel = BirthdayPickerViewController(initialDate: initialDate) { [weak self] newDate in + self?.updateBirthday(newDate) + } + panel.modalPresentationStyle = .overFullScreen + present(panel, animated: false) + } + + private func updateGender(_ gender: Gender) { + guard let userID = LoginStore.shared.state.value.loginUserInfo?.userID else { return } + var user = UserProfile(userID: userID) + user.gender = gender + LoginStore.shared.setSelfInfo(userProfile: user, completion: nil) } private func updateBirthday(_ date: Date) { - let birthday = UInt32(date.timeIntervalSince1970) - if let userID = loginStore.state.value.loginUserInfo?.userID { - var user = UserProfile(userID: userID) - user.birthday = birthday - loginStore.setSelfInfo(userProfile: user, completion: nil) + guard let userID = LoginStore.shared.state.value.loginUserInfo?.userID else { return } + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + var user = UserProfile(userID: userID) + user.birthday = UInt32(formatter.string(from: date)) + LoginStore.shared.setSelfInfo(userProfile: user, completion: nil) + } + + private func presentTextEditDialog(title: String, currentText: String, onSave: @escaping (String) -> Void) { + let dialog = TextInputDialogViewController(title: title, initialText: currentText) { text in + onSave(text) } + present(dialog, animated: true) } } -private struct nicknameEditSheet: View { - @Environment(\.presentationMode) var presentationMode - @State private var newNickname: String = "" - let currentNickname: String - let onSave: (String) -> Void +// MARK: - Birthday Picker Panel - var body: some View { - TextEditSheet( - title: LocalizedChatString("ProfileEditName"), - currentText: currentNickname, - placeholder: LocalizedChatString("ProfileEditName"), - helpText: LocalizedChatString("ProfileEditNameDesc"), - onSave: onSave - ) +private final class BirthdayPickerViewController: UIViewController { + private static let panelCornerRadius: CGFloat = 12 + + private static let panelHeight: CGFloat = 300 + + private let backgroundDimView = UIView() + + private let panelView = UIView() + + private let cancelButton = UIButton(type: .custom) + + private let saveButton = UIButton(type: .custom) + + private let datePicker = UIDatePicker() + + private let onSave: (Date) -> Void + + init(initialDate: Date, onSave: @escaping (Date) -> Void) { + self.onSave = onSave + super.init(nibName: nil, bundle: nil) + datePicker.date = initialDate + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + let colors = ChatUIKitTheme.colors + backgroundDimView.backgroundColor = UIColor.black.withAlphaComponent(0.4) + panelView.backgroundColor = colors.bgColorOperate + panelView.layer.cornerRadius = Self.panelCornerRadius + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.setTitleColor(colors.textColorLink, for: .normal) + saveButton.setTitle(LocalizedChatString("Save"), for: .normal) + saveButton.setTitleColor(colors.textColorLink, for: .normal) + datePicker.datePickerMode = .date + datePicker.preferredDatePickerStyle = .wheels + datePicker.locale = Locale(identifier: DemoLanguageManager.shared.currentLanguage) + + view.addSubview(backgroundDimView) + view.addSubview(panelView) + panelView.addSubview(cancelButton) + panelView.addSubview(saveButton) + panelView.addSubview(datePicker) + backgroundDimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + panelView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(Self.panelHeight) + } + cancelButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.top.equalToSuperview().offset(12) + } + saveButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.top.equalToSuperview().offset(12) + } + datePicker.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.top.equalTo(saveButton.snp.bottom).offset(8) + make.bottom.lessThanOrEqualToSuperview() + } + + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + saveButton.addTarget(self, action: #selector(handleSave), for: .touchUpInside) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleCancel)) + backgroundDimView.addGestureRecognizer(tap) + } + + @objc private func handleCancel() { + dismiss(animated: false) + } + + @objc private func handleSave() { + onSave(datePicker.date) + dismiss(animated: false) } } -private struct selfSignatureEditSheet: View { - @Environment(\.presentationMode) var presentationMode - @State private var newSignature: String = "" - let currentSignature: String - let onSave: (String) -> Void +private final class AvatarPickerPanel: UIViewController { + private static let panelCornerRadius: CGFloat = 16 - var body: some View { - TextEditSheet( - title: LocalizedChatString("ProfileEditSignture"), - currentText: currentSignature, - placeholder: LocalizedChatString("ProfileEditSignture"), - helpText: LocalizedChatString("ProfileEditNameDesc"), - onSave: onSave - ) + private static let panelHeightRatio: CGFloat = 0.6 + + private static let headerPadding: CGFloat = 16 + + private static let headerVerticalPadding: CGFloat = 14 + + private static let gridPadding: CGFloat = 12 + + private static let cellPadding: CGFloat = 8 + + private static let columnCount: CGFloat = 4 + + private let imageUrlList: [String] + + private let onSelect: (String) -> Void + + private let panelView = UIView() + + private let titleLabel = UILabel() + + private let closeButton = UIButton(type: .custom) + + private var collectionView: UICollectionView! + + init(imageUrlList: [String], onSelect: @escaping (String) -> Void) { + self.imageUrlList = imageUrlList + self.onSelect = onSelect + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + let colors = ChatUIKitTheme.colors + view.backgroundColor = UIColor.black.withAlphaComponent(0.4) + panelView.backgroundColor = colors.bgColorOperate + panelView.layer.cornerRadius = Self.panelCornerRadius + panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + panelView.clipsToBounds = true + titleLabel.text = LocalizedChatString("ChooseAvatar") + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + closeButton.setTitle("✕", for: .normal) + closeButton.titleLabel?.font = .systemFont(ofSize: 18) + closeButton.setTitleColor(colors.textColorSecondary, for: .normal) + + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = 0 + layout.sectionInset = UIEdgeInsets(top: Self.gridPadding, left: Self.gridPadding, bottom: Self.gridPadding, right: Self.gridPadding) + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.register(AvatarPickerCell.self, forCellWithReuseIdentifier: AvatarPickerCell.reuseIdentifier) + collectionView.dataSource = self + collectionView.delegate = self + + view.addSubview(panelView) + panelView.addSubview(titleLabel) + panelView.addSubview(closeButton) + panelView.addSubview(collectionView) + panelView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(UIScreen.main.bounds.height * Self.panelHeightRatio) + } + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(Self.headerPadding) + make.top.equalToSuperview().offset(Self.headerVerticalPadding) + } + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.headerPadding) + make.centerY.equalTo(titleLabel) + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.headerVerticalPadding) + make.leading.trailing.bottom.equalToSuperview() + } + + closeButton.addTarget(self, action: #selector(handleClose), for: .touchUpInside) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleClose)) + view.addGestureRecognizer(tap) + } + + @objc private func handleClose() { + dismiss(animated: false) } } -private struct birthdayEditSheet: View { - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var languageState: LanguageState - @EnvironmentObject var themeState: ThemeState - @State private var selectedDate: Date - let currentDate: Date - let onSave: (Date) -> Void +extension AvatarPickerPanel: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + imageUrlList.count + } - init(currentDate: Date, onSave: @escaping (Date) -> Void) { - self.currentDate = currentDate - self.onSave = onSave - self._selectedDate = State(initialValue: currentDate) + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AvatarPickerCell.reuseIdentifier, for: indexPath) as? AvatarPickerCell else { + return UICollectionViewCell() + } + cell.configure(imageUrl: imageUrlList[indexPath.item]) + return cell } - var body: some View { - VStack(spacing: 20) { - HStack(spacing: 20) { - Button(LocalizedChatString("Cancel")) { - presentationMode.wrappedValue.dismiss() - } - .foregroundColor(themeState.colors.textColorLink) + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + onSelect(imageUrlList[indexPath.item]) + dismiss(animated: false) + } - Spacer() + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let totalSpacing = Self.gridPadding * 2 + let itemWidth = floor((collectionView.bounds.width - totalSpacing) / Self.columnCount) + return CGSize(width: itemWidth, height: itemWidth) + } +} - Button(LocalizedChatString("Save")) { - onSave(selectedDate) - presentationMode.wrappedValue.dismiss() - } - .foregroundColor(themeState.colors.textColorLink) - } - .padding(.horizontal) +private final class AvatarPickerCell: UICollectionViewCell { + static let reuseIdentifier = "AvatarPickerCell" - DatePicker( - "", - selection: $selectedDate, - displayedComponents: .date - ) - .datePickerStyle(WheelDatePickerStyle()) - .labelsHidden() - .environment(\.locale, Locale(identifier: languageState.currentLanguage)) + private let avatarView = ChatAvatarView(size: .xl, isRound: true) + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(avatarView) + let padding: CGFloat = 8 + avatarView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(ChatAvatarSize.xl.size) + make.leading.greaterThanOrEqualToSuperview().offset(padding) + make.trailing.lessThanOrEqualToSuperview().offset(-padding) } } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(imageUrl: String) { + avatarView.configure(avatarURL: imageUrl, fallbackName: "") + } } -private struct GenderActionSheetModifier: ViewModifier { - @Binding var isPresented: Bool - let onGenderSelected: (Int) -> Void +// MARK: - About - func body(content: Content) -> some View { - content - .confirmationDialog(LocalizedChatString("ProfileEditGender"), isPresented: $isPresented, titleVisibility: .visible) { - Button(LocalizedChatString("Male")) { - onGenderSelected(1) - } - Button(LocalizedChatString("Female")) { - onGenderSelected(2) +final class AboutViewController: UIViewController { + private static let privacyURL = "https://privacy.qq.com/document/preview/1cfe904fb7004b8ab1193a55857f7272" + + private static let userAgreementURL = "https://web.sdk.qcloud.com/document/Tencent-IM-User-Agreement.html" + + private static let personalInformationURL = "https://privacy.qq.com/document/preview/45ba982a1ce6493597a00f8c86b52a1e" + + private static let thirdPartySharingURL = "https://privacy.qq.com/document/preview/dea84ac4bb88454794928b77126e9246" + + private static let contactUsURL = "https://cloud.tencent.com/document/product/269/59590" + + private static let icpBeianURL = "https://beian.miit.gov.cn" + + private static let headerHeight: CGFloat = 56 + + private static let horizontalPadding: CGFloat = 16 + + private static let rowVerticalPadding: CGFloat = 12 + + private static let dividerHeight: CGFloat = 0.5 + + private static let groupSpacerHeight: CGFloat = 10 + + private static let entryRowMinHeight: CGFloat = 48 + + private let backButton = UIButton(type: .custom) + + private let titleLabel = UILabel() + + private let contentStack = UIStackView() + + private let footerStack = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + private func constructViewHierarchy() { + let header = UIView() + header.addSubview(backButton) + header.addSubview(titleLabel) + view.addSubview(header) + header.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.headerHeight) + } + backButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(28) + } + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + let spacer = UIView() + spacer.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + spacer.snp.makeConstraints { make in + make.height.equalTo(Self.groupSpacerHeight) + } + + contentStack.axis = .vertical + contentStack.spacing = 0 + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("AboutSDKVersion"), value: V2TIMManager.sharedInstance().getVersion(), showArrow: false, url: nil)) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("AboutPrivacyRegulations"), value: "", showArrow: true, url: Self.privacyURL)) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("AboutUserAgreement"), value: "", showArrow: true, url: Self.userAgreementURL)) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("AboutDisclaimer"), value: "", showArrow: true, url: nil, isDisclaimer: true)) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("AboutPersonalInformationCollectionList"), value: "", showArrow: true, url: Self.personalInformationURL)) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("About3PartySharingList"), value: "", showArrow: true, url: Self.thirdPartySharingURL)) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeInfoRow(title: LocalizedChatString("AboutContactUs"), value: "", showArrow: true, url: Self.contactUsURL)) + + footerStack.axis = .vertical + footerStack.spacing = 10 + footerStack.alignment = .center + let icpButton = UIButton(type: .custom) + icpButton.setTitle("ICP备案号:粤B2-20090059-2674A >", for: .normal) + icpButton.setTitleColor(ChatUIKitTheme.colors.textColorTertiary, for: .normal) + icpButton.titleLabel?.font = .systemFont(ofSize: 12) + icpButton.addTarget(self, action: #selector(handleICPTapped), for: .touchUpInside) + let copyrightLineOne = UILabel() + copyrightLineOne.text = "腾讯公司 版权所有" + copyrightLineOne.font = .systemFont(ofSize: 12) + copyrightLineOne.textColor = ChatUIKitTheme.colors.textColorTertiary + let copyrightLineTwo = UILabel() + copyrightLineTwo.text = "Copyright © 2020-2024 Tencent. All Rights Reserved." + copyrightLineTwo.font = .systemFont(ofSize: 12) + copyrightLineTwo.textColor = ChatUIKitTheme.colors.textColorTertiary + footerStack.addArrangedSubview(icpButton) + footerStack.addArrangedSubview(copyrightLineOne) + footerStack.addArrangedSubview(copyrightLineTwo) + + view.addSubview(spacer) + view.addSubview(contentStack) + view.addSubview(footerStack) + spacer.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom) + make.leading.trailing.equalToSuperview() + } + contentStack.snp.makeConstraints { make in + make.top.equalTo(spacer.snp.bottom) + make.leading.trailing.equalToSuperview() + } + footerStack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-34) + make.top.greaterThanOrEqualTo(contentStack.snp.bottom).offset(20) + } + } + + private func activateConstraints() {} + + private func setupViewStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + titleLabel.text = LocalizedChatString("AboutTencentIM") + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + let backImage = AtomicXChatResources.image(named: "contact_info_back")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate) + backButton.setImage(backImage, for: .normal) + backButton.tintColor = ChatUIKitTheme.colors.textColorPrimary + backButton.addTarget(self, action: #selector(handleBackTapped), for: .touchUpInside) + } + + private func makeInfoRow(title: String, value: String, showArrow: Bool, url: String?, isDisclaimer: Bool = false) -> UIView { + let colors = ChatUIKitTheme.colors + let row = UIControl() + row.backgroundColor = colors.bgColorOperate + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + titleLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + row.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.entryRowMinHeight) + } + row.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) + } + var trailingAnchorView: UIView = titleLabel + if showArrow { + let arrowImageView = UIImageView(image: AtomicXChatResources.image(named: "contact_info_arrow_right")?.withRenderingMode(.alwaysTemplate)) + arrowImageView.tintColor = colors.textColorTertiary + arrowImageView.contentMode = .scaleAspectFit + arrowImageView.setContentHuggingPriority(.required, for: .horizontal) + row.addSubview(arrowImageView) + arrowImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + trailingAnchorView = arrowImageView + } + if !value.isEmpty { + let valueLabel = UILabel() + valueLabel.text = value + valueLabel.font = .systemFont(ofSize: 16) + valueLabel.textColor = colors.textColorPrimary + row.addSubview(valueLabel) + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + if showArrow { + make.trailing.equalTo(trailingAnchorView.snp.leading).offset(-8) + } else { + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) } - Button(LocalizedChatString("Cancel"), role: .cancel) {} } + } + if isDisclaimer { + row.addTarget(self, action: #selector(handleDisclaimerTapped), for: .touchUpInside) + } else if let url = url { + row.addAction(UIAction { _ in + guard let target = URL(string: url) else { return } + UIApplication.shared.open(target) + }, for: .touchUpInside) + } + return row + } + + private func makeDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary + divider.snp.makeConstraints { make in + make.height.equalTo(Self.dividerHeight) + } + return divider + } + + @objc private func handleBackTapped() { + navigationController?.popViewController(animated: true) + } + + @objc private func handleICPTapped() { + guard let url = URL(string: Self.icpBeianURL) else { return } + UIApplication.shared.open(url) + } + + @objc private func handleDisclaimerTapped() { + let alert = UIAlertController( + title: LocalizedChatString("AboutDisclaimer"), + message: LocalizedChatString("AboutDisclaimerText"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Accept"), style: .destructive)) + present(alert, animated: true) } } -private struct SheetModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 16.0, *) { - content - .presentationDetents([.height(300)]) - .presentationDragIndicator(.hidden) - .interactiveDismissDisabled(false) - } else { - content +// MARK: - Primary Color Picker + +private extension UIColor { + convenience init(demoHex: String) { + var hex = demoHex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if hex.hasPrefix("#") { hex.removeFirst() } + var value: UInt64 = 0 + guard hex.count == 6, Scanner(string: hex).scanHexInt64(&value) else { + self.init(red: 0x1C / 255.0, green: 0x66 / 255.0, blue: 0xE5 / 255.0, alpha: 1) + return } + self.init(red: CGFloat((value >> 16) & 0xFF) / 255.0, + green: CGFloat((value >> 8) & 0xFF) / 255.0, + blue: CGFloat(value & 0xFF) / 255.0, + alpha: 1) + } + + var demoHexString: String { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: nil) + return String(format: "#%02X%02X%02X", Int(round(red * 255)), Int(round(green * 255)), Int(round(blue * 255))) } } -private struct ListBackgroundModifier: ViewModifier { - let backgroundColor: Color +final class GradientSliderBarView: UIView { + private static let spectrumHeight: CGFloat = 24 - func body(content: Content) -> some View { - if #available(iOS 16.0, *) { - content - .scrollContentBackground(.hidden) - .background(backgroundColor) - } else { - content - .onAppear { - UITableView.appearance().backgroundColor = .clear - } - .background(backgroundColor) + private static let shadowPadding: CGFloat = 4 + + private static let thumbBezel: CGFloat = 3 + + private static let thumbRingWidth: CGFloat = 1 + + var progress: CGFloat = 0 { + didSet { setNeedsDisplay() } + } + + var thumbColor: UIColor = .white { + didSet { setNeedsDisplay() } + } + + var onProgressChanged: ((CGFloat) -> Void)? + + private let sliderGradientLayer = CAGradientLayer() + + private var gradientColors: [UIColor] = [.black, .white] { + didSet { applyGradientColors() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + isOpaque = false + semanticContentAttribute = .forceLeftToRight + sliderGradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + sliderGradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + layer.addSublayer(sliderGradientLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: Self.spectrumHeight + Self.shadowPadding * 2) + } + + func setGradientColors(_ colors: [UIColor]) { + guard colors.count >= 2 else { return } + gradientColors = colors + } + + override func layoutSubviews() { + super.layoutSubviews() + let trackRect = trackRectForLayout() + sliderGradientLayer.frame = trackRect + sliderGradientLayer.cornerRadius = trackRect.height / 2 + setNeedsDisplay() + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { return } + let trackRect = trackRectForLayout() + let thumbRadius = trackRect.height / 2 + let usableWidth = max(trackRect.width - thumbRadius * 2, 0) + let thumbX = trackRect.minX + thumbRadius + progress * usableWidth + let thumbY = trackRect.midY + let thumbRect = CGRect(x: thumbX - thumbRadius, y: thumbY - thumbRadius, width: thumbRadius * 2, height: thumbRadius * 2) + context.saveGState() + context.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.16).cgColor) + UIColor.white.setFill() + context.fillEllipse(in: thumbRect) + context.restoreGState() + thumbColor.setFill() + context.fillEllipse(in: thumbRect.insetBy(dx: Self.thumbBezel, dy: Self.thumbBezel)) + UIColor.black.withAlphaComponent(0.16).setStroke() + let ringPath = UIBezierPath(ovalIn: thumbRect.insetBy(dx: Self.thumbRingWidth / 2, dy: Self.thumbRingWidth / 2)) + ringPath.lineWidth = Self.thumbRingWidth + ringPath.stroke() + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + handleTouch(touches) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + handleTouch(touches) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + handleTouch(touches) + } + + private func handleTouch(_ touches: Set) { + guard let point = touches.first?.location(in: self) else { return } + let trackRect = trackRectForLayout() + let thumbRadius = trackRect.height / 2 + let usableWidth = max(trackRect.width - thumbRadius * 2, 1) + let clampedX = min(max(point.x, trackRect.minX + thumbRadius), trackRect.maxX - thumbRadius) + progress = (clampedX - trackRect.minX - thumbRadius) / usableWidth + onProgressChanged?(progress) + } + + private func trackRectForLayout() -> CGRect { + return CGRect(x: 0, y: Self.shadowPadding, width: bounds.width, height: bounds.height - Self.shadowPadding * 2) + } + + private func applyGradientColors() { + sliderGradientLayer.colors = gradientColors.map { $0.cgColor } + setNeedsDisplay() + } +} + +final class PrimaryColorPickerViewController: UIViewController { + static let defaultPrimaryHex = "#1C66E5" + + private static let contentPadding: CGFloat = 16 + + private static let cornerRadius: CGFloat = 12 + + private static let horizontalMargin: CGFloat = 36 + + private static let previewSize: CGFloat = 48 + + private static let sliderBottomSpacing: CGFloat = 12 + + private static let sliderLastBottomSpacing: CGFloat = 16 + + private static let titleBottomSpacing: CGFloat = 12 + + private static let previewBottomSpacing: CGFloat = 8 + + private static let hexBottomSpacing: CGFloat = 12 + + private static let buttonMinWidth: CGFloat = 72 + + private static let buttonMinHeight: CGFloat = 44 + + private let onColorSelected: (String) -> Void + + private var hue: CGFloat = 0 + + private var saturation: CGFloat = 0 + + private var brightness: CGFloat = 0 + + private let containerView = UIView() + + private let previewView = UIView() + + private let hexLabel = UILabel() + + private let hueBar = GradientSliderBarView() + + private let saturationBar = GradientSliderBarView() + + private let brightnessBar = GradientSliderBarView() + + init(selectedHex: String, onColorSelected: @escaping (String) -> Void) { + self.onColorSelected = onColorSelected + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + UIColor(demoHex: selectedHex).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + setupViewStyle() + bindInteraction() + refreshDependentBars() + notifyPreviewChanged() + } + + private func constructViewHierarchy() { + let dimView = UIControl() + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.45) + dimView.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside) + view.addSubview(dimView) + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + view.addSubview(containerView) + containerView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(Self.horizontalMargin) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + containerView.backgroundColor = colors.bgColorDialog + containerView.layer.cornerRadius = Self.cornerRadius + containerView.clipsToBounds = true + + let stack = UIStackView() + stack.axis = .vertical + containerView.addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.contentPadding) + } + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("SelectThemeColorTitle") + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + stack.addArrangedSubview(titleLabel) + stack.setCustomSpacing(Self.titleBottomSpacing, after: titleLabel) + + previewView.layer.cornerRadius = Self.previewSize / 2 + previewView.layer.borderWidth = 1.5 + previewView.layer.borderColor = colors.strokeColorPrimary.cgColor + let previewContainer = UIView() + previewContainer.addSubview(previewView) + previewView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(Self.previewSize) + make.top.bottom.equalToSuperview() + } + stack.addArrangedSubview(previewContainer) + stack.setCustomSpacing(Self.previewBottomSpacing, after: previewContainer) + + hexLabel.font = .systemFont(ofSize: 13) + hexLabel.textColor = colors.textColorSecondary + hexLabel.textAlignment = .center + stack.addArrangedSubview(hexLabel) + stack.setCustomSpacing(Self.hexBottomSpacing, after: hexLabel) + + addLabeledSlider(stack: stack, title: LocalizedChatString("ThemeColorHue"), bar: hueBar) + addLabeledSlider(stack: stack, title: LocalizedChatString("ThemeColorSaturation"), bar: saturationBar) + addLabeledSlider(stack: stack, title: LocalizedChatString("ThemeColorBrightness"), bar: brightnessBar, last: true) + + let buttonRow = UIStackView() + buttonRow.axis = .horizontal + buttonRow.alignment = .center + let resetButton = makeButton(title: LocalizedChatString("ThemeColorReset"), color: colors.textColorSecondary, bold: false, action: #selector(handleResetTapped)) + let cancelButton = makeButton(title: LocalizedChatString("Cancel"), color: colors.textColorSecondary, bold: false, action: #selector(handleCancelTapped)) + let confirmButton = makeButton(title: LocalizedChatString("Confirm"), color: colors.textColorLink, bold: true, action: #selector(handleConfirmTapped)) + let spacer = UIView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + buttonRow.addArrangedSubview(resetButton) + buttonRow.addArrangedSubview(spacer) + buttonRow.addArrangedSubview(cancelButton) + buttonRow.addArrangedSubview(confirmButton) + stack.addArrangedSubview(buttonRow) + + hueBar.progress = hue / 360 + saturationBar.progress = saturation + brightnessBar.progress = brightness + } + + private func addLabeledSlider(stack: UIStackView, title: String, bar: GradientSliderBarView, last: Bool = false) { + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 11) + label.textColor = ChatUIKitTheme.colors.textColorSecondary + stack.addArrangedSubview(label) + stack.setCustomSpacing(4, after: label) + stack.addArrangedSubview(bar) + stack.setCustomSpacing(last ? Self.sliderLastBottomSpacing : Self.sliderBottomSpacing, after: bar) + } + + private func makeButton(title: String, color: UIColor, bold: Bool, action: Selector) -> UIButton { + let button = UIButton(type: .custom) + button.setTitle(title, for: .normal) + button.setTitleColor(color, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: bold ? .bold : .regular) + button.addTarget(self, action: action, for: .touchUpInside) + button.snp.makeConstraints { make in + make.width.greaterThanOrEqualTo(Self.buttonMinWidth) + make.height.greaterThanOrEqualTo(Self.buttonMinHeight) + } + return button + } + + private func bindInteraction() { + hueBar.setGradientColors(hueGradientColors()) + hueBar.onProgressChanged = { [weak self] progress in + guard let self = self else { return } + self.hue = progress * 360 + self.refreshDependentBars() + self.notifyPreviewChanged() + } + saturationBar.onProgressChanged = { [weak self] progress in + guard let self = self else { return } + self.saturation = progress + self.brightnessBar.setGradientColors(self.brightnessGradientColors()) + self.notifyPreviewChanged() } + brightnessBar.onProgressChanged = { [weak self] progress in + guard let self = self else { return } + self.brightness = progress + self.saturationBar.setGradientColors(self.saturationGradientColors()) + self.notifyPreviewChanged() + } + } + + private func currentColor() -> UIColor { + return UIColor(hue: hue / 360, saturation: saturation, brightness: brightness, alpha: 1) + } + + private func hueGradientColors() -> [UIColor] { + return (0 ... 6).map { UIColor(hue: CGFloat($0) / 6, saturation: 1, brightness: 1, alpha: 1) } + } + + private func saturationGradientColors() -> [UIColor] { + return [ + UIColor(hue: hue / 360, saturation: 0, brightness: brightness, alpha: 1), + UIColor(hue: hue / 360, saturation: 1, brightness: brightness, alpha: 1) + ] + } + + private func brightnessGradientColors() -> [UIColor] { + return [ + .black, + UIColor(hue: hue / 360, saturation: saturation, brightness: 1, alpha: 1) + ] + } + + private func refreshDependentBars() { + saturationBar.setGradientColors(saturationGradientColors()) + brightnessBar.setGradientColors(brightnessGradientColors()) + } + + private func notifyPreviewChanged() { + let color = currentColor() + previewView.backgroundColor = color + hexLabel.text = color.demoHexString + hueBar.thumbColor = color + saturationBar.thumbColor = color + brightnessBar.thumbColor = color + } + + @objc private func handleResetTapped() { + UIColor(demoHex: Self.defaultPrimaryHex).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + hueBar.progress = hue / 360 + saturationBar.progress = saturation + brightnessBar.progress = brightness + refreshDependentBars() + notifyPreviewChanged() + } + + @objc private func handleCancelTapped() { + dismiss(animated: true) + } + + @objc private func handleConfirmTapped() { + onColorSelected(currentColor().demoHexString) + dismiss(animated: true) } } diff --git a/chat/demo/ChatDemo/Pages/VoiceMessageSettingPages.swift b/chat/demo/ChatDemo/Pages/VoiceMessageSettingPages.swift new file mode 100644 index 000000000..f469a0a17 --- /dev/null +++ b/chat/demo/ChatDemo/Pages/VoiceMessageSettingPages.swift @@ -0,0 +1,786 @@ +import AtomicXCore +import ChatUIKit +import SnapKit +import UIKit + +private final class SettingSubPageHeaderView: UIView { + private static let horizontalPadding: CGFloat = 16 + + private static let verticalPadding: CGFloat = 12 + + private static let backButtonSize: CGFloat = 24 + + private let backButton = UIButton(type: .custom) + + private let titleLabel = UILabel() + + private let onBack: () -> Void + + init(title: String, onBack: @escaping () -> Void) { + self.onBack = onBack + super.init(frame: .zero) + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 17, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + let backImage = AtomicXChatResources.image(named: "contact_info_back")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate) + backButton.setImage(backImage, for: .normal) + backButton.tintColor = colors.textColorPrimary + backButton.addTarget(self, action: #selector(handleBack), for: .touchUpInside) + addSubview(backButton) + addSubview(titleLabel) + backButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.backButtonSize) + } + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func handleBack() { + onBack() + } +} + +private final class SettingEntryRowView: UIControl { + private static let horizontalPadding: CGFloat = 16 + + private static let verticalPadding: CGFloat = 12 + + private static let arrowSpacing: CGFloat = 8 + + private let titleLabel = UILabel() + + private let valueLabel = UILabel() + + private let arrowImageView = UIImageView() + + init(title: String) { + super.init(frame: .zero) + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.textColor = colors.textColorSecondary + valueLabel.font = .systemFont(ofSize: 16) + valueLabel.textColor = colors.textColorPrimary + valueLabel.lineBreakMode = .byTruncatingTail + arrowImageView.image = AtomicXChatResources.image(named: "contact_info_arrow_right")?.withRenderingMode(.alwaysTemplate) + arrowImageView.tintColor = colors.textColorTertiary + arrowImageView.contentMode = .scaleAspectFit + arrowImageView.setContentHuggingPriority(.required, for: .horizontal) + addSubview(titleLabel) + addSubview(valueLabel) + addSubview(arrowImageView) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + } + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) + make.trailing.equalTo(arrowImageView.snp.leading).offset(-Self.arrowSpacing) + make.centerY.equalToSuperview() + } + arrowImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setValue(_ value: String) { + valueLabel.text = value + } +} + +// MARK: - 语音消息设置 + +final class VoiceMessageSettingViewController: UIViewController { + private static let groupSpacerHeight: CGFloat = 10 + + private lazy var cloneRow = SettingEntryRowView(title: LocalizedChatString("VoiceClone")) + + private lazy var selectRow = SettingEntryRowView(title: LocalizedChatString("VoiceSelect")) + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + let header = SettingSubPageHeaderView(title: LocalizedChatString("VoiceMessageSettings")) { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary + let spacer = UIView() + spacer.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + view.addSubview(header) + view.addSubview(spacer) + view.addSubview(cloneRow) + view.addSubview(divider) + view.addSubview(selectRow) + header.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + spacer.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.groupSpacerHeight) + } + cloneRow.snp.makeConstraints { make in + make.top.equalTo(spacer.snp.bottom) + make.leading.trailing.equalToSuperview() + } + divider.snp.makeConstraints { make in + make.top.equalTo(cloneRow.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + selectRow.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom) + make.leading.trailing.equalToSuperview() + } + cloneRow.addTarget(self, action: #selector(handleCloneTapped), for: .touchUpInside) + selectRow.addTarget(self, action: #selector(handleSelectTapped), for: .touchUpInside) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + let name = VoiceMessageConfig.shared.getSelectedVoiceName() + selectRow.setValue(name.isEmpty ? LocalizedChatString("voice_message_voice_default") : name) + } + + @objc private func handleCloneTapped() { + navigationController?.pushViewController(VoiceCloneViewController(), animated: true) + } + + @objc private func handleSelectTapped() { + navigationController?.pushViewController(VoiceSelectViewController(), animated: true) + } +} + +// MARK: - 音色选择 + +final class VoiceSelectViewController: UIViewController { + private static let badgeFontSize: CGFloat = 10 + + private static let badgeCornerRadius: CGFloat = 4 + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + + private var defaultVoices: [CustomVoiceItem] = [] + + private var customVoices: [CustomVoiceItem] = [] + + private var selectedId = "" + + private var isLoading = true + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + let header = SettingSubPageHeaderView(title: LocalizedChatString("VoiceSelect")) { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + view.addSubview(header) + view.addSubview(tableView) + header.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + tableView.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "VoiceCell") + defaultVoices = VoiceItemProvider.defaultVoiceList() + selectedId = VoiceMessageConfig.shared.getSelectedVoiceId() + loadCustomVoices() + } + + private func loadCustomVoices() { + isLoading = true + AiMediaProcessManager.getCustomVoiceList(onSuccess: { [weak self] list in + self?.isLoading = false + self?.customVoices = list + self?.tableView.reloadData() + }, onFailure: { [weak self] _, _ in + self?.isLoading = false + self?.customVoices = [] + self?.tableView.reloadData() + }) + } + + private func selectVoice(_ voice: CustomVoiceItem) { + VoiceMessageConfig.shared.setSelectedVoice(id: voice.voiceId, name: voice.name) + selectedId = voice.voiceId + tableView.reloadData() + } + + private func deleteVoice(_ voice: CustomVoiceItem) { + AiMediaProcessManager.deleteCustomVoice(voiceId: voice.voiceId, onSuccess: { [weak self] in + guard let self = self else { return } + self.customVoices.removeAll { $0.voiceId == voice.voiceId } + if self.selectedId == voice.voiceId { + VoiceMessageConfig.shared.setSelectedVoice(id: "", name: "") + self.selectedId = "" + } + self.tableView.reloadData() + }, onFailure: { [weak self] _, _ in + let alert = UIAlertController(title: LocalizedChatString("VoiceDeleteFailed"), message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: LocalizedChatString("AlertConfirm"), style: .default)) + self?.present(alert, animated: true) + }) + } +} + +extension VoiceSelectViewController: UITableViewDataSource, UITableViewDelegate { + + func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == 0 { + return defaultVoices.count + } + return max(customVoices.count, isLoading ? 0 : 1) + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return section == 0 ? LocalizedChatString("VoiceDefaultGroup") : LocalizedChatString("VoiceCustomGroup") + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "VoiceCell", for: indexPath) + let colors = ChatUIKitTheme.colors + cell.backgroundColor = colors.bgColorOperate + cell.selectionStyle = .none + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + if indexPath.section == 1 && customVoices.isEmpty && !isLoading { + let emptyLabel = UILabel() + emptyLabel.text = LocalizedChatString("VoiceCustomEmpty") + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = colors.textColorTertiary + cell.contentView.addSubview(emptyLabel) + emptyLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + } + return cell + } + let voice = indexPath.section == 0 ? defaultVoices[indexPath.row] : customVoices[indexPath.row] + let nameLabel = UILabel() + nameLabel.text = voice.name + nameLabel.font = .systemFont(ofSize: 16) + nameLabel.textColor = colors.textColorPrimary + nameLabel.lineBreakMode = .byTruncatingTail + cell.contentView.addSubview(nameLabel) + nameLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(12) + make.bottom.equalToSuperview().offset(-12) + } + var trailingView: UIView = nameLabel + if indexPath.section == 1 { + let badgeLabel = UILabel() + badgeLabel.text = LocalizedChatString("VoiceCustomBadge") + badgeLabel.font = .systemFont(ofSize: Self.badgeFontSize) + badgeLabel.textColor = colors.textColorButton + badgeLabel.backgroundColor = colors.buttonColorPrimaryDefault + badgeLabel.layer.cornerRadius = Self.badgeCornerRadius + badgeLabel.layer.masksToBounds = true + let idLabel = UILabel() + idLabel.text = voice.voiceId + idLabel.font = .systemFont(ofSize: 12) + idLabel.textColor = colors.textColorTertiary + idLabel.lineBreakMode = .byTruncatingTail + cell.contentView.addSubview(badgeLabel) + cell.contentView.addSubview(idLabel) + badgeLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel.snp.trailing).offset(8) + make.centerY.equalToSuperview() + } + idLabel.snp.makeConstraints { make in + make.leading.equalTo(badgeLabel.snp.trailing).offset(8) + make.centerY.equalToSuperview() + } + trailingView = idLabel + } + if voice.voiceId == selectedId { + let checkmark = UIImageView(image: UIImage(systemName: "checkmark")) + checkmark.tintColor = colors.textColorLink + cell.contentView.addSubview(checkmark) + checkmark.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(trailingView.snp.trailing).offset(8) + } + } + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if indexPath.section == 1 && customVoices.isEmpty { + return + } + let voice = indexPath.section == 0 ? defaultVoices[indexPath.row] : customVoices[indexPath.row] + selectVoice(voice) + } + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard indexPath.section == 1, !customVoices.isEmpty else { return nil } + let voice = customVoices[indexPath.row] + let deleteAction = UIContextualAction(style: .destructive, title: LocalizedChatString("VoiceDelete")) { [weak self] _, _, completion in + self?.deleteVoice(voice) + completion(true) + } + return UISwipeActionsConfiguration(actions: [deleteAction]) + } +} + +// MARK: - 音色克隆 + +private final class WaveformView: UIView { + private static let barCount = 24 + + private static let barWidth: CGFloat = 3 + + private static let barSpacing: CGFloat = 3 + + private static let barCornerRadius: CGFloat = 1.5 + + private static let maxBarHeight: CGFloat = 36 + + private static let minBarHeight: CGFloat = 3 + + private var bars: [UIView] = [] + + private var currentSamples: [CGFloat] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 12 + backgroundColor = ChatUIKitTheme.colors.bgColorInput + for _ in 0 ..< Self.barCount { + let bar = UIView() + bar.backgroundColor = ChatUIKitTheme.colors.buttonColorPrimaryDefault + bar.layer.cornerRadius = Self.barCornerRadius + addSubview(bar) + bars.append(bar) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + relayoutBars() + } + + func updateSamples(_ samples: [CGFloat]) { + currentSamples = samples + relayoutBars() + } + + private func relayoutBars() { + guard bounds.width > 0, bounds.height > 0 else { return } + let centerY = bounds.height / 2 + let totalWidth = CGFloat(Self.barCount) * Self.barWidth + CGFloat(Self.barCount - 1) * Self.barSpacing + let startX = (bounds.width - totalWidth) / 2 + for (index, bar) in bars.enumerated() { + let sample = index < currentSamples.count ? currentSamples[index] : 0.1 + let height = max(Self.minBarHeight, sample * Self.maxBarHeight) + let x = startX + CGFloat(index) * (Self.barWidth + Self.barSpacing) + bar.frame = CGRect(x: x, y: centerY - height / 2, width: Self.barWidth, height: height) + } + } +} + +final class VoiceCloneViewController: UIViewController { + private static let minCloneSeconds = 3 + + private static let maxCloneMs = 30000 + + private static let recordButtonSize: CGFloat = 64 + + private static let horizontalPadding: CGFloat = 16 + + private let scrollView = UIScrollView() + + private let contentStack = UIStackView() + + private let tipLabel = UILabel() + + private let readingTitleLabel = UILabel() + + private let sampleLabel = UILabel() + + private let waveformView = WaveformView() + + private let timeLabel = UILabel() + + private let recordButton = UIButton(type: .custom) + + private let statusLabel = UILabel() + + private let authTipLabel = UILabel() + + private let nameField = UITextField() + + private let submitButton = UIButton(type: .custom) + + private let submitIndicator = UIActivityIndicatorView(style: .medium) + + private var isRecording = false + + private var isSubmitting = false + + private var recordedPath: String? + + private var durationMs = 0 + + private var powerSamples: [CGFloat] = Array(repeating: 0.1, count: 24) + + private var statusKey = "VoiceCloneStart" + + private var waveformTimer: Timer? + + private var latestRealPower: CGFloat? + + private var waveformPhase: CGFloat = 0 + + private var canSubmit: Bool { + return recordedPath != nil && !isSubmitting && !isRecording + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + let header = SettingSubPageHeaderView(title: LocalizedChatString("VoiceClone")) { [weak self] in + self?.cancelRecordingIfNeeded() + self?.navigationController?.popViewController(animated: true) + } + view.addSubview(header) + view.addSubview(scrollView) + header.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + scrollView.addSubview(contentStack) + contentStack.axis = .vertical + contentStack.spacing = 16 + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 20, left: Self.horizontalPadding, bottom: 20, right: Self.horizontalPadding)) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-Self.horizontalPadding * 2) + } + buildContent() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + cancelRecordingIfNeeded() + } + + private func buildContent() { + let colors = ChatUIKitTheme.colors + tipLabel.text = LocalizedChatString("VoiceCloneTip") + tipLabel.font = .systemFont(ofSize: 14) + tipLabel.textColor = colors.textColorSecondary + tipLabel.numberOfLines = 0 + readingTitleLabel.text = LocalizedChatString("VoiceCloneReadingTitle") + readingTitleLabel.font = .systemFont(ofSize: 16) + readingTitleLabel.textColor = colors.textColorPrimary + readingTitleLabel.textAlignment = .center + readingTitleLabel.numberOfLines = 0 + sampleLabel.text = "“\(LocalizedChatString("VoiceCloneSample"))”" + sampleLabel.font = .systemFont(ofSize: 16) + sampleLabel.textColor = colors.textColorPrimary + sampleLabel.textAlignment = .center + sampleLabel.numberOfLines = 0 + waveformView.snp.makeConstraints { make in + make.height.equalTo(56) + } + waveformView.updateSamples(powerSamples) + timeLabel.text = "00:00" + timeLabel.font = .monospacedDigitSystemFont(ofSize: 16, weight: .medium) + timeLabel.textColor = colors.textColorPrimary + timeLabel.textAlignment = .center + recordButton.setImage(UIImage(systemName: "mic.fill"), for: .normal) + recordButton.tintColor = colors.textColorButton + recordButton.backgroundColor = colors.buttonColorPrimaryDefault + recordButton.layer.cornerRadius = Self.recordButtonSize / 2 + recordButton.snp.makeConstraints { make in + make.width.height.equalTo(Self.recordButtonSize) + } + recordButton.addTarget(self, action: #selector(toggleRecord), for: .touchUpInside) + let recordButtonContainer = UIView() + recordButtonContainer.addSubview(recordButton) + recordButton.snp.makeConstraints { make in + make.center.equalToSuperview() + make.top.bottom.equalToSuperview() + } + statusLabel.font = .systemFont(ofSize: 13) + statusLabel.textColor = colors.textColorSecondary + statusLabel.textAlignment = .center + statusLabel.text = LocalizedChatString(statusKey) + authTipLabel.text = LocalizedChatString("VoiceCloneAuthTip") + authTipLabel.font = .systemFont(ofSize: 12) + authTipLabel.textColor = colors.textColorTertiary + authTipLabel.numberOfLines = 0 + nameField.placeholder = LocalizedChatString("VoiceCloneNameHint") + nameField.font = .systemFont(ofSize: 16) + nameField.textColor = colors.textColorPrimary + nameField.backgroundColor = colors.bgColorInput + nameField.layer.cornerRadius = 8 + nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + nameField.leftViewMode = .always + nameField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0)) + nameField.rightViewMode = .always + nameField.returnKeyType = .done + nameField.delegate = self + nameField.snp.makeConstraints { make in + make.height.equalTo(44) + } + submitButton.setTitle(LocalizedChatString("VoiceCloneSubmit"), for: .normal) + submitButton.setTitleColor(colors.textColorButton, for: .normal) + submitButton.titleLabel?.font = .systemFont(ofSize: 16) + submitButton.layer.cornerRadius = 8 + submitButton.snp.makeConstraints { make in + make.height.equalTo(44) + } + submitButton.addTarget(self, action: #selector(submit), for: .touchUpInside) + submitIndicator.color = colors.textColorButton + submitIndicator.hidesWhenStopped = true + submitButton.addSubview(submitIndicator) + submitIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + refreshSubmitButton() + + contentStack.addArrangedSubview(tipLabel) + contentStack.addArrangedSubview(readingTitleLabel) + contentStack.addArrangedSubview(sampleLabel) + contentStack.addArrangedSubview(waveformView) + contentStack.addArrangedSubview(timeLabel) + contentStack.addArrangedSubview(recordButtonContainer) + contentStack.addArrangedSubview(statusLabel) + contentStack.addArrangedSubview(authTipLabel) + contentStack.addArrangedSubview(nameField) + contentStack.addArrangedSubview(submitButton) + + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + backgroundTap.cancelsTouchesInView = false + scrollView.addGestureRecognizer(backgroundTap) + } + + private func refreshSubmitButton() { + let colors = ChatUIKitTheme.colors + submitButton.isEnabled = canSubmit + submitButton.backgroundColor = canSubmit ? colors.buttonColorPrimaryDefault : colors.buttonColorPrimaryDisabled + } + + private func refreshRecordButton() { + let colors = ChatUIKitTheme.colors + recordButton.setImage(UIImage(systemName: isRecording ? "stop.fill" : "mic.fill"), for: .normal) + recordButton.backgroundColor = isRecording ? colors.textColorError : colors.buttonColorPrimaryDefault + } + + private func updateTimeLabel() { + let totalSec = durationMs / 1000 + timeLabel.text = String(format: "%02d:%02d", totalSec / 60, totalSec % 60) + } + + @objc private func handleBackgroundTapped() { + view.endEditing(true) + } + + @objc private func toggleRecord() { + guard !isSubmitting else { return } + if isRecording { + AudioRecorder.sharedRecorder.stopRecord() + return + } + recordedPath = nil + durationMs = 0 + powerSamples = Array(repeating: 0.1, count: 24) + latestRealPower = nil + waveformPhase = 0 + updateTimeLabel() + waveformView.updateSamples(powerSamples) + refreshSubmitButton() + let recorder = AudioRecorder.sharedRecorder + recorder.onRecordTime = { [weak self] timeMs in + DispatchQueue.main.async { + self?.durationMs = timeMs + self?.updateTimeLabel() + } + } + recorder.onPowerLevel = { [weak self] power in + self?.latestRealPower = min(1, max(0.1, CGFloat(power + 60) / 60)) + } + recorder.onRecordingComplete = { [weak self] resultCode, filePath, durationSec in + DispatchQueue.main.async { + self?.isRecording = false + self?.stopWaveformTimer() + self?.refreshRecordButton() + self?.handleRecordComplete(resultCode: resultCode, filePath: filePath, durationSec: durationSec) + } + } + isRecording = true + refreshRecordButton() + statusKey = "VoiceCloneStop" + statusLabel.text = LocalizedChatString(statusKey) + startWaveformTimer() + recorder.startRecord(minDurationMs: Self.minCloneSeconds * 1000, maxDurationMs: Self.maxCloneMs) + } + + private func startWaveformTimer() { + stopWaveformTimer() + let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.waveformPhase += 0.6 + let next: CGFloat + if let realPower = self.latestRealPower { + next = realPower + } else { + let sine = (sin(self.waveformPhase) + 1) / 2 + let jitter = CGFloat.random(in: -0.15 ... 0.15) + next = min(1, max(0.1, 0.25 + sine * 0.6 + jitter)) + } + self.powerSamples.append(next) + if self.powerSamples.count > 24 { + self.powerSamples.removeFirst(self.powerSamples.count - 24) + } + self.waveformView.updateSamples(self.powerSamples) + } + } + RunLoop.main.add(timer, forMode: .common) + waveformTimer = timer + } + + private func stopWaveformTimer() { + waveformTimer?.invalidate() + waveformTimer = nil + latestRealPower = nil + } + + private func handleRecordComplete(resultCode: AudioRecordResultCode, filePath: String, durationSec: Int) { + switch resultCode { + case .success, .exceedMaxDuration: + if durationSec >= Self.minCloneSeconds && !filePath.isEmpty { + recordedPath = filePath + durationMs = durationSec * 1000 + statusKey = "VoiceCloneDone" + } else { + resetRecordingState() + showAlert(LocalizedChatString("VoiceCloneTooShortMessage")) + } + case .errorLessThanMinDuration: + resetRecordingState() + showAlert(LocalizedChatString("VoiceCloneTooShortMessage")) + case .errorRecordPermissionDenied: + resetRecordingState() + showAlert(LocalizedChatString("VoiceClonePermissionDenied")) + case .errorCancel: + resetRecordingState() + default: + resetRecordingState() + showAlert(LocalizedChatString("VoiceCloneRecordFailed")) + } + statusLabel.text = LocalizedChatString(statusKey) + updateTimeLabel() + refreshSubmitButton() + } + + private func resetRecordingState() { + recordedPath = nil + durationMs = 0 + statusKey = "VoiceCloneStart" + } + + private func cancelRecordingIfNeeded() { + guard isRecording else { return } + AudioRecorder.sharedRecorder.cancelRecord() + isRecording = false + stopWaveformTimer() + refreshRecordButton() + } + + @objc private func submit() { + guard let path = recordedPath, !path.isEmpty else { + showAlert(LocalizedChatString("VoiceCloneEmptyRecord")) + return + } + guard !isSubmitting else { return } + let trimmedName = (nameField.text ?? "").trimmingCharacters(in: .whitespaces) + let name = trimmedName.isEmpty ? LocalizedChatString("VoiceCloneDefaultName") : trimmedName + isSubmitting = true + submitButton.setTitle(nil, for: .normal) + submitIndicator.startAnimating() + refreshSubmitButton() + AiMediaProcessManager.voiceClone(filePath: path, voiceName: name, onSuccess: { [weak self] voiceId in + guard let self = self else { return } + self.isSubmitting = false + self.submitIndicator.stopAnimating() + self.submitButton.setTitle(LocalizedChatString("VoiceCloneSubmit"), for: .normal) + self.refreshSubmitButton() + VoiceMessageConfig.shared.setSelectedVoice(id: voiceId, name: name) + let alert = UIAlertController( + title: LocalizedChatString("VoiceCloneSuccessTitle"), + message: LocalizedChatString("VoiceCloneSuccessMessage"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("AlertConfirm"), style: .default) { _ in + self.navigationController?.popViewController(animated: true) + }) + self.present(alert, animated: true) + }, onFailure: { [weak self] _, _ in + guard let self = self else { return } + self.isSubmitting = false + self.submitIndicator.stopAnimating() + self.submitButton.setTitle(LocalizedChatString("VoiceCloneSubmit"), for: .normal) + self.refreshSubmitButton() + self.showAlert(LocalizedChatString("VoiceCloneFailed")) + }) + } + + private func showAlert(_ message: String) { + let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: LocalizedChatString("AlertConfirm"), style: .default)) + present(alert, animated: true) + } +} + +extension VoiceCloneViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/chat/demo/ChatDemo/RootViewController.swift b/chat/demo/ChatDemo/RootViewController.swift new file mode 100644 index 000000000..f1a649c21 --- /dev/null +++ b/chat/demo/ChatDemo/RootViewController.swift @@ -0,0 +1,162 @@ +import AtomicXCore +import ChatUIKit +import Combine +import UIKit + +final class DemoLanguageManager { + static let shared = DemoLanguageManager() + + static let languageDidChangeNotification = Notification.Name("DemoLanguageDidChangeNotification") + + private(set) var currentLanguage: String + + let supportedLanguages: [(code: String, nativeName: String)] = [ + ("en", "English"), + ("zh-Hans", "简体中文"), + ("zh-Hant", "繁體中文"), + ("ar", "العربية") + ] + + var isRTL: Bool { + ["ar", "he", "fa", "ur"].contains(currentLanguage) + } + + func setLanguage(_ code: String) { + guard code != currentLanguage else { return } + currentLanguage = code + LanguageHelper.saveLanguage(code) + NotificationCenter.default.post(name: Self.languageDidChangeNotification, object: nil) + } + + func currentLanguageName() -> String { + supportedLanguages.first(where: { $0.code == currentLanguage })?.nativeName ?? "English" + } + + private init() { + currentLanguage = LanguageHelper.getCurrentLanguage() + } +} + +final class RootViewController: UIViewController { + private var currentChild: UIViewController? + + private var cancellables = Set() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + applyLayoutDirection() + bindLoginStatus() + bindLanguageChange() + bindPrimaryColorChange() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + applyLayoutDirection() + } + + private func bindLoginStatus() { + let initial = LoginStore.shared.state.value.loginStatus + switchRoot(for: initial) + LoginStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \LoginState.loginStatus)) + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.switchRoot(for: status) + } + .store(in: &cancellables) + } + + private func bindLanguageChange() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleLanguageDidChange), + name: DemoLanguageManager.languageDidChangeNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAutoLoginDidFail), + name: AutoLoginViewController.didFailNotification, + object: nil + ) + } + + @objc private func handleAutoLoginDidFail() { + switchTo(LocalLoginViewController()) + } + + @objc private func handleLanguageDidChange() { + applyLayoutDirection() + guard currentChild is HomeTabBarController else { + if currentChild is LocalLoginViewController { + switchTo(LocalLoginViewController(), rebuild: true) + } + return + } + switchTo(HomeTabBarController(), rebuild: true) + } + + private func bindPrimaryColorChange() { + ThemeState.shared.$currentTheme + .map(\.primaryColor) + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.rebuildCurrentChild() + } + .store(in: &cancellables) + } + + private func rebuildCurrentChild() { + if currentChild is HomeTabBarController { + switchTo(HomeTabBarController(), rebuild: true) + } else if currentChild is LocalLoginViewController { + switchTo(LocalLoginViewController(), rebuild: true) + } + } + + private func applyLayoutDirection() { + let attribute: UISemanticContentAttribute = DemoLanguageManager.shared.isRTL ? .forceRightToLeft : .forceLeftToRight + view.window?.semanticContentAttribute = attribute + view.semanticContentAttribute = attribute + } + + private func switchRoot(for status: LoginStatus) { + switch status { + case .logined: + if !(currentChild is HomeTabBarController) { + switchTo(HomeTabBarController()) + } + case .unlogin: + if currentChild is AutoLoginViewController { + return + } + if AutoLoginViewController.hasSavedCredentials { + switchTo(AutoLoginViewController()) + } else if !(currentChild is LocalLoginViewController) { + switchTo(LocalLoginViewController()) + } + } + } + + private func switchTo(_ viewController: UIViewController, rebuild: Bool = false) { + if !rebuild, type(of: viewController) == type(of: currentChild ?? UIViewController()) { + return + } + let previous = currentChild + addChild(viewController) + viewController.view.frame = view.bounds + viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(viewController.view) + viewController.didMove(toParent: self) + currentChild = viewController + if let previous = previous { + previous.willMove(toParent: nil) + previous.view.removeFromSuperview() + previous.removeFromParent() + } + } +} diff --git a/chat/demo/ChatDemo/SceneDelegate.swift b/chat/demo/ChatDemo/SceneDelegate.swift index 9e16fb8c2..7cacfbb1c 100644 --- a/chat/demo/ChatDemo/SceneDelegate.swift +++ b/chat/demo/ChatDemo/SceneDelegate.swift @@ -1,20 +1,13 @@ -import SwiftUI import UIKit -class SceneDelegate: UIResponder, UIWindowSceneDelegate { +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - if let windowScene = scene as? UIWindowScene { - let window = UIWindow(windowScene: windowScene) - window.rootViewController = UIHostingController(rootView: ContentView()) - self.window = window - window.makeKeyAndVisible() - } + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + window.rootViewController = RootViewController() + window.makeKeyAndVisible() + self.window = window } - - func sceneDidDisconnect(_ scene: UIScene) {} - func sceneDidBecomeActive(_ scene: UIScene) {} - func sceneWillResignActive(_ scene: UIScene) {} - func sceneWillEnterForeground(_ scene: UIScene) {} - func sceneDidEnterBackground(_ scene: UIScene) {} } diff --git a/chat/demo/ChatDemo/StyleSettings.swift b/chat/demo/ChatDemo/StyleSettings.swift deleted file mode 100644 index 9913662de..000000000 --- a/chat/demo/ChatDemo/StyleSettings.swift +++ /dev/null @@ -1,7 +0,0 @@ -import AtomicX -import SwiftUI - -class AppStyleSettings: ObservableObject { - @Published var MessageListConfigProtocol: MessageListConfigProtocol = ChatMessageListConfig() - @Published var MessageInputConfigProtocol: MessageInputConfigProtocol = ChatMessageInputConfig() -} diff --git a/chat/demo/ChatDemo/TUIKitDemoApp.swift b/chat/demo/ChatDemo/TUIKitDemoApp.swift deleted file mode 100644 index f21edef74..000000000 --- a/chat/demo/ChatDemo/TUIKitDemoApp.swift +++ /dev/null @@ -1,10 +0,0 @@ -import AtomicXCore -import SwiftUI - -struct TUIKitDemoApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/chat/demo/ChatDemo/appConfig.json b/chat/demo/ChatDemo/appConfig.json index 07f134253..45aa33101 100644 --- a/chat/demo/ChatDemo/appConfig.json +++ b/chat/demo/ChatDemo/appConfig.json @@ -1,7 +1,7 @@ { "theme": { "mode": "light", - "primaryColor": "#E65100" + "primaryColor": "" }, "messageList": { "alignment": "two-sided", diff --git a/chat/demo/ChatDemo/telephone_area_code.json b/chat/demo/ChatDemo/telephone_area_code.json new file mode 100644 index 000000000..f1e5e98d9 --- /dev/null +++ b/chat/demo/ChatDemo/telephone_area_code.json @@ -0,0 +1,1556 @@ +[ + { + "name": "Chinese Mainland", + "chineseName": "中国大陆", + "arabicName": "الصين الرئيسية", + "codeName": "CN", + "code": "86" + }, + { + "name": "Andorra", + "chineseName": "安道尔", + "arabicName": "أندورا", + "codeName": "AD", + "code": "376" + }, + { + "name": "United Arab Emirates", + "chineseName": "阿拉伯联合酋长国", + "arabicName": "الإمارات العربية المتحدة", + "codeName": "AE", + "code": "971" + }, + { + "name": "Afghanistan", + "chineseName": "阿富汗", + "arabicName": "أفغانستان", + "codeName": "AF", + "code": "93" + }, + { + "name": "Antigua and Barbuda", + "chineseName": "安提瓜和巴布达", + "arabicName": "أنتيغوا وبربودا", + "codeName": "AG", + "code": "1268" + }, + { + "name": "Anguilla", + "chineseName": "安圭拉", + "arabicName": "أنغويلا", + "codeName": "AI", + "code": "1264" + }, + { + "name": "Albania", + "chineseName": "阿尔巴尼亚", + "arabicName": "ألبانيا", + "codeName": "AL", + "code": "355" + }, + { + "name": "Armenia", + "chineseName": "亚美尼亚", + "arabicName": "أرمينيا", + "codeName": "AM", + "code": "374" + }, + { + "name": "Angola", + "chineseName": "安哥拉", + "arabicName": "أنغولا", + "codeName": "AO", + "code": "244" + }, + { + "name": "Argentina", + "chineseName": "阿根廷", + "arabicName": "الأرجنتين", + "codeName": "AR", + "code": "54" + }, + { + "name": "American Samoa", + "chineseName": "美属萨摩亚", + "arabicName": "ساموا الأمريكية", + "codeName": "AS", + "code": "1684" + }, + { + "name": "Austria", + "chineseName": "奥地利", + "arabicName": "النمسا", + "codeName": "AT", + "code": "43" + }, + { + "name": "Australia", + "chineseName": "澳大利亚", + "arabicName": "أستراليا", + "codeName": "AU", + "code": "61" + }, + { + "name": "Aruba", + "chineseName": "阿鲁巴", + "arabicName": "أروبا", + "codeName": "AW", + "code": "297" + }, + { + "name": "Azerbaijan", + "chineseName": "阿塞拜疆", + "arabicName": "أذربيجان", + "codeName": "AZ", + "code": "994" + }, + { + "name": "Bosnia and Herzegovina", + "chineseName": "波斯尼亚和黑塞哥维那", + "arabicName": "البوسنة والهرسك", + "codeName": "BA", + "code": "387" + }, + { + "name": "Barbados", + "chineseName": "巴巴多斯", + "arabicName": "باربادوس", + "codeName": "BB", + "code": "1246" + }, + { + "name": "Bangladesh", + "chineseName": "孟加拉国", + "arabicName": "بنغلاديش", + "codeName": "BD", + "code": "880" + }, + { + "name": "Belgium", + "chineseName": "比利时", + "arabicName": "بلجيكا", + "codeName": "BE", + "code": "32" + }, + { + "name": "Burkina Faso", + "chineseName": "布基纳法索", + "arabicName": "بوركينا فاسو", + "codeName": "BF", + "code": "226" + }, + { + "name": "Bulgaria", + "chineseName": "保加利亚", + "arabicName": "بلغاريا", + "codeName": "BG", + "code": "359" + }, + { + "name": "Bahrain", + "chineseName": "巴林", + "arabicName": "البحرين", + "codeName": "BH", + "code": "973" + }, + { + "name": "Burundi", + "chineseName": "布隆迪", + "arabicName": "بوروندي", + "codeName": "BI", + "code": "257" + }, + { + "name": "Benin", + "chineseName": "贝宁", + "arabicName": "بنين", + "codeName": "BJ", + "code": "229" + }, + { + "name": "Bermuda", + "chineseName": "百慕大群岛", + "arabicName": "برمودا", + "codeName": "BM", + "code": "1441" + }, + { + "name": "Brunei", + "chineseName": "文莱", + "arabicName": "بروناي", + "codeName": "BN", + "code": "673" + }, + { + "name": "Bolivia", + "chineseName": "玻利维亚", + "arabicName": "بوليفيا", + "codeName": "BO", + "code": "591" + }, + { + "name": "Caribisch Nederland", + "chineseName": "荷兰加勒比", + "arabicName": "الجزر الكاريبية الهولندية", + "codeName": "BQ", + "code": "599" + }, + { + "name": "Brazil", + "chineseName": "巴西", + "arabicName": "البرازيل", + "codeName": "BR", + "code": "55" + }, + { + "name": "Bahamas", + "chineseName": "巴哈马", + "arabicName": "الباهاما", + "codeName": "BS", + "code": "1242" + }, + { + "name": "Bhutan", + "chineseName": "不丹", + "arabicName": "بوتان", + "codeName": "BT", + "code": "975" + }, + { + "name": "Botswana", + "chineseName": "博茨瓦纳", + "arabicName": "بوتسوانا", + "codeName": "BW", + "code": "267" + }, + { + "name": "Belarus", + "chineseName": "白俄罗斯", + "arabicName": "بيلاروسيا", + "codeName": "BY", + "code": "375" + }, + { + "name": "Belize", + "chineseName": "伯利兹", + "arabicName": "بليز", + "codeName": "BZ", + "code": "501" + }, + { + "name": "Canada", + "chineseName": "加拿大", + "arabicName": "كندا", + "codeName": "CA", + "code": "1" + }, + { + "name": "Democratic Republic of theCongo", + "chineseName": "刚果民主共和国", + "arabicName": "جمهورية الكونغو الديمقراطية", + "codeName": "CD", + "code": "243" + }, + { + "name": "Central African Republic", + "chineseName": "中非共和国", + "arabicName": "جمهورية أفريقيا الوسطى", + "codeName": "CF", + "code": "236" + }, + { + "name": "Republic Of The Congo", + "chineseName": "刚果共和国", + "arabicName": "جمهورية الكونغو", + "codeName": "CG", + "code": "242" + }, + { + "name": "Switzerland", + "chineseName": "瑞士", + "arabicName": "سويسرا", + "codeName": "CH", + "code": "41" + }, + { + "name": "Ivory Coast", + "chineseName": "科特迪瓦", + "arabicName": "ساحل العاج", + "codeName": "CI", + "code": "225" + }, + { + "name": "Cook Islands", + "chineseName": "库克群岛", + "arabicName": "جزر كوك", + "codeName": "CK", + "code": "682" + }, + { + "name": "Chile", + "chineseName": "智利", + "arabicName": "تشيلي", + "codeName": "CL", + "code": "56" + }, + { + "name": "Cameroon", + "chineseName": "喀麦隆", + "arabicName": "الكاميرون", + "codeName": "CM", + "code": "237" + }, + { + "name": "Colombia", + "chineseName": "哥伦比亚", + "arabicName": "كولومبيا", + "codeName": "CO", + "code": "57" + }, + { + "name": "CostaRica", + "chineseName": "哥斯达黎加", + "arabicName": "كوستاريكا", + "codeName": "CR", + "code": "506" + }, + { + "name": "Cape Verde", + "chineseName": "佛得角", + "arabicName": "الرأس الأخضر", + "codeName": "CV", + "code": "238" + }, + { + "name": "Curacao", + "chineseName": "库拉索", + "arabicName": "كوراساو", + "codeName": "CW", + "code": "599" + }, + { + "name": "Cyprus", + "chineseName": "塞浦路斯", + "arabicName": "قبرص", + "codeName": "CY", + "code": "357" + }, + { + "name": "Czech", + "chineseName": "捷克", + "arabicName": "جمهورية التشيك", + "codeName": "CZ", + "code": "420" + }, + { + "name": "Germany", + "chineseName": "德国", + "arabicName": "ألمانيا", + "codeName": "DE", + "code": "49" + }, + { + "name": "Djibouti", + "chineseName": "吉布提", + "arabicName": "جيبوتي", + "codeName": "DJ", + "code": "253" + }, + { + "name": "Denmark", + "chineseName": "丹麦", + "arabicName": "الدانمرك", + "codeName": "DK", + "code": "45" + }, + { + "name": "Dominica", + "chineseName": "多米尼加", + "arabicName": "دومينيكا", + "codeName": "DM", + "code": "1767" + }, + { + "name": "dominican republic", + "chineseName": "多米尼加共和国", + "arabicName": "جمهورية الدومينيكان", + "codeName": "DO", + "code": "1809" + }, + { + "name": "Algeria", + "chineseName": "阿尔及利亚", + "arabicName": "الجزائر", + "codeName": "DZ", + "code": "213" + }, + { + "name": "Ecuador", + "chineseName": "厄瓜多尔", + "arabicName": "الإكوادور", + "codeName": "EC", + "code": "593" + }, + { + "name": "Estonia", + "chineseName": "爱沙尼亚", + "arabicName": "إستونيا", + "codeName": "EE", + "code": "372" + }, + { + "name": "Egypt", + "chineseName": "埃及", + "arabicName": "مصر", + "codeName": "EG", + "code": "20" + }, + { + "name": "Eritrea", + "chineseName": "厄立特里亚", + "arabicName": "إريتريا", + "codeName": "ER", + "code": "291" + }, + { + "name": "Spain", + "chineseName": "西班牙", + "arabicName": "إسبانيا", + "codeName": "ES", + "code": "34" + }, + { + "name": "Ethiopia", + "chineseName": "埃塞俄比亚", + "arabicName": "إثيوبيا", + "codeName": "ET", + "code": "251" + }, + { + "name": "Finland", + "chineseName": "芬兰", + "arabicName": "فنلندا", + "codeName": "FI", + "code": "358" + }, + { + "name": "Fiji", + "chineseName": "斐济", + "arabicName": "فيجي", + "codeName": "FJ", + "code": "679" + }, + { + "name": "Micronesia", + "chineseName": "密克罗尼西亚", + "arabicName": "ميكرونيزيا", + "codeName": "FM", + "code": "691" + }, + { + "name": "Faroe Islands", + "chineseName": "法罗群岛", + "arabicName": "جزر فارو", + "codeName": "FO", + "code": "298" + }, + { + "name": "France", + "chineseName": "法国", + "arabicName": "فرنسا", + "codeName": "FR", + "code": "33" + }, + { + "name": "Gabon", + "chineseName": "加蓬", + "arabicName": "الغابون", + "codeName": "GA", + "code": "241" + }, + { + "name": "United Kingdom", + "chineseName": "英国", + "arabicName": "المملكة المتحدة", + "codeName": "GB", + "code": "44" + }, + { + "name": "Grenada", + "chineseName": "格林纳达", + "arabicName": "غرينادا", + "codeName": "GD", + "code": "1473" + }, + { + "name": "Georgia", + "chineseName": "格鲁吉亚", + "arabicName": "جورجيا", + "codeName": "GE", + "code": "995" + }, + { + "name": "French Guiana", + "chineseName": "法属圭亚那", + "arabicName": "غويانا الفرنسية", + "codeName": "GF", + "code": "594" + }, + { + "name": "Ghana", + "chineseName": "加纳", + "arabicName": "غانا", + "codeName": "GH", + "code": "233" + }, + { + "name": "Gibraltar", + "chineseName": "直布罗陀", + "arabicName": "جبل طارق", + "codeName": "GI", + "code": "350" + }, + { + "name": "Greenland", + "chineseName": "格陵兰岛", + "arabicName": "جرينلاند", + "codeName": "GL", + "code": "299" + }, + { + "name": "Gambia", + "chineseName": "冈比亚", + "arabicName": "غامبيا", + "codeName": "GM", + "code": "220" + }, + { + "name": "Guinea", + "chineseName": "几内亚", + "arabicName": "غينيا", + "codeName": "GN", + "code": "224" + }, + { + "name": "Guadeloupe", + "chineseName": "瓜德罗普岛", + "arabicName": "جوادلوب", + "codeName": "GP", + "code": "590" + }, + { + "name": "Equatorial Guinea", + "chineseName": "赤道几内亚", + "arabicName": "غينيا الاستوائية", + "codeName": "GQ", + "code": "240" + }, + { + "name": "Greece", + "chineseName": "希腊", + "arabicName": "اليونان", + "codeName": "GR", + "code": "30" + }, + { + "name": "Guatemala", + "chineseName": "危地马拉", + "arabicName": "غواتيمالا", + "codeName": "GT", + "code": "502" + }, + { + "name": "Guam", + "chineseName": "关岛", + "arabicName": "جوام", + "codeName": "GU", + "code": "1671" + }, + { + "name": "Guinea-Bissau", + "chineseName": "几内亚比绍共和国", + "arabicName": "غينيا بيساو", + "codeName": "GW", + "code": "245" + }, + { + "name": "Guyana", + "chineseName": "圭亚那", + "arabicName": "غيانا", + "codeName": "GY", + "code": "592" + }, + { + "name": "Hong Kong (China)", + "chineseName": "中国香港", + "arabicName": "هونغ كونغ الصينية", + "codeName": "HK", + "code": "852" + }, + { + "name": "Honduras", + "chineseName": "洪都拉斯", + "arabicName": "هندوراس", + "codeName": "HN", + "code": "504" + }, + { + "name": "Croatia", + "chineseName": "克罗地亚", + "arabicName": "كرواتيا", + "codeName": "HR", + "code": "385" + }, + { + "name": "Haiti", + "chineseName": "海地", + "arabicName": "هايتي", + "codeName": "HT", + "code": "509" + }, + { + "name": "Hungary", + "chineseName": "匈牙利", + "arabicName": "المجر", + "codeName": "HU", + "code": "36" + }, + { + "name": "Indonesia", + "chineseName": "印度尼西亚", + "arabicName": "إندونيسيا", + "codeName": "ID", + "code": "62" + }, + { + "name": "Ireland", + "chineseName": "爱尔兰", + "arabicName": "أيرلندا", + "codeName": "IE", + "code": "353" + }, + { + "name": "Israel", + "chineseName": "以色列", + "arabicName": "إسرائيل", + "codeName": "IL", + "code": "972" + }, + { + "name": "India", + "chineseName": "印度", + "arabicName": "الهند", + "codeName": "IN", + "code": "91" + }, + { + "name": "Iraq", + "chineseName": "伊拉克", + "arabicName": "العراق", + "codeName": "IQ", + "code": "964" + }, + { + "name": "Iceland", + "chineseName": "冰岛", + "arabicName": "أيسلندا", + "codeName": "IS", + "code": "354" + }, + { + "name": "Italy", + "chineseName": "意大利", + "arabicName": "إيطاليا", + "codeName": "IT", + "code": "39" + }, + { + "name": "Jamaica", + "chineseName": "牙买加", + "arabicName": "جامايكا", + "codeName": "JM", + "code": "1876" + }, + { + "name": "Jordan", + "chineseName": "约旦", + "arabicName": "الأردن", + "codeName": "JO", + "code": "962" + }, + { + "name": "Japan", + "chineseName": "日本", + "arabicName": "اليابان", + "codeName": "JP", + "code": "81" + }, + { + "name": "Kenya", + "chineseName": "肯尼亚", + "arabicName": "كينيا", + "codeName": "KE", + "code": "254" + }, + { + "name": "Kyrgyzstan", + "chineseName": "吉尔吉斯斯坦", + "arabicName": "قيرغيزستان", + "codeName": "KG", + "code": "996" + }, + { + "name": "Cambodia", + "chineseName": "柬埔寨", + "arabicName": "كمبوديا", + "codeName": "KH", + "code": "855" + }, + { + "name": "Kiribati", + "chineseName": "基里巴斯", + "arabicName": "كيريباتي", + "codeName": "KI", + "code": "686" + }, + { + "name": "Comoros", + "chineseName": "科摩罗", + "arabicName": "جزر القمر", + "codeName": "KM", + "code": "269" + }, + { + "name": "Saint Kitts and Nevis", + "chineseName": "圣基茨和尼维斯", + "arabicName": "سانت كيتس ونيفيس", + "codeName": "KN", + "code": "1869" + }, + { + "name": "South Korea", + "chineseName": "韩国", + "arabicName": "كوريا الجنوبية", + "codeName": "KR", + "code": "82" + }, + { + "name": "Kuwait", + "chineseName": "科威特", + "arabicName": "الكويت", + "codeName": "KW", + "code": "965" + }, + { + "name": "Cayman Islands", + "chineseName": "开曼群岛", + "arabicName": "جزر كايمان", + "codeName": "KY", + "code": "1345" + }, + { + "name": "Kazakhstan", + "chineseName": "哈萨克斯坦", + "arabicName": "كازاخستان", + "codeName": "KZ", + "code": "7" + }, + { + "name": "Laos", + "chineseName": "老挝", + "arabicName": "لاوس", + "codeName": "LA", + "code": "856" + }, + { + "name": "Lebanon", + "chineseName": "黎巴嫩", + "arabicName": "لبنان", + "codeName": "LB", + "code": "961" + }, + { + "name": "Saint Lucia", + "chineseName": "圣卢西亚", + "arabicName": "سانت لوسيا", + "codeName": "LC", + "code": "1758" + }, + { + "name": "Liechtenstein", + "chineseName": "列支敦士登", + "arabicName": "ليختنشتاين", + "codeName": "LI", + "code": "423" + }, + { + "name": "Sri Lanka", + "chineseName": "斯里兰卡", + "arabicName": "سريلانكا", + "codeName": "LK", + "code": "94" + }, + { + "name": "Liberia", + "chineseName": "利比里亚", + "arabicName": "ليبيريا", + "codeName": "LR", + "code": "231" + }, + { + "name": "Lesotho", + "chineseName": "莱索托", + "arabicName": "ليسوتو", + "codeName": "LS", + "code": "266" + }, + { + "name": "Lithuania", + "chineseName": "立陶宛", + "arabicName": "ليتوانيا", + "codeName": "LT", + "code": "370" + }, + { + "name": "Luxembourg", + "chineseName": "卢森堡", + "arabicName": "لوكسمبورغ", + "codeName": "LU", + "code": "352" + }, + { + "name": "Latvia", + "chineseName": "拉脱维亚", + "arabicName": "لاتفيا", + "codeName": "LV", + "code": "371" + }, + { + "name": "Libya", + "chineseName": "利比亚", + "arabicName": "ليبيا", + "codeName": "LY", + "code": "218" + }, + { + "name": "Morocco", + "chineseName": "摩洛哥", + "arabicName": "المغرب", + "codeName": "MA", + "code": "212" + }, + { + "name": "Monaco", + "chineseName": "摩纳哥", + "arabicName": "موناكو", + "codeName": "MC", + "code": "377" + }, + { + "name": "Moldova", + "chineseName": "摩尔多瓦", + "arabicName": "مولدوفا", + "codeName": "MD", + "code": "373" + }, + { + "name": "Montenegro", + "chineseName": "黑山", + "arabicName": "الجبل الأسود", + "codeName": "ME", + "code": "382" + }, + { + "name": "Madagascar", + "chineseName": "马达加斯加", + "arabicName": "مدغشقر", + "codeName": "MG", + "code": "261" + }, + { + "name": "Marshall Islands", + "chineseName": "马绍尔群岛", + "arabicName": "جزر مارشال", + "codeName": "MH", + "code": "692" + }, + { + "name": "Macedonia", + "chineseName": "马其顿", + "arabicName": "مقدونيا", + "codeName": "MK", + "code": "389" + }, + { + "name": "Mali", + "chineseName": "马里", + "arabicName": "مالي", + "codeName": "ML", + "code": "223" + }, + { + "name": "Myanmar", + "chineseName": "缅甸", + "arabicName": "ميانمار", + "codeName": "MM", + "code": "95" + }, + { + "name": "Mongolia", + "chineseName": "蒙古", + "arabicName": "منغوليا", + "codeName": "MN", + "code": "976" + }, + { + "name": "Macau (China)", + "chineseName": "中国澳门", + "arabicName": "ماكاو الصينية", + "codeName": "MO", + "code": "853" + }, + { + "name": "Mauritania", + "chineseName": "毛里塔尼亚", + "arabicName": "موريتانيا", + "codeName": "MR", + "code": "222" + }, + { + "name": "Montserrat", + "chineseName": "蒙特塞拉特岛", + "arabicName": "مونتسيرات", + "codeName": "MS", + "code": "1664" + }, + { + "name": "Malta", + "chineseName": "马耳他", + "arabicName": "مالطا", + "codeName": "MT", + "code": "356" + }, + { + "name": "Mauritius", + "chineseName": "毛里求斯", + "arabicName": "موريشيوس", + "codeName": "MU", + "code": "230" + }, + { + "name": "Maldives", + "chineseName": "马尔代夫", + "arabicName": "جزر المالديف", + "codeName": "MV", + "code": "960" + }, + { + "name": "Malawi", + "chineseName": "马拉维", + "arabicName": "ملاوي", + "codeName": "MW", + "code": "265" + }, + { + "name": "Mexico", + "chineseName": "墨西哥", + "arabicName": "المكسيك", + "codeName": "MX", + "code": "52" + }, + { + "name": "Malaysia", + "chineseName": "马来西亚", + "arabicName": "ماليزيا", + "codeName": "MY", + "code": "60" + }, + { + "name": "Mozambique", + "chineseName": "莫桑比克", + "arabicName": "موزمبيق", + "codeName": "MZ", + "code": "258" + }, + { + "name": "Namibia", + "chineseName": "纳米比亚", + "arabicName": "ناميبيا", + "codeName": "NA", + "code": "264" + }, + { + "name": "New Caledonia", + "chineseName": "新喀里多尼亚", + "arabicName": "كاليدونيا الجديدة", + "codeName": "NC", + "code": "687" + }, + { + "name": "Niger", + "chineseName": "尼日尔", + "arabicName": "النيجر", + "codeName": "NE", + "code": "227" + }, + { + "name": "Nigeria", + "chineseName": "尼日利亚", + "arabicName": "نيجيريا", + "codeName": "NG", + "code": "234" + }, + { + "name": "Nicaragua", + "chineseName": "尼加拉瓜", + "arabicName": "نيكاراغوا", + "codeName": "NI", + "code": "505" + }, + { + "name": "Netherlands", + "chineseName": "荷兰", + "arabicName": "هولندا", + "codeName": "NL", + "code": "31" + }, + { + "name": "Norway", + "chineseName": "挪威", + "arabicName": "النرويج", + "codeName": "NO", + "code": "47" + }, + { + "name": "Nepal", + "chineseName": "尼泊尔", + "arabicName": "نيبال", + "codeName": "NP", + "code": "977" + }, + { + "name": "Nauru", + "chineseName": "瑙鲁", + "arabicName": "ناورو", + "codeName": "NR", + "code": "674" + }, + { + "name": "New Zealand", + "chineseName": "新西兰", + "arabicName": "نيوزيلندا", + "codeName": "NZ", + "code": "64" + }, + { + "name": "Oman", + "chineseName": "阿曼", + "arabicName": "عمان", + "codeName": "OM", + "code": "968" + }, + { + "name": "Panama", + "chineseName": "巴拿马", + "arabicName": "بنما", + "codeName": "PA", + "code": "507" + }, + { + "name": "Peru", + "chineseName": "秘鲁", + "arabicName": "بيرو", + "codeName": "PE", + "code": "51" + }, + { + "name": "French Polynesia", + "chineseName": "法属波利尼西亚", + "arabicName": "بولينيزيا الفرنسية", + "codeName": "PF", + "code": "689" + }, + { + "name": "Papua New Guinea", + "chineseName": "巴布亚新几内亚", + "arabicName": "بابوا غينيا الجديدة", + "codeName": "PG", + "code": "675" + }, + { + "name": "Philippines", + "chineseName": "菲律宾", + "arabicName": "الفلبين", + "codeName": "PH", + "code": "63" + }, + { + "name": "Pakistan", + "chineseName": "巴基斯坦", + "arabicName": "باكستان", + "codeName": "PK", + "code": "92" + }, + { + "name": "Poland", + "chineseName": "波兰", + "arabicName": "بولندا", + "codeName": "PL", + "code": "48" + }, + { + "name": "Saint Pierre and Miquelon", + "chineseName": "圣彼埃尔和密克隆岛", + "arabicName": "سان بيير وميكلون", + "codeName": "PM", + "code": "508" + }, + { + "name": "Puerto Rico", + "chineseName": "波多黎各", + "arabicName": "بورتوريكو", + "codeName": "PR", + "code": "1787" + }, + { + "name": "Portugal", + "chineseName": "葡萄牙", + "arabicName": "البرتغال", + "codeName": "PT", + "code": "351" + }, + { + "name": "Palau", + "chineseName": "帕劳", + "arabicName": "بالاو", + "codeName": "PW", + "code": "680" + }, + { + "name": "Paraguay", + "chineseName": "巴拉圭", + "arabicName": "باراغواي", + "codeName": "PY", + "code": "595" + }, + { + "name": "Qatar", + "chineseName": "卡塔尔", + "arabicName": "قطر", + "codeName": "QA", + "code": "974" + }, + { + "name": "Réunion Island", + "chineseName": "留尼汪", + "arabicName": "ريونيون", + "codeName": "RE", + "code": "262" + }, + { + "name": "Romania", + "chineseName": "罗马尼亚", + "arabicName": "رومانيا", + "codeName": "RO", + "code": "40" + }, + { + "name": "Serbia", + "chineseName": "塞尔维亚", + "arabicName": "صربيا", + "codeName": "RS", + "code": "381" + }, + { + "name": "Russia", + "chineseName": "俄罗斯", + "arabicName": "روسيا", + "codeName": "RU", + "code": "7" + }, + { + "name": "Rwanda", + "chineseName": "卢旺达", + "arabicName": "رواندا", + "codeName": "RW", + "code": "250" + }, + { + "name": "Saudi Arabia", + "chineseName": "沙特阿拉伯", + "arabicName": "السعودية", + "codeName": "SA", + "code": "966" + }, + { + "name": "Solomon Islands", + "chineseName": "所罗门群岛", + "arabicName": "جزر سليمان", + "codeName": "SB", + "code": "677" + }, + { + "name": "Seychelles", + "chineseName": "塞舌尔", + "arabicName": "سيشل", + "codeName": "SC", + "code": "248" + }, + { + "name": "Sudan", + "chineseName": "苏丹", + "arabicName": "السودان", + "codeName": "SD", + "code": "249" + }, + { + "name": "Sweden", + "chineseName": "瑞典", + "arabicName": "السويد", + "codeName": "SE", + "code": "46" + }, + { + "name": "Singapore", + "chineseName": "新加坡", + "arabicName": "سنغافورة", + "codeName": "SG", + "code": "65" + }, + { + "name": "Slovenia", + "chineseName": "斯洛文尼亚", + "arabicName": "سلوفينيا", + "codeName": "SI", + "code": "386" + }, + { + "name": "Slovakia", + "chineseName": "斯洛伐克", + "arabicName": "سلوفاكيا", + "codeName": "SK", + "code": "421" + }, + { + "name": "Sierra Leone", + "chineseName": "塞拉利昂", + "arabicName": "سيراليون", + "codeName": "SL", + "code": "232" + }, + { + "name": "San Marino", + "chineseName": "圣马力诺", + "arabicName": "سان مارينو", + "codeName": "SM", + "code": "378" + }, + { + "name": "Senegal", + "chineseName": "塞内加尔", + "arabicName": "السنغال", + "codeName": "SN", + "code": "221" + }, + { + "name": "Somalia", + "chineseName": "索马里", + "arabicName": "الصومال", + "codeName": "SO", + "code": "252" + }, + { + "name": "Suriname", + "chineseName": "苏里南", + "arabicName": "سورينام", + "codeName": "SR", + "code": "597" + }, + { + "name": "Sao Tome and Principe", + "chineseName": "圣多美和普林西比", + "arabicName": "ساو تومي وبرينسيبي", + "codeName": "ST", + "code": "239" + }, + { + "name": "ElSalvador", + "chineseName": "萨尔瓦多", + "arabicName": "السلفادور", + "codeName": "SV", + "code": "503" + }, + { + "name": "Swaziland", + "chineseName": "斯威士兰", + "arabicName": "سوازيلاند", + "codeName": "SZ", + "code": "268" + }, + { + "name": "Turks and Caicos Islands", + "chineseName": "特克斯和凯科斯群岛", + "arabicName": "جزر توركس وكايكوس", + "codeName": "TC", + "code": "1649" + }, + { + "name": "Chad", + "chineseName": "乍得", + "arabicName": "تشاد", + "codeName": "TD", + "code": "235" + }, + { + "name": "Togo", + "chineseName": "多哥", + "arabicName": "توغو", + "codeName": "TG", + "code": "228" + }, + { + "name": "Thailand", + "chineseName": "泰国", + "arabicName": "تايلاند", + "codeName": "TH", + "code": "66" + }, + { + "name": "Tajikistan", + "chineseName": "塔吉克斯坦", + "arabicName": "طاجيكستان", + "codeName": "TJ", + "code": "992" + }, + { + "name": "East Timor", + "chineseName": "东帝汶", + "arabicName": "تيمور الشرقية", + "codeName": "TL", + "code": "670" + }, + { + "name": "Turkmenistan", + "chineseName": "土库曼斯坦", + "arabicName": "تركمانستان", + "codeName": "TM", + "code": "993" + }, + { + "name": "Tunisia", + "chineseName": "突尼斯", + "arabicName": "تونس", + "codeName": "TN", + "code": "216" + }, + { + "name": "Tonga", + "chineseName": "汤加", + "arabicName": "تونغا", + "codeName": "TO", + "code": "676" + }, + { + "name": "Turkey", + "chineseName": "土耳其", + "arabicName": "تركيا", + "codeName": "TR", + "code": "90" + }, + { + "name": "Trinidad and Tobago", + "chineseName": "特立尼达和多巴哥", + "arabicName": "ترينيداد وتوباغو", + "codeName": "TT", + "code": "1868" + }, + { + "name": "Taiwan (China)", + "chineseName": "中国台湾", + "arabicName": "تايوان الصينية", + "codeName": "TW", + "code": "886" + }, + { + "name": "Tanzania", + "chineseName": "坦桑尼亚", + "arabicName": "تنزانيا", + "codeName": "TZ", + "code": "255" + }, + { + "name": "Uganda", + "chineseName": "乌干达", + "arabicName": "أوغندا", + "codeName": "UG", + "code": "256" + }, + { + "name": "United States", + "chineseName": "美国", + "arabicName": "الولايات المتحدة", + "codeName": "US", + "code": "1" + }, + { + "name": "Uruguay", + "chineseName": "乌拉圭", + "arabicName": "أوروغواي", + "codeName": "UY", + "code": "598" + }, + { + "name": "Uzbekistan", + "chineseName": "乌兹别克斯坦", + "arabicName": "أوزبكستان", + "codeName": "UZ", + "code": "998" + }, + { + "name": "Saint Vincent and The Grenadines", + "chineseName": "圣文森特和格林纳丁斯", + "arabicName": "سانت فنسنت وجزر غرينادين", + "codeName": "VC", + "code": "1784" + }, + { + "name": "Venezuela", + "chineseName": "委内瑞拉", + "arabicName": "فنزويلا", + "codeName": "VE", + "code": "58" + }, + { + "name": "VirginIslands,British", + "chineseName": "英属维尔京群岛", + "arabicName": "جزر العذراء البريطانية", + "codeName": "VG", + "code": "1284" + }, + { + "name": "Vietnam", + "chineseName": "越南", + "arabicName": "فيتنام", + "codeName": "VN", + "code": "84" + }, + { + "name": "Vanuatu", + "chineseName": "瓦努阿图", + "arabicName": "فانواتو", + "codeName": "VU", + "code": "678" + }, + { + "name": "Samoa", + "chineseName": "萨摩亚", + "arabicName": "ساموا", + "codeName": "WS", + "code": "685" + }, + { + "name": "Yemen", + "chineseName": "也门", + "arabicName": "اليمن", + "codeName": "YE", + "code": "967" + }, + { + "name": "Mayotte", + "chineseName": "马约特", + "arabicName": "مايوت", + "codeName": "YT", + "code": "269" + }, + { + "name": "South Africa", + "chineseName": "南非", + "arabicName": "جنوب أفريقيا", + "codeName": "ZA", + "code": "27" + }, + { + "name": "Zambia", + "chineseName": "赞比亚", + "arabicName": "زامبيا", + "codeName": "ZM", + "code": "260" + }, + { + "name": "Zimbabwe", + "chineseName": "津巴布韦", + "arabicName": "زيمبابوي", + "codeName": "ZW", + "code": "263" + }, + { + "name": "Kosovo", + "chineseName": "科索沃共和国", + "arabicName": "كوسوفو", + "codeName": "XK", + "code": "383" + }, + { + "name": "Virgin Islands, US", + "chineseName": "美属维尔京群岛", + "arabicName": "جزر العذراء الأمريكية", + "codeName": "VI", + "code": "1340" + }, + { + "name": "Sint Maarten (Dutch Part)", + "chineseName": "荷属圣马丁", + "arabicName": "سانت مارتن الهولندية", + "codeName": "SX", + "code": "1721" + }, + { + "name": "South Sudan", + "chineseName": "南苏丹", + "arabicName": "جنوب السودان", + "codeName": "SS", + "code": "211" + }, + { + "name": "Palestinian Territory", + "chineseName": "巴勒斯坦", + "arabicName": "فلسطين", + "codeName": "PS", + "code": "970" + }, + { + "name": "Martinique", + "chineseName": "马提尼克", + "arabicName": "مارتينيك", + "codeName": "MQ", + "code": "596" + }, + { + "name": "Northern Mariana Islands", + "chineseName": "北马里亚纳群岛", + "arabicName": "جزر ماريانا الشمالية", + "codeName": "MP", + "code": "1670" + }, + { + "name": "Western Sahara", + "chineseName": "西撒哈拉", + "arabicName": "الصحراء الغربية", + "codeName": "EH", + "code": "212" + }, + { + "name": "Netherlands Antilles", + "chineseName": "荷属安的列斯", + "arabicName": "جزر الأنتيل الهولندية", + "codeName": "AN", + "code": "599" + }, + { + "name": "Ascension Island", + "chineseName": "阿森松岛", + "arabicName": "جزيرة أسينشيون", + "codeName": "AC", + "code": "247" + } +] \ No newline at end of file diff --git a/chat/demo/ChatDemo/verification.html b/chat/demo/ChatDemo/verification.html new file mode 100644 index 000000000..973d799a1 --- /dev/null +++ b/chat/demo/ChatDemo/verification.html @@ -0,0 +1,69 @@ + + + + + + Verification + + + + + + diff --git a/chat/demo/Podfile b/chat/demo/Podfile index 2c02e3359..2a49611ab 100644 --- a/chat/demo/Podfile +++ b/chat/demo/Podfile @@ -3,33 +3,13 @@ platform :ios, '15.0' target 'ChatDemo' do use_frameworks! :linkage => :static - pod 'AtomicXCore', :path => '../../../../../tuikit_engine/atomicx/swift/AtomicXCore' - pod 'AtomicX/Chat', :path => '../../../ios_swiftui/AtomicX.podspec' + #pod 'AtomicXCore', :path => '../../../../../tuikit_engine/atomicx/swift/AtomicXCore' pod 'ChatUIKit', :path => '../uikit/ChatUIKit.podspec' - pod 'RTCRoomEngine', :path => '../../../../../tuikit_engine/RTCRoomEngine.podspec' -# pod 'MessageList', :path => '../../atomic-x/Sources/MessageList' -# pod 'MessageInput', :path => '../../atomic-x/Sources/MessageInput' -# pod 'ConversationList', :path => '../../atomic-x/Sources/ConversationList' -# pod 'ContactList', :path => '../../atomic-x/Sources/ContactList' -# pod 'ChatSetting', :path => '../../atomic-x/Sources/ChatSetting' -# pod 'Search', :path => '../../atomic-x/Sources/Search' -# pod 'BaseComponent', :path => '../../atomic-x/Sources/BaseComponent' -# pod 'AudioPlayer', :path => '../../atomic-x/Sources/AudioPlayer' -# pod 'VideoPlayer', :path => '../../atomic-x/Sources/VideoPlayer' -# pod 'ImageViewer', :path => '../../atomic-x/Sources/ImageViewer' -# pod 'VideoPicker', :path => '../../atomic-x/Sources/VideoPicker' -# pod 'ImagePicker', :path => '../../atomic-x/Sources/ImagePicker' -# pod 'FilePicker', :path => '../../atomic-x/Sources/FilePicker' -# pod 'EmojiPicker', :path => '../../atomic-x/Sources/EmojiPicker' -# pod 'AudioRecorder', :path => '../../atomic-x/Sources/AudioRecorder' -# pod 'VideoRecorder', :path => '../../atomic-x/Sources/VideoRecorder' + pod 'TUICallKit_Swift', :path => '../../call/TUICallKit_Swift.podspec' + #pod 'RTCRoomEngine', :path => '../../../../../tuikit_engine/RTCRoomEngine.podspec' + pod 'TXIMSDK_Plus_iOS_XCFramework', '9.0.7661' -#测试 -#pod 'TXLiteAVSDK_Professional', :path => '/Users/lzmlsfe/Desktop/project/eddardliu/TXLiteAVSDK_Professional_IMUGC' - -# # 添加TXIMSDK_Plus_Swift_iOS_XCFramework依赖 -# pod 'TXIMSDK_Plus_Swift_iOS_XCFramework' -end +end #Pods config post_install do |installer| @@ -51,5 +31,6 @@ post_install do |installer| end end + end diff --git a/chat/demo/Podfile.lock b/chat/demo/Podfile.lock index 58bb19457..7ff82d911 100644 --- a/chat/demo/Podfile.lock +++ b/chat/demo/Podfile.lock @@ -1,66 +1,119 @@ PODS: - - AtomicX/Chat (1.0.0): + - AlbumPicker (1.0.8): + - AlbumPickerCore (= 1.0.8) + - AlbumPickerCore (1.0.8): + - TXIMSDK_Plus_iOS_XCFramework + - AtomicX (1.0.0): - AtomicXCore - - Kingfisher (~> 7.0) - - Masonry - - SnapKit - - AtomicXCore (3.0.5): + - Kingfisher - RTCRoomEngine/Professional + - SnapKit + - TUICore + - TXIMSDK_Plus_iOS_XCFramework (>= 8.8.7357) + - TXLiteAVSDK_Professional + - AtomicXCore (4.3.6): + - AtomicXCore/Core (= 4.3.6) + - RTCRoomEngine/Professional (>= 4.3.3) + - TXIMSDK_Plus_iOS_XCFramework (>= 9.0.7661) + - TXLiteAVSDK_Professional (>= 13.4.21067) + - AtomicXCore/Core (4.3.6): + - RTCRoomEngine/Professional (>= 4.3.3) + - TXIMSDK_Plus_iOS_XCFramework (>= 9.0.7661) + - TXLiteAVSDK_Professional (>= 13.4.21067) - ChatUIKit (1.0.0): - - AtomicX/Chat + - AlbumPicker - AtomicXCore - Kingfisher - Masonry - - Kingfisher (7.12.0) + - SnapKit + - TUICallKit_Swift + - Kingfisher (8.11.0) - Masonry (1.1.0) - - RTCRoomEngine (3.4.0): - - RTCRoomEngine/Professional (= 3.4.0) - - TXIMSDK_Plus_iOS_XCFramework - - TXLiteAVSDK_Professional (>= 11.7.15304) - - RTCRoomEngine/Professional (3.4.0): - - TXIMSDK_Plus_iOS_XCFramework - - TXLiteAVSDK_Professional (>= 11.7.15304) + - RTCRoomEngine/Professional (4.3.5): + - TXIMSDK_Plus_iOS_XCFramework (>= 8.4.6676) + - TXLiteAVSDK_Professional (>= 12.8.0.19666) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) - SnapKit (5.7.1) - - TXIMSDK_Plus_iOS_XCFramework (8.6.7040) - - TXLiteAVSDK_Professional (12.7.19272): - - TXLiteAVSDK_Professional/Professional (= 12.7.19272) - - TXLiteAVSDK_Professional/Professional (12.7.19272) + - TIMPush (9.0.7660): + - TXIMSDK_Plus_iOS_XCFramework (>= 9.0.7652) + - TUICallKit_Swift (1.0.0): + - AtomicX + - AtomicXCore + - SDWebImage + - SnapKit + - TUICallKit_Swift/Professional (= 1.0.0) + - TUICore + - TUICallKit_Swift/Professional (1.0.0): + - AtomicX + - AtomicXCore + - RTCRoomEngine/Professional + - SDWebImage + - SnapKit + - TUICore + - TXLiteAVSDK_Professional + - TUICore (9.0.7660): + - SDWebImage + - TUICore/ImSDK_Plus (= 9.0.7660) + - TUICore/Base (9.0.7660): + - SDWebImage + - TUICore/ImSDK_Plus (9.0.7660): + - SDWebImage + - TUICore/Base + - TXIMSDK_Plus_iOS_XCFramework + - TXIMSDK_Plus_iOS_XCFramework (9.0.7661) + - TXLiteAVSDK_Professional (13.4.21067): + - TXLiteAVSDK_Professional/Professional (= 13.4.21067) + - TXLiteAVSDK_Professional/Professional (13.4.21067) DEPENDENCIES: - - AtomicX/Chat (from `../../atomic-x/AtomicX.podspec`) - - AtomicXCore (from `../../../../../tuikit_engine/atomicx/swift/AtomicXCore`) + - AtomicX (from `../../atomic_x/AtomicX.podspec`) - ChatUIKit (from `../uikit/ChatUIKit.podspec`) - - RTCRoomEngine (from `../../../../../tuikit_engine/RTCRoomEngine.podspec`) + - TIMPush (= 9.0.7660) + - TUICallKit_Swift (from `../../call/TUICallKit_Swift.podspec`) + - TXIMSDK_Plus_iOS_XCFramework (= 9.0.7661) SPEC REPOS: trunk: + - AlbumPicker + - AlbumPickerCore + - AtomicXCore - Kingfisher - Masonry + - RTCRoomEngine + - SDWebImage - SnapKit + - TIMPush + - TUICore - TXIMSDK_Plus_iOS_XCFramework - TXLiteAVSDK_Professional EXTERNAL SOURCES: AtomicX: - :path: "../../atomic-x/AtomicX.podspec" - AtomicXCore: - :path: "../../../../../tuikit_engine/atomicx/swift/AtomicXCore" + :path: "../../atomic_x/AtomicX.podspec" ChatUIKit: :path: "../uikit/ChatUIKit.podspec" - RTCRoomEngine: - :path: "../../../../../tuikit_engine/RTCRoomEngine.podspec" + TUICallKit_Swift: + :path: "../../call/TUICallKit_Swift.podspec" SPEC CHECKSUMS: - AtomicX: c8b90d3121840f11479fb4761e0d7bb8cfc036a5 - AtomicXCore: b028201ff3bc52eb51afa55abef8dea55888a2cb - ChatUIKit: 484854eb3b8cdc27e2d59c897c4203643b1cf171 - Kingfisher: 53a10ea35051a436b5fb626ca2dd8f3144d755e9 + AlbumPicker: 812fc5e6aeba0c0877bbc125e030ca1efbfdf109 + AlbumPickerCore: 6b2819051b1b30e0764c271924f22181226cec14 + AtomicX: f85743c2e53fe41ebbe807a9a660b1020dbe9477 + AtomicXCore: f9663c0b57e86097fd1449792038c3e760e4eba1 + ChatUIKit: 58a94d786cc0339ee3b4a8d9f29409d78d38d3c0 + Kingfisher: ee4ff4936d5b399164b6d01a0dfc0388583287b6 Masonry: 678fab65091a9290e40e2832a55e7ab731aad201 - RTCRoomEngine: a44ed189a2a603835986014d8765c2268a9df6fb + RTCRoomEngine: a66491444169f35b2d0267bc0b08c07c0bf21a0f + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a - TXIMSDK_Plus_iOS_XCFramework: 36ea2cdb544315d79e520cd54d0bbb1fbb1110bc - TXLiteAVSDK_Professional: 11c510bf9fd5bb24c8065f913ab61284bdcdf4c6 + TIMPush: 1931db99bfafc819ee0d5bcea73ecff5c3040d29 + TUICallKit_Swift: 0188f845d3cdeddc4e3f4fc9d27496e986b1c91e + TUICore: f23efd42114613206437cfd1cd6934c30d232d3f + TXIMSDK_Plus_iOS_XCFramework: 833d9b6cd4fec18531b17d35d89c7f0f55060272 + TXLiteAVSDK_Professional: 73d77e84c5a8b6c73bc06445662e3871f247e7a5 -PODFILE CHECKSUM: 284af0cc8798a64bce46b577905f747da3dd4b5a +PODFILE CHECKSUM: cd82957790b00be9b8cb4ddfd0fe1997e84b1bfb COCOAPODS: 1.16.2 diff --git a/chat/demo/build.sh b/chat/demo/build.sh index 14878e380..47a74a255 100755 --- a/chat/demo/build.sh +++ b/chat/demo/build.sh @@ -8,7 +8,6 @@ MajorVersion=${MajorVersion-0} MinorVersion=${MinorVersion-0} FixVersion=${FixVersion-0} BuildNo=${BuildNo-0} -AtomicXCoreZipName=${AtomicXCoreZipName:-AtomicXCore.zip} ######################## 1、变量赋值 ######################## export SDK_VERSION=${MajorVersion}.${MinorVersion}.${BuildNo} @@ -25,9 +24,10 @@ export OUTPUT_DIR=${SHELL_DIR}/../../../../../bin export SYMBOLS_DIR=${OUTPUT_DIR}/symbols_ios_${SDK_VERSION} DEMO_BUILD_DIR=${BUILD_DIR}/Build/Products +WORKSPACE_NAME="ChatDemo" +SCHEME_NAME="ChatDemo" +APP_NAME="ChatDemo" CHATDEMO_PLIST_PATH=${SHELL_DIR}/ChatDemo/Info.plist -ATOMICX_COMPONENTS_DIR=${SHELL_DIR}/../../atomic-x -ATOMICX_PODSPEC_PATH=${SHELL_DIR}/../../atomic-x/AtomicX.podspec # 获取编译工具 xcodebuild BUILD_BIN=$XCODE_PATH$compileEnv @@ -42,20 +42,45 @@ if [[ ${BUILD_SDK_VERSION} = "" ]]; then exit 1 fi -BUILD_SDK_MAC_CATALYST_VERSION=$(${BUILD_BIN} -showsdks | grep macos | sort -r | head -n 1 | grep -o '[0-9]*\.[0-9]*$') -if [[ ${BUILD_SDK_MAC_CATALYST_VERSION} = "" ]]; then - echo "Error: No MacOS SDK ..." - exit 1 -fi - -# 真机和模拟器版本 BUILD_SDK_IPHONEOS="iphoneos${BUILD_SDK_VERSION}" -BUILD_SDK_IPHONESIMULATOR="iphonesimulator${BUILD_SDK_VERSION}" -BUILD_SDK_MAC_CATALYST="macosx${BUILD_SDK_MAC_CATALYST_VERSION}" -echo "Build System -> ${BUILD_SDK_IPHONEOS} ${BUILD_SDK_IPHONESIMULATOR} ${BUILD_SDK_MAC_CATALYST}" +echo "Build System -> ${BUILD_SDK_IPHONEOS}" ######################## 2、函数定义 ######################## +# 前置检查:AtomicXCore / TUICallKit_Swift / RTCRoomEngine 全部走本地源码依赖(:path), +# 与本地开发环境一致;CI 需先把 tuikit_engine 源码检出/上传到工作区根目录的 tuikit_engine/。 +function checkLocalDependencies() { + local missing=0 + + if [[ -f "/Volumes/data/workspace/tuikit_engine/RTCRoomEngine.podspec" ]] \ + || [[ -f "${SHELL_DIR}/../../../../../tuikit_engine/RTCRoomEngine.podspec" ]]; then + echo "Found RTCRoomEngine source dependency (tuikit_engine)" + else + echo "Error: tuikit_engine/RTCRoomEngine.podspec not found." + missing=1 + fi + + if [[ -f "${SHELL_DIR}/../../../../../tuikit_engine/atomicx/swift/AtomicXCore/AtomicXCore.podspec" ]]; then + echo "Found AtomicXCore source dependency (tuikit_engine)" + else + echo "Error: tuikit_engine/atomicx/swift/AtomicXCore/AtomicXCore.podspec not found." + missing=1 + fi + + if [[ -f "${SHELL_DIR}/../../call/TUICallKit_Swift.podspec" ]]; then + echo "Found TUICallKit_Swift source dependency (in-repo)" + else + echo "Error: ios/chat/call/TUICallKit_Swift.podspec not found." + missing=1 + fi + + if [[ ${missing} -ne 0 ]]; then + echo "Error: 本地源码依赖缺失。请先将 tuikit_engine 源码检出/上传到 CI 工作区," + echo "Error: 使其与本仓库保持与本地一致的相对布局(工作区根目录下 client_uikit/ 与 tuikit_engine/ 平级)。" + exit 1 + fi +} + # 修改 RTCRoomEngine.podspec 路径为云端环境 function modifyRTCRoomEnginePaths() { # 尝试多个可能的 podspec 路径 @@ -63,7 +88,7 @@ function modifyRTCRoomEnginePaths() { "/Volumes/data/workspace/tuikit_engine/RTCRoomEngine.podspec" "${SHELL_DIR}/../../../../../tuikit_engine/RTCRoomEngine.podspec" ) - + local podspec_path="" for path in "${possible_paths[@]}"; do if [[ -f "${path}" ]]; then @@ -71,109 +96,94 @@ function modifyRTCRoomEnginePaths() { break fi done - - if [[ -n "${podspec_path}" ]]; then - echo "Found RTCRoomEngine.podspec at: ${podspec_path}" - echo "Modifying RTCRoomEngine.podspec for build compatibility..." - - # 备份原始文件 - cp "${podspec_path}" "${podspec_path}.backup" - - # 1. 修改路径 - 将相对路径改为云端绝对路径 - local relative_path="\${PROJECT_DIR}/../../../" - local cloud_path="/Volumes/data/workspace/tuikit_engine/" - sed -i "" "s|\${PROJECT_DIR}/../../../|${cloud_path}|g" "${podspec_path}" - - # 2. 添加模块名配置 - if ! grep -q "module_name.*RTCRoomEngine" "${podspec_path}"; then - awk '/spec.name.*RTCRoomEngine/ { print; print " spec.module_name = '\''RTCRoomEngine'\''"; next } { print }' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" - fi - - # 3. 添加 call_record 和 call_record/oc 到 extensions 数组 - if ! grep -q "src/extensions/call_record" "${podspec_path}"; then - echo "Adding call_record implementation to extensions..." - # 使用 awk 添加 call_record 相关条目到 extensions 数组末尾 - awk ' - /^ extensions = \[/ { in_extensions = 1; print; next } - in_extensions && /^ \]/ { - print " '\''src/extensions/call_record/*'\''," - print " '\''src/extensions/call_record/oc/*'\''," - print "" - in_extensions = 0 - } - { print } - ' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" - fi - - # 4. 添加 call pipeline 到 pipeline 数组 - if ! grep -q "src/pipeline/call" "${podspec_path}"; then - echo "Adding call pipeline to pipeline array..." - # 使用 awk 添加 call pipeline 到 pipeline 数组末尾 - awk ' - /^ pipeline = \[/ { in_pipeline = 1; print; next } - in_pipeline && /^ \]/ { - print " '\''src/pipeline/call/**/*'\''," - in_pipeline = 0 - } - { print } - ' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" - fi - - # 5. 添加 call_record 头文件路径到 project_header_files - if ! grep -q "src/extensions/call_record.*\.h" "${podspec_path}"; then - echo "Adding call_record headers to project_header_files..." - # 使用 awk 在 project_header_files 中添加 call_record 头文件 - awk ' - /src\/platform_adapter\/oc\/\*\*\/\*\.h/ { - print - print " '\''src/extensions/call_record/*.h'\''," - print " '\''src/extensions/call_record/oc/*.h'\''," - next - } - { print } - ' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" - fi - - # 6. 注释掉 platform_adapter/oc/call 的排除规则 - echo "Uncommenting platform_adapter/oc/call exclusion..." - # 如果存在排除规则,将其注释掉 - sed -i "" "s/^[[:space:]]*'src\/platform_adapter\/oc\/call\/\*\.{h,mm,cc}',/ # 'src\/platform_adapter\/oc\/call\/*.{h,mm,cc}', # 注释掉以解决 TUICallEngine 链接问题/" "${podspec_path}" - - # 7. Add CoreTelephony framework dependency - if ! grep -q "CoreTelephony" "${podspec_path}"; then - echo "Adding CoreTelephony framework..." - if grep -q "spec.frameworks" "${podspec_path}"; then - # frameworks already exists, append CoreTelephony - sed -i "" "s/spec.frameworks.*=.*'/spec.frameworks = 'CoreTelephony', '/g" "${podspec_path}" - else - # no frameworks, add after requires_arc - awk '/spec.requires_arc/ { print; print " spec.frameworks = '\''CoreTelephony'\''"; next } { print }' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" - fi - fi - echo "RTCRoomEngine.podspec modifications completed:" - echo " ✓ Cloud paths configured" - echo " ✓ Module name added" - echo " ✓ Call record OC implementation included" - echo " ✓ Call pipeline added" - echo " ✓ Call record headers included" - echo " ✓ Platform adapter call exclusion disabled" - - export MODIFIED_PODSPEC_PATH="${podspec_path}" - - # 验证 podspec 语法 - echo "Validating RTCRoomEngine.podspec syntax..." - if ! pod spec lint "${podspec_path}" --allow-warnings --quick > /dev/null 2>&1; then - echo "Warning: RTCRoomEngine.podspec validation failed, but continuing..." + if [[ -z "${podspec_path}" ]]; then + echo "Error: RTCRoomEngine.podspec not found in any expected location." + echo "Error: 请先将 tuikit_engine 源码检出/上传到 CI 工作区根目录。" + exit 1 + fi + + echo "Found RTCRoomEngine.podspec at: ${podspec_path}" + echo "Modifying RTCRoomEngine.podspec for build compatibility..." + + # 备份原始文件 + cp "${podspec_path}" "${podspec_path}.backup" + + # 1. 修改路径 - 将相对路径改为云端绝对路径 + local cloud_path="/Volumes/data/workspace/tuikit_engine/" + sed -i "" "s|\${PROJECT_DIR}/../../../|${cloud_path}|g" "${podspec_path}" + + # 2. 添加模块名配置 + if ! grep -q "module_name.*RTCRoomEngine" "${podspec_path}"; then + awk '/spec.name.*RTCRoomEngine/ { print; print " spec.module_name = '\''RTCRoomEngine'\''"; next } { print }' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" + fi + + # 3. 添加 call_record 和 call_record/oc 到 extensions 数组 + if ! grep -q "src/extensions/call_record" "${podspec_path}"; then + echo "Adding call_record implementation to extensions..." + awk ' + /^ extensions = \[/ { in_extensions = 1; print; next } + in_extensions && /^ \]/ { + print " '\''src/extensions/call_record/*'\''," + print " '\''src/extensions/call_record/oc/*'\''," + print "" + in_extensions = 0 + } + { print } + ' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" + fi + + # 4. 添加 call pipeline 到 pipeline 数组 + if ! grep -q "src/pipeline/call" "${podspec_path}"; then + echo "Adding call pipeline to pipeline array..." + awk ' + /^ pipeline = \[/ { in_pipeline = 1; print; next } + in_pipeline && /^ \]/ { + print " '\''src/pipeline/call/**/*'\''," + in_pipeline = 0 + } + { print } + ' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" + fi + + # 5. 添加 call_record 头文件路径到 project_header_files + if ! grep -q "src/extensions/call_record.*\.h" "${podspec_path}"; then + echo "Adding call_record headers to project_header_files..." + awk ' + /src\/platform_adapter\/oc\/\*\*\/\*\.h/ { + print + print " '\''src/extensions/call_record/*.h'\''," + print " '\''src/extensions/call_record/oc/*.h'\''," + next + } + { print } + ' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" + fi + + # 6. 注释掉 platform_adapter/oc/call 的排除规则 + echo "Uncommenting platform_adapter/oc/call exclusion..." + sed -i "" "s/^[[:space:]]*'src\/platform_adapter\/oc\/call\/\*\.{h,mm,cc}',/ # 'src\/platform_adapter\/oc\/call\/*.{h,mm,cc}', # 注释掉以解决 TUICallEngine 链接问题/" "${podspec_path}" + + # 7. Add CoreTelephony framework dependency + if ! grep -q "CoreTelephony" "${podspec_path}"; then + echo "Adding CoreTelephony framework..." + if grep -q "spec.frameworks" "${podspec_path}"; then + sed -i "" "s/spec.frameworks.*=.*'/spec.frameworks = 'CoreTelephony', '/g" "${podspec_path}" else - echo "RTCRoomEngine.podspec validation passed" + awk '/spec.requires_arc/ { print; print " spec.frameworks = '\''CoreTelephony'\''"; next } { print }' "${podspec_path}" > "${podspec_path}.tmp" && mv "${podspec_path}.tmp" "${podspec_path}" fi + fi + + echo "RTCRoomEngine.podspec modifications completed" + + export MODIFIED_PODSPEC_PATH="${podspec_path}" + + # 验证 podspec 语法 + echo "Validating RTCRoomEngine.podspec syntax..." + if ! pod spec lint "${podspec_path}" --allow-warnings --quick > /dev/null 2>&1; then + echo "Warning: RTCRoomEngine.podspec validation failed, but continuing..." else - echo "Warning: RTCRoomEngine.podspec not found in any expected location" - echo "Searched paths:" - for path in "${possible_paths[@]}"; do - echo " - ${path}" - done + echo "RTCRoomEngine.podspec validation passed" fi } @@ -184,168 +194,24 @@ function restoreRTCRoomEnginePaths() { mv "${MODIFIED_PODSPEC_PATH}.backup" "${MODIFIED_PODSPEC_PATH}" echo "RTCRoomEngine.podspec restored from: ${MODIFIED_PODSPEC_PATH}" unset MODIFIED_PODSPEC_PATH - else - echo "No backup found for RTCRoomEngine.podspec or no modification was made" fi } -# 处理 AtomicXCore.zip 并修改 Podfile -function setupAtomicXCore() { - # 创建临时目录 - ATOMICX_TEMP_DIR="/tmp/atomicx_core_${SDK_VERSION}" - rm -rf ${ATOMICX_TEMP_DIR} - mkdir -p ${ATOMICX_TEMP_DIR} - - # 使用 curl 下载 AtomicXCore.zip - echo "Downloading ${AtomicXCoreZipName} from bkrepo..." - ATOMICX_ZIP_PATH="${ATOMICX_TEMP_DIR}/${AtomicXCoreZipName}" - - curl -L --user yiliangwang:920dbf8df42f87226887636c2510416e "https://bkrepo.woa.com/generic/timsdk-ios/custom/atomicxcore_iOS/${AtomicXCoreZipName}" -o "${ATOMICX_ZIP_PATH}" - - # echo "Downloading AtomicXCore.zip from bkrepo..." - # ATOMICX_ZIP_PATH="${ATOMICX_TEMP_DIR}/AtomicXCore.zip" - - # curl -L --user yiliangwang:920dbf8df42f87226887636c2510416e "https://bkrepo.woa.com/generic/timsdk-ios/custom/atomicxcore_iOS/AtomicXCore.zip" -o "${ATOMICX_ZIP_PATH}" - - if [[ $? -ne 0 ]] || [[ ! -f "${ATOMICX_ZIP_PATH}" ]]; then - echo "Error: Failed to download AtomicXCore.zip" - exit 1 - fi - - echo "Successfully downloaded AtomicXCore.zip" - - # 解压 AtomicXCore.zip - echo "Extracting AtomicXCore.zip..." - unzip -q "${ATOMICX_ZIP_PATH}" -d ${ATOMICX_TEMP_DIR} - - # 查找解压后的 AtomicXCore.xcframework - ATOMICX_FRAMEWORK_PATH=$(find ${ATOMICX_TEMP_DIR} -name "AtomicXCore.xcframework" -type d | head -1) - - if [[ -d "${ATOMICX_FRAMEWORK_PATH}" ]]; then - echo "Found AtomicXCore.xcframework at: ${ATOMICX_FRAMEWORK_PATH}" - export ATOMICX_CORE_FRAMEWORK_PATH="${ATOMICX_FRAMEWORK_PATH}" - - # 创建本地 AtomicXCore 目录并复制 xcframework - LOCAL_ATOMICX_DIR="${SHELL_DIR}/LocalAtomicXCore" - rm -rf ${LOCAL_ATOMICX_DIR} - mkdir -p ${LOCAL_ATOMICX_DIR} - cp -R "${ATOMICX_FRAMEWORK_PATH}" ${LOCAL_ATOMICX_DIR}/ - - # 创建 LICENSE 文件(避免 podspec 警告) - cat > ${LOCAL_ATOMICX_DIR}/LICENSE << EOF -MIT License - -Copyright (c) 2025 Tencent - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -EOF - - # 创建 AtomicXCore.podspec 文件 - cat > ${LOCAL_ATOMICX_DIR}/AtomicXCore.podspec << EOF -Pod::Spec.new do |spec| - spec.name = "AtomicXCore" - spec.version = "${SDK_VERSION}" - spec.summary = "AtomicXCore framework" - spec.description = "AtomicXCore framework for iOS" - spec.homepage = "https://github.com/tencentyun/TIMSDK" - spec.license = { :type => "MIT", :file => "LICENSE" } - spec.author = { "Tencent" => "restapi_im@tencent.com" } - spec.platform = :ios, "14.0" - spec.source = { :git => "https://github.com/tencentyun/TIMSDK.git", :tag => spec.version.to_s } - spec.vendored_frameworks = "AtomicXCore.xcframework" - spec.requires_arc = true - - # 添加依赖 - 关键修复:确保 RTCRoomEngine 依赖正确配置 - spec.dependency 'RTCRoomEngine' - spec.dependency 'TXIMSDK_Plus_iOS_XCFramework' - spec.dependency 'TXLiteAVSDK_Professional' - - # 设置编译配置以解决模块兼容性问题 - spec.pod_target_xcconfig = { - 'SWIFT_VERSION' => '5.0', - 'OTHER_SWIFT_FLAGS' => '-D COCOAPODS', - 'SWIFT_INCLUDE_PATHS' => '\$(inherited) \$(PODS_ROOT)/RTCRoomEngine', - 'HEADER_SEARCH_PATHS' => '\$(inherited) \$(PODS_ROOT)/RTCRoomEngine' - } -end -EOF - - # 创建 README.md 文件 - cat > ${LOCAL_ATOMICX_DIR}/README.md << EOF -# AtomicXCore - -This is a temporary local pod for AtomicXCore framework. - -## Version -${SDK_VERSION} - -## Usage -This pod is automatically generated during the build process. -EOF - - echo "Created LocalAtomicXCore directory structure:" - echo "- AtomicXCore.xcframework" - echo "- AtomicXCore.podspec" - echo "- LICENSE" - echo "- README.md" - - # 备份原始 Podfile - cp ${SHELL_DIR}/Podfile ${SHELL_DIR}/Podfile.backup - - # 修改 Podfile,将 AtomicXCore 的路径指向本地解压的 xcframework - # 处理可能的不同路径格式 - sed -i "" "s|pod 'AtomicXCore', :path => '../../../../../tuikit_engine/atomicx/swift/AtomicXCore'|pod 'AtomicXCore', :path => './LocalAtomicXCore'|g" ${SHELL_DIR}/Podfile - sed -i "" "s|pod 'AtomicXCore', :path => './LocalAtomicXCore'|pod 'AtomicXCore', :path => './LocalAtomicXCore'|g" ${SHELL_DIR}/Podfile - - echo "Created AtomicXCore.podspec and modified Podfile to use local AtomicXCore.xcframework" - else - echo "Error: AtomicXCore.xcframework not found in extracted files" - exit 1 - fi -} - # 清理并重新安装 CocoaPods function cleanAndInstallPods() { echo "Cleaning and reinstalling CocoaPods..." cd ${SHELL_DIR} - - # 清理 Pods 相关文件 + rm -rf Pods/ - rm -rf ChatDemo.xcworkspace + rm -rf ${WORKSPACE_NAME}.xcworkspace rm -f Podfile.lock - - # 清理 CocoaPods 缓存以避免模块冲突 + echo "Cleaning CocoaPods cache..." pod cache clean --all > /dev/null 2>&1 || true - - # 确保 RTCRoomEngine 能被正确找到 - echo "Verifying RTCRoomEngine availability..." - if [[ -n "${MODIFIED_PODSPEC_PATH}" && -f "${MODIFIED_PODSPEC_PATH}" ]]; then - echo "RTCRoomEngine.podspec found at: ${MODIFIED_PODSPEC_PATH}" - else - echo "Warning: RTCRoomEngine.podspec not found, this may cause build issues" - fi - - # 重新安装 pods,增加详细输出以便调试 + echo "Installing pods with verbose output..." pod install --repo-update --verbose - + if [[ $? -ne 0 ]]; then echo "pod install failed !!! " echo "Attempting pod install without repo update..." @@ -355,169 +221,55 @@ function cleanAndInstallPods() { exit 1 fi fi - - # 验证 RTCRoomEngine 模块是否正确安装 - if [[ -d "Pods/RTCRoomEngine" ]]; then - echo "RTCRoomEngine pod installed successfully" - # 检查模块映射文件 - if [[ -f "Pods/RTCRoomEngine/RTCRoomEngine.modulemap" ]] || [[ -f "Pods/Target Support Files/RTCRoomEngine/RTCRoomEngine.modulemap" ]]; then - echo "RTCRoomEngine module map found" - else - echo "Warning: RTCRoomEngine module map not found" - fi - else - echo "Warning: RTCRoomEngine pod not found in Pods directory" - fi - + echo "pod install success !!!" } function buildChatDemo() { - echo ">>>>>>>>> build ChatDemo begin !!! " - - # 设置 AtomicXCore - setupAtomicXCore - + echo ">>>>>>>>> build ${SCHEME_NAME} begin !!! " + # 清理并重新安装 pods cleanAndInstallPods # 清理构建目录,避免多次构建 demo,脏数据影响签名 rm -rf ${DEMO_BUILD_DIR}/* - ${BUILD_BIN} -workspace ChatDemo.xcworkspace -scheme ChatDemo -configuration Release -sdk ${BUILD_SDK_IPHONEOS} -derivedDataPath ${BUILD_DIR} + # 签名兜底:构建机未安装 Wildcard_202608 描述文件时关闭签名, + # 保证编译与 ipa 打包不被签名阻断(该产物不可装机,仅供 CI 编译验证); + # 构建机已安装描述文件时正常走 Manual 签名(与旧 demo CI 行为一致)。 + SIGNING_OVERRIDES="" + PROFILE_DIR="${HOME}/Library/MobileDevice/Provisioning Profiles" + if ! grep -rqsa "Wildcard_202608" "${PROFILE_DIR}" 2>/dev/null; then + echo "Warning: Provisioning profile 'Wildcard_202608' not found, building with CODE_SIGNING_ALLOWED=NO" + SIGNING_OVERRIDES="CODE_SIGNING_ALLOWED=NO" + fi + + ${BUILD_BIN} -workspace ${WORKSPACE_NAME}.xcworkspace -scheme ${SCHEME_NAME} -configuration Release -sdk ${BUILD_SDK_IPHONEOS} -derivedDataPath ${BUILD_DIR} ${SIGNING_OVERRIDES} if [[ $? -ne 0 ]]; then - echo "build iphoneos ChatDemo failed !!! " + echo "build iphoneos ${SCHEME_NAME} failed !!! " exit 1 else - echo "build iphoneos ChatDemo success !!!" + echo "build iphoneos ${SCHEME_NAME} success !!!" fi - # 压缩 Payload 生成 ChatDemo.ipa - echo "zip ChatDemo.ipa" + # 压缩 Payload 生成 ipa + echo "zip ${APP_NAME}.ipa" mkdir ${DEMO_BUILD_DIR}/Release-iphoneos/Payload - cp -R ${DEMO_BUILD_DIR}/Release-iphoneos/ChatDemo.app ${DEMO_BUILD_DIR}/Release-iphoneos/Payload + cp -R ${DEMO_BUILD_DIR}/Release-iphoneos/${APP_NAME}.app ${DEMO_BUILD_DIR}/Release-iphoneos/Payload cd ${DEMO_BUILD_DIR}/Release-iphoneos - zip -r -q ${OUTPUT_DIR}/ChatDemo_${SDK_VERSION}.ipa Payload iTunesArtwork + zip -r -q ${OUTPUT_DIR}/${APP_NAME}_${SDK_VERSION}.ipa Payload rm -rf ${DEMO_BUILD_DIR}/Release-iphoneos/Payload # 复制当前版本的符号表 - echo "copy ChatDemo dSYM" - cp -rf ${DEMO_BUILD_DIR}/Release-iphoneos/ChatDemo.app.dSYM ${SYMBOLS_DIR}/ -} - -function buildAtomicXComponents() { - echo ">>>>>>>>> build AtomicX Components begin !!! " - - # 设置 AtomicXCore - setupAtomicXCore - - # 清理并重新安装 pods - cleanAndInstallPods - - # 清理构建目录,避免多次构建 demo,脏数据影响签名 - rm -rf ${DEMO_BUILD_DIR}/Release-iphoneos/* - rm -rf ${DEMO_BUILD_DIR}/Release-iphonesimulator/* - - # 编译真机架构 - ${BUILD_BIN} -workspace ChatDemo.xcworkspace -scheme ChatDemo -configuration Release -sdk ${BUILD_SDK_IPHONEOS} -derivedDataPath ${BUILD_DIR} - - if [[ $? -ne 0 ]]; then - echo "build iphoneos ChatDemo failed !!! " - exit 1 - else - echo "build iphoneos ChatDemo success !!!" - fi - - # 编译模拟器架构 - ${BUILD_BIN} -workspace ChatDemo.xcworkspace -scheme ChatDemo -configuration Release -arch arm64 -arch x86_64 -sdk ${BUILD_SDK_IPHONESIMULATOR} -derivedDataPath ${BUILD_DIR} - - if [[ $? -ne 0 ]]; then - echo "build simulator ChatDemo failed !!! " - exit 1 - else - echo "build simulator ChatDemo success !!!" - fi - - SimulatorFrameworks_Dir=${DEMO_BUILD_DIR}/Plugin/SimulatorFrameworks - IphoneosFrameworks_Dir=${DEMO_BUILD_DIR}/Plugin/IphoneosFrameworks - mkdir -p ${SimulatorFrameworks_Dir} - mkdir -p ${IphoneosFrameworks_Dir} - cp -R ${DEMO_BUILD_DIR}/Release-iphonesimulator/ChatDemo.app/Frameworks ${SimulatorFrameworks_Dir} - cp -R ${DEMO_BUILD_DIR}/Release-iphoneos/ChatDemo.app/Frameworks ${IphoneosFrameworks_Dir} - - # 处理 AtomicX 相关的 Frameworks - cd ${IphoneosFrameworks_Dir}/Frameworks - for filename in $(ls .); do - if [[ "$filename" == *.framework ]]; then - frameworkArray=(${filename//./ }) - frameworkName=${frameworkArray[0]} - - # 只处理 AtomicX 相关的框架 - if [[ "$frameworkName" == *"AtomicX"* ]] || [[ "$frameworkName" == *"Chat"* ]] || [[ "$frameworkName" == *"Message"* ]] || [[ "$frameworkName" == *"Conversation"* ]] || [[ "$frameworkName" == *"Contact"* ]]; then - echo "Processing AtomicX framework: ${frameworkName}" - - # 创建 xcframework - ${BUILD_BIN} -create-xcframework -framework "${frameworkName}.framework" -framework "${SimulatorFrameworks_Dir}/Frameworks/${frameworkName}.framework" -output "${frameworkName}.xcframework" - - # 从Demo的产物里拷贝出来每个frameworkName的dSYM - dSYMPath="${DEMO_BUILD_DIR}/Release-iphoneos/${frameworkName}/${frameworkName}.framework.dSYM" - if [ -d "$dSYMPath" ]; then - cp -R "$dSYMPath" "${SYMBOLS_DIR}/" - rm -rf "$dSYMPath" - echo "Copied dSYM file to ${SYMBOLS_DIR}/" - else - echo "Warning: dSYM file for ${frameworkName} not found." - fi - - # 添加头文件和模块(如果存在) - for archName in $(ls ${frameworkName}.xcframework); do - plugin_path="${ATOMICX_COMPONENTS_DIR}/Sources/${frameworkName}" - echo "Current archName: $archName, frameworkName: $frameworkName, plugin_path: $plugin_path" - - if [ -d "$plugin_path" ]; then - # 手动添加头文件到插件的 xcframework - if [ -d "${plugin_path}/Headers" ]; then - cp -R "${plugin_path}/Headers" "${frameworkName}.xcframework/${archName}/${frameworkName}.framework/" - fi - if [ -d "${plugin_path}/Modules" ]; then - cp -R "${plugin_path}/Modules" "${frameworkName}.xcframework/${archName}/${frameworkName}.framework/" - fi - # 手动把 PrivacyInfo.xcprivacy 导入到最终的 xcframework 文件 - if [ -f "${plugin_path}/Resources/PrivacyInfo.xcprivacy" ]; then - cp -R "${plugin_path}/Resources/PrivacyInfo.xcprivacy" "${frameworkName}.xcframework/${archName}/${frameworkName}.framework/" - fi - fi - done - - zip -r ${frameworkName}_${SDK_VERSION}.xcframework.zip ${frameworkName}.xcframework - cp -rf ${frameworkName}_${SDK_VERSION}.xcframework.zip ${OUTPUT_DIR}/ - fi - fi - done - - # 处理 AtomicXCore.xcframework - if [[ -n "${ATOMICX_CORE_FRAMEWORK_PATH}" && -d "${ATOMICX_CORE_FRAMEWORK_PATH}" ]]; then - echo "Processing AtomicXCore.xcframework..." - cd ${IphoneosFrameworks_Dir}/Frameworks - - # 复制 AtomicXCore.xcframework 到当前目录 - cp -R "${ATOMICX_CORE_FRAMEWORK_PATH}" ./ - - # 创建 atomicx.xcframework 的压缩包 - zip -r atomicx_${SDK_VERSION}.xcframework.zip AtomicXCore.xcframework - cp -rf atomicx_${SDK_VERSION}.xcframework.zip ${OUTPUT_DIR}/ - - echo "AtomicXCore.xcframework processed and copied to output directory" - fi - - rm -rf ${DEMO_BUILD_DIR}/Plugin + echo "copy ${APP_NAME} dSYM" + cp -rf ${DEMO_BUILD_DIR}/Release-iphoneos/${APP_NAME}.app.dSYM ${SYMBOLS_DIR}/ } # 压缩符号表 function zipSymbols() { echo ">>>>>>>>> zipSymbols begin !!!" - FINAL_SYMBOLS_DIR=temp_symbols_ios_${1}_${SDK}_${SDK_VERSION} + FINAL_SYMBOLS_DIR=temp_symbols_ios_${1}_${SDK_VERSION} cd ${OUTPUT_DIR} mv symbols_ios_${SDK_VERSION} ${FINAL_SYMBOLS_DIR} zip -r ${FINAL_SYMBOLS_DIR}.zip ${FINAL_SYMBOLS_DIR} @@ -530,65 +282,34 @@ function zipSymbols() { fi } -# 更新 AtomicX 插件版本号 -function updateAtomicXVersion() { - if [ -f "${ATOMICX_PODSPEC_PATH}" ]; then - echo "Updating version in: ${ATOMICX_PODSPEC_PATH}" - sed -i "" "s/spec.version.*= \\'.*\\'/spec.version = \\'${SDK_VERSION}\\'/g" "${ATOMICX_PODSPEC_PATH}" - fi -} +######################## 3、构建主流程 ######################## -# 恢复 Podfile -function restorePodfile() { - if [ -f "${SHELL_DIR}/Podfile.backup" ]; then - echo "Restoring original Podfile..." - mv ${SHELL_DIR}/Podfile.backup ${SHELL_DIR}/Podfile - fi - - # 清理临时文件 - if [[ -n "${ATOMICX_TEMP_DIR}" && -d "${ATOMICX_TEMP_DIR}" ]]; then - echo "Cleaning up temporary AtomicXCore files..." - rm -rf ${ATOMICX_TEMP_DIR} - fi - - if [[ -d "${SHELL_DIR}/LocalAtomicXCore" ]]; then - echo "Cleaning up local AtomicXCore directory..." - rm -rf ${SHELL_DIR}/LocalAtomicXCore - fi -} +# 任何退出路径(成功/失败/Ctrl-C)都恢复 RTCRoomEngine.podspec,避免污染工作区。 +trap 'restoreRTCRoomEnginePaths' EXIT -######################## 3、构建主流程 ######################## +# 前置检查:三个依赖全部走本地源码(:path),缺失则直接失败并给出指引 +checkLocalDependencies # 修改 RTCRoomEngine.podspec 路径为云端环境 modifyRTCRoomEnginePaths rm -rf ${OUTPUT_DIR} -mkdir ${OUTPUT_DIR} -mkdir ${SYMBOLS_DIR} +mkdir -p ${OUTPUT_DIR} +mkdir -p ${SYMBOLS_DIR} # ChatDemo -echo "-------------------- build ChatDemo start --------------------" +echo "-------------------- build ${SCHEME_NAME} start --------------------" if [ -f "${CHATDEMO_PLIST_PATH}" ]; then /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${SDK_VERSION}" "${CHATDEMO_PLIST_PATH}" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${SDK_VERSION}" "${CHATDEMO_PLIST_PATH}" fi buildChatDemo -zipSymbols "ChatDemo" -echo "-------------------- build ChatDemo end --------------------" - -# AtomicX Components -echo "-------------------- build AtomicX Components start --------------------" -updateAtomicXVersion -buildAtomicXComponents -zipSymbols "AtomicX" -echo "-------------------- build AtomicX Components end --------------------" +zipSymbols "${SCHEME_NAME}" +echo "-------------------- build ${SCHEME_NAME} end --------------------" # 清理和恢复 -restorePodfile restoreRTCRoomEnginePaths echo "-------------------- Build completed --------------------" echo "Generated files:" -echo "- Application IPA: ${OUTPUT_DIR}/ChatDemo_${SDK_VERSION}.ipa" -echo "- AtomicX Framework: ${OUTPUT_DIR}/atomicx_${SDK_VERSION}.xcframework.zip" -echo "- Other AtomicX component frameworks are also generated in ${OUTPUT_DIR}/" \ No newline at end of file +echo "- Application IPA: ${OUTPUT_DIR}/${APP_NAME}_${SDK_VERSION}.ipa" diff --git a/chat/demo/buildServer.json b/chat/demo/buildServer.json deleted file mode 100644 index 8037c71ff..000000000 --- a/chat/demo/buildServer.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "xcode build server", - "version": "0.2", - "bspVersion": "2.0", - "languages": [ - "c", - "cpp", - "objective-c", - "objective-cpp", - "swift" - ], - "argv": [ - "/opt/homebrew/bin/xcode-build-server" - ], - "workspace": "/Users/xiangzhang/Desktop/code/imsdk_develop/imsdk/uikit-next/ios/TUIKitDemo.xcworkspace", - "build_root": "/Users/xiangzhang/Library/Developer/Xcode/DerivedData/TUIKitDemo-abmuxfvhwzuxdiflozpldxhvhgcq", - "scheme": "TUIKitDemo", - "kind": "xcode" -} \ No newline at end of file diff --git a/chat/uikit/ChatPage.swift b/chat/uikit/ChatPage.swift index 8c8519e19..870727627 100644 --- a/chat/uikit/ChatPage.swift +++ b/chat/uikit/ChatPage.swift @@ -1,170 +1,505 @@ -import AtomicX import AtomicXCore -import Foundation -import Kingfisher -import SwiftUI +import Combine +import SnapKit +import UIKit -extension MessageInfo { - var contentText: String? { - return messageBody?.text - } +public final class ChatPage: UIViewController { + private static let navBarHeight: CGFloat = 48 - var imageInfo: (image: UIImage?, size: CGSize?) { - guard let body = messageBody, messageType == .image else { - return (nil, nil) - } - if let imagePath = body.originalImagePath, let image = UIImage(contentsOfFile: imagePath) { - return (image, CGSize(width: body.originalImageWidth, height: body.originalImageHeight)) + private static let navHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let backButtonSize: CGFloat = 28 + + private static let moreButtonSize: CGFloat = 24 + + private static let backIconSlotWidth: CGFloat = 16 + + private static let unreadBadgeSize: CGFloat = 22 + + private static let unreadBadgeLeadingGap: CGFloat = 2 + + private static let unreadBadgeHorizontalPadding: CGFloat = 6 + + private static let failureBarSideInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let failureBarBottomSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let failureBarHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let failureBarVerticalPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let failureBarMinHeight: CGFloat = 40 + + private static let failureBarIconSize: CGFloat = 16 + + private static let failureBarIconTextGap: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let failureBarMaxTextWidth: CGFloat = 340 + + private static let failureBarDisplayDuration: TimeInterval = 3 + + private static let failureBarFadeDuration: TimeInterval = 0.25 + + private static let dividerHeight: CGFloat = 0.5 + + private static let titleCenterXPriority = UILayoutPriority(rawValue: 750) + + private static let failureBarCornerRadius: CGFloat = 6 + + private static let failureBarMaxLineCount: Int = 3 + + private static let backIconFallbackPointSize: CGFloat = 18 + + private static let unreadBadgeMaxCount: Int = 99 + + private static let titleFontSize: CGFloat = 17 + + private static let moreIconPointSize: CGFloat = 20 + + private let conversation: ConversationInfo + + private let locateMessage: MessageInfo? + + private let onBack: (() -> Void)? + + private let onUserAvatarClick: ((String) -> Void)? + + private let onNavigationAvatarClick: (() -> Void)? + + private let navigationBar = UIView() + + private let backButton = UIButton(type: .system) + + private let unreadBadgeView = UIView() + + private let unreadBadgeLabel = UILabel() + + private let titleLabel = UILabel() + + private let moreButton = UIButton(type: .system) + + private let cancelMultiSelectButton = UIButton(type: .system) + + private let divider = UIView() + + private let sendFailureBar = UIView() + + private let sendFailureIconView = UIImageView() + + private let sendFailureLabel = UILabel() + + private var sendFailureHideWorkItem: DispatchWorkItem? + + private let conversationListStore = ConversationListStore.create() + + private var unreadCancellable: AnyCancellable? + + private var isInMultiSelectMode = false + + private var latestUnreadTotal: Int64 = 0 + + private let messageInputConfig: MessageInputConfigProtocol + + private lazy var messageListConfig: ChatMessageListConfig = { + var config = ChatMessageListConfig(isShowRightAvatar: true) + config.excludeCustomMessagesByBusinessID(MessageInputView.typingMessageBusinessID) + return config + }() + + private var typingCancellable: AnyCancellable? + + private lazy var messageListView: MessageListView = { + let view = MessageListView( + conversationID: conversation.id, + + config: messageListConfig, + locateMessage: locateMessage, + onUserClick: { [weak self] userID in + self?.onUserAvatarClick?(userID) + } + ) + view.onMultiSelectModeChange = { [weak self] isMultiSelect in + self?.updateInputVisibility(multiSelect: isMultiSelect) } - return (nil, CGSize(width: body.originalImageWidth, height: body.originalImageHeight)) - } + return view + }() - var videoInfo: (snapshot: UIImage?, duration: Int?) { - guard let body = messageBody, messageType == .video else { - return (nil, nil) + private lazy var messageInputView: MessageInputView = { + let view = MessageInputView( + conversationID: conversation.id, + config: messageInputConfig + ) + view.onSendFailure = { [weak self] reason in + self?.showSendFailure(reason) } - if let snapshotPath = body.videoSnapshotPath { - return (UIImage(contentsOfFile: snapshotPath), body.videoDuration) - } else { - return (nil, body.videoDuration) + return view + }() + + private var inputCollapseConstraint: NSLayoutConstraint? + + public var topBannerView: UIView? { + didSet { + guard oldValue !== topBannerView else { return } + oldValue?.removeFromSuperview() + updateTopBannerLayout() } } -} -public struct ChatPage: View { - @EnvironmentObject var themeState: ThemeState - @StateObject private var toast = Toast() - @State private var multiSelectBottomBar: AnyView? = nil - let onBack: (() -> Void)? - let onUserAvatarClick: ((String) -> Void)? - let onNavigationAvatarClick: (() -> Void)? - let locateMessage: MessageInfo? - private var conversation: ConversationInfo + // MARK: - Init public init( conversation: ConversationInfo, locateMessage: MessageInfo? = nil, + messageInputConfig: MessageInputConfigProtocol = ChatMessageInputConfig(), onBack: (() -> Void)? = nil, onUserAvatarClick: ((String) -> Void)? = nil, onNavigationAvatarClick: (() -> Void)? = nil ) { self.conversation = conversation self.locateMessage = locateMessage + self.messageInputConfig = messageInputConfig self.onBack = onBack self.onUserAvatarClick = onUserAvatarClick self.onNavigationAvatarClick = onNavigationAvatarClick + super.init(nibName: nil, bundle: nil) } - public var body: some View { - return VStack(spacing: 0) { - self.navigationBarView - - Divider() - .background(self.themeState.colors.strokeColorPrimary) - - ZStack(alignment: .bottom) { - VStack(spacing: 0) { - MessageList( - conversationID: self.conversation.id, - locateMessage: self.locateMessage, - onUserClick: { userID in - onUserAvatarClick?(userID) - }, - onMultiSelectModeChange: { isMultiSelect, bottomBar in - self.multiSelectBottomBar = bottomBar - } - ) - - if let bottomBar = multiSelectBottomBar { - bottomBar - } else { - self.messageInputAreaView - } - } - .ignoresSafeArea(.keyboard) - } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + unreadCancellable?.cancel() + sendFailureHideWorkItem?.cancel() + typingCancellable?.cancel() + messageInputView.invalidateTypingIndicator() + NotificationCenter.default.removeObserver(self, name: ChatBackgroundStore.didChangeNotification, object: nil) + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + ChatUIKitLayoutDirection.install() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + bindUnreadBadge() + bindChatBackground() + updateTopBannerLayout() + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + messageInputView.bottomSafeAreaInset = view.safeAreaInsets.bottom + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + messageListView.hostVisibilityDidChange(true) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + messageListView.hostVisibilityDidChange(false) + } + + // MARK: - Actions + + private func constructViewHierarchy() { + view.addSubview(navigationBar) + navigationBar.addSubview(backButton) + navigationBar.addSubview(unreadBadgeView) + unreadBadgeView.addSubview(unreadBadgeLabel) + navigationBar.addSubview(titleLabel) + navigationBar.addSubview(moreButton) + navigationBar.addSubview(cancelMultiSelectButton) + view.addSubview(divider) + view.addSubview(messageListView) + view.addSubview(messageInputView) + view.addSubview(sendFailureBar) + sendFailureBar.addSubview(sendFailureIconView) + sendFailureBar.addSubview(sendFailureLabel) + } + + private func activateConstraints() { + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.navBarHeight) + } + backButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.navHorizontalInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.backButtonSize) + } + unreadBadgeView.snp.makeConstraints { make in + + make.leading.equalTo(backButton.snp.leading).offset(Self.backIconSlotWidth + Self.unreadBadgeLeadingGap) + make.centerY.equalToSuperview() + make.height.equalTo(Self.unreadBadgeSize) + make.width.greaterThanOrEqualTo(Self.unreadBadgeSize) + } + unreadBadgeLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(Self.unreadBadgeHorizontalPadding) + } + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview().priority(Self.titleCenterXPriority) + make.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(Self.navHorizontalInset) + make.trailing.lessThanOrEqualTo(moreButton.snp.leading).offset(-Self.navHorizontalInset) + } + moreButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.navHorizontalInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.moreButtonSize) + } + cancelMultiSelectButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.navHorizontalInset) + make.centerY.equalToSuperview() + make.height.equalTo(Self.backButtonSize) + } + divider.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.dividerHeight) + } + messageListView.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(messageInputView.snp.top) + } + + messageInputView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + inputCollapseConstraint = messageInputView.heightAnchor.constraint(equalToConstant: 0) + + sendFailureBar.snp.makeConstraints { make in + make.bottom.equalTo(messageInputView.snp.top).offset(-Self.failureBarBottomSpacing) + make.centerX.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(Self.failureBarSideInset) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.failureBarSideInset) + make.height.greaterThanOrEqualTo(Self.failureBarMinHeight) + } + sendFailureIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.failureBarHorizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.failureBarIconSize) + } + sendFailureLabel.snp.makeConstraints { make in + make.leading.equalTo(sendFailureIconView.snp.trailing).offset(Self.failureBarIconTextGap) + make.trailing.equalToSuperview().offset(-Self.failureBarHorizontalPadding) + make.top.equalToSuperview().offset(Self.failureBarVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.failureBarVerticalPadding) + make.width.lessThanOrEqualTo(Self.failureBarMaxTextWidth) } - .background( - themeState.colors.bgColorOperate - .ignoresSafeArea() - ) - .toast(toast) } - private var navigationBarView: some View { - HStack { - Button(action: { - onBack?() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(themeState.colors.textColorLink) + private func bindInteraction() { + backButton.addTarget(self, action: #selector(handleBackTapped), for: .touchUpInside) + cancelMultiSelectButton.addTarget(self, action: #selector(handleCancelMultiSelectTapped), for: .touchUpInside) + moreButton.addTarget(self, action: #selector(handleMoreTapped), for: .touchUpInside) + bindTypingIndicator() + } + + private func updateTopBannerLayout() { + guard isViewLoaded else { return } + if let banner = topBannerView { + view.insertSubview(banner, belowSubview: sendFailureBar) + banner.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom) + make.leading.trailing.equalToSuperview() } - .padding(.leading, 16) - - Button(action: { - onNavigationAvatarClick?() - }) { - HStack(spacing: 12) { - Avatar( - url: conversation.avatarURL, - name: conversation.title ?? conversation.conversationID, - size: .s - ) - - VStack(alignment: .leading, spacing: 2) { - Text(conversation.title ?? conversation.conversationID) - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(themeState.colors.textColorPrimary) - .lineLimit(1) - - if conversation.type == .group { - Text("群聊") - .font(.system(size: 12)) - .foregroundColor(themeState.colors.textColorSecondary) - } - } - } + messageListView.snp.remakeConstraints { make in + make.top.equalTo(banner.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(messageInputView.snp.top) } - .buttonStyle(PlainButtonStyle()) + } else { + messageListView.snp.remakeConstraints { make in + make.top.equalTo(divider.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(messageInputView.snp.top) + } + } + } - Spacer() + private func bindTypingIndicator() { + guard let typingPublisher = messageInputView.typingPublisher else { return } + messageInputView.onTypingContentChanged = { [weak self] hasContent in + self?.messageInputView.sendTypingStatus(hasContent: hasContent) } - .frame(height: 44) + typingCancellable = typingPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isTyping in + guard let self = self else { return } + if isTyping { + self.titleLabel.text = LocalizedChatString("Typing") + } else { + self.titleLabel.text = self.conversation.title ?? self.conversation.conversationID + } + } + } + + private func bindChatBackground() { + applyChatBackground() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleChatBackgroundChanged(_:)), + name: ChatBackgroundStore.didChangeNotification, + object: nil + ) + } + + private func applyChatBackground() { + let uri = ChatBackgroundStore.shared.imageURI(forConversationID: conversation.id) + messageListView.applyChatBackgroundImage(uri: uri) + } + + @objc private func handleChatBackgroundChanged(_ notification: Notification) { + guard let changedID = notification.userInfo?[ChatBackgroundStore.conversationIDUserInfoKey] as? String, + changedID == conversation.id else { return } + applyChatBackground() } - private var messageInputAreaView: some View { - VStack(spacing: 0) { - MessageInput( - conversationID: conversation.id + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + + sendFailureBar.backgroundColor = colors.bgColorOperate + sendFailureBar.layer.cornerRadius = Self.failureBarCornerRadius + sendFailureBar.layer.masksToBounds = true + sendFailureBar.alpha = 0 + sendFailureBar.isHidden = true + sendFailureBar.isUserInteractionEnabled = false + + sendFailureIconView.image = AtomicXChatResources.image(named: "message_toast_error") + sendFailureIconView.contentMode = .scaleAspectFit + + sendFailureLabel.font = FontScheme.caption2Regular + sendFailureLabel.textColor = colors.textColorPrimary + sendFailureLabel.numberOfLines = Self.failureBarMaxLineCount + sendFailureLabel.textAlignment = .natural + + if let backImage = AtomicXChatResources.image(named: "contact_info_back") { + backButton.setImage(backImage.withRenderingMode(.alwaysTemplate), for: .normal) + } else { + backButton.setImage( + UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.backIconFallbackPointSize, weight: .semibold)), + for: .normal ) - .padding(.bottom, 8) } + backButton.tintColor = colors.textColorPrimary + backButton.contentHorizontalAlignment = .leading + + unreadBadgeView.backgroundColor = colors.buttonColorOff + unreadBadgeView.layer.cornerRadius = Self.unreadBadgeSize / 2 + unreadBadgeView.clipsToBounds = true + unreadBadgeView.isHidden = true + unreadBadgeLabel.textColor = colors.textColorButton + unreadBadgeLabel.font = FontScheme.caption3Regular + unreadBadgeLabel.textAlignment = .center + + titleLabel.text = conversation.title ?? conversation.conversationID + titleLabel.font = .systemFont(ofSize: Self.titleFontSize, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.textAlignment = .center + + moreButton.setImage( + UIImage(systemName: "ellipsis")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.moreIconPointSize, weight: .semibold)), + for: .normal + ) + moreButton.tintColor = colors.textColorSecondary + + cancelMultiSelectButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelMultiSelectButton.setTitleColor(colors.textColorLink, for: .normal) + cancelMultiSelectButton.titleLabel?.font = FontScheme.caption1Regular + cancelMultiSelectButton.contentHorizontalAlignment = .leading + cancelMultiSelectButton.isHidden = true + + divider.backgroundColor = colors.strokeColorPrimary + messageInputView.backgroundColor = colors.bgColorOperate } - private func setupNotificationObservers() { - NotificationCenter.default.addObserver( - forName: UIResponder.keyboardWillShowNotification, - object: nil, - queue: .main - ) { notification in - handleKeyboardShow(notification) + private func showSendFailure(_ reason: String) { + guard !reason.isEmpty else { return } + sendFailureHideWorkItem?.cancel() + sendFailureLabel.text = reason + sendFailureBar.isHidden = false + UIView.animate(withDuration: Self.failureBarFadeDuration) { + self.sendFailureBar.alpha = 1 } - NotificationCenter.default.addObserver( - forName: UIResponder.keyboardWillHideNotification, - object: nil, - queue: .main - ) { notification in - handleKeyboardHide(notification) + let hideWorkItem = DispatchWorkItem { [weak self] in + self?.hideSendFailure() } + sendFailureHideWorkItem = hideWorkItem + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.failureBarDisplayDuration, + execute: hideWorkItem + ) } - private func removeNotificationObservers() { - NotificationCenter.default.removeObserver(self) + private func hideSendFailure() { + UIView.animate( + withDuration: Self.failureBarFadeDuration, + animations: { self.sendFailureBar.alpha = 0 }, + completion: { [weak self] _ in + guard self?.sendFailureBar.alpha == 0 else { return } + self?.sendFailureBar.isHidden = true + self?.sendFailureLabel.text = nil + } + ) } - private func handleKeyboardShow(_ notification: Notification) {} + private func bindUnreadBadge() { + unreadCancellable = conversationListStore.state + .subscribe(StatePublisherSelector(keyPath: \ConversationListState.totalUnreadCount)) + .receive(on: DispatchQueue.main) + .sink { [weak self] total in + self?.updateUnreadBadge(total) + } + } + + private func updateUnreadBadge(_ total: Int64) { + latestUnreadTotal = total + let count = Int(total) + if count > 0 { + unreadBadgeLabel.text = count > Self.unreadBadgeMaxCount ? "99+" : "\(count)" + unreadBadgeView.isHidden = isInMultiSelectMode + } else { + unreadBadgeView.isHidden = true + } + } - private func handleKeyboardHide(_ notification: Notification) {} + private func updateInputVisibility(multiSelect: Bool) { + isInMultiSelectMode = multiSelect + if multiSelect { + messageInputView.endEditing(true) + } + messageInputView.isHidden = multiSelect + inputCollapseConstraint?.isActive = multiSelect + backButton.isHidden = multiSelect + moreButton.isHidden = multiSelect + cancelMultiSelectButton.isHidden = !multiSelect + updateUnreadBadge(latestUnreadTotal) + } + + @objc private func handleBackTapped() { + onBack?() + } + + @objc private func handleCancelMultiSelectTapped() { + messageListView.exitMultiSelectMode() + } + + @objc private func handleMoreTapped() { + onNavigationAvatarClick?() + } } diff --git a/chat/uikit/ChatUIKit.podspec b/chat/uikit/ChatUIKit.podspec index 43d215761..9ff4c44ce 100644 --- a/chat/uikit/ChatUIKit.podspec +++ b/chat/uikit/ChatUIKit.podspec @@ -10,25 +10,21 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/your-repo/atomic-x-chat.git', :tag => s.version.to_s } s.ios.deployment_target = '15.0' s.swift_version = '5.0' + s.frameworks = 'UIKit', 'Foundation', 'AVFoundation', 'AVKit', 'SwiftUI', 'SafariServices' s.dependency 'Kingfisher' s.dependency 'AtomicXCore' s.dependency 'Masonry' - s.dependency 'AtomicX/Chat' + s.dependency 'SnapKit' + s.dependency 'AlbumPicker' + s.dependency 'TUICallKit_Swift' - s.source_files = '*.{swift,h,m}' - # s.resources = [ - # 'Resources/assets/chat/**/*.{xcassets,json,png}', - # 'Resources/strings/ChatLocalizable.bundle', - # 'Resources/strings/EmojiFace.bundle' - # ] - # s.resource_bundles = { - # 'AtomicXBundle' => [ - # 'Resources/assets/**/*.{xcassets,json,png,bundle}', - # 'Resources/strings/ChatLocalizable.bundle', - # 'Resources/strings/EmojiFace.bundle', - # 'Resources/strings/VideoRecorderLocalizable.bundle', - # ] - # } + s.source_files = '**/*.{swift,h,m}' + s.resource_bundles = { + 'ChatUIKitBundle' => [ + 'resources/strings/**/*.{bundle,xcstrings}', + 'resources/assets/chat/**/*.{xcassets,json,png,bundle}' + ] + } end diff --git a/chat/uikit/ContactsPage.swift b/chat/uikit/ContactsPage.swift index cd09bed9d..bbdda507f 100644 --- a/chat/uikit/ContactsPage.swift +++ b/chat/uikit/ContactsPage.swift @@ -1,97 +1,143 @@ -import AtomicX import AtomicXCore -import SwiftUI +import SnapKit +import UIKit -public struct ContactsPage: View { - @EnvironmentObject var themeState: ThemeState - @State private var showAddContactMenu = false - @State private var showAddFriend = false - @State private var showJoinGroup = false - private let contactStore: ContactListStore - let onContactClick: ((AZOrderedListItem) -> Void)? - let onGroupClick: ((AZOrderedListItem) -> Void)? +public final class ContactsPage: UIViewController { + private static let headerVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let addButtonSize: CGFloat = 24 + + private static let titleFontSize: CGFloat = 17 + + private let onContactClick: ((AZOrderedListItem) -> Void)? + + private let onGroupClick: ((AZOrderedListItem) -> Void)? + + private let headerView = UIView() + + private let titleLabel = UILabel() + + private let addButton = UIButton(type: .system) + + private lazy var contactListView = ContactListView( + onContactClick: { [weak self] user in self?.onContactClick?(user) }, + onGroupClick: { [weak self] group in self?.onGroupClick?(group) } + ) + + // MARK: - Init public init( - contactStore: ContactListStore = ContactListStore.create(), onContactClick: ((AZOrderedListItem) -> Void)? = nil, onGroupClick: ((AZOrderedListItem) -> Void)? = nil ) { - self.contactStore = contactStore self.onContactClick = onContactClick self.onGroupClick = onGroupClick + super.init(nibName: nil, bundle: nil) } - public var body: some View { - VStack(spacing: 0) { - headerView - ContactList( - contactStore: contactStore, - onContactClick: onContactClick, - onGroupClick: onGroupClick - ) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + ChatUIKitLayoutDirection.install() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + // MARK: - Actions + + private func constructViewHierarchy() { + view.addSubview(headerView) + headerView.addSubview(titleLabel) + headerView.addSubview(addButton) + view.addSubview(contactListView) + } + + private func activateConstraints() { + headerView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() } - .background(themeState.colors.bgColorOperate.ignoresSafeArea(edges: .top)) - .overlay( - Group { - if showAddContactMenu { - VStack { - HStack { - Spacer() - AddContactPopView( - onDismiss: { - showAddContactMenu = false - }, - onShowAddFriend: { - showAddFriend = true - }, - onShowJoinGroup: { - showJoinGroup = true - } - ) - .padding(.trailing, 16) - .padding(.top, 50) - } - Spacer() - } - .background( - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - showAddContactMenu = false - } - ) - .animation(.easeInOut(duration: 0.2), value: showAddContactMenu) - } - } - ) - .sheet(isPresented: $showAddFriend) { - AddFriendView(contactStore: contactStore) + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(Self.horizontalPadding + Self.addButtonSize) + make.trailing.lessThanOrEqualTo(addButton.snp.leading).offset(-Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.headerVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.headerVerticalPadding) + } + addButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalTo(titleLabel) + make.width.height.equalTo(Self.addButtonSize) } - .sheet(isPresented: $showJoinGroup) { - JoinGroupView(contactStore: contactStore) + contactListView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() } } - private var headerView: some View { - HStack { - Text(LocalizedChatString("TabContacts")) - .font(.system(size: 34, weight: .semibold)) - .tracking(0.3) - .foregroundColor(themeState.colors.textColorPrimary) - .padding(.leading, 16) - Spacer() - Button(action: { - showAddContactMenu = true - }) { - Image(systemName: "plus") - .font(.system(size: 20)) - .foregroundColor(themeState.colors.buttonColorPrimaryDefault) - .frame(width: 28, height: 28) - .cornerRadius(14) - } - .padding(.trailing, 16) + private func bindInteraction() { + addButton.addTarget(self, action: #selector(handleAddTapped), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + headerView.backgroundColor = colors.bgColorOperate + + titleLabel.text = LocalizedChatString("ContactsPageTitle") + titleLabel.font = .systemFont(ofSize: Self.titleFontSize, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + + addButton.setImage(AtomicXChatResources.image(named: "contact_add_circle"), for: .normal) + addButton.tintColor = colors.textColorPrimary + } + + @objc private func handleAddTapped() { + let menu = BubbleMenuViewController( + anchorView: addButton, + items: [ + BubbleMenuViewController.Item( + icon: AtomicXChatResources.image(named: "contact_add_friend"), + title: LocalizedChatString("ContactsAddFriends"), + action: { [weak self] in self?.presentAddFriend() } + ), + BubbleMenuViewController.Item( + icon: AtomicXChatResources.image(named: "contact_add_group"), + title: LocalizedChatString("ContactsJoinGroup"), + action: { [weak self] in self?.presentJoinGroup() } + ), + ] + ) + present(menu, animated: false) + } + + private func presentAddFriend() { + let addFriend = AddFriendViewController() + addFriend.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(addFriend, animated: true) + } + + private func presentJoinGroup() { + let joinGroup = JoinGroupViewController() + joinGroup.onEnterGroupChat = { [weak self] group in + let item = AZOrderedListItem( + userID: group.groupID, + avatarURL: group.avatarURL, + title: ContactDisplayFormatter.name(for: group) + ) + self?.onGroupClick?(item) } - .padding(.top, 12) - .padding(.bottom, 16) + joinGroup.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(joinGroup, animated: true) } } diff --git a/chat/uikit/ConversationsPage.swift b/chat/uikit/ConversationsPage.swift index 59bc28e91..e44145e11 100644 --- a/chat/uikit/ConversationsPage.swift +++ b/chat/uikit/ConversationsPage.swift @@ -1,6 +1,6 @@ -import AtomicX import AtomicXCore -import SwiftUI +import SnapKit +import UIKit public struct NavigationInfo { public let conversation: ConversationInfo @@ -12,379 +12,224 @@ public struct NavigationInfo { } } -class SelectedUsersContainer: ObservableObject { - @Published var users: [UserPickerItem] = [] -} +public final class ConversationsPage: UIViewController { + private static let headerVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let addButtonSize: CGFloat = 24 + + private static let searchBarHeight: CGFloat = 36 + + private static let searchBarVerticalPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) -public struct ConversationsPage: View { - @EnvironmentObject var themeState: ThemeState - @StateObject private var selectedUsersContainer = SelectedUsersContainer() - @State private var showUserPicker = false - @State private var showConfigSheet = false - @State private var showStartConversation = false - @State private var showCreateGroup = false - @State private var showChatsMenu = false - @State var friendList: [ContactInfo] = [] + private static let titleFontSize: CGFloat = 17 - var onConversationClick: ((NavigationInfo) -> Void)? - private var contactListStore: ContactListStore + private let onConversationClick: ((NavigationInfo) -> Void)? + + private let headerView = UIView() + + private let titleLabel = UILabel() + + private let addButton = UIButton(type: .system) + + private lazy var searchEntryBar = SearchEntryBarView(onTapItem: { [weak self] result in + self?.handleSearchResult(result) + }) + + private lazy var conversationListView = ConversationListView( + onConversationClick: { [weak self] conversation in + self?.onConversationClick?(NavigationInfo(conversation: conversation)) + } + ) + + // MARK: - Init public init(onConversationClick: ((NavigationInfo) -> Void)? = nil) { - self.contactListStore = ContactListStore.create() self.onConversationClick = onConversationClick + super.init(nibName: nil, bundle: nil) } - public var body: some View { - VStack(spacing: 0) { - headerView - - SearchBar(onTapItem: { result in - handleSearchResult(result) - }) - .padding(.vertical, 8) - - ConversationList( - onConversationClick: { conversation in - onConversationClick?(NavigationInfo(conversation: conversation)) - } - ) - .environmentObject(themeState) - } - .background(themeState.colors.bgColorOperate.ignoresSafeArea(edges: .top)) - .onReceive(contactListStore.state.subscribe(StatePublisherSelector(keyPath: \ContactListState.friendList))) { friendList in - if self.friendList != friendList { - self.friendList = friendList - } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + ChatUIKitLayoutDirection.install() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + ContactStore.shared.loadFriends(completion: nil) + } + + // MARK: - Search Result Handling + + private func constructViewHierarchy() { + view.addSubview(headerView) + headerView.addSubview(titleLabel) + headerView.addSubview(addButton) + view.addSubview(searchEntryBar) + view.addSubview(conversationListView) + } + + private func activateConstraints() { + headerView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() } - .onAppear { - contactListStore.fetchFriendList(completion: { _ in }) + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(Self.horizontalPadding + Self.addButtonSize) + make.trailing.lessThanOrEqualTo(addButton.snp.leading).offset(-Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.headerVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.headerVerticalPadding) } - .overlay( - ChatsMenuOverlay( - showChatsMenu: $showChatsMenu, - showStartConversation: $showStartConversation, - showUserPicker: $showUserPicker - ) - ) - .sheet(isPresented: $showStartConversation) { - StartConversationSheet( - contactListStore: contactListStore, - onUserSelected: { user in - showStartConversation = false - let conversation = createConversationFromUser(user) - onConversationClick?(NavigationInfo(conversation: conversation)) - } - ) + addButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalTo(titleLabel) + make.width.height.equalTo(Self.addButtonSize) } - .sheet(isPresented: $showUserPicker, onDismiss: { - if selectedUsersContainer.users.isEmpty {} - }) { - UserPickerSheet( - contactListStore: contactListStore, - selectedUsersContainer: selectedUsersContainer, - showUserPicker: $showUserPicker, - showConfigSheet: $showConfigSheet - ) + searchEntryBar.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(Self.searchBarVerticalPadding) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.searchBarHeight) } - .sheet(isPresented: $showConfigSheet, onDismiss: { - selectedUsersContainer.users = [] - }) { - ConfigGroupInfoView( - members: selectedUsersContainer.users, - contactListStore: contactListStore, - onComplete: { createdGroupID, groupName, conversationId in - showConfigSheet = false - if let groupID = createdGroupID, - let name = groupName, - let convId = conversationId - { - let conversation = createConversationFromGroupInfo( - groupID: groupID, - groupName: name, - conversationId: convId - ) - onConversationClick?(NavigationInfo(conversation: conversation)) - } - }, - onBack: { - showConfigSheet = false - } - ) + conversationListView.snp.makeConstraints { make in + make.top.equalTo(searchEntryBar.snp.bottom).offset(Self.searchBarVerticalPadding) + make.leading.trailing.bottom.equalToSuperview() } } - private var headerView: some View { - HStack { - Text(LocalizedChatString("TabChats")) - .font(.system(size: 34, weight: .semibold)) - .tracking(0.3) - .foregroundColor(themeState.colors.textColorPrimary) - .background(themeState.colors.clearColor) - .padding(.leading, 16) - Spacer() - if AppBuilderConfig.shared.enableCreateConversation { - Button(action: { - showChatsMenu.toggle() - }) { - Image(systemName: "plus") - .font(.system(size: 20)) - .foregroundColor(themeState.colors.buttonColorPrimaryDefault) - .frame(width: 28, height: 28) - .cornerRadius(14) - } - .padding(.trailing, 16) - } - } - .padding(.top, 12) - .padding(.bottom, 16) + private func bindInteraction() { + addButton.addTarget(self, action: #selector(handleAddTapped), for: .touchUpInside) } - // MARK: - Helper Methods + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate - private func handleSearchResult(_ result: Any) { - if let friendInfo = result as? FriendSearchInfo { - // Navigate to C2C conversation - let conversationID = ChatUtil.getC2CConversationID(friendInfo.userID) - var conversation = ConversationInfo(conversationID: conversationID) - conversation.type = .c2c - conversation.title = friendInfo.friendRemark ?? friendInfo.userInfo.nickname - conversation.avatarURL = friendInfo.userInfo.avatarURL - onConversationClick?(NavigationInfo(conversation: conversation)) - } else if let groupInfo = result as? GroupSearchInfo { - // Navigate to group conversation - let conversationID = ChatUtil.getGroupConversationID(groupInfo.groupID) - var conversation = ConversationInfo(conversationID: conversationID) - conversation.type = .group - conversation.title = groupInfo.groupName - conversation.avatarURL = groupInfo.groupAvatarURL - onConversationClick?(NavigationInfo(conversation: conversation)) - } else if let messageDict = result as? [String: Any], - let messageInfo = messageDict["message"] as? MessageInfo, - let conversationID = messageDict["conversationID"] as? String - { - // Navigate to conversation with message location (from search detail view) - var conversation = ConversationInfo(conversationID: conversationID) - if conversationID.hasPrefix("c2c_") { - conversation.type = .c2c - } else { - conversation.type = .group - } - conversation.title = messageDict["conversationName"] as? String - conversation.avatarURL = messageDict["conversationAvatar"] as? String - onConversationClick?(NavigationInfo(conversation: conversation, locateMessage: messageInfo)) - } else if let conversationDict = result as? [String: Any], - let conversationID = conversationDict["conversationID"] as? String - { - // Navigate to conversation from detail view - var conversation = ConversationInfo(conversationID: conversationID) - if conversationID.hasPrefix("c2c_") { - conversation.type = .c2c - } else { - conversation.type = .group - } - conversation.title = conversationDict["conversationName"] as? String - conversation.avatarURL = conversationDict["conversationAvatar"] as? String - onConversationClick?(NavigationInfo(conversation: conversation)) - } + titleLabel.text = LocalizedChatString("TabChats") + titleLabel.font = .systemFont(ofSize: Self.titleFontSize, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + + addButton.setImage(AtomicXChatResources.image(named: "contact_add_circle"), for: .normal) + addButton.tintColor = colors.textColorPrimary + addButton.isHidden = !AppBuilderConfig.shared.enableCreateConversation } - private func createConversationFromUser(_ user: AZOrderedListItem) -> ConversationInfo { - var conversation = ConversationInfo(conversationID: ChatUtil.getC2CConversationID(user.id)) - conversation.avatarURL = user.avatarURL - conversation.type = .c2c - conversation.title = user.title ?? user.id - return conversation + @objc private func handleAddTapped() { + let menu = BubbleMenuViewController( + anchorView: addButton, + items: [ + BubbleMenuViewController.Item( + icon: AtomicXChatResources.image(named: "contact_add_friend"), + title: LocalizedChatString("ChatsNewChatText"), + action: { [weak self] in self?.presentStartConversation() } + ), + BubbleMenuViewController.Item( + icon: AtomicXChatResources.image(named: "contact_add_group"), + title: LocalizedChatString("ChatsNewGroupText"), + action: { [weak self] in self?.presentUserPicker() } + ), + ] + ) + present(menu, animated: false) } - private func createConversationFromGroup(_ groupInfo: Any) -> ConversationInfo { - var conversation = ConversationInfo(conversationID: ChatUtil.getGroupConversationID(UUID().uuidString)) - conversation.type = .group - conversation.title = "New Group" - return conversation + private func presentStartConversation() { + let picker = CreateC2CConversationViewController(onUserSelected: { [weak self] user in + guard let self = self else { return } + let conversation = self.makeConversation(fromUser: user) + self.openChatRemovingIntermediatePages(NavigationInfo(conversation: conversation)) + }) + picker.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(picker, animated: true) } - private func createConversationFromGroupMembers(members: [UserPickerItem], groupName: String) -> ConversationInfo { - var conversation = ConversationInfo(conversationID: ChatUtil.getGroupConversationID(UUID().uuidString)) - conversation.type = .group - conversation.title = groupName - conversation.avatarURL = nil + private func presentUserPicker() { + let picker = CreateGroupConversationViewController(onComplete: { [weak self] createdGroupID, groupName, conversationId in + guard let self = self, + let groupID = createdGroupID, + let name = groupName, + let convId = conversationId else { return } + let conversation = self.makeConversation(groupID: groupID, groupName: name, conversationId: convId) + self.openChatRemovingIntermediatePages(NavigationInfo(conversation: conversation)) + }) + picker.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(picker, animated: true) + } + + private func openChatRemovingIntermediatePages(_ info: NavigationInfo) { + onConversationClick?(info) + guard let navigationController = navigationController else { return } + let stack = navigationController.viewControllers.filter { + !($0 is CreateC2CConversationViewController) + && !($0 is CreateGroupConversationViewController) + } + if stack.count != navigationController.viewControllers.count { + navigationController.setViewControllers(stack, animated: false) + } + } + + private func makeConversation(fromUser user: AZOrderedListItem) -> ConversationInfo { + var conversation = ConversationInfo(conversationID: ChatUtil.getC2CConversationID(user.userID)) + conversation.type = .c2c + conversation.title = user.title ?? user.userID + conversation.avatarURL = user.avatarURL return conversation } - private func createConversationFromGroupInfo(groupID: String, groupName: String, conversationId: String) -> ConversationInfo { + private func makeConversation(groupID: String, groupName: String, conversationId: String) -> ConversationInfo { var conversation = ConversationInfo(conversationID: conversationId) conversation.type = .group conversation.title = groupName conversation.avatarURL = nil return conversation } -} - -struct ChatsMenuOverlay: View { - @Binding var showChatsMenu: Bool - @Binding var showStartConversation: Bool - @Binding var showUserPicker: Bool - - var body: some View { - Group { - if showChatsMenu { - VStack { - HStack { - Spacer() - PopMenu(menuItems: [ - PopMenuInfo( - title: LocalizedChatString("ChatsNewChatText"), - onClick: { - showChatsMenu = false - showStartConversation = true - } - ), - PopMenuInfo( - title: LocalizedChatString("ChatsNewGroupText"), - onClick: { - showChatsMenu = false - showUserPicker = true - } - ), - ]) - .padding(.trailing, 16) - .padding(.top, 50) - } - Spacer() - } - .background( - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - showChatsMenu = false - } - ) - .animation(.easeInOut(duration: 0.2), value: showChatsMenu) - } - } - } -} - -struct StartConversationSheet: View { - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var themeState: ThemeState - @State private var friendList: [ContactInfo] = [] - @State private var isLoading = true - let contactListStore: ContactListStore - var onUserSelected: (AZOrderedListItem) -> Void - - var body: some View { - NavigationView { - VStack { - if isLoading && friendList.isEmpty { - Spacer() - ProgressView() - Spacer() - } else { - AZOrderedList( - userList: orderedListItems, - onItemClick: { user in - presentationMode.wrappedValue.dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - onUserSelected(user) - } - } - ) - } - } - .navigationBarTitle(LocalizedChatString("ChatsNewChatText"), displayMode: .inline) - .navigationBarItems(leading: Button(LocalizedChatString("Cancel")) { - presentationMode.wrappedValue.dismiss() - } - .foregroundColor(themeState.colors.textColorLink)) - } - .onAppear { - loadFriendList() - } - .onReceive(contactListStore.state.subscribe(StatePublisherSelector(keyPath: \ContactListState.friendList))) { newFriendList in - DispatchQueue.main.async { - self.friendList = newFriendList - } - } - } - - private func loadFriendList() { - isLoading = true - let currentFriendList = contactListStore.state.value.friendList - if !currentFriendList.isEmpty { - DispatchQueue.main.async { - self.friendList = currentFriendList - self.isLoading = false - } + private func handleSearchResult(_ result: Any) { + if let friendInfo = result as? FriendSearchInfo { + navigateToFriend(friendInfo) + } else if let groupInfo = result as? GroupSearchInfo { + navigateToGroup(groupInfo) + } else if let dict = result as? [String: Any] { + navigateFromDictionary(dict) } - - contactListStore.fetchFriendList(completion: { result in - DispatchQueue.main.async { - switch result { - case .success: - print("StartConversationSheet: Successfully fetched friend list") - case .failure(let error): - print("StartConversationSheet: Failed to fetch friend list: \(error)") - self.isLoading = false - } - } - }) } - private var orderedListItems: [AZOrderedListItem] { - return friendList.map { contact in - AZOrderedListItem( - userID: contact.contactID, - avatarURL: contact.avatarURL, - title: contact.title - ) - } + private func navigateToFriend(_ friendInfo: FriendSearchInfo) { + let conversationID = ChatUtil.getC2CConversationID(friendInfo.userID) + var conversation = ConversationInfo(conversationID: conversationID) + conversation.type = .c2c + conversation.title = friendInfo.friendRemark ?? friendInfo.userInfo?.nickname ?? friendInfo.userID + conversation.avatarURL = friendInfo.userInfo?.avatarURL + onConversationClick?(NavigationInfo(conversation: conversation)) } -} -// MARK: - UserPickerSheet - -struct UserPickerSheet: View { - let contactListStore: ContactListStore - @ObservedObject var selectedUsersContainer: SelectedUsersContainer - @Binding var showUserPicker: Bool - @Binding var showConfigSheet: Bool - @State private var friendList: [ContactInfo] = [] - - var body: some View { - NavigationView { - UserPicker( - userList: userPickerItems, - maxCount: 0, - onSelectedChanged: { users in - selectedUsersContainer.users = users - showUserPicker = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - showConfigSheet = true - } - } - ) - } - .navigationViewStyle(StackNavigationViewStyle()) - .onAppear { - contactListStore.fetchFriendList(completion: { _ in - }) - } - .onReceive(contactListStore.state.subscribe(StatePublisherSelector(keyPath: \ContactListState.friendList))) { newFriendList in - self.friendList = newFriendList - } + private func navigateToGroup(_ groupInfo: GroupSearchInfo) { + let conversationID = ChatUtil.getGroupConversationID(groupInfo.groupID) + var conversation = ConversationInfo(conversationID: conversationID) + conversation.type = .group + conversation.title = groupInfo.groupName + conversation.avatarURL = groupInfo.groupAvatarURL + onConversationClick?(NavigationInfo(conversation: conversation)) } - // Computed property that always reflects current friendList state - private var userPickerItems: [UserPickerItem] { - let items = friendList.map { contact in - UserPickerItem( - userID: contact.contactID, - avatarURL: contact.avatarURL, - title: contact.title ?? contact.contactID - ) + private func navigateFromDictionary(_ dict: [String: Any]) { + guard let conversationID = dict["conversationID"] as? String else { return } + var conversation = ConversationInfo(conversationID: conversationID) + conversation.type = conversationID.hasPrefix("c2c_") ? .c2c : .group + conversation.title = dict["conversationName"] as? String + conversation.avatarURL = dict["conversationAvatar"] as? String + if let messageInfo = dict["message"] as? MessageInfo { + onConversationClick?(NavigationInfo(conversation: conversation, locateMessage: messageInfo)) + } else { + onConversationClick?(NavigationInfo(conversation: conversation)) } - return items } } diff --git a/chat/uikit/LICENSE b/chat/uikit/LICENSE new file mode 100644 index 000000000..0d72eb2db --- /dev/null +++ b/chat/uikit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tencent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/chat/uikit/components/AudioPlayer/AudioPlayer.swift b/chat/uikit/components/AudioPlayer/AudioPlayer.swift new file mode 100644 index 000000000..47aa2b8c3 --- /dev/null +++ b/chat/uikit/components/AudioPlayer/AudioPlayer.swift @@ -0,0 +1,138 @@ +import AVFoundation +import AVKit +import Combine +import UIKit + +class AudioPlayer: NSObject, ObservableObject { + @Published public var isPlaying: Bool = false + + @Published public var isPaused: Bool = false + + @Published public var currentPlayingURL: URL? = nil + + private var player: AVPlayer? + + private var playerItem: AVPlayerItem? + + private var endObserver: NSObjectProtocol? + + private var failedObserver: NSObjectProtocol? + + private var itemStatusObservation: NSKeyValueObservation? + + public static func create() -> AudioPlayer { + return AudioPlayer() + } + + func play(_ url: URL) { + if isPlaying, currentPlayingURL == url { + teardownPlayer() + return + } + if player != nil { + teardownPlayer() + } + playInternal(url) + } + + func pause() { + guard let player = player, isPlaying else { return } + player.pause() + isPlaying = false + isPaused = true + } + + func resume() { + guard let player = player, isPaused else { return } + player.play() + isPlaying = true + isPaused = false + } + + public func stop() { + guard player != nil else { return } + teardownPlayer() + } + + deinit { + teardownPlayer() + } + + private func playInternal(_ url: URL) { + #if os(iOS) + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("AudioPlayer: AVAudioSession setup failed: \(error.localizedDescription)") + teardownPlayer() + return + } + #endif + + let item = AVPlayerItem(url: url) + let avPlayer = AVPlayer(playerItem: item) + avPlayer.actionAtItemEnd = .pause + + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + print("AudioPlayer: Playback finished") + self?.teardownPlayer() + } + + failedObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: item, + queue: .main + ) { [weak self] notification in + let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error + print("AudioPlayer: Item failed to play to end: \(error?.localizedDescription ?? "unknown")") + self?.teardownPlayer() + } + + itemStatusObservation = item.observe(\.status, options: [.new]) { [weak self] observedItem, _ in + guard let self = self else { return } + switch observedItem.status { + case .failed: + print("AudioPlayer: Item status failed: \(observedItem.error?.localizedDescription ?? "unknown")") + DispatchQueue.main.async { self.teardownPlayer() } + case .readyToPlay, .unknown: + break + @unknown default: + break + } + } + + playerItem = item + player = avPlayer + currentPlayingURL = url + isPlaying = true + isPaused = false + + avPlayer.play() + } + + private func teardownPlayer() { + if let observer = endObserver { + NotificationCenter.default.removeObserver(observer) + endObserver = nil + } + if let observer = failedObserver { + NotificationCenter.default.removeObserver(observer) + failedObserver = nil + } + itemStatusObservation?.invalidate() + itemStatusObservation = nil + + player?.pause() + player = nil + playerItem = nil + + isPlaying = false + isPaused = false + currentPlayingURL = nil + } +} diff --git a/chat/uikit/components/AudioRecorder/AudioRecorder.swift b/chat/uikit/components/AudioRecorder/AudioRecorder.swift new file mode 100644 index 000000000..adda855a2 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorder.swift @@ -0,0 +1,35 @@ +import AVFoundation +import AVKit +import UIKit + + +public class AudioRecorder: NSObject, ObservableObject { + static let shared = AudioRecorderImpl() + + public static var sharedRecorder: AudioRecorder { shared } + + public var onRecordingComplete: ((_ resultCode: AudioRecordResultCode, _ filePath: String, _ durationMs: Int) -> Void)? + public var onRecordTime: ((_ timeMs: Int) -> Void)? + public var onPowerLevel: ((_ powerLevel: Int) -> Void)? + + public func startRecord(filepath: String? = nil, enableAIDeNoise: Bool = false, minDurationMs: Int = 1000, maxDurationMs: Int = 60000) {} + + public func stopRecord() {} + + public func cancelRecord() {} + + override internal init() { + super.init() + } +} + +public enum AudioRecordResultCode: Int { + case exceedMaxDuration = 1 + case success = 0 + case errorCancel = -1 + case errorRecording = -2 + case errorStorageUnavailable = -3 + case errorLessThanMinDuration = -4 + case errorRecordInnerFail = -5 + case errorRecordPermissionDenied = -6 +} diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.h b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.h new file mode 100644 index 000000000..e348d6d98 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.h @@ -0,0 +1,24 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, AudioRecordSignatureResultCode) { + AudioRecordSignatureResultCodeSUCCESS = 0, + AudioRecordSignatureResultCodeErrorNoLiteavSdk = -7, + AudioRecordSignatureResultCodeEerrorNoIMSdk = -8, + AudioRecordSignatureResultCodeEerrorAppIdIsEmpty = -9, + AudioRecordSignatureResultCodeEerrorNoSignature = -10 +}; + +@interface AuidoRecordSignatureChecker : NSObject ++ (void)load; ++ (instancetype)shareInstance; +- (void)startUpdateSignature; +- (Boolean)setSignatureToSDK:(NSString*)sdkAppId; +- (AudioRecordSignatureResultCode)getSetSignatureResult; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.m b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.m new file mode 100644 index 000000000..fec98e822 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecordSignatureChecker.m @@ -0,0 +1,262 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "AudioRecordSignatureChecker.h" +#import + +#define SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_START 0.f +#define SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_SUCCESS 3600 * 1000.0f +#define SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_FAIL 1000.0f +#define SCHEDULE_UPDATE_SIGNATURE_RETRY_TIMES 3 +#define ERR_SDK_INTERFACE_NOT_SUPPORT 7013 +#define ERR_SDK_NOT_INITIALIZED 6013 + +@interface AuidoRecordSignatureChecker () { + NSTimer* _timer; + NSInvocation *getSignatureInvocation; + + NSString* _signature; + NSInteger _expiredTime; + AudioRecordSignatureResultCode _resultCode; + + void (^_succ)(NSObject *result); + void (^_fail)(int code, NSString *desc); +} +@property (nonatomic, assign) NSInteger retryCount; +@end + +@implementation AuidoRecordSignatureChecker + ++ (void)load { + [[AuidoRecordSignatureChecker shareInstance] startUpdateSignature]; +} + ++ (instancetype)shareInstance { + static id instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _retryCount = 0; + _expiredTime = 0; + _resultCode = AudioRecordSignatureResultCodeEerrorNoSignature; + + } + return self; +} + +- (void)startUpdateSignature{ + NSLog(@"TUIMultimediaSignatureChecker startUpdateSignature"); + if (![self createGetSignatureInvocation]) { + _resultCode = AudioRecordSignatureResultCodeEerrorNoIMSdk; + return; + } + + [self scheduleUpdateSignature:SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_START]; +} + +- (Boolean)setSignatureToSDK:(NSString*)sdkAppId { + if (_signature == nil || [_signature length] == 0) { + return NO; + } + + if (sdkAppId == nil || [sdkAppId length] == 0) { + NSLog(@"sdk appid is empty"); + _resultCode = AudioRecordSignatureResultCodeEerrorAppIdIsEmpty; + return NO; + } + + NSDictionary *param = @{ + @"api" : @"setSignature", + @"params" : @{ + @"appid" : sdkAppId, + @"signature" : _signature, + }, + }; + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:param options:0 error:&error]; + if (error != nil) { + NSLog(@"AuidoRecordSignatureChecker GetMultimediaIsSupport Error:%@", error); + } + NSString *paramStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSLog(@"AuidoRecordSignatureChecker: setSignature:%@", paramStr); + + if (![self setSignatureToUGCSdk:paramStr]) { + NSLog(@"callExperimentalAPI: method is not available."); + _resultCode = AudioRecordSignatureResultCodeErrorNoLiteavSdk; + return false; + } + + _resultCode = AudioRecordSignatureResultCodeSUCCESS; + NSLog(@"AuidoRecordSignatureChecker: set signature to sdk success"); + return true; +} + +- (AudioRecordSignatureResultCode)getSetSignatureResult { + return _resultCode; +} + +- (void)updateSignatureIfNeed { + if ([NSDate.now timeIntervalSince1970] < _expiredTime && _resultCode == AudioRecordSignatureResultCodeSUCCESS) { + [self scheduleUpdateSignature:(_expiredTime - [NSDate.now timeIntervalSince1970]) *1000]; + return; + } + _resultCode = AudioRecordSignatureResultCodeEerrorNoSignature; + + if (getSignatureInvocation == nil) { + NSLog(@"getSignatureInvocation is empty"); + _resultCode = AudioRecordSignatureResultCodeEerrorNoIMSdk; + return; + } + + [getSignatureInvocation invoke]; +} + +- (void)onGetSignatureSucc:(NSObject *) result { + if (result == nil || ![result isKindOfClass:NSDictionary.class]) { + NSLog(@"getVideoEditSignature: data = nil"); + return; + } + NSDictionary *data = (NSDictionary *)result; + NSLog(@"getVideoEditSignature: data = %@", data); + NSNumber *expiredTime = data[@"expired_time"]; + NSString *signature = data[@"signature"]; + if (![expiredTime isKindOfClass:NSNumber.class]) { + NSLog(@"getVideoEditSignature: expiredTime type error"); + return; + } + if (![signature isKindOfClass:NSString.class]) { + NSLog(@"getVideoEditSignature: signature type error"); + return; + } + + self.retryCount = 0; + self->_expiredTime = [expiredTime integerValue]; + self->_signature = signature; + NSLog(@"getVideoEditSignature: succeed. signature=%@, expiredTime=%@", self->_signature, @(self->_expiredTime)); + [self scheduleUpdateSignature:([expiredTime integerValue] - [NSDate.now timeIntervalSince1970]) * 1000]; +} + +- (void) onGetSignatureFail:(int) code desc:(NSString*) desc { + NSLog(@"getVideoEditSignature: failed. code=%@, desc=%@", @(code), desc); + if (code == ERR_SDK_INTERFACE_NOT_SUPPORT) { + [self cancelTimer]; + return; + } + if (code == ERR_SDK_NOT_INITIALIZED) { + self.retryCount = 0; + } + + if (self.retryCount ++ > SCHEDULE_UPDATE_SIGNATURE_RETRY_TIMES) { + self.retryCount = 0; + return; + } else { + NSLog(@"getVideoEditSignature: Attempting to get signature for the %ld time",self.retryCount); + [self scheduleUpdateSignature:SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_FAIL]; + } +} + +-(void)scheduleUpdateSignature:(float)interval { + [self cancelTimer]; + interval = interval / 1000; + _timer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(updateSignatureIfNeed) userInfo:nil repeats:NO]; +} + +- (void)cancelTimer { + if (_timer != nil) { + [_timer invalidate]; + _timer = nil; + } +} + +- (Boolean) createGetSignatureInvocation { + Class V2TIMManagerClass = NSClassFromString(@"V2TIMManager"); + if (!V2TIMManagerClass) { + NSLog(@"AuidoRecordSignatureChecker can not find V2TIMManager"); + return NO; + } + + SEL sharedInstanceSelector = NSSelectorFromString(@"sharedInstance"); + if (![V2TIMManagerClass respondsToSelector:sharedInstanceSelector]) { + NSLog(@"AuidoRecordSignatureChecker can not find V2TIMManager sharedInstance function"); + return NO; + } + + NSMethodSignature *sharedInstanceSignature = [V2TIMManagerClass methodSignatureForSelector:sharedInstanceSelector]; + NSInvocation *sharedInstanceInvocation = [NSInvocation invocationWithMethodSignature:sharedInstanceSignature]; + sharedInstanceInvocation.target = V2TIMManagerClass; + sharedInstanceInvocation.selector = sharedInstanceSelector; + [sharedInstanceInvocation invoke]; + + __unsafe_unretained id temp; + [sharedInstanceInvocation getReturnValue:&temp]; + id sharedInstance = temp; + + if (!sharedInstance) { + NSLog(@"AuidoRecordSignatureChecker can not get V2TIMManager instance"); + return NO; + } + + SEL apiSelector = NSSelectorFromString(@"callExperimentalAPI:param:succ:fail:"); + if (![sharedInstance respondsToSelector:apiSelector]) { + NSLog(@"AuidoRecordSignatureChecker has not callExperimentalAPI:param:succ:fail:"); + return NO; + } + + NSMethodSignature *methodSignature = [sharedInstance methodSignatureForSelector:apiSelector]; + getSignatureInvocation = [NSInvocation invocationWithMethodSignature:methodSignature]; + getSignatureInvocation.target = sharedInstance; + getSignatureInvocation.selector = apiSelector; + + NSString *apiName = @"getVideoEditSignature"; + [getSignatureInvocation setArgument:&apiName atIndex:2]; + + id param = nil; + [getSignatureInvocation setArgument:¶m atIndex:3]; + + _succ = ^(NSObject *result) { + [self onGetSignatureSucc:result]; + }; + [getSignatureInvocation setArgument:&_succ atIndex:4]; + + _fail = ^(int code, NSString *desc) { + [self onGetSignatureFail:code desc:desc]; + }; + [getSignatureInvocation setArgument:&_fail atIndex:5]; + + return YES; +} + +- (Boolean) setSignatureToUGCSdk:(NSString *)param { + Class TXUGCBase = NSClassFromString(@"TXUGCBase"); + if (TXUGCBase == nil) { + NSLog(@"AuidoRecordSignatureChecker can not find class TXUGCBase"); + return NO; + } + + SEL selector = NSSelectorFromString(@"callExperimentalAPI:"); + if (![TXUGCBase respondsToSelector:selector]) { + NSLog(@"AuidoRecordSignatureChecker TXUGCBase has not method callExperimentalAPI"); + return NO; + } + NSMethodSignature *signature = [TXUGCBase methodSignatureForSelector:selector]; + if (!signature) { + NSLog(@"AuidoRecordSignatureChecker can not get method: %@", @"callExperimentalAPI"); + return NO; + } + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + invocation.target = TXUGCBase; + invocation.selector = selector; + + [invocation setArgument:¶m atIndex:2]; + [invocation invoke]; + return YES; +} + +@end diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderImpl.swift b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderImpl.swift new file mode 100644 index 000000000..593aa6d49 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderImpl.swift @@ -0,0 +1,194 @@ + +import AVFoundation +import AtomicXCore +import Foundation +import os.log + +internal class AudioRecorderImpl: AudioRecorder { + private let logger = Logger(subsystem: "AudioRecoder", category: "AudioRecorderControl") + + private static let kMinDuration:Int = 1000 + + private static let kMaxDuration:Int = 60000 + + private var recordedFilePath: String? + + private var recorder:AudioRecorderInternalProtocol? + + private var isCancelRecord:Bool = false + + private var isRecording: Bool = false + private var startRequested: Bool = false + + private var minDurationMs: Int = AudioRecorderImpl.kMinDuration + + private var maxDurationMs: Int = AudioRecorderImpl.kMaxDuration + + private var currentTime:Int = 0 + + override public func startRecord(filepath: String? = nil, enableAIDeNoise: Bool = false, + minDurationMs: Int = AudioRecorderImpl.kMinDuration, maxDurationMs: Int = AudioRecorderImpl.kMaxDuration) { + logger.info("start record file path is \( filepath ?? "") enableAIDeNoise : \(enableAIDeNoise) minDuration: \(minDurationMs) maxDuration : \(maxDurationMs)") + + self.minDurationMs = minDurationMs + self.maxDurationMs = maxDurationMs + self.enableAIDeNoise(enableAIDeNoise) + + startRequested = true + checkMicPermission { [weak self] isGranted, _ in + guard let self = self else { return } + DispatchQueue.main.async { + if isGranted { + self.executeOnMainThread { + guard self.startRequested else { return } + self.startRecordInternal(filepath) + } + } else { + self.logger.error("request record audio permission refuse") + self.notifyCompleteOnMainThread(.errorRecordPermissionDenied) + } + } + } + } + + override public func stopRecord() { + logger.info("stop record") + startRequested = false + executeOnMainThread { + if (!self.isRecording) { + return + } + self.isRecording = false + if let recorder = self.recorder { + recorder.stopRecord() + } + } + } + + override public func cancelRecord() { + logger.info("cancel record") + startRequested = false + executeOnMainThread { + if (!self.isRecording) { + return + } + self.isCancelRecord = true + self.notifyCompleteOnMainThread(.errorCancel) + self.stopRecord() + } + } + + override init() { + super.init() + + recorder = AudioRecorderTXUGC() + if recorder == nil { + recorder = AudioRecorderSystem() + } + + var listener = AudioRecorderListener() + listener.onProgress = { [weak self] duration in + self?.currentTime = duration + self?.onRecordTime?(duration) + } + listener.onPower = { [weak self] power in + self?.onPowerLevel?(power) + } + listener.onComplete = onComplete + recorder?.setListener(listener) + } + + func executeOnMainThread(_ closure: @escaping () -> Void) { + if Thread.isMainThread { + closure() + } else { + DispatchQueue.main.async { + closure() + } + } + } + + private func enableAIDeNoise(_ enable: Bool){ + + logger.info("enable ai de noise. enbale:\(enable)") + let sdkAppId = NSNumber(value: LoginStore.shared.sdkAppID).stringValue + AuidoRecordSignatureChecker.shareInstance().setSignatureToSDK(sdkAppId) + recorder?.enableAIDeNoise(enable) + } + + private func startRecordInternal(_ filepath: String? = nil){ + logger.info("start record internal") + if isRecording { + logger.error("recording failed because it is currently being recorded") + notifyCompleteOnMainThread(.errorRecording) + return + } + + recordedFilePath = filepath + if recordedFilePath == nil { + recordedFilePath = createRecordedFilePath() + } + + guard let recordedFilePath = recordedFilePath, !recordedFilePath.isEmpty else { + logger.error("start record fail. because recorder init fail or recorded file path is empty") + notifyCompleteOnMainThread(.errorStorageUnavailable) + return + } + + guard let recorder = recorder else { + logger.error("recorder is nil") + return + } + + recorder.startRecord(recordedFilePath, minDurationMs, maxDurationMs) + isRecording = true + isCancelRecord = false + } + + private func createRecordedFilePath()->String { + let path = ChatUtil.generateMediaPath(messageType: .audio, withExtension: "m4a") + let directory = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true, attributes: nil) + return path + } + + private func checkMicPermission(completion: @escaping (Bool, Bool) -> Void) { + let permission = AVAudioSession.sharedInstance().recordPermission + + if permission == .denied || permission == .undetermined { + AVAudioSession.sharedInstance().requestRecordPermission { granted in + DispatchQueue.main.async { + completion(granted, true) + } + } + return + } + + let isGranted = permission == .granted + completion(isGranted, false) + } + + private func notifyCompleteOnMainThread(_ retCode: AudioRecordResultCode) { + executeOnMainThread { + if !self.isCancelRecord { + self.onRecordingComplete?(retCode, self.recordedFilePath ?? "", Int(self.currentTime) / 1000) + } else { + DispatchQueue.global().async { + self.removeRecordFile(self.recordedFilePath) + } + } + self.currentTime = 0; + } + } + + private func onComplete(_ retCode:AudioRecordResultCode) { + notifyCompleteOnMainThread(retCode); + } + + private func removeRecordFile(_ path : String?) { + logger.info("remove record file") + if let path = path, FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + } + } +} diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderInternalProtocol.swift b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderInternalProtocol.swift new file mode 100644 index 000000000..e9ea829f6 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderInternalProtocol.swift @@ -0,0 +1,14 @@ +import Foundation + +internal struct AudioRecorderListener { + var onProgress: (Int) -> Void = { _ in } + var onPower: (Int) -> Void = { _ in } + var onComplete: (AudioRecordResultCode) -> Void = { _ in } +} + +internal protocol AudioRecorderInternalProtocol: AnyObject { + func startRecord(_ path : String, _ minDuration: Int, _ maxDuration: Int) + func stopRecord() + func setListener(_ listener: AudioRecorderListener) + func enableAIDeNoise(_ enable : Bool) +} diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderSystem.swift b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderSystem.swift new file mode 100644 index 000000000..9fd5c8607 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderSystem.swift @@ -0,0 +1,132 @@ + +import os.log +import AVFoundation +import Foundation + +internal class AudioRecorderSystem: AudioRecorderInternalProtocol { + let logger = Logger(subsystem: "AudioRecoder", category: "AudioRecorderSystem") + + private static let kMinDuration:Int = 1000 + + private static let kMaxDuration: Int = 60000 + + private static let meterUpdateInterval: TimeInterval = 0.2 + + private static let toastDuration: TimeInterval = 5 + + private var listener: AudioRecorderListener? + + private var recorder: AVAudioRecorder? + + private var minDurationMs : Int = AudioRecorderSystem.kMinDuration + + private var maxDurationMs : Int = AudioRecorderSystem.kMaxDuration + + private var recordTimer: Timer? + + private var currentPower: Int = 0 + + private var currentTime: TimeInterval = 0 + + private var recordSetting:[String: Any] + + init() { + recordSetting = [ + AVSampleRateKey: 16000.0, + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVLinearPCMBitDepthKey: 16, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + } + + func startRecord(_ path : String, _ minDuration: Int, _ maxDuration: Int) { + self.minDurationMs = minDuration + self.maxDurationMs = maxDuration + + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playAndRecord) + try session.setActive(true) + } catch { + logger.error("Failed to set audio session category or activate session: \(error)") + listener?.onComplete(.errorRecordInnerFail); + return + } + + let url = URL(fileURLWithPath: path) + do { + recorder = try AVAudioRecorder(url: url, settings: recordSetting) + recorder?.isMeteringEnabled = true + recorder?.prepareToRecord() + recorder?.record() + recorder?.updateMeters() + } catch { + logger.error("Failed to initialize AVAudioRecorder: \(error)") + listener?.onComplete(.errorRecordInnerFail); + return + } + + recordTimer = Timer.scheduledTimer(timeInterval: Self.meterUpdateInterval, target: self, selector: #selector(onRecordTimer), userInfo: nil, repeats: true) + RunLoop.current.add(recordTimer!, forMode: .common) + logger.info("start system recording") + } + + func stopRecord() { + guard AVAudioSession.sharedInstance().recordPermission != .denied else { + return + } + onRecordTimer() + + recorder?.stop() + recorder = nil + + recordTimer?.invalidate() + recordTimer = nil + + if Int(currentTime) * 1000 < minDurationMs { + listener?.onComplete(.errorLessThanMinDuration) + } else if Int(currentTime) * 1000 > maxDurationMs { + listener?.onComplete(.exceedMaxDuration) + } else { + listener?.onComplete(.success) + } + + currentTime = 0 + logger.info("stop system recording") + } + + func setListener(_ listener: AudioRecorderListener) { + self.listener = listener + } + + func enableAIDeNoise(_ enable : Bool) { + if (!enable) { + return; + } + + logger.error("system audio record do not support ai de noise"); + #if DEBUG + WindowToastManager.shared.show(LocalizedChatString("AudioAuthorizationPrompter"), type: .warning, duration: Self.toastDuration) + #endif + } + + @objc private func onRecordTimer() { + recorder?.updateMeters() + + let time = recorder?.currentTime ?? 0 + print("record time = \(time)") + if time > currentTime { + currentTime = time + listener?.onProgress(Int(time * 1000)) + } + + if Int(time * 1000) > maxDurationMs { + stopRecord() + } + + let power = recorder?.averagePower(forChannel: 0) ?? 0 + currentPower = Int(power) + listener?.onPower(currentPower) + } +} diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderTXUGC.swift b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderTXUGC.swift new file mode 100644 index 000000000..65d39b916 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/AudioRecorderTXUGC.swift @@ -0,0 +1,138 @@ + +import os.log + +internal class AudioRecorderTXUGC: AudioRecorderInternalProtocol { + let logger = Logger(subsystem: "AudioRecoder", category: "AudioRecorderTXUGC") + + static let AUDIO_SAMPLE_RATE: Int = 48000 + + static let AUDIO_CHANNEL: Int = 2 + + static let AUDIO_BITRATE_BPS: Int = 50 * 1024 + + static let ERROR_LESS_THAN_MIN_DURATION = 2 + + static let RECORD_RESULT_OK_BEYOND_MAX_DURATION = 3 + + static let START_RECORD_ERR_LICENCE_VERIFICATION_FAILED = -5 + + private static let defaultMinDurationMs: Int = 1000 + + private static let defaultMaxDurationMs: Int = 6000 + + private static let toastDuration: TimeInterval = 5 + + private var minDurationMs : Int = AudioRecorderTXUGC.defaultMinDurationMs + + private var maxDurationMs : Int = AudioRecorderTXUGC.defaultMaxDurationMs + + private var recorder: TXUGCAudioRecorderReflector? + + private var ugcRecoderReflectorListener: AudioRecorderListenerProxy? + + private var listener: AudioRecorderListener? + + private var path: String = "local" + + private var isUseAiDeNoise: Bool + + init?() { + recorder = TXUGCAudioRecorderReflector() + guard let recorder = recorder else { + logger.error("TXUGCAudioRecorderReflector init fail.") + return nil + } + + ugcRecoderReflectorListener = AudioRecorderListenerProxy() + guard let ugcRecoderReflectorListener = ugcRecoderReflectorListener else { + return nil + } + + listener = nil + isUseAiDeNoise = true; + + ugcRecoderReflectorListener.recordProgressCallback = self.onRecordProgress + ugcRecoderReflectorListener.recordCompleteCallback = self.onRecordComplete + if !recorder.setRecordDelegate(ugcRecoderReflectorListener) { + logger.error("set record delegate fail.") + return nil + } + } + + func startRecord(_ path : String, _ minDuration: Int, _ maxDuration: Int) { + self.path = path + self.minDurationMs = minDuration + self.maxDurationMs = maxDuration + + guard let recorder = recorder else { + logger.error("start recoder fail. recoder is nil") + return; + } + + let config: [String: Any] = [ + "audioSampleRate": AudioRecorderTXUGC.AUDIO_SAMPLE_RATE, + "audioChannel": AudioRecorderTXUGC.AUDIO_CHANNEL, + "audioBitrateBps": AudioRecorderTXUGC.AUDIO_BITRATE_BPS, + "minDurationMs": minDuration, + "maxDurationMs": maxDuration, + "enableAIDeNoise": isUseAiDeNoise + ] + + let result = recorder.startRecord(videoPath: path, config: config) + logger.info("start record. result:\(result.rawValue)") + if (result == .success) { + return + } + + if (result == .licenseFailed) { + handleLicenceVerificationFailed() + } else { + listener?.onComplete(.errorRecordInnerFail) + } + } + + func stopRecord() { + if let recorder = recorder { + recorder.stopRecord(); + } + } + + func setListener(_ listener: AudioRecorderListener) { + self.listener = listener; + } + + func enableAIDeNoise(_ enable : Bool) { + isUseAiDeNoise = enable + } + + private func handleLicenceVerificationFailed() { + logger.info("handle licence verification failed."); + + #if DEBUG + WindowToastManager.shared.show(LocalizedChatString("AudioAuthorizationPrompter"), type: .warning, duration: Self.toastDuration) + #endif + + if (isUseAiDeNoise) { + isUseAiDeNoise = false + startRecord(self.path, minDurationMs, maxDurationMs) + } + } + + private func onRecordProgress(milliSecond:Int) { + if let listener = listener { + listener.onProgress(milliSecond) + } + } + + private func onRecordComplete(_ retCode :Int, _ msg : String, _ videoPath :String) { + if (retCode == 0) { + listener?.onComplete(.success) + } else if (retCode == AudioRecorderTXUGC.ERROR_LESS_THAN_MIN_DURATION) { + listener?.onComplete(.errorLessThanMinDuration) + } else if (retCode == AudioRecorderTXUGC.RECORD_RESULT_OK_BEYOND_MAX_DURATION) { + listener?.onComplete(.exceedMaxDuration) + } else { + listener?.onComplete(.errorRecordInnerFail) + } + } +} diff --git a/chat/uikit/components/AudioRecorder/AudioRecorderCore/TXUGCAudioRecorderReflector.swift b/chat/uikit/components/AudioRecorder/AudioRecorderCore/TXUGCAudioRecorderReflector.swift new file mode 100644 index 000000000..4e3f766e9 --- /dev/null +++ b/chat/uikit/components/AudioRecorder/AudioRecorderCore/TXUGCAudioRecorderReflector.swift @@ -0,0 +1,127 @@ + +import Foundation +import ObjectiveC + +internal class TXUGCAudioRecorderReflector { + enum TXUGCRecordResultCode: Int { + case ok = 0 + case okInterrupt = 1 + case okUnreachMinDuration = 2 + case okBeyondMaxDuration = 3 + case failed = 1001 + } + + @objc public enum TXUGCStartAudioRecordResultCode: Int { + case success = 0 + case pathEmpty = -2 + case micUnavailable = -4 + case licenseFailed = -5 + case noliteavsdk = -6 + } + + enum TXAudioSampleRate: Int { + case rate44100 = 44100 + case rate48000 = 48000 + } + + enum TXAudioChannel: Int { + case mono = 1 + case stereo = 2 + } + + private var recorderInstance: AnyObject? + + private var recorderClass: AnyClass? + + private var configClass: AnyClass? + + init?() { + recorderClass = NSClassFromString("TXUGCAudioRecorder") + configClass = NSClassFromString("TXUGCAudioConfig") + + guard let recorderClass = recorderClass as? NSObject.Type, + let configClass = configClass as? NSObject.Type else { + return nil + } + + let shareInstanceSelector = NSSelectorFromString("shareInstance") + if recorderClass.responds(to: shareInstanceSelector) { + let result = recorderClass.perform(shareInstanceSelector) + recorderInstance = result?.takeUnretainedValue() + } + + if recorderInstance == nil { + return nil + } + } + + func setRecordDelegate(_ delegate: AnyObject) -> Bool{ + guard let recorder = recorderInstance else { return false} + + let selector = NSSelectorFromString("setRecordDelegate:") + if recorder.responds(to: selector) { + _ = recorder.perform(selector, with: delegate)?.takeUnretainedValue() + return true + } + return false + } + + func startRecord(videoPath: String, config: [String: Any]) -> TXUGCStartAudioRecordResultCode { + if recorderClass == nil { + return .noliteavsdk + } + + guard let recorder = recorderInstance, + let configObj = createConfigObject(with: config) else { + return .noliteavsdk + } + + let selector = NSSelectorFromString("startRecord:config:") + let methodSignature = recorder.method(for: selector) + typealias StartRecordFunction = @convention(c) (AnyObject, Selector, NSString, AnyObject) -> Int + let function = unsafeBitCast(methodSignature, to: StartRecordFunction.self) + let resultCode = function(recorder, selector, videoPath as NSString, configObj) + return TXUGCStartAudioRecordResultCode(rawValue: resultCode) ?? .success + } + + func stopRecord() { + print("stopRecord") + guard let recorder = recorderInstance else { return } + + let selector = NSSelectorFromString("stopRecord") + if recorder.responds(to: selector) { + _ = recorder.perform(selector)?.takeUnretainedValue() + } + } + + private func createConfigObject(with config: [String: Any]) -> AnyObject? { + guard let configClass = configClass else { return nil } + let configInstance = configClass.alloc() + config.forEach { key, value in + configInstance.setValue(value, forKey: key) + } + return configInstance + } +} + +// MARK: - AudioRecorderListenerProxy +class AudioRecorderListenerProxy: NSObject { + var recordProgressCallback: ((Int) -> Void)? + var recordCompleteCallback: ((Int, String, String) -> Void)? + + @objc func onRecordProgress(_ milliSecond: Int) { + recordProgressCallback?(milliSecond) + } + + @objc func onRecordComplete(_ result: AnyObject) { + var retCode = -1 + var descMsg = "" + var videoPath = "" + + retCode = result.value(forKey: "retCode") as? Int ?? 0 + descMsg = result.value(forKey: "descMsg") as? String ?? "" + videoPath = result.value(forKey: "videoPath") as? String ?? "" + print("on record complete. retcode:\(retCode) descMsg:\(descMsg) videoPath:\(videoPath)") + recordCompleteCallback?(retCode, descMsg, videoPath) + } +} diff --git a/chat/uikit/components/BaseComponent/basiccontrols/AlertDialog.swift b/chat/uikit/components/BaseComponent/basiccontrols/AlertDialog.swift new file mode 100644 index 000000000..fb94e20b7 --- /dev/null +++ b/chat/uikit/components/BaseComponent/basiccontrols/AlertDialog.swift @@ -0,0 +1,325 @@ +import UIKit + +private class AlertWindow: UIWindow { + override init(windowScene: UIWindowScene) { + super.init(windowScene: windowScene) + windowLevel = UIWindow.Level.alert + 1 + backgroundColor = UIColor.clear + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class AlertDialogView: UIView { + var onConfirm: (() -> Void)? + + var onCancel: (() -> Void)? + + var onDismiss: (() -> Void)? + + private static let dialogWidth: CGFloat = 327 + + private static let cornerRadius: CGFloat = CGFloat(RadiusScheme.superLargeRadius) + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private static let titleTopPadding: CGFloat = CGFloat(SpacingScheme.titleSpacing) + + private static let messageTopWithoutTitle: CGFloat = CGFloat(SpacingScheme.titleSpacing) + + private static let messageTopWithTitle: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let messageBottomPadding: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let messageMaxHeight: CGFloat = 330 + + private static let messageLineSpacing: CGFloat = 6 + + private static let buttonSpacingHeight: CGFloat = 20 + + private static let buttonRowHeight: CGFloat = 56 + + private static let dividerWidth: CGFloat = 1 + + private let titleLabel = UILabel() + + private let messageScrollView = UIScrollView() + + private let messageLabel = UILabel() + + private let topDivider = UIView() + + private let buttonStack = UIStackView() + + private var messageScrollViewTopToTitle: NSLayoutConstraint! + + private var messageScrollViewTopToSuper: NSLayoutConstraint! + + private var topDividerTopToMessage: NSLayoutConstraint! + + private var topDividerTopToTitle: NSLayoutConstraint! + + private var topDividerTopToSuper: NSLayoutConstraint! + + init(title: String?, message: String?, cancelText: String?, confirmText: String) { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + configure(title: title, message: message, cancelText: cancelText, confirmText: confirmText) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func constructViewHierarchy() { + addSubview(titleLabel) + addSubview(messageScrollView) + messageScrollView.addSubview(messageLabel) + addSubview(topDivider) + addSubview(buttonStack) + } + + private func activateConstraints() { + titleLabel.translatesAutoresizingMaskIntoConstraints = false + messageScrollView.translatesAutoresizingMaskIntoConstraints = false + messageLabel.translatesAutoresizingMaskIntoConstraints = false + topDivider.translatesAutoresizingMaskIntoConstraints = false + buttonStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: Self.dialogWidth), + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: Self.titleTopPadding), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalPadding), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalPadding), + messageScrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalPadding), + messageScrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalPadding), + messageScrollView.heightAnchor.constraint(lessThanOrEqualToConstant: Self.messageMaxHeight), + messageLabel.topAnchor.constraint(equalTo: messageScrollView.topAnchor), + messageLabel.leadingAnchor.constraint(equalTo: messageScrollView.leadingAnchor), + messageLabel.trailingAnchor.constraint(equalTo: messageScrollView.trailingAnchor), + messageLabel.bottomAnchor.constraint(equalTo: messageScrollView.bottomAnchor), + messageLabel.widthAnchor.constraint(equalTo: messageScrollView.widthAnchor), + topDivider.leadingAnchor.constraint(equalTo: leadingAnchor), + topDivider.trailingAnchor.constraint(equalTo: trailingAnchor), + topDivider.heightAnchor.constraint(equalToConstant: Self.dividerWidth), + buttonStack.topAnchor.constraint(equalTo: topDivider.bottomAnchor), + buttonStack.leadingAnchor.constraint(equalTo: leadingAnchor), + buttonStack.trailingAnchor.constraint(equalTo: trailingAnchor), + buttonStack.heightAnchor.constraint(equalToConstant: Self.buttonRowHeight), + buttonStack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + messageScrollViewTopToTitle = messageScrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Self.messageTopWithTitle) + messageScrollViewTopToSuper = messageScrollView.topAnchor.constraint(equalTo: topAnchor, constant: Self.messageTopWithoutTitle) + topDividerTopToMessage = topDivider.topAnchor.constraint(equalTo: messageScrollView.bottomAnchor, constant: Self.messageBottomPadding + Self.buttonSpacingHeight) + topDividerTopToTitle = topDivider.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Self.buttonSpacingHeight) + topDividerTopToSuper = topDivider.topAnchor.constraint(equalTo: topAnchor, constant: Self.titleTopPadding + Self.buttonSpacingHeight) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorDialog + layer.cornerRadius = Self.cornerRadius + layer.masksToBounds = true + titleLabel.font = ThemeState.shared.fonts.body4Bold + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + messageLabel.font = ThemeState.shared.fonts.caption1Regular + messageLabel.textColor = colors.textColorSecondary + messageLabel.numberOfLines = 0 + topDivider.backgroundColor = colors.strokeColorModule + buttonStack.axis = .horizontal + buttonStack.distribution = .fillEqually + buttonStack.spacing = 0 + } + + private func configure(title: String?, message: String?, cancelText: String?, confirmText: String) { + let hasTitle = !(title ?? "").isEmpty + let hasMessage = !(message ?? "").isEmpty + titleLabel.text = title + titleLabel.isHidden = !hasTitle + if hasMessage { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = Self.messageLineSpacing + messageLabel.attributedText = NSAttributedString( + string: message ?? "", + attributes: [ + .font: ThemeState.shared.fonts.caption1Regular, + .foregroundColor: ChatUIKitTheme.colors.textColorSecondary, + .paragraphStyle: paragraphStyle + ] + ) + } + messageScrollView.isHidden = !hasMessage + messageScrollViewTopToTitle.isActive = hasTitle + messageScrollViewTopToSuper.isActive = !hasTitle + if hasMessage { + topDividerTopToMessage.isActive = true + } else if hasTitle { + topDividerTopToTitle.isActive = true + } else { + topDividerTopToSuper.isActive = true + } + + if let cancelText = cancelText { + let cancelButton = makeButton(title: cancelText, color: ChatUIKitTheme.colors.textColorPrimary) + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + buttonStack.addArrangedSubview(cancelButton) + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorModule + divider.translatesAutoresizingMaskIntoConstraints = false + divider.widthAnchor.constraint(equalToConstant: Self.dividerWidth).isActive = true + buttonStack.addArrangedSubview(divider) + } + let confirmButton = makeButton(title: confirmText, color: ChatUIKitTheme.colors.textColorLink) + confirmButton.addTarget(self, action: #selector(handleConfirm), for: .touchUpInside) + buttonStack.addArrangedSubview(confirmButton) + } + + private func makeButton(title: String, color: UIColor) -> UIButton { + let button = UIButton(type: .custom) + button.setTitle(title, for: .normal) + button.setTitleColor(color, for: .normal) + button.titleLabel?.font = ThemeState.shared.fonts.caption1Medium + return button + } + + @objc private func handleConfirm() { + onDismiss?() + onConfirm?() + } + + @objc private func handleCancel() { + onDismiss?() + onCancel?() + } +} + +class WindowAlertManager: NSObject { + public static let shared = WindowAlertManager() + + private static let maskBackgroundAlpha: CGFloat = 0.4 + + private static let windowReleaseDelay: TimeInterval = 0.1 + + private var alertWindow: AlertWindow? + + private var onConfirm: (() -> Void)? + + private var onCancel: (() -> Void)? + + private var onDismiss: (() -> Void)? + + public func showAlert( + title: String? = nil, + message: String? = nil, + cancelText: String? = nil, + confirmText: String = LocalizedChatString("Confirm"), + onConfirm: @escaping () -> Void = {}, + onCancel: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil + ) { + DispatchQueue.main.async { + self.onConfirm = onConfirm + self.onCancel = onCancel + self.onDismiss = onDismiss + if cancelText == nil { + self.presentSystemAlert(title: title, message: message, confirmText: confirmText) + } else { + self.presentCustomAlert(title: title, message: message, cancelText: cancelText, confirmText: confirmText) + } + } + } + + public func dismiss() { + DispatchQueue.main.async { + self.onDismiss?() + self.dismissAlert() + self.clearCallbacks() + } + } + + private override init() {} + + private func presentSystemAlert(title: String?, message: String?, confirmText: String) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController else { + return + } + var topViewController = rootViewController + while let presented = topViewController.presentedViewController { + topViewController = presented + } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: confirmText, style: .default) { [weak self] _ in + self?.onConfirm?() + self?.dismiss() + }) + topViewController.present(alert, animated: true) + } + + private func presentCustomAlert(title: String?, message: String?, cancelText: String?, confirmText: String) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return + } + dismissAlert() + let window = AlertWindow(windowScene: windowScene) + LanguageHelper.applyLayoutDirection(to: window) + let rootViewController = UIViewController() + rootViewController.view.backgroundColor = UIColor.black.withAlphaComponent(Self.maskBackgroundAlpha) + window.rootViewController = rootViewController + + let dialog = AlertDialogView(title: title, message: message, cancelText: cancelText, confirmText: confirmText) + dialog.onConfirm = { [weak self] in self?.onConfirm?() } + dialog.onCancel = { [weak self] in self?.onCancel?() } + dialog.onDismiss = { [weak self] in self?.dismiss() } + rootViewController.view.addSubview(dialog) + dialog.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dialog.centerXAnchor.constraint(equalTo: rootViewController.view.centerXAnchor), + dialog.centerYAnchor.constraint(equalTo: rootViewController.view.centerYAnchor) + ]) + + let maskTap = UITapGestureRecognizer(target: self, action: #selector(handleMaskTapped)) + maskTap.cancelsTouchesInView = false + maskTap.delegate = self + rootViewController.view.addGestureRecognizer(maskTap) + + alertWindow = window + window.isHidden = false + } + + @objc private func handleMaskTapped() { + dismiss() + } + + private func dismissAlert() { + alertWindow?.isHidden = true + let windowToRelease = alertWindow + alertWindow = nil + DispatchQueue.main.asyncAfter(deadline: .now() + Self.windowReleaseDelay) { + windowToRelease?.rootViewController = nil + } + } + + private func clearCallbacks() { + onConfirm = nil + onCancel = nil + onDismiss = nil + } +} + +extension WindowAlertManager: UIGestureRecognizerDelegate { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var view = touch.view + while let current = view { + if current is AlertDialogView { return false } + view = current.superview + } + return true + } +} diff --git a/chat/uikit/components/BaseComponent/basiccontrols/Toast.swift b/chat/uikit/components/BaseComponent/basiccontrols/Toast.swift new file mode 100644 index 000000000..4899b6d66 --- /dev/null +++ b/chat/uikit/components/BaseComponent/basiccontrols/Toast.swift @@ -0,0 +1,329 @@ +import UIKit + +enum ToastPosition { + case center + case bottom(CGFloat) +} + +enum ToastType { + case loading + case info + case success + case warning + case error + case help + var iconName: String { + switch self { + case .loading: + return "loading-blue" + case .info: + return "info-circle-filled" + case .success: + return "check-circle-filled" + case .warning: + return "error-circle-filled" + case .error: + return "error-circle-filled" + case .help: + return "help-circle-filled" + } + } + + var iconColor: UIColor { + let colors = ChatUIKitTheme.colors + switch self { + case .loading, .info, .help: + return colors.textColorLink + case .success: + return colors.textColorSuccess + case .warning: + return colors.textColorWarning + case .error: + return colors.textColorError + } + } +} + +private final class ToastContentView: UIView { + private static let iconSize: CGFloat = 16 + + private static let iconTextSpacing: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let verticalPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let cornerRadius: CGFloat = 6 + + private static let borderWidth: CGFloat = 1 + + private static let shadowRadiusLarge: CGFloat = 8 + + private static let shadowOffsetLarge = CGSize(width: 0, height: 6) + + private static let trailingIconCornerRadius: CGFloat = 3 + + private static let fullCircleRadians = CGFloat.pi * 2 + + private static let loadingRotationDuration: TimeInterval = 1.0 + + private let iconImageView = UIImageView() + + private let messageLabel = UILabel() + + private let trailingIconImageView = UIImageView() + + private let onDismiss: () -> Void + + init(type: ToastType?, message: String, customIcon: String?, trailingIcon: UIImage? = nil, onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints(showTrailingIcon: trailingIcon != nil) + setupViewStyle() + configure(type: type, message: message, customIcon: customIcon, trailingIcon: trailingIcon) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapped)) + addGestureRecognizer(tap) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func constructViewHierarchy() { + addSubview(iconImageView) + addSubview(messageLabel) + addSubview(trailingIconImageView) + } + + private func activateConstraints(showTrailingIcon: Bool) { + iconImageView.translatesAutoresizingMaskIntoConstraints = false + messageLabel.translatesAutoresizingMaskIntoConstraints = false + trailingIconImageView.translatesAutoresizingMaskIntoConstraints = false + var constraints = [ + iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalPadding), + iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconImageView.heightAnchor.constraint(equalToConstant: Self.iconSize), + messageLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: Self.iconTextSpacing), + messageLabel.topAnchor.constraint(equalTo: topAnchor, constant: Self.verticalPadding), + messageLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.verticalPadding) + ] + if showTrailingIcon { + constraints.append(contentsOf: [ + messageLabel.trailingAnchor.constraint(equalTo: trailingIconImageView.leadingAnchor, constant: -Self.iconTextSpacing), + trailingIconImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalPadding), + trailingIconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + trailingIconImageView.widthAnchor.constraint(equalToConstant: Self.iconSize), + trailingIconImageView.heightAnchor.constraint(equalToConstant: Self.iconSize) + ]) + } else { + constraints.append(messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalPadding)) + } + NSLayoutConstraint.activate(constraints) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.floatingColorDefault + layer.cornerRadius = Self.cornerRadius + layer.borderWidth = Self.borderWidth + layer.borderColor = colors.strokeColorSecondary.cgColor + layer.shadowColor = colors.shadowColor.cgColor + layer.shadowOpacity = 1 + layer.shadowRadius = Self.shadowRadiusLarge + layer.shadowOffset = Self.shadowOffsetLarge + messageLabel.font = ThemeState.shared.fonts.caption2Medium + messageLabel.textColor = colors.textColorPrimary + messageLabel.numberOfLines = 0 + } + + private func configure(type: ToastType?, message: String, customIcon: String?, trailingIcon: UIImage?) { + messageLabel.text = message + if let trailingIcon = trailingIcon { + trailingIconImageView.image = trailingIcon + trailingIconImageView.contentMode = .scaleAspectFit + trailingIconImageView.layer.cornerRadius = Self.trailingIconCornerRadius + trailingIconImageView.clipsToBounds = true + } else { + trailingIconImageView.isHidden = true + } + guard let type = type else { + iconImageView.isHidden = true + messageLabel.textAlignment = .center + return + } + iconImageView.image = AtomicXChatResources.image(named: customIcon ?? type.iconName)?.withRenderingMode(.alwaysTemplate) + iconImageView.tintColor = type.iconColor + if type == .loading { + startLoadingAnimation() + } + } + + private func startLoadingAnimation() { + let rotation = CABasicAnimation(keyPath: "transform.rotation.z") + rotation.toValue = Self.fullCircleRadians + rotation.duration = Self.loadingRotationDuration + rotation.repeatCount = .infinity + rotation.isRemovedOnCompletion = false + iconImageView.layer.add(rotation, forKey: "toastLoadingRotation") + } + + @objc private func handleTapped() { + onDismiss() + } +} + +private class ToastWindow: UIWindow { + private static let levelIncrementAboveAlert: CGFloat = 2 + + override var canBecomeKey: Bool { + return false + } + + override init(windowScene: UIWindowScene) { + super.init(windowScene: windowScene) + windowLevel = UIWindow.Level.alert + Self.levelIncrementAboveAlert + backgroundColor = UIColor.clear + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + guard let hitView = hitView, + hitView != self, + hitView != self.rootViewController?.view else { + return nil + } + return hitView + } +} + +private class ToastRootViewController: UIViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .default + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.clear + } +} + +public class WindowToastManager { + public static let shared = WindowToastManager() + + private static let horizontalMargin: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private static let windowReleaseDelay: TimeInterval = 0.1 + + private var toastWindow: ToastWindow? + + private var contentView: ToastContentView? + + private var hideTimer: Timer? + + func loading(_ message: String) { + show(message, type: .loading) + } + + public func info(_ message: String) { + show(message, type: .info) + } + + public func success(_ message: String) { + show(message, type: .success) + } + + func warning(_ message: String) { + show(message, type: .warning) + } + + public func error(_ message: String) { + show(message, type: .error) + } + + func show( + _ message: String, + type: ToastType? = nil, + icon: String? = nil, + duration: TimeInterval = 2.0, + position: ToastPosition = .center, + trailingIcon: UIImage? = nil + ) { + runOnMain { + self.presentToast(type: type, message: message, customIcon: icon, trailingIcon: trailingIcon, position: position) + self.hideTimer?.invalidate() + self.hideTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in + DispatchQueue.main.async { + self.hide() + } + } + } + } + + func hide() { + runOnMain { + self.hideTimer?.invalidate() + self.dismissToast() + } + } + + private init() {} + + private func presentToast(type: ToastType?, message: String, customIcon: String?, trailingIcon: UIImage?, position: ToastPosition) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return + } + dismissToast() + + let window = ToastWindow(windowScene: windowScene) + LanguageHelper.applyLayoutDirection(to: window) + window.rootViewController = ToastRootViewController() + let content = ToastContentView(type: type, message: message, customIcon: customIcon, trailingIcon: trailingIcon) { [weak self] in + self?.hide() + } + guard let rootView = window.rootViewController?.view else { return } + rootView.addSubview(content) + content.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + content.centerXAnchor.constraint(equalTo: rootView.centerXAnchor), + content.leadingAnchor.constraint(greaterThanOrEqualTo: rootView.leadingAnchor, constant: Self.horizontalMargin), + content.trailingAnchor.constraint(lessThanOrEqualTo: rootView.trailingAnchor, constant: -Self.horizontalMargin) + ]) + switch position { + case .center: + content.centerYAnchor.constraint(equalTo: rootView.centerYAnchor).isActive = true + case .bottom(let offset): + content.bottomAnchor.constraint(equalTo: rootView.bottomAnchor, constant: -offset).isActive = true + } + + toastWindow = window + contentView = content + window.isHidden = false + } + + private func dismissToast() { + toastWindow?.isHidden = true + let windowToRelease = toastWindow + toastWindow = nil + contentView = nil + DispatchQueue.main.asyncAfter(deadline: .now() + Self.windowReleaseDelay) { + windowToRelease?.rootViewController = nil + } + } + + private func runOnMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async { + block() + } + } + } +} diff --git a/chat/uikit/components/BaseComponent/basiccontrols/readme.md b/chat/uikit/components/BaseComponent/basiccontrols/readme.md new file mode 100644 index 000000000..76e5313fd --- /dev/null +++ b/chat/uikit/components/BaseComponent/basiccontrols/readme.md @@ -0,0 +1,464 @@ +# BaseComponent SwiftUI 组件使用说明 + +## 目录 + +- [Avatar](#avatar) +- [Button](#button) +- [Toast](#toast) +- [AlertDialog](#alertdialog) +- [Label](#label) +- [Badge](#badge) +- [Switch](#switch) +- [Bubble](#bubble) +- [PopMenu](#popmenu) +- [主题语义化样式枚举](#主题语义化样式枚举) + +--- + +## Avatar + +### 用法 + +#### 便捷用法 +```swift +Avatar(url: "https://example.com/avatar.jpg", name: "张三") +``` + +#### 完整用法 +```swift +Avatar( + content: .image(url: "https://example.com/avatar.jpg", name: "张三"), + size: .m, // .xs / .s / .m / .l / .xl / .xxl + shape: .round, // .round / .roundedRectangle / .rectangle + status: .online, // .none / .online / .offline + badge: .count(5), // .none / .dot / .text("VIP") / .count(5) + onClick: { /* ... */ } +) +``` + +### 参数说明 +- `content`: 头像内容(.image/.text/.symbol/.local) +- `size`: 头像尺寸 +- `shape`: 头像形状 +- `status`: 在线状态点 +- `badge`: 角标 +- `onClick`: 点击回调(可选) + +### 字体适配 +头像内文字的字体会根据 `size` 自动适配,无需手动指定。 + +--- + +## Button + +### 用法 + +#### 便捷用法 +```swift +FilledButton(text: "主按钮") { /* ... */ } +OutlinedButton(text: "次按钮") { /* ... */ } +IconButton(text: "带图标", icon: Image(systemName: "star")) { /* ... */ } +``` + +#### 完整用法 +```swift +ButtonView( + content: .iconWithText(text: "收藏", icon: Image(systemName: "star")), + enabled: true, + type: .filled, // .filled / .outlined / .noBorder + colorType: .primary, // .primary / .secondary / .danger + size: .m, // .xs / .s / .m / .l + onClick: { /* ... */ } +) +``` + +### 参数说明 +- `content`: 按钮内容(.textOnly/.iconOnly/.iconWithText) +- `enabled`: 是否可用(默认 true) +- `type`: 按钮类型(填充、描边、无边框) +- `colorType`: 语义色类型(主色、次色、危险) +- `size`: 按钮尺寸(xs/s/m/l) +- `onClick`: 点击回调 + +### 字体适配 +按钮字体会根据 `size` 自动适配,无需手动指定。 + +### 主题环境 +需要 `.environmentObject(themeState)` 注入主题。 + +--- + +## Toast + +### 用法 + +#### 页面集成 +```swift +@StateObject private var toast = Toast() + +var body: some View { + VStack { + // ... 你的页面内容 ... + Button("显示 Toast") { + toast.success("操作成功!") + } + } + .toast(toast) // 必须加在页面最外层 +} +``` + +#### 便捷用法 +```swift +toast.loading("加载中...") +toast.info("信息提示") +toast.success("操作成功") +toast.warning("警告信息") +toast.error("错误信息") +toast.simple("纯文字提示") +``` + +#### 完整用法 +```swift +toast.show("自定义内容", type: .info, icon: "iconName", duration: 2.0) +``` + +### 主题环境 +Toast 组件自动适配深色/浅色主题,无需额外配置。 + +--- + +## AlertDialog + +### 用法 + +#### 便捷用法 +```swift +@State private var showAlert = false +Button("弹窗") { showAlert = true } + .alertDialog(isPresented: $showAlert, message: "操作成功!") +``` + +#### 完整用法 +```swift +.alertDialog( + isPresented: $showAlert, + title: "确认操作", + message: "是否确认执行此操作?", + confirmText: "确认", + onConfirm: { /* 确认回调 */ }, + secondaryText: "取消", + onSecondary: { /* 取消回调 */ } +) +``` + +### 参数说明 +- `isPresented`: 显示绑定 +- `title`: 标题(可选) +- `message`: 内容(可选) +- `confirmText`: 确认按钮文字(默认"我知道了") +- `onConfirm`: 确认回调 +- `secondaryText`: 次要按钮文字(可选) +- `onSecondary`: 次要按钮回调(可选) + +--- + +## Label + +### 用法 + +#### 便捷用法 +```swift +TitleLabel(size: .s, text: "主标题-小") +SubTitleLabel(size: .s, text: "副标题-小") +ItemLabel(size: .s, text: "条目-小") +DangerLabel(size: .s, text: "警告-小") +TagLabel(size: .m, text: "标签", colorType: .blue) +``` + +#### 完整用法 +```swift +CustomLabel( + text: "自定义Label", + font: .system(size: 16), + textColor: .red, + backgroundColor: .yellow, + lineLimit: 2, + icon: "iconName", // 可选,assets图片名 + iconPosition: .start // .start/.end +) +``` + +### 参数说明 +- `size`: .s / .m / .l(小/中/大) +- `text`: 文本内容 +- `font`: 字体(仅 CustomLabel 支持) +- `textColor`: 文字颜色(CustomLabel 支持自定义,其它自动适配主题) +- `backgroundColor`: 背景色(CustomLabel 支持自定义,其它自动适配主题) +- `lineLimit`: 行数限制(默认1,CustomLabel可自定义) +- `icon`: 图标(assets图片名,CustomLabel/TagLabel 支持) +- `iconPosition`: 图标位置(.start/.end,CustomLabel/TagLabel 支持) +- `colorType`: TagLabel 颜色类型(.white/.blue/.green/.gray/.orange/.red) + +### 字体适配 +Label 字体会根据 `size` 和类型自动适配,无需手动指定。 + +### 主题环境 +Label 组件自动适配主题色,无需手动设置。 + +--- + +## Badge + +### 用法 +```swift +Badge(text: "99+") +Badge(type: .dot) +Badge(text: "新消息", type: .text) +``` + +### 参数说明 +- `text`: 显示文本(可选) +- `type`: .text / .dot + +--- + +## Switch + +### 用法 +```swift +BasicSwitch(checked: isOn, onCheckedChange: { isOn = $0 }) +``` + +#### 完整用法 +```swift +SwitchView( + checked: isOn, + onCheckedChange: { isOn = $0 }, + enabled: true, + loading: false, + size: .l, // .s / .m / .l + type: .basic // .basic / .withText / .withIcon +) +``` + +### 参数说明 +- `checked`: 当前开关状态(Bool) +- `onCheckedChange`: 状态变更回调 ((Bool) -> Void)? +- `enabled`: 是否可用(默认 true) +- `loading`: 是否显示加载动画(默认 false) +- `size`: 尺寸(.s / .m / .l) +- `type`: 展示类型(.basic / .withText / .withIcon) + +--- + +## Bubble + +### 用法 +```swift +LeftBottomSquareBubble(backgroundColor: .green) { + Text("左下直角气泡") +} +RightBottomSquareBubble(backgroundColor: .blue) { + Text("右下直角气泡") +} +AllRoundBubble(backgroundColor: .orange) { + Text("全圆角气泡") +} +LeftTopSquareBubble(backgroundColor: .purple) { + Text("左上直角气泡") +} +RightTopSquareBubble(backgroundColor: .pink) { + Text("右上直角气泡") +} +``` + +#### 通用用法 +```swift +Bubble( + bubbleColorType: .filled, // .filled / .outlined / .both + backgroundColor: .gray, + highlightColors: [.gray, .white], // 可选,渐变色 + radii: [18, 18, 0, 18], // 四角圆角 + borderColor: .red, // 可选,描边色 + borderWidth: 2 // 可选,描边宽度 +) { + Text("自定义内容") +} +``` + +### 参数说明 +- `bubbleColorType`: 气泡类型(.filled 填充,.outlined 描边,.both 填充+描边) +- `backgroundColor`: 背景色 +- `highlightColors`: 渐变色数组(可选) +- `radii`: 四角圆角半径 [左上, 右上, 右下, 左下] +- `borderColor`: 边框颜色(可选) +- `borderWidth`: 边框宽度(可选) +- `content`: 气泡内容(ViewBuilder) + +--- + +## PopMenu + +### 用法 +```swift +PopMenu(items: menuItems, onSelect: { selected in + // 处理选中项 +}) +``` + +### 参数说明 +- `items`: 菜单项数组 +- `onSelect`: 选中回调 + +--- + +## 主题语义化样式枚举 + +### 1. 颜色 + +#### SemanticColorScheme 全量属性 +- textColorPrimary +- textColorSecondary +- textColorTertiary +- textColorDisable +- textColorButton +- textColorButtonDisabled +- textColorLink +- textColorLinkHover +- textColorLinkActive +- textColorLinkDisabled +- textColorAntiPrimary +- textColorAntiSecondary +- textColorWarning +- textColorSuccess +- textColorError +- bgColorTopBar +- bgColorOperate +- bgColorDialog +- bgColorDialogModule +- bgColorEntryCard +- bgColorFunction +- bgColorBottomBar +- bgColorInput +- bgColorBubbleReciprocal +- bgColorBubbleOwn +- bgColorDefault +- bgColorTagMask +- bgColorElementMask +- bgColorMask +- bgColorMaskDisappeared +- bgColorMaskBegin +- strokeColorPrimary +- strokeColorSecondary +- strokeColorModule +- shadowColor +- listColorDefault +- listColorHover +- listColorFocused +- buttonColorPrimaryDefault +- buttonColorPrimaryHover +- buttonColorPrimaryActive +- buttonColorPrimaryDisabled +- buttonColorSecondaryDefault +- buttonColorSecondaryHover +- buttonColorSecondaryActive +- buttonColorSecondaryDisabled +- buttonColorAccept +- buttonColorHangupDefault +- buttonColorHangupDisabled +- buttonColorHangupHover +- buttonColorHangupActive +- buttonColorOn +- buttonColorOff +- dropdownColorDefault +- dropdownColorHover +- dropdownColorActive +- scrollbarColorDefault +- scrollbarColorHover +- floatingColorDefault +- floatingColorOperate +- checkboxColorSelected +- toastColorWarning +- toastColorSuccess +- toastColorError +- toastColorDefault +- tagColorLevel1 +- tagColorLevel2 +- tagColorLevel3 +- tagColorLevel4 +- switchColorOff +- switchColorOn +- switchColorButton +- sliderColorFilled +- sliderColorEmpty +- sliderColorButton +- tabColorSelected +- tabColorUnselected +- tabColorOption +- clearColor + +### 2. 字体 + +#### SemanticFontScheme 全量属性 +- title1Bold +- title2Bold +- title3Bold +- title4Bold +- body1Bold +- body2Bold +- body3Bold +- body4Bold +- caption1Bold +- caption2Bold +- caption3Bold +- caption4Bold +- title1Medium +- title2Medium +- title3Medium +- title4Medium +- body1Medium +- body2Medium +- body3Medium +- body4Medium +- caption1Medium +- caption2Medium +- caption3Medium +- caption4Medium +- title1Regular +- title2Regular +- title3Regular +- title4Regular +- body1Regular +- body2Regular +- body3Regular +- body4Regular +- caption1Regular +- caption2Regular +- caption3Regular +- caption4Regular + +### 3. 圆角 + +#### RadiusScheme 全量 case +- tipsRadius +- smallRadius +- alertRadius +- largeRadius +- superLargeRadius +- roundRadius + +### 4. 间距 + +#### SpacingScheme 全量 case +- iconTextSpacing +- smallSpacing +- iconIconSpacing +- bubbleSpacing +- contentSpacing +- normalSpacing +- titleSpacing +- cardSpacing +- largeSpacing +- maxSpacing + +--- \ No newline at end of file diff --git a/chat/uikit/components/BaseComponent/theme/Colors.swift b/chat/uikit/components/BaseComponent/theme/Colors.swift new file mode 100644 index 000000000..7d5ff5821 --- /dev/null +++ b/chat/uikit/components/BaseComponent/theme/Colors.swift @@ -0,0 +1,497 @@ +import UIKit + +let LightSemanticScheme = SemanticColorScheme( + + textColorPrimary: Colors.Black2, + textColorSecondary: Colors.Black4, + textColorTertiary: Colors.Black5, + textColorDisable: Colors.Black6, + textColorButton: Colors.White1, + textColorButtonDisabled: Colors.White1, + textColorLink: Colors.ThemeLight6, + textColorLinkHover: Colors.ThemeLight5, + textColorLinkActive: Colors.ThemeLight7, + textColorLinkDisabled: Colors.ThemeLight2, + textColorAntiPrimary: Colors.Black2, + textColorAntiSecondary: Colors.Black4, + textColorWarning: Colors.OrangeLight6, + textColorSuccess: Colors.GreenLight6, + textColorError: Colors.RedLight6, + + bgColorTopBar: Colors.GrayLight1, + bgColorOperate: Colors.White1, + bgColorDialog: Colors.White1, + bgColorDialogModule: Colors.GrayLight2, + bgColorEntryCard: Colors.GrayLight2, + bgColorFunction: Colors.GrayLight2, + bgColorBottomBar: Colors.White1, + bgColorInput: Colors.GrayLight2, + bgColorBubbleReciprocal: Colors.GrayLight2, + bgColorBubbleOwn: Colors.ThemeLight2, + bgColorDefault: Colors.GrayLight2, + bgColorTagMask: Colors.White4, + bgColorElementMask: Colors.Black6, + bgColorMask: Colors.Black4, + bgColorMaskDisappeared: Colors.White7, + bgColorMaskBegin: Colors.White1, + bgColorAvatar: Colors.ThemeLight2, + + strokeColorPrimary: Colors.GrayLight3, + strokeColorSecondary: Colors.GrayLight2, + strokeColorModule: Colors.GrayLight3, + + shadowColor: Colors.Black8, + + listColorDefault: Colors.White1, + listColorHover: Colors.GrayLight1, + listColorFocused: Colors.ThemeLight1, + + buttonColorPrimaryDefault: Colors.ThemeLight6, + buttonColorPrimaryHover: Colors.ThemeLight5, + buttonColorPrimaryActive: Colors.ThemeLight7, + buttonColorPrimaryDisabled: Colors.ThemeLight2, + buttonColorSecondaryDefault: Colors.GrayLight2, + buttonColorSecondaryHover: Colors.GrayLight1, + buttonColorSecondaryActive: Colors.GrayLight3, + buttonColorSecondaryDisabled: Colors.GrayLight1, + buttonColorAccept: Colors.GreenLight6, + buttonColorHangupDefault: Colors.RedLight6, + buttonColorHangupDisabled: Colors.RedLight2, + buttonColorHangupHover: Colors.RedLight5, + buttonColorHangupActive: Colors.RedLight7, + buttonColorOn: Colors.White1, + buttonColorOff: Colors.Black5, + + dropdownColorDefault: Colors.White1, + dropdownColorHover: Colors.GrayLight1, + dropdownColorActive: Colors.ThemeLight1, + + scrollbarColorDefault: Colors.Black7, + scrollbarColorHover: Colors.Black6, + + floatingColorDefault: Colors.White1, + floatingColorOperate: Colors.GrayLight2, + + checkboxColorSelected: Colors.ThemeLight6, + + toastColorWarning: Colors.OrangeLight1, + toastColorSuccess: Colors.GreenLight1, + toastColorError: Colors.RedLight1, + toastColorDefault: Colors.ThemeLight1, + + tagColorLevel1: Colors.AccentTurquoiseLight, + tagColorLevel2: Colors.ThemeLight5, + tagColorLevel3: Colors.AccentPurpleLight, + tagColorLevel4: Colors.AccentMagentaLight, + + switchColorOff: Colors.GrayLight4, + switchColorOn: Colors.ThemeLight6, + switchColorButton: Colors.White1, + + sliderColorFilled: Colors.ThemeLight6, + sliderColorEmpty: Colors.GrayLight3, + sliderColorButton: Colors.White1, + + tabColorSelected: Colors.ThemeLight2, + tabColorUnselected: Colors.GrayLight2, + tabColorOption: Colors.GrayLight3, + + clearColor: Colors.Transparent +) +let DarkSemanticScheme = SemanticColorScheme( + + textColorPrimary: Colors.White2, + textColorSecondary: Colors.White4, + textColorTertiary: Colors.White6, + textColorDisable: Colors.White7, + textColorButton: Colors.White1, + textColorButtonDisabled: Colors.White5, + textColorLink: Colors.ThemeDark6, + textColorLinkHover: Colors.ThemeDark5, + textColorLinkActive: Colors.ThemeDark7, + textColorLinkDisabled: Colors.ThemeDark2, + textColorAntiPrimary: Colors.Black2, + textColorAntiSecondary: Colors.Black4, + textColorWarning: Colors.OrangeDark6, + textColorSuccess: Colors.GreenDark6, + textColorError: Colors.RedDark6, + + bgColorTopBar: Colors.GrayDark1, + bgColorOperate: Colors.GrayDark2, + bgColorDialog: Colors.GrayDark2, + bgColorDialogModule: Colors.GrayDark3, + bgColorEntryCard: Colors.GrayDark3, + bgColorFunction: Colors.GrayDark4, + bgColorBottomBar: Colors.GrayDark3, + bgColorInput: Colors.GrayDark3, + bgColorBubbleReciprocal: Colors.GrayDark3, + bgColorBubbleOwn: Colors.ThemeDark7, + bgColorDefault: Colors.GrayDark1, + bgColorTagMask: Colors.Black4, + bgColorElementMask: Colors.Black6, + bgColorMask: Colors.Black4, + bgColorMaskDisappeared: Colors.Black8, + bgColorMaskBegin: Colors.Black2, + bgColorAvatar: Colors.ThemeDark2, + + strokeColorPrimary: Colors.GrayDark4, + strokeColorSecondary: Colors.GrayDark3, + strokeColorModule: Colors.GrayDark5, + + shadowColor: Colors.Black8, + + listColorDefault: Colors.GrayDark2, + listColorHover: Colors.GrayDark3, + listColorFocused: Colors.ThemeDark2, + + buttonColorPrimaryDefault: Colors.ThemeDark6, + buttonColorPrimaryHover: Colors.ThemeDark5, + buttonColorPrimaryActive: Colors.ThemeDark7, + buttonColorPrimaryDisabled: Colors.ThemeDark2, + buttonColorSecondaryDefault: Colors.GrayDark4, + buttonColorSecondaryHover: Colors.GrayDark3, + buttonColorSecondaryActive: Colors.GrayDark5, + buttonColorSecondaryDisabled: Colors.GrayDark3, + buttonColorAccept: Colors.GreenDark6, + buttonColorHangupDefault: Colors.RedDark6, + buttonColorHangupDisabled: Colors.RedDark2, + buttonColorHangupHover: Colors.RedDark5, + buttonColorHangupActive: Colors.RedDark7, + buttonColorOn: Colors.White1, + buttonColorOff: Colors.Black5, + + dropdownColorDefault: Colors.GrayDark3, + dropdownColorHover: Colors.GrayDark4, + dropdownColorActive: Colors.GrayDark2, + + scrollbarColorDefault: Colors.White7, + scrollbarColorHover: Colors.White6, + + floatingColorDefault: Colors.GrayDark3, + floatingColorOperate: Colors.GrayDark4, + + checkboxColorSelected: Colors.ThemeDark5, + + toastColorWarning: Colors.OrangeDark2, + toastColorSuccess: Colors.GreenDark2, + toastColorError: Colors.RedDark2, + toastColorDefault: Colors.ThemeDark2, + + tagColorLevel1: Colors.AccentTurquoiseDark, + tagColorLevel2: Colors.ThemeDark5, + tagColorLevel3: Colors.AccentPurpleDark, + tagColorLevel4: Colors.AccentMagentaDark, + + switchColorOff: Colors.GrayDark4, + switchColorOn: Colors.ThemeDark5, + switchColorButton: Colors.White1, + + sliderColorFilled: Colors.ThemeDark5, + sliderColorEmpty: Colors.GrayDark5, + sliderColorButton: Colors.White1, + + tabColorSelected: Colors.GrayDark5, + tabColorUnselected: Colors.GrayDark4, + tabColorOption: Colors.GrayDark4, + + clearColor: Colors.Transparent +) +public struct SemanticColorScheme { + public let textColorPrimary: UIColor + + public let textColorSecondary: UIColor + + public let textColorTertiary: UIColor + + let textColorDisable: UIColor + + public let textColorButton: UIColor + + let textColorButtonDisabled: UIColor + + public let textColorLink: UIColor + + let textColorLinkHover: UIColor + + let textColorLinkActive: UIColor + + let textColorLinkDisabled: UIColor + + let textColorAntiPrimary: UIColor + + let textColorAntiSecondary: UIColor + + public let textColorWarning: UIColor + + let textColorSuccess: UIColor + + public let textColorError: UIColor + + public let bgColorTopBar: UIColor + + public let bgColorOperate: UIColor + + let bgColorDialog: UIColor + + let bgColorDialogModule: UIColor + + let bgColorEntryCard: UIColor + + let bgColorFunction: UIColor + + public let bgColorBottomBar: UIColor + + public let bgColorInput: UIColor + + let bgColorBubbleReciprocal: UIColor + + public let bgColorBubbleOwn: UIColor + + let bgColorDefault: UIColor + + let bgColorTagMask: UIColor + + let bgColorElementMask: UIColor + + let bgColorMask: UIColor + + let bgColorMaskDisappeared: UIColor + + let bgColorMaskBegin: UIColor + + let bgColorAvatar: UIColor + + public let strokeColorPrimary: UIColor + + public let strokeColorSecondary: UIColor + + let strokeColorModule: UIColor + + public let shadowColor: UIColor + + let listColorDefault: UIColor + + let listColorHover: UIColor + + let listColorFocused: UIColor + + public let buttonColorPrimaryDefault: UIColor + + let buttonColorPrimaryHover: UIColor + + let buttonColorPrimaryActive: UIColor + + public let buttonColorPrimaryDisabled: UIColor + + public let buttonColorSecondaryDefault: UIColor + + let buttonColorSecondaryHover: UIColor + + let buttonColorSecondaryActive: UIColor + + let buttonColorSecondaryDisabled: UIColor + + let buttonColorAccept: UIColor + + let buttonColorHangupDefault: UIColor + + let buttonColorHangupDisabled: UIColor + + let buttonColorHangupHover: UIColor + + let buttonColorHangupActive: UIColor + + let buttonColorOn: UIColor + + let buttonColorOff: UIColor + + let dropdownColorDefault: UIColor + + let dropdownColorHover: UIColor + + let dropdownColorActive: UIColor + + let scrollbarColorDefault: UIColor + + let scrollbarColorHover: UIColor + + let floatingColorDefault: UIColor + + let floatingColorOperate: UIColor + + let checkboxColorSelected: UIColor + + public let toastColorWarning: UIColor + + let toastColorSuccess: UIColor + + let toastColorError: UIColor + + let toastColorDefault: UIColor + + let tagColorLevel1: UIColor + + let tagColorLevel2: UIColor + + let tagColorLevel3: UIColor + + let tagColorLevel4: UIColor + + let switchColorOff: UIColor + + public let switchColorOn: UIColor + + let switchColorButton: UIColor + + let sliderColorFilled: UIColor + + let sliderColorEmpty: UIColor + + let sliderColorButton: UIColor + + let tabColorSelected: UIColor + + let tabColorUnselected: UIColor + + let tabColorOption: UIColor + + let clearColor: UIColor +} + +// MARK: - Colors + +enum Colors { + + static let Black1 = UIColor(hexValue: 0xFF000000) + static let Black2 = UIColor(hexValue: 0xE6000000) + static let Black3 = UIColor(hexValue: 0xB8000000) + static let Black4 = UIColor(hexValue: 0x8C000000) + static let Black5 = UIColor(hexValue: 0x66000000) + static let Black6 = UIColor(hexValue: 0x40000000) + static let Black7 = UIColor(hexValue: 0x1F000000) + static let Black8 = UIColor(hexValue: 0x0F000000) + + static let White1 = UIColor(hexValue: 0xFFFFFFFF) + static let White2 = UIColor(hexValue: 0xEDFFFFFF) + static let White3 = UIColor(hexValue: 0xBFFFFFFF) + static let White4 = UIColor(hexValue: 0x8CFFFFFF) + static let White5 = UIColor(hexValue: 0x6BFFFFFF) + static let White6 = UIColor(hexValue: 0x4DFFFFFF) + static let White7 = UIColor(hexValue: 0x24FFFFFF) + + static let GrayLight1 = UIColor(hexValue: 0xFFF9FAFC) + static let GrayLight2 = UIColor(hexValue: 0xFFF0F2F7) + static let GrayLight3 = UIColor(hexValue: 0xFFE6E9F0) + static let GrayLight4 = UIColor(hexValue: 0xFFD1D4DE) + static let GrayLight5 = UIColor(hexValue: 0xFFC0C3CC) + static let GrayLight6 = UIColor(hexValue: 0xFFB3B6BE) + static let GrayLight7 = UIColor(hexValue: 0xFFA5A9B0) + + static let GrayDark1 = UIColor(hexValue: 0xFF131417) + static let GrayDark2 = UIColor(hexValue: 0xFF1F2024) + static let GrayDark3 = UIColor(hexValue: 0xFF2B2C30) + static let GrayDark4 = UIColor(hexValue: 0xFF3A3C42) + static let GrayDark5 = UIColor(hexValue: 0xFF48494F) + static let GrayDark6 = UIColor(hexValue: 0xFF54565C) + static let GrayDark7 = UIColor(hexValue: 0xFF646A70) + + static let ThemeLight1 = UIColor(hexValue: 0xFFEBF3FF) + static let ThemeLight2 = UIColor(hexValue: 0xFFCCE2FF) + static let ThemeLight3 = UIColor(hexValue: 0xFFADCFFF) + static let ThemeLight4 = UIColor(hexValue: 0xFF7AAFFF) + static let ThemeLight5 = UIColor(hexValue: 0xFF4588F5) + static let ThemeLight6 = UIColor(hexValue: 0xFF1C66E5) + static let ThemeLight7 = UIColor(hexValue: 0xFF0D49BF) + static let ThemeLight8 = UIColor(hexValue: 0xFF033099) + static let ThemeLight9 = UIColor(hexValue: 0xFF001F73) + static let ThemeLight10 = UIColor(hexValue: 0xFF00124D) + + static let ThemeDark1 = UIColor(hexValue: 0xFF1C2333) + static let ThemeDark2 = UIColor(hexValue: 0xFF243047) + static let ThemeDark3 = UIColor(hexValue: 0xFF2F4875) + static let ThemeDark4 = UIColor(hexValue: 0xFF305BA6) + static let ThemeDark5 = UIColor(hexValue: 0xFF2B6AD6) + static let ThemeDark6 = UIColor(hexValue: 0xFF4086FF) + static let ThemeDark7 = UIColor(hexValue: 0xFF5C9DFF) + static let ThemeDark8 = UIColor(hexValue: 0xFF78B0FF) + static let ThemeDark9 = UIColor(hexValue: 0xFF9CC7FF) + static let ThemeDark10 = UIColor(hexValue: 0xFFC2DEFF) + + static let GreenLight1 = UIColor(hexValue: 0xFFE1F7EB) + static let GreenLight2 = UIColor(hexValue: 0xFFB6F0D1) + static let GreenLight3 = UIColor(hexValue: 0xFF84E3B5) + static let GreenLight4 = UIColor(hexValue: 0xFF5AD69E) + static let GreenLight5 = UIColor(hexValue: 0xFF3CC98C) + static let GreenLight6 = UIColor(hexValue: 0xFF0ABF77) + static let GreenLight7 = UIColor(hexValue: 0xFF09A768) + static let GreenLight8 = UIColor(hexValue: 0xFF078F59) + static let GreenLight9 = UIColor(hexValue: 0xFF067049) + static let GreenLight10 = UIColor(hexValue: 0xFF044D37) + + static let GreenDark1 = UIColor(hexValue: 0xFF1A2620) + static let GreenDark2 = UIColor(hexValue: 0xFF22352C) + static let GreenDark3 = UIColor(hexValue: 0xFF2F4F3F) + static let GreenDark4 = UIColor(hexValue: 0xFF377355) + static let GreenDark5 = UIColor(hexValue: 0xFF368F65) + static let GreenDark6 = UIColor(hexValue: 0xFF38A673) + static let GreenDark7 = UIColor(hexValue: 0xFF62B58B) + static let GreenDark8 = UIColor(hexValue: 0xFF8BC7A9) + static let GreenDark9 = UIColor(hexValue: 0xFFA9D4BD) + static let GreenDark10 = UIColor(hexValue: 0xFFC8E5D5) + + static let RedLight1 = UIColor(hexValue: 0xFFFFE7E6) + static let RedLight2 = UIColor(hexValue: 0xFFFCC9C7) + static let RedLight3 = UIColor(hexValue: 0xFFFAAEAC) + static let RedLight4 = UIColor(hexValue: 0xFFF58989) + static let RedLight5 = UIColor(hexValue: 0xFFE86666) + static let RedLight6 = UIColor(hexValue: 0xFFE54545) + static let RedLight7 = UIColor(hexValue: 0xFFC93439) + static let RedLight8 = UIColor(hexValue: 0xFFAD2934) + static let RedLight9 = UIColor(hexValue: 0xFF8F222D) + static let RedLight10 = UIColor(hexValue: 0xFF6B1A27) + + static let RedDark1 = UIColor(hexValue: 0xFF2B1C1F) + static let RedDark2 = UIColor(hexValue: 0xFF422324) + static let RedDark3 = UIColor(hexValue: 0xFF613234) + static let RedDark4 = UIColor(hexValue: 0xFF8A4242) + static let RedDark5 = UIColor(hexValue: 0xFFC2544E) + static let RedDark6 = UIColor(hexValue: 0xFFE6594C) + static let RedDark7 = UIColor(hexValue: 0xFFE57A6E) + static let RedDark8 = UIColor(hexValue: 0xFFF3A599) + static let RedDark9 = UIColor(hexValue: 0xFFFACBC3) + static let RedDark10 = UIColor(hexValue: 0xFFFAE4DE) + + static let OrangeLight1 = UIColor(hexValue: 0xFFFFEEDB) + static let OrangeLight2 = UIColor(hexValue: 0xFFFFD6B2) + static let OrangeLight3 = UIColor(hexValue: 0xFFFFBE85) + static let OrangeLight4 = UIColor(hexValue: 0xFFFFA455) + static let OrangeLight5 = UIColor(hexValue: 0xFFFF8B2B) + static let OrangeLight6 = UIColor(hexValue: 0xFFFF7200) + static let OrangeLight7 = UIColor(hexValue: 0xFFE05D00) + static let OrangeLight8 = UIColor(hexValue: 0xFFBF4900) + static let OrangeLight9 = UIColor(hexValue: 0xFF8F370B) + static let OrangeLight10 = UIColor(hexValue: 0xFF662200) + + static let OrangeDark1 = UIColor(hexValue: 0xFF211A19) + static let OrangeDark2 = UIColor(hexValue: 0xFF35231A) + static let OrangeDark3 = UIColor(hexValue: 0xFF462E1F) + static let OrangeDark4 = UIColor(hexValue: 0xFF653C21) + static let OrangeDark5 = UIColor(hexValue: 0xFF96562A) + static let OrangeDark6 = UIColor(hexValue: 0xFFE37F32) + static let OrangeDark7 = UIColor(hexValue: 0xFFE39552) + static let OrangeDark8 = UIColor(hexValue: 0xFFEEAD72) + static let OrangeDark9 = UIColor(hexValue: 0xFFF7CFA4) + static let OrangeDark10 = UIColor(hexValue: 0xFFF9E9D1) + + static let Transparent = UIColor.clear + + static let AccentTurquoiseLight = UIColor(hexValue: 0xFF00ABD6) + static let AccentPurpleLight = UIColor(hexValue: 0xFF8157FF) + static let AccentMagentaLight = UIColor(hexValue: 0xFFF5457F) + static let AccentOrangeLight = UIColor(hexValue: 0xFFFF6A4C) + + static let AccentTurquoiseDark = UIColor(hexValue: 0xFF008FB2) + static let AccentPurpleDark = UIColor(hexValue: 0xFF693CF0) + static let AccentMagentaDark = UIColor(hexValue: 0xFFC22F56) + static let AccentOrangeDark = UIColor(hexValue: 0xFFF25B35) +} diff --git a/chat/uikit/components/BaseComponent/theme/Fonts.swift b/chat/uikit/components/BaseComponent/theme/Fonts.swift new file mode 100644 index 000000000..a4edc9180 --- /dev/null +++ b/chat/uikit/components/BaseComponent/theme/Fonts.swift @@ -0,0 +1,118 @@ +import UIKit + +let FontScheme = SemanticFontScheme( + title1Bold: Fonts.Bold40, + title2Bold: Fonts.Bold36, + title3Bold: Fonts.Bold34, + title4Bold: Fonts.Bold32, + body1Bold: Fonts.Bold28, + body2Bold: Fonts.Bold24, + body3Bold: Fonts.Bold20, + body4Bold: Fonts.Bold18, + caption1Bold: Fonts.Bold16, + caption2Bold: Fonts.Bold14, + caption3Bold: Fonts.Bold12, + caption4Bold: Fonts.Bold10, + title1Medium: Fonts.Medium40, + title2Medium: Fonts.Medium36, + title3Medium: Fonts.Medium34, + title4Medium: Fonts.Medium32, + body1Medium: Fonts.Medium28, + body2Medium: Fonts.Medium24, + body3Medium: Fonts.Medium20, + body4Medium: Fonts.Medium18, + caption1Medium: Fonts.Medium16, + caption2Medium: Fonts.Medium14, + caption3Medium: Fonts.Medium12, + caption4Medium: Fonts.Medium10, + title1Regular: Fonts.Regular40, + title2Regular: Fonts.Regular36, + title3Regular: Fonts.Regular34, + title4Regular: Fonts.Regular32, + body1Regular: Fonts.Regular28, + body2Regular: Fonts.Regular24, + body3Regular: Fonts.Regular20, + body4Regular: Fonts.Regular18, + caption1Regular: Fonts.Regular16, + caption2Regular: Fonts.Regular14, + caption3Regular: Fonts.Regular12, + caption4Regular: Fonts.Regular10 +) + +public struct SemanticFontScheme { + let title1Bold: UIFont + let title2Bold: UIFont + let title3Bold: UIFont + let title4Bold: UIFont + let body1Bold: UIFont + let body2Bold: UIFont + let body3Bold: UIFont + let body4Bold: UIFont + let caption1Bold: UIFont + let caption2Bold: UIFont + let caption3Bold: UIFont + let caption4Bold: UIFont + let title1Medium: UIFont + let title2Medium: UIFont + let title3Medium: UIFont + let title4Medium: UIFont + let body1Medium: UIFont + let body2Medium: UIFont + let body3Medium: UIFont + let body4Medium: UIFont + let caption1Medium: UIFont + let caption2Medium: UIFont + let caption3Medium: UIFont + let caption4Medium: UIFont + let title1Regular: UIFont + let title2Regular: UIFont + let title3Regular: UIFont + let title4Regular: UIFont + let body1Regular: UIFont + let body2Regular: UIFont + let body3Regular: UIFont + let body4Regular: UIFont + let caption1Regular: UIFont + let caption2Regular: UIFont + let caption3Regular: UIFont + let caption4Regular: UIFont +} + +private enum Fonts { + static let Bold40 = UIFont.systemFont(ofSize: 40, weight: .bold) + static let Bold36 = UIFont.systemFont(ofSize: 36, weight: .bold) + static let Bold34 = UIFont.systemFont(ofSize: 34, weight: .bold) + static let Bold32 = UIFont.systemFont(ofSize: 32, weight: .bold) + static let Bold28 = UIFont.systemFont(ofSize: 28, weight: .bold) + static let Bold24 = UIFont.systemFont(ofSize: 24, weight: .bold) + static let Bold20 = UIFont.systemFont(ofSize: 20, weight: .bold) + static let Bold18 = UIFont.systemFont(ofSize: 18, weight: .bold) + static let Bold16 = UIFont.systemFont(ofSize: 16, weight: .bold) + static let Bold14 = UIFont.systemFont(ofSize: 14, weight: .bold) + static let Bold12 = UIFont.systemFont(ofSize: 12, weight: .bold) + static let Bold10 = UIFont.systemFont(ofSize: 10, weight: .bold) + static let Medium40 = UIFont.systemFont(ofSize: 40, weight: .medium) + static let Medium36 = UIFont.systemFont(ofSize: 36, weight: .medium) + static let Medium34 = UIFont.systemFont(ofSize: 34, weight: .medium) + static let Medium32 = UIFont.systemFont(ofSize: 32, weight: .medium) + static let Medium28 = UIFont.systemFont(ofSize: 28, weight: .medium) + static let Medium24 = UIFont.systemFont(ofSize: 24, weight: .medium) + static let Medium20 = UIFont.systemFont(ofSize: 20, weight: .medium) + static let Medium18 = UIFont.systemFont(ofSize: 18, weight: .medium) + static let Medium16 = UIFont.systemFont(ofSize: 16, weight: .medium) + static let Medium14 = UIFont.systemFont(ofSize: 14, weight: .medium) + static let Medium12 = UIFont.systemFont(ofSize: 12, weight: .medium) + static let Medium10 = UIFont.systemFont(ofSize: 10, weight: .medium) + static let Regular40 = UIFont.systemFont(ofSize: 40, weight: .regular) + static let Regular36 = UIFont.systemFont(ofSize: 36, weight: .regular) + static let Regular34 = UIFont.systemFont(ofSize: 34, weight: .regular) + static let Regular32 = UIFont.systemFont(ofSize: 32, weight: .regular) + static let Regular28 = UIFont.systemFont(ofSize: 28, weight: .regular) + static let Regular24 = UIFont.systemFont(ofSize: 24, weight: .regular) + static let Regular20 = UIFont.systemFont(ofSize: 20, weight: .regular) + static let Regular18 = UIFont.systemFont(ofSize: 18, weight: .regular) + static let Regular16 = UIFont.systemFont(ofSize: 16, weight: .regular) + static let Regular14 = UIFont.systemFont(ofSize: 14, weight: .regular) + static let Regular12 = UIFont.systemFont(ofSize: 12, weight: .regular) + static let Regular10 = UIFont.systemFont(ofSize: 10, weight: .regular) +} diff --git a/chat/uikit/components/BaseComponent/theme/Radius.swift b/chat/uikit/components/BaseComponent/theme/Radius.swift new file mode 100644 index 000000000..e71d6cbf2 --- /dev/null +++ b/chat/uikit/components/BaseComponent/theme/Radius.swift @@ -0,0 +1,27 @@ +import Foundation + +let RadiusScheme = SemanticRadiusScheme( + tipsRadius: Radius.Radius4, + smallRadius: Radius.Radius8, + alertRadius: Radius.Radius12, + largeRadius: Radius.Radius16, + superLargeRadius: Radius.Radius20, + roundRadius: Radius.Radius999 +) +public struct SemanticRadiusScheme { + let tipsRadius: Int + let smallRadius: Int + let alertRadius: Int + let largeRadius: Int + let superLargeRadius: Int + let roundRadius: Int +} + +private enum Radius { + static let Radius4 = 4 + static let Radius8 = 8 + static let Radius12 = 12 + static let Radius16 = 16 + static let Radius20 = 20 + static let Radius999 = 999 +} diff --git a/chat/uikit/components/BaseComponent/theme/Spacing.swift b/chat/uikit/components/BaseComponent/theme/Spacing.swift new file mode 100644 index 000000000..1b2b64ab8 --- /dev/null +++ b/chat/uikit/components/BaseComponent/theme/Spacing.swift @@ -0,0 +1,40 @@ +import Foundation + +let SpacingScheme = SemanticSpacingScheme( + iconTextSpacing: Spacings.Spacing4, + smallSpacing: Spacings.Spacing8, + iconIconSpacing: Spacings.Spacing12, + bubbleSpacing: Spacings.Spacing16, + contentSpacing: Spacings.Spacing20, + normalSpacing: Spacings.Spacing24, + titleSpacing: Spacings.Spacing32, + cardSpacing: Spacings.Spacing40, + largeSpacing: Spacings.Spacing56, + maxSpacing: Spacings.Spacing72 +) + +public struct SemanticSpacingScheme { + let iconTextSpacing: Int + let smallSpacing: Int + let iconIconSpacing: Int + let bubbleSpacing: Int + let contentSpacing: Int + let normalSpacing: Int + let titleSpacing: Int + let cardSpacing: Int + let largeSpacing: Int + let maxSpacing: Int +} + +private enum Spacings { + static let Spacing4 = 4 + static let Spacing8 = 8 + static let Spacing12 = 12 + static let Spacing16 = 16 + static let Spacing20 = 20 + static let Spacing24 = 24 + static let Spacing32 = 32 + static let Spacing40 = 40 + static let Spacing56 = 56 + static let Spacing72 = 72 +} diff --git a/chat/uikit/components/BaseComponent/theme/ThemeState.swift b/chat/uikit/components/BaseComponent/theme/ThemeState.swift new file mode 100644 index 000000000..93e51eed2 --- /dev/null +++ b/chat/uikit/components/BaseComponent/theme/ThemeState.swift @@ -0,0 +1,605 @@ +import Combine +import UIKit + +public enum ThemeMode: String, Equatable, Codable, CaseIterable { + case system + case light + case dark +} + +public struct ThemeConfig: Equatable, Codable { + public let mode: ThemeMode + + public let primaryColor: String? + + private enum CodingKeys: String, CodingKey { + case mode + case primaryColor + } + + public init(mode: ThemeMode, primaryColor: String? = nil) { + self.mode = mode + self.primaryColor = primaryColor + } + + public static func == (lhs: ThemeConfig, rhs: ThemeConfig) -> Bool { + return lhs.mode == rhs.mode && lhs.primaryColor == rhs.primaryColor + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + mode = try container.decode(ThemeMode.self, forKey: .mode) + primaryColor = try container.decodeIfPresent(String.self, forKey: .primaryColor) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(mode, forKey: .mode) + try container.encodeIfPresent(primaryColor, forKey: .primaryColor) + } +} + +public class ThemeState: ObservableObject { + @Published public var currentTheme: ThemeConfig = .init(mode: .system) + + public static let shared = ThemeState() + + public var currentMode: ThemeMode { + return currentTheme.mode + } + + public var currentPrimaryColor: String? { + return currentTheme.primaryColor + } + + var hasCustomPrimaryColor: Bool { + return currentTheme.primaryColor != nil + } + + public var colors: SemanticColorScheme { + if let cached = cachedColorScheme, let cachedConfig = cachedThemeConfig, cachedConfig == currentTheme { + return cached + } + + let newColorScheme = calculateColorScheme() + cachedColorScheme = newColorScheme + cachedThemeConfig = currentTheme + + return newColorScheme + } + + var fonts: SemanticFontScheme { + return FontScheme + } + + var radius: SemanticRadiusScheme { + return RadiusScheme + } + + public var spacing: SemanticSpacingScheme { + return SpacingScheme + } + + var isDarkMode: Bool { + switch currentTheme.mode { + case .light: + return false + case .dark: + return true + case .system: + #if os(iOS) || os(tvOS) + return systemInterfaceStyle == .dark + #elseif os(macOS) + return NSAppearance.currentDrawing().name == .darkAqua + #else + return false + #endif + } + } + + private static let ThemeKey = "BaseComponentThemeKey" + + @Published private var systemInterfaceStyle: UIUserInterfaceStyle = .unspecified + + private var cachedColorScheme: SemanticColorScheme? + + private var cachedThemeConfig: ThemeConfig? + + init() { + loadTheme() + setupSystemThemeObserver() + applyInterfaceStyleOverride() + } + + public func setThemeMode(_ mode: ThemeMode) { + clearCache() + currentTheme = ThemeConfig(mode: mode, primaryColor: currentTheme.primaryColor) + saveTheme() + applyInterfaceStyleOverride() + } + + public func setPrimaryColor(_ hex: String) { + clearCache() + currentTheme = ThemeConfig(mode: currentTheme.mode, primaryColor: hex) + saveTheme() + } + + public func clearPrimaryColor() { + clearCache() + currentTheme = ThemeConfig(mode: currentTheme.mode, primaryColor: nil) + saveTheme() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func applyInterfaceStyleOverride() { + #if os(iOS) || os(tvOS) + let style: UIUserInterfaceStyle + switch currentTheme.mode { + case .light: style = .light + case .dark: style = .dark + case .system: style = .unspecified + } + DispatchQueue.main.async { + for scene in UIApplication.shared.connectedScenes { + guard let windowScene = scene as? UIWindowScene else { continue } + for window in windowScene.windows { + window.overrideUserInterfaceStyle = style + } + } + } + #endif + } + + private func setupSystemThemeObserver() { + #if os(iOS) || os(tvOS) + updateSystemInterfaceStyle() + + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateSystemInterfaceStyle() + } + + NotificationCenter.default.addObserver( + forName: Notification.Name("UITraitCollectionDidChangeNotification"), + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateSystemInterfaceStyle() + } + #endif + } + + private func updateSystemInterfaceStyle() { + #if os(iOS) || os(tvOS) + let newStyle: UIUserInterfaceStyle + + if #available(iOS 13.0, *) { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first + { + newStyle = window.traitCollection.userInterfaceStyle + } else { + newStyle = UIScreen.main.traitCollection.userInterfaceStyle + } + } else { + newStyle = .unspecified + } + + if systemInterfaceStyle != newStyle { + systemInterfaceStyle = newStyle + clearCache() + } + #endif + } + + private func getSystemScheme() -> SemanticColorScheme { + return isDarkMode ? DarkSemanticScheme : LightSemanticScheme + } + + private func calculateColorScheme() -> SemanticColorScheme { + let effectiveMode: ThemeMode + + switch currentTheme.mode { + case .system: + effectiveMode = isDarkMode ? .dark : .light + case .light, .dark: + effectiveMode = currentTheme.mode + } + + if let primaryColor = currentTheme.primaryColor { + let customColor = UIColor(hexString: primaryColor) + return getCustomScheme(isLight: effectiveMode == .light, + baseScheme: effectiveMode == .light ? LightSemanticScheme : DarkSemanticScheme, + primary: customColor) + } + + switch effectiveMode { + case .light: + return LightSemanticScheme + case .dark: + return DarkSemanticScheme + case .system: + return getSystemScheme() + } + } + + private func clearCache() { + cachedColorScheme = nil + cachedThemeConfig = nil + } + + private func saveTheme() { + if let data = try? JSONEncoder().encode(currentTheme) { + UserDefaults.standard.set(data, forKey: Self.ThemeKey) + UserDefaults.standard.synchronize() + } + } + + private func loadTheme() { + if let data = UserDefaults.standard.data(forKey: Self.ThemeKey), + let theme = try? JSONDecoder().decode(ThemeConfig.self, from: data) + { + currentTheme = theme + return + } + + let config = AppBuilderConfig.shared + let mode: ThemeMode + switch config.themeMode { + case .system: + mode = .system + case .light: + mode = .light + case .dark: + mode = .dark + } + + var primaryColor: String? = nil + if !config.primaryColor.isEmpty { + if config.primaryColor.range(of: "^#[0-9A-Fa-f]{6}$", options: .regularExpression) != nil { + primaryColor = config.primaryColor + } + } + + currentTheme = ThemeConfig(mode: mode, primaryColor: primaryColor) + } + + private func getCustomScheme(isLight: Bool, baseScheme: SemanticColorScheme, primary: UIColor) -> SemanticColorScheme { + let hexColor = primary.hexString() + + let lightPalette = ThemeColorGenerator.generateColorPalette(baseColor: hexColor, theme: "light") + let darkPalette = ThemeColorGenerator.generateColorPalette(baseColor: hexColor, theme: "dark") + + let themeLight1 = UIColor(hexString: lightPalette[0]) + let themeLight2 = UIColor(hexString: lightPalette[1]) + let themeLight5 = UIColor(hexString: lightPalette[4]) + let themeLight6 = UIColor(hexString: lightPalette[5]) + let themeLight7 = UIColor(hexString: lightPalette[6]) + let themeDark2 = UIColor(hexString: darkPalette[1]) + let themeDark5 = UIColor(hexString: darkPalette[4]) + let themeDark6 = UIColor(hexString: darkPalette[5]) + let themeDark7 = UIColor(hexString: darkPalette[6]) + + return SemanticColorScheme( + textColorPrimary: baseScheme.textColorPrimary, + textColorSecondary: baseScheme.textColorSecondary, + textColorTertiary: baseScheme.textColorTertiary, + textColorDisable: baseScheme.textColorDisable, + textColorButton: baseScheme.textColorButton, + textColorButtonDisabled: baseScheme.textColorButtonDisabled, + textColorLink: isLight ? themeLight6 : themeDark6, + textColorLinkHover: isLight ? themeLight5 : themeDark5, + textColorLinkActive: isLight ? themeLight7 : themeDark7, + textColorLinkDisabled: isLight ? themeLight2 : themeDark2, + textColorAntiPrimary: baseScheme.textColorAntiPrimary, + textColorAntiSecondary: baseScheme.textColorAntiSecondary, + textColorWarning: baseScheme.textColorWarning, + textColorSuccess: baseScheme.textColorSuccess, + textColorError: baseScheme.textColorError, + bgColorTopBar: baseScheme.bgColorTopBar, + bgColorOperate: baseScheme.bgColorOperate, + bgColorDialog: baseScheme.bgColorDialog, + bgColorDialogModule: baseScheme.bgColorDialogModule, + bgColorEntryCard: baseScheme.bgColorEntryCard, + bgColorFunction: baseScheme.bgColorFunction, + bgColorBottomBar: baseScheme.bgColorBottomBar, + bgColorInput: baseScheme.bgColorInput, + bgColorBubbleReciprocal: baseScheme.bgColorBubbleReciprocal, + bgColorBubbleOwn: isLight ? themeLight2 : themeDark7, + bgColorDefault: baseScheme.bgColorDefault, + bgColorTagMask: baseScheme.bgColorTagMask, + bgColorElementMask: baseScheme.bgColorElementMask, + bgColorMask: baseScheme.bgColorMask, + bgColorMaskDisappeared: baseScheme.bgColorMaskDisappeared, + bgColorMaskBegin: baseScheme.bgColorMaskBegin, + bgColorAvatar: isLight ? themeLight2 : themeDark2, + strokeColorPrimary: baseScheme.strokeColorPrimary, + strokeColorSecondary: baseScheme.strokeColorSecondary, + strokeColorModule: baseScheme.strokeColorModule, + shadowColor: baseScheme.shadowColor, + listColorDefault: baseScheme.listColorDefault, + listColorHover: baseScheme.listColorHover, + listColorFocused: isLight ? themeLight1 : themeDark2, + buttonColorPrimaryDefault: isLight ? themeLight6 : themeDark6, + buttonColorPrimaryHover: isLight ? themeLight5 : themeDark5, + buttonColorPrimaryActive: isLight ? themeLight7 : themeDark7, + buttonColorPrimaryDisabled: isLight ? themeLight2 : themeDark2, + buttonColorSecondaryDefault: baseScheme.buttonColorSecondaryDefault, + buttonColorSecondaryHover: baseScheme.buttonColorSecondaryHover, + buttonColorSecondaryActive: baseScheme.buttonColorSecondaryActive, + buttonColorSecondaryDisabled: baseScheme.buttonColorSecondaryDisabled, + buttonColorAccept: baseScheme.buttonColorAccept, + buttonColorHangupDefault: baseScheme.buttonColorHangupDefault, + buttonColorHangupDisabled: baseScheme.buttonColorHangupDisabled, + buttonColorHangupHover: baseScheme.buttonColorHangupHover, + buttonColorHangupActive: baseScheme.buttonColorHangupActive, + buttonColorOn: baseScheme.buttonColorOn, + buttonColorOff: baseScheme.buttonColorOff, + dropdownColorDefault: baseScheme.dropdownColorDefault, + dropdownColorHover: baseScheme.dropdownColorHover, + dropdownColorActive: isLight ? themeLight1 : themeDark2, + scrollbarColorDefault: baseScheme.scrollbarColorDefault, + scrollbarColorHover: baseScheme.scrollbarColorHover, + floatingColorDefault: baseScheme.floatingColorDefault, + floatingColorOperate: baseScheme.floatingColorOperate, + checkboxColorSelected: isLight ? themeLight6 : themeDark5, + toastColorWarning: baseScheme.toastColorWarning, + toastColorSuccess: baseScheme.toastColorSuccess, + toastColorError: baseScheme.toastColorError, + toastColorDefault: isLight ? themeLight1 : themeDark2, + tagColorLevel1: baseScheme.tagColorLevel1, + tagColorLevel2: baseScheme.tagColorLevel2, + tagColorLevel3: baseScheme.tagColorLevel3, + tagColorLevel4: baseScheme.tagColorLevel4, + switchColorOff: baseScheme.switchColorOff, + switchColorOn: isLight ? themeLight6 : themeDark5, + switchColorButton: baseScheme.switchColorButton, + sliderColorFilled: isLight ? themeLight6 : themeDark5, + sliderColorEmpty: baseScheme.sliderColorEmpty, + sliderColorButton: baseScheme.sliderColorButton, + tabColorSelected: isLight ? themeLight2 : themeDark5, + tabColorUnselected: baseScheme.tabColorUnselected, + tabColorOption: baseScheme.tabColorOption, + clearColor: baseScheme.clearColor + ) + } +} + +class ThemeColorGenerator { + private static let BLUE_PALETTE: [String: [String]] = [ + "light": [ + "#ebf3ff", "#cce2ff", "#adcfff", "#7aafff", "#4588f5", + "#1c66e5", "#0d49bf", "#033099", "#001f73", "#00124d" + ], + "dark": [ + "#1c2333", "#243047", "#2f4875", "#305ba6", "#2b6ad6", + "#4086ff", "#5c9dff", "#78b0ff", "#9cc7ff", "#c2deff" + ] + ] + + private static let GREEN_PALETTE: [String: [String]] = [ + "light": [ + "#dcfae9", "#b6f0d1", "#84e3b5", "#5ad69e", "#3cc98c", + "#0abf77", "#09a768", "#078f59", "#067049", "#044d37" + ], + "dark": [ + "#1a2620", "#22352c", "#2f4f3f", "#377355", "#368f65", + "#38a673", "#62b58b", "#8bc7a9", "#a9d4bd", "#c8e5d5" + ] + ] + + private static let RED_PALETTE: [String: [String]] = [ + "light": [ + "#ffe7e6", "#fcc9c7", "#faaeac", "#f58989", "#e86666", + "#e54545", "#c93439", "#ad2934", "#8f222d", "#6b1a27" + ], + "dark": [ + "#2b1c1f", "#422324", "#613234", "#8a4242", "#c2544e", + "#e6594c", "#e57a6e", "#f3a599", "#facbc3", "#fae4de" + ] + ] + + private static let ORANGE_PALETTE: [String: [String]] = [ + "light": [ + "#ffeedb", "#ffd6b2", "#ffbe85", "#ffa455", "#ff8b2b", + "#ff7200", "#e05d00", "#bf4900", "#8f370b", "#662200" + ], + "dark": [ + "#211a19", "#35231a", "#462e1f", "#653c21", "#96562a", + "#e37f32", "#e39552", "#eead72", "#f7cfa4", "#f9e9d1" + ] + ] + + private static let HSL_ADJUSTMENTS: [String: [Int: (s: Double, l: Double)]] = [ + "light": [ + 1: (s: -40, l: 45), + 2: (s: -30, l: 35), + 3: (s: -20, l: 25), + 4: (s: -10, l: 15), + 5: (s: -5, l: 5), + 6: (s: 0, l: 0), + 7: (s: 5, l: -10), + 8: (s: 10, l: -20), + 9: (s: 15, l: -30), + 10: (s: 20, l: -40) + ], + "dark": [ + 1: (s: -60, l: -35), + 2: (s: -50, l: -25), + 3: (s: -40, l: -15), + 4: (s: -30, l: -5), + 5: (s: -20, l: 5), + 6: (s: 0, l: 0), + 7: (s: -10, l: 15), + 8: (s: -20, l: 30), + 9: (s: -30, l: 45), + 10: (s: -40, l: 60) + ] + ] + + static func generateColorPalette(baseColor: String, theme: String) -> [String] { + if isStandardColor(color: baseColor) { + let palette = getClosestPalette(color: baseColor) + let targetColors = palette[theme] ?? palette["light"]! + return targetColors + } + return generateDynamicColorVariations(baseColor: baseColor, theme: theme) + } + + private static func getClosestPalette(color: String) -> [String: [String]] { + let hsl = hexToHSL(color) + let colorDistance = { (c1: (h: Double, s: Double, l: Double), c2: (h: Double, s: Double, l: Double)) -> Double in + let dh = min(abs(c1.h - c2.h), 360 - abs(c1.h - c2.h)) + let ds = c1.s - c2.s + let dl = c1.l - c2.l + return sqrt(dh * dh + ds * ds + dl * dl) + } + let palettes = [ + (palette: BLUE_PALETTE, baseColor: BLUE_PALETTE["light"]![5]), + (palette: GREEN_PALETTE, baseColor: GREEN_PALETTE["light"]![5]), + (palette: RED_PALETTE, baseColor: RED_PALETTE["light"]![5]), + (palette: ORANGE_PALETTE, baseColor: ORANGE_PALETTE["light"]![5]) + ] + let distances = palettes.map { paletteInfo in + (palette: paletteInfo.palette, distance: colorDistance(hsl, hexToHSL(paletteInfo.baseColor))) + } + return distances.sorted { $0.distance < $1.distance }[0].palette + } + + private static func isStandardColor(color: String) -> Bool { + let standardColors = [ + BLUE_PALETTE["light"]![5], + GREEN_PALETTE["light"]![5], + RED_PALETTE["light"]![5], + ORANGE_PALETTE["light"]![5] + ] + let inputHsl = hexToHSL(color) + return standardColors.contains { standardColor in + let standardHsl = hexToHSL(standardColor) + let dh = min(abs(inputHsl.h - standardHsl.h), 360 - abs(inputHsl.h - standardHsl.h)) + return dh < 15 && abs(inputHsl.s - standardHsl.s) < 15 && abs(inputHsl.l - standardHsl.l) < 15 + } + } + + private static func adjustColor(color: String, adjustment: (s: Double, l: Double)) -> String { + let hsl = hexToHSL(color) + let newS = max(0, min(100, hsl.s + adjustment.s)) + let newL = max(0, min(100, hsl.l + adjustment.l)) + return hslToHex(h: hsl.h, s: newS, l: newL) + } + + private static func generateDynamicColorVariations(baseColor: String, theme: String) -> [String] { + var variations: [String] = [] + let adjustments = HSL_ADJUSTMENTS[theme] ?? HSL_ADJUSTMENTS["light"]! + let baseHsl = hexToHSL(baseColor) + let saturationFactor = baseHsl.s > 70 ? 0.8 : baseHsl.s < 30 ? 1.2 : 1.0 + let lightnessFactor = baseHsl.l > 70 ? 0.8 : baseHsl.l < 30 ? 1.2 : 1.0 + for i in 1 ... 10 { + let adjustment = adjustments[i] ?? (s: 0, l: 0) + let adjustedS = adjustment.s * saturationFactor + let adjustedL = adjustment.l * lightnessFactor + variations.append(adjustColor(color: baseColor, adjustment: (s: adjustedS, l: adjustedL))) + } + return variations + } + + private static func hexToHSL(_ hex: String) -> (h: Double, s: Double, l: Double) { + let hex = hex.replacingOccurrences(of: "#", with: "") + let scanner = Scanner(string: hex) + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) + let r = Double((rgb >> 16) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double(rgb & 0xFF) / 255.0 + let max = Swift.max(r, g, b) + let min = Swift.min(r, g, b) + var h: Double = 0 + var s: Double = 0 + let l = (max + min) / 2.0 + if max != min { + let d = max - min + s = l > 0.5 ? d / (2.0 - max - min) : d / (max + min) + switch max { + case r: + h = (g - b) / d + (g < b ? 6.0 : 0.0) + case g: + h = (b - r) / d + 2.0 + case b: + h = (r - g) / d + 4.0 + default: + break + } + h /= 6.0 + } + return (h: h * 360.0, s: s * 100.0, l: l * 100.0) + } + + private static func hslToHex(h: Double, s: Double, l: Double) -> String { + let h = h / 360.0 + let s = s / 100.0 + let l = l / 100.0 + let c = (1.0 - abs(2.0 * l - 1.0)) * s + let x = c * (1.0 - abs((h * 6.0).truncatingRemainder(dividingBy: 2.0) - 1.0)) + let m = l - c / 2.0 + var r: Double = 0, g: Double = 0, b: Double = 0 + switch Int(h * 6.0) { + case 0: + r = c; g = x; b = 0 + case 1: + r = x; g = c; b = 0 + case 2: + r = 0; g = c; b = x + case 3: + r = 0; g = x; b = c + case 4: + r = x; g = 0; b = c + case 5: + r = c; g = 0; b = x + default: + break + } + let red = Int((r + m) * 255.0) + let green = Int((g + m) * 255.0) + let blue = Int((b + m) * 255.0) + return String(format: "#%02X%02X%02X", red, green, blue) + } +} + +public extension UIColor { + convenience init(hexValue: UInt32) { + let alpha = CGFloat((hexValue >> 24) & 0xFF) / 255.0 + let red = CGFloat((hexValue >> 16) & 0xFF) / 255.0 + let green = CGFloat((hexValue >> 8) & 0xFF) / 255.0 + let blue = CGFloat(hexValue & 0xFF) / 255.0 + self.init(red: red, green: green, blue: blue, alpha: alpha) + } + + convenience init(hexString: String) { + let hex = hexString.replacingOccurrences(of: "#", with: "") + let scanner = Scanner(string: hex) + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) + let red = CGFloat((rgb >> 16) & 0xFF) / 255.0 + let green = CGFloat((rgb >> 8) & 0xFF) / 255.0 + let blue = CGFloat(rgb & 0xFF) / 255.0 + self.init(red: red, green: green, blue: blue, alpha: 1.0) + } + + func hexString() -> String { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let r = Int(red * 255.0) + let g = Int(green * 255.0) + let b = Int(blue * 255.0) + return String(format: "#%02X%02X%02X", r, g, b) + } +} diff --git a/chat/uikit/components/BaseComponent/utils/AppBuilderHelper.swift b/chat/uikit/components/BaseComponent/utils/AppBuilderHelper.swift new file mode 100644 index 000000000..9b1a89e3f --- /dev/null +++ b/chat/uikit/components/BaseComponent/utils/AppBuilderHelper.swift @@ -0,0 +1,112 @@ +import Foundation + +enum MessageAlignment: String, CaseIterable { + case left + case right + case twoSided = "two-sided" +} + +enum MessageAction: String, CaseIterable { + case copy + case recall + + case forward + case delete + +} + +enum ConversationAction: String, CaseIterable { + case delete + case mute + case pin + case markUnread + case clearHistory +} + +enum GlobalAvatarShape: String, CaseIterable { + case circular + case square + case rounded +} + +public class AppBuilderConfig { + public static let shared = AppBuilderConfig() + var themeMode: ThemeMode = .system + var primaryColor: String = "#1C66E5" + var messageAlignment: MessageAlignment = .twoSided + public var enableReadReceipt: Bool = false + public var translateTargetLanguage: String = "" + + var messageActionList: [MessageAction] = [.copy, .recall, .delete] + var enableCreateConversation: Bool = true + var conversationActionList: [ConversationAction] = [.delete, .mute, .pin, .markUnread, .clearHistory] + var hideSendButton: Bool = false + + var hideSearch: Bool = false + var avatarShape: GlobalAvatarShape = .circular + private init() {} +} + +public class AppBuilderHelper { + public static func setJsonPath(path: String) { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + else { + return + } + parseConfig(from: json) + } + + private static func parseConfig(from json: [String: Any]) { + let config = AppBuilderConfig.shared + if let theme = json["theme"] as? [String: Any] { + if let modeString = theme["mode"] as? String, + let mode = ThemeMode(rawValue: modeString) + { + config.themeMode = mode + } + if let primaryColor = theme["primaryColor"] as? String { + config.primaryColor = primaryColor + } + } + if let messageList = json["messageList"] as? [String: Any] { + if let alignmentString = messageList["alignment"] as? String, + let alignment = MessageAlignment(rawValue: alignmentString) + { + config.messageAlignment = alignment + } + if let enableReadReceipt = messageList["enableReadReceipt"] as? Bool { + config.enableReadReceipt = enableReadReceipt + } + if let actionList = messageList["messageActionList"] as? [String] { + config.messageActionList = actionList.compactMap { MessageAction(rawValue: $0) } + } + } + if let conversationList = json["conversationList"] as? [String: Any] { + if let enableCreateConversation = conversationList["enableCreateConversation"] as? Bool { + config.enableCreateConversation = enableCreateConversation + } + if let actionList = conversationList["conversationActionList"] as? [String] { + config.conversationActionList = actionList.compactMap { ConversationAction(rawValue: $0) } + } + } + if let messageInput = json["messageInput"] as? [String: Any] { + if let hideSendButton = messageInput["hideSendButton"] as? Bool { + config.hideSendButton = hideSendButton + } + + } + if let search = json["search"] as? [String: Any] { + if let hideSearch = search["hideSearch"] as? Bool { + config.hideSearch = hideSearch + } + } + if let avatar = json["avatar"] as? [String: Any] { + if let shapeString = avatar["shape"] as? String, + let shape = GlobalAvatarShape(rawValue: shapeString) + { + config.avatarShape = shape + } + } + } +} diff --git a/chat/uikit/components/BaseComponent/utils/BundleHelper.swift b/chat/uikit/components/BaseComponent/utils/BundleHelper.swift new file mode 100644 index 000000000..ec4365c76 --- /dev/null +++ b/chat/uikit/components/BaseComponent/utils/BundleHelper.swift @@ -0,0 +1,86 @@ +class BundleHelper { + static func atomicXBundle() -> Bundle { + let bundlePath = getBundlePath(bundleName: "ChatUIKitBundle", classType: BundleHelper.self, frameworkName: "") + guard let atomicXBundle = Bundle(path: bundlePath) else { + return Bundle.main + } + return atomicXBundle + } + + static func findLocalizableBundle(bundleName: String, classType: AnyClass, language: String, frameworkName: String) -> Bundle? { + var bundleCache: [String: Bundle] = [:] + let languageDir = "Localizable/\(language)" + let cacheKey = "\(bundleName)_\(languageDir)" + var bundle = bundleCache[cacheKey] + if bundle == nil { + let bundlePath = getBundlePath(bundleName: bundleName, classType: classType, frameworkName: frameworkName) + if let path = Bundle(path: bundlePath)?.path(forResource: languageDir, ofType: "lproj") { + bundle = Bundle(path: path) + if let bundle = bundle { + bundleCache[cacheKey] = bundle + } + } + } + return bundle + } + + static func getBundlePath(bundleName: String, classType: AnyClass, frameworkName: String) -> String { + var bundlePathCache: [String: String] = [:] + let classTypeString = NSStringFromClass(classType) + let bundlePathKey = "\(bundleName)_\(classTypeString)" + if let bundlePath = bundlePathCache[bundlePathKey] { + return bundlePath + } + + var bundlePath = Bundle.main.path(forResource: bundleName, ofType: "bundle") + + if bundlePath == nil || bundlePath?.isEmpty == true { + let frameworkBundlePath = Bundle(for: classType).path(forResource: frameworkName, ofType: "bundle") + if let frameworkBundle = Bundle(path: frameworkBundlePath ?? "") { + bundlePath = frameworkBundle.path(forResource: bundleName, ofType: "bundle") + } + } + + if (bundlePath == nil || bundlePath?.isEmpty == true) && !frameworkName.isEmpty { + var path = Bundle.main.bundlePath + path = (path as NSString).appendingPathComponent("Frameworks") + path = (path as NSString).appendingPathComponent(frameworkName) + path = (path as NSString).appendingPathExtension("framework") ?? path + path = (path as NSString).appendingPathComponent(bundleName) + bundlePath = (path as NSString).appendingPathExtension("bundle") + } + if let finalPath = bundlePath { + bundlePathCache[bundlePathKey] = finalPath + } + return bundlePath ?? "" + } +} + +public class AtomicXChatResources { + static let frameworkBundle = Bundle(for: AtomicXChatResources.self) + static var resourceBundle: Bundle { + if let bundlePath = frameworkBundle.path(forResource: "ChatUIKitBundle", ofType: "bundle"), + let bundle = Bundle(path: bundlePath) + { + return bundle + } + return frameworkBundle + } + + private static let rtlMirroredImageNames: Set = [ + "contact_info_back", + "contact_info_arrow_right", + "message_forward", + "message_recall", + "message_multi_forward_separate", + "message_multi_forward_merge", + "message_call_audio", + "message_call_video" + ] + + public static func image(named name: String) -> UIImage? { + let image = UIImage(named: name, in: resourceBundle, compatibleWith: nil) + guard let image = image, rtlMirroredImageNames.contains(name) else { return image } + return image.imageFlippedForRightToLeftLayoutDirection() + } +} diff --git a/chat/uikit/components/BaseComponent/utils/DateHelper.swift b/chat/uikit/components/BaseComponent/utils/DateHelper.swift new file mode 100644 index 000000000..efd2efc3c --- /dev/null +++ b/chat/uikit/components/BaseComponent/utils/DateHelper.swift @@ -0,0 +1,132 @@ + +class DateHelper { + private static let sundayFirstWeekday = 1 + + private static let mondayFirstWeekday = 2 + + private static let mondayBasedWeekdayShift = 5 + + private static let daysPerWeek = 7 + + private static let secondsPerMinute = 60 + + private static let secondsPerHour = 3600 + + private static var isChineseLanguage: Bool { + LanguageHelper.getCurrentLanguage().hasPrefix("zh") + } + + private static var shortDatePattern: String { + isChineseLanguage ? "M月d日" : "M/d/yy" + } + + private static var fullDatePattern: String { + isChineseLanguage ? "yyyy年M月d日" : "M/d/yy" + } + + static func convertDateToMessageTimeStr(_ date: Date) -> String { + if date == Date.distantPast { + return "" + } + let calendar = Calendar.current + let timeFormatter = DateFormatter() + timeFormatter.locale = Locale(identifier: "en_US_POSIX") + timeFormatter.dateFormat = "HH:mm" + let timeString = timeFormatter.string(from: date) + + if calendar.isDateInToday(date) { + return timeString + } + if calendar.isDateInYesterday(date) { + return "\(LocalizedChatString("Yesterday")) \(timeString)" + } + + let now = Date() + let nowComponents = calendar.dateComponents([.year], from: now) + let dateComponents = calendar.dateComponents([.year], from: date) + let isSameYear = nowComponents.year == dateComponents.year + let isSameWeek = startOfWeekMonday(now) == startOfWeekMonday(date) + let languageLocale = Locale(identifier: LanguageHelper.getCurrentLanguage()) + + if isSameYear && isSameWeek { + let weekdayFormatter = DateFormatter() + weekdayFormatter.dateFormat = "EEEE" + weekdayFormatter.locale = languageLocale + return "\(weekdayFormatter.string(from: date)) \(timeString)" + } + + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = isSameYear ? Self.shortDatePattern : Self.fullDatePattern + dayFormatter.locale = Locale(identifier: "en_US_POSIX") + return "\(dayFormatter.string(from: date)) \(timeString)" + } + + static func convertDateToYMDStr(_ date: Date) -> String { + if date == Date.distantPast { + return "" + } + + let dateFmt = DateFormatter() + dateFmt.locale = Locale(identifier: "en_US_POSIX") + + let calendar = Calendar.current + var customCalendar = calendar + customCalendar.firstWeekday = sundayFirstWeekday + + let now = Date() + let nowComponent = customCalendar.dateComponents([.day, .month, .year, .weekOfMonth], from: now) + let dateComponent = customCalendar.dateComponents([.day, .month, .year, .weekOfMonth], from: date) + + if nowComponent.year == dateComponent.year { + if nowComponent.month == dateComponent.month { + if nowComponent.weekOfMonth == dateComponent.weekOfMonth { + if nowComponent.day == dateComponent.day { + dateFmt.dateFormat = "HH:mm" + } else { + dateFmt.dateFormat = "EEEE" + let identifier = LanguageHelper.getCurrentLanguage() + dateFmt.locale = Locale(identifier: identifier) + } + } else { + dateFmt.dateFormat = Self.shortDatePattern + } + } else { + dateFmt.dateFormat = Self.shortDatePattern + } + } else { + dateFmt.dateFormat = Self.fullDatePattern + } + return dateFmt.string(from: date) + } + + static func formatDurationSeconds(_ totalSeconds: Int) -> String { + if totalSeconds <= 0 { + return "00:00" + } + let hours = totalSeconds / secondsPerHour + let minutes = totalSeconds % secondsPerHour / secondsPerMinute + let seconds = totalSeconds % secondsPerMinute + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } + + static func formatDurationMillis(_ milliseconds: Int) -> String { + return formatDurationSeconds(milliseconds / 1000) + } + + static func formatCallDuration(_ totalSeconds: Int) -> String { + let safeSeconds = max(totalSeconds, 0) + return String(format: "%02d:%02d", safeSeconds / secondsPerMinute, safeSeconds % secondsPerMinute) + } + + private static func startOfWeekMonday(_ date: Date) -> Date { + var calendar = Calendar.current + calendar.firstWeekday = mondayFirstWeekday + let startOfDay = calendar.startOfDay(for: date) + let weekday = calendar.component(.weekday, from: startOfDay) + let mondayBasedIndex = (weekday + mondayBasedWeekdayShift) % daysPerWeek + return calendar.date(byAdding: .day, value: -mondayBasedIndex, to: startOfDay) ?? startOfDay + } +} diff --git a/chat/uikit/components/BaseComponent/utils/LanguageHelper.swift b/chat/uikit/components/BaseComponent/utils/LanguageHelper.swift new file mode 100644 index 000000000..9a511c38c --- /dev/null +++ b/chat/uikit/components/BaseComponent/utils/LanguageHelper.swift @@ -0,0 +1,74 @@ +import Foundation +import UIKit + +let AppLanguageKey = "AtomicXLanguageKey" +let AppLanguageChangedNotification = "AtomicXLanguageChangedKey" + +@inline(__always) +public func LocalizedChatString(_ key: String) -> String { + LanguageHelper.localizedChatString(key) +} + +public class LanguageHelper { + class func getLocalizedString(forKey key: String, bundle bundleName: String, classType: AnyClass, frameworkName: String) -> String { + let currentLanguage = getCurrentLanguage() + if let bundle = BundleHelper.findLocalizableBundle( + bundleName: bundleName, + classType: classType, + language: currentLanguage, + frameworkName: frameworkName + ) { + return bundle.localizedString(forKey: key, value: key, table: nil) + } + return key + } + + class func localizedChatString(_ key: String) -> String { + return getLocalizedString(forKey: key, bundle: "ChatLocalizable", classType: LanguageHelper.self, frameworkName: "ChatUIKitBundle") + } + + + public static func getCurrentLanguage() -> String { + if let savedLanguage = UserDefaults.standard.string(forKey: AppLanguageKey), !savedLanguage.isEmpty { + return savedLanguage + } + return getSystemLanguage() + } + + public static var isRTL: Bool { + let language = getCurrentLanguage() + return ["ar", "he", "fa", "ur"].contains { language.hasPrefix($0) } + } + + public static var currentSemanticContentAttribute: UISemanticContentAttribute { + isRTL ? .forceRightToLeft : .forceLeftToRight + } + + public static func applyLayoutDirection(to view: UIView) { + view.semanticContentAttribute = currentSemanticContentAttribute + } + + public static func saveLanguage(_ languageCode: String) { + UserDefaults.standard.set(languageCode, forKey: AppLanguageKey) + UserDefaults.standard.synchronize() + NotificationCenter.default.post(name: Notification.Name(AppLanguageChangedNotification), object: nil) + } + + private static func getSystemLanguage() -> String { + let preferredLanguage = Locale.preferredLanguages.first ?? "en" + if preferredLanguage.hasPrefix("en") { + return "en" + } else if preferredLanguage.hasPrefix("zh") { + if preferredLanguage.contains("Hans") { + return "zh-Hans" + } else { + return "zh-Hant" + } + } else if preferredLanguage.hasPrefix("ar") { + return "ar" + } else { + return "en" + } + } +} + diff --git a/chat/uikit/components/VideoPlayer/VideoPlayer.swift b/chat/uikit/components/VideoPlayer/VideoPlayer.swift new file mode 100644 index 000000000..ec69f394c --- /dev/null +++ b/chat/uikit/components/VideoPlayer/VideoPlayer.swift @@ -0,0 +1,149 @@ +import AVKit +import Combine +import UIKit + +struct VideoData { + let uri: String + let localPath: String? + public let width: Int + public let height: Int + let duration: TimeInterval? + let snapshotUrl: String? + let snapshotLocalPath: String? + init( + uri: String, + localPath: String? = nil, + width: Int = 0, + height: Int = 0, + duration: TimeInterval? = nil, + snapshotUrl: String? = nil, + snapshotLocalPath: String? = nil + ) { + self.uri = uri + self.localPath = localPath + self.width = width + self.height = height + self.duration = duration + self.snapshotUrl = snapshotUrl + self.snapshotLocalPath = snapshotLocalPath + } +} + +class VideoPlayer: ObservableObject { + public static let shared = VideoPlayer() + + @Published public var isPresented = false + @Published public var currentVideoData: VideoData? + @Published public var player: AVPlayer? + + func play(videoData: VideoData) { + currentVideoData = videoData + setupPlayer(with: videoData) + isPresented = true + } + + func playWithUIKit(videoData: VideoData, onPresented: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil) { + currentVideoData = videoData + + let videoURL: URL + if let localPath = videoData.localPath, !localPath.isEmpty { + videoURL = URL(fileURLWithPath: localPath) + } else { + videoURL = URL(string: videoData.uri) ?? URL(fileURLWithPath: videoData.uri) + } + + let player = AVPlayer(url: videoURL) + let playerVC = AVPlayerViewController() + playerVC.player = player + + let containerVC = VideoPlayerContainerViewController(playerViewController: playerVC) + containerVC.onDismiss = onDismiss + containerVC.modalPresentationStyle = .fullScreen + containerVC.modalTransitionStyle = .crossDissolve + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + var topVC = rootVC + while let presented = topVC.presentedViewController { + topVC = presented + } + topVC.present(containerVC, animated: true) { + player.play() + onPresented?() + } + } + } + + public func dismiss() { + player?.pause() + player = nil + currentVideoData = nil + isPresented = false + } + + private init() {} + + private func setupPlayer(with videoData: VideoData) { + let videoURL: URL + if let localPath = videoData.localPath, !localPath.isEmpty { + videoURL = URL(fileURLWithPath: localPath) + } else { + videoURL = URL(string: videoData.uri) ?? URL(fileURLWithPath: videoData.uri) + } + + let asset = AVURLAsset(url: videoURL) + let playerItem = AVPlayerItem(asset: asset) + let newPlayer = AVPlayer(playerItem: playerItem) + + newPlayer.automaticallyWaitsToMinimizeStalling = false + + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: playerItem, + queue: .main + ) { [weak self] _ in + self?.dismiss() + } + + newPlayer.play() + player = newPlayer + } +} + +class VideoPlayerContainerViewController: UIViewController { + var onDismiss: (() -> Void)? + + override var prefersStatusBarHidden: Bool { + return true + } + + private let playerViewController: AVPlayerViewController + + init(playerViewController: AVPlayerViewController) { + self.playerViewController = playerViewController + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + addChild(playerViewController) + view.addSubview(playerViewController.view) + playerViewController.view.frame = view.bounds + playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + playerViewController.didMove(toParent: self) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + guard isBeingDismissed || presentingViewController == nil else { return } + playerViewController.player?.pause() + onDismiss?() + onDismiss = nil + } +} diff --git a/chat/uikit/components/ai/AiMediaProcessManager.swift b/chat/uikit/components/ai/AiMediaProcessManager.swift new file mode 100644 index 000000000..142db6b82 --- /dev/null +++ b/chat/uikit/components/ai/AiMediaProcessManager.swift @@ -0,0 +1,260 @@ +import Foundation +import ImSDK_Plus + +public final class AiMediaProcessManager { + private static let ApiUploadFile = "uploadFile" + + private static let ApiConvertTextToVoice = "convertTextToVoice" + + private static let ApiVoiceClone = "voiceClone" + + private static let ApiGetCustomVoiceList = "getCustomVoiceList" + + private static let ApiDeleteCustomVoice = "deleteCustomVoice" + + private static let ApiConvertVoiceToText = "convertVoiceToText" + + private static let KeyFilePath = "filePath" + + private static let KeyFileType = "fileType" + + private static let KeyText = "text" + + private static let KeyVoiceId = "voiceId" + + private static let KeyVoiceName = "voiceName" + + private static let KeyAudioFormat = "audioFormat" + + private static let KeyAudioUrl = "audioUrl" + + private static let KeyLanguage = "language" + + private static let KeyPromptText = "promptText" + + private static let KeyVoiceList = "voiceList" + + private static let KeyUrl = "url" + + private static let EmptyJSON = "{}" + + private static let ErrorUnknown: Int32 = -1 + + private static let FileTypeAudio = 3 + + static func uploadAudioFile(filePath: String, + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + callExperimentalAPI(api: ApiUploadFile, + params: buildUploadFileParams(filePath: filePath), + onSuccess: { result in + if result.isEmpty { + onFailure(ErrorUnknown, "upload url is empty") + } else { + onSuccess(result) + } + }, + onFailure: onFailure) + } + + static func convertTextToVoice(text: String, + voiceId: String = "", + audioFormat: String = "wav", + language: String = "", + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + callExperimentalAPI(api: ApiConvertTextToVoice, + params: buildConvertTextToVoiceParams(text: text, voiceId: voiceId, audioFormat: audioFormat, language: language), + onSuccess: { result in + if let audioUrl = parseAudioUrl(result), !audioUrl.isEmpty { + onSuccess(audioUrl) + } else { + onFailure(ErrorUnknown, "tts audio url is empty") + } + }, + onFailure: onFailure) + } + + public static func voiceClone(filePath: String, + voiceName: String, + promptText: String = "", + language: String = "", + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + uploadAudioFile(filePath: filePath, + onSuccess: { audioUrl in + callExperimentalAPI(api: ApiVoiceClone, + params: buildVoiceCloneParams(voiceName: voiceName, audioUrl: audioUrl, promptText: promptText, language: language), + onSuccess: { result in + if let voiceId = parseVoiceId(result), !voiceId.isEmpty { + onSuccess(voiceId) + } else { + onFailure(ErrorUnknown, "voice clone id is empty") + } + }, + onFailure: onFailure) + }, + onFailure: onFailure) + } + + public static func getCustomVoiceList(onSuccess: @escaping ([CustomVoiceItem]) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + callExperimentalAPI(api: ApiGetCustomVoiceList, + params: EmptyJSON, + onSuccess: { result in + onSuccess(parseVoiceList(result)) + }, + onFailure: onFailure) + } + + public static func deleteCustomVoice(voiceId: String, + onSuccess: @escaping () -> Void, + onFailure: @escaping (Int32, String) -> Void) { + callExperimentalAPI(api: ApiDeleteCustomVoice, + params: buildDeleteCustomVoiceParams(voiceId: voiceId), + onSuccess: { _ in + onSuccess() + }, + onFailure: onFailure) + } + + static func translateSingleText(text: String, + targetLanguage: String, + sourceLanguage: String = "", + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + V2TIMManager.sharedInstance().translateText(sourceTextList: [text], + sourceLanguage: sourceLanguage.isEmpty ? nil : sourceLanguage, + targetLanguage: targetLanguage) { code, desc, result in + DispatchQueue.main.async { + if code != 0 { + onFailure(code, desc ?? "") + return + } + guard let translated = result?[text], !translated.isEmpty else { + onFailure(ErrorUnknown, "translation result is empty") + return + } + onSuccess(translated) + } + } + } + + // MARK: - Voice to Text(对齐 Android `AudioTranscriber`) + + static func convertVoiceToText(url: String, + language: String = "", + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + callExperimentalAPI(api: ApiConvertVoiceToText, + params: buildConvertVoiceToTextParams(url: url, language: language), + onSuccess: { result in + let text = result.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + onFailure(ErrorUnknown, "voice to text result is empty") + } else { + onSuccess(text) + } + }, + onFailure: onFailure) + } + + static func convertLocalAudioToText(filePath: String, + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + uploadAudioFile(filePath: filePath, + onSuccess: { url in + convertVoiceToText(url: url, language: "", onSuccess: onSuccess, onFailure: onFailure) + }, + onFailure: onFailure) + } + + // MARK: - Constants + + private static func callExperimentalAPI(api: String, + params: String, + onSuccess: @escaping (String) -> Void, + onFailure: @escaping (Int32, String) -> Void) { + V2TIMManager.sharedInstance().callExperimentalAPI(api: api, param: params as NSObject) { result in + DispatchQueue.main.async { + onSuccess(result?.description ?? "") + } + } fail: { code, desc in + DispatchQueue.main.async { + onFailure(code, desc ?? "") + } + } + } + + private static func parseAudioUrl(_ result: String) -> String? { + guard !result.isEmpty, + let data = result.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let url = json[KeyAudioUrl] as? String, !url.isEmpty else { + return nil + } + return url + } + + private static func parseVoiceId(_ result: String) -> String? { + guard !result.isEmpty, + let data = result.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = json[KeyVoiceId] as? String, !id.isEmpty else { + return nil + } + return id + } + + private static func parseVoiceList(_ result: String) -> [CustomVoiceItem] { + guard !result.isEmpty, + let data = result.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let array = json[KeyVoiceList] as? [[String: Any]] else { + return [] + } + var list: [CustomVoiceItem] = [] + for item in array { + guard let id = item[KeyVoiceId] as? String, !id.isEmpty else { continue } + let name = item[KeyVoiceName] as? String ?? "" + list.append(CustomVoiceItem(voiceId: id, name: name, isDefault: false)) + } + return list + } + + private static func buildUploadFileParams(filePath: String) -> String { + let dict: [String: Any] = [KeyFilePath: filePath, KeyFileType: FileTypeAudio] + return jsonString(from: dict) ?? "" + } + + private static func buildConvertTextToVoiceParams(text: String, voiceId: String, audioFormat: String, language: String) -> String { + var dict: [String: Any] = [KeyText: text, KeyAudioFormat: audioFormat, KeyLanguage: language] + if !voiceId.isEmpty { + dict[KeyVoiceId] = voiceId + } + return jsonString(from: dict) ?? "" + } + + private static func buildConvertVoiceToTextParams(url: String, language: String) -> String { + var dict: [String: Any] = [KeyUrl: url, KeyLanguage: language] + return jsonString(from: dict) ?? "" + } + + private static func buildVoiceCloneParams(voiceName: String, audioUrl: String, promptText: String, language: String) -> String { + let dict: [String: Any] = [KeyVoiceName: voiceName, KeyAudioUrl: audioUrl, KeyPromptText: promptText, KeyLanguage: language] + return jsonString(from: dict) ?? "" + } + + private static func buildDeleteCustomVoiceParams(voiceId: String) -> String { + let dict: [String: Any] = [KeyVoiceId: voiceId] + return jsonString(from: dict) ?? "" + } + + private static func jsonString(from dict: [String: Any]) -> String? { + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []), + let string = String(data: data, encoding: .utf8) else { + return nil + } + return string + } +} diff --git a/chat/uikit/components/ai/tts/TtsPlaybackHelper.swift b/chat/uikit/components/ai/tts/TtsPlaybackHelper.swift new file mode 100644 index 000000000..8fb66e883 --- /dev/null +++ b/chat/uikit/components/ai/tts/TtsPlaybackHelper.swift @@ -0,0 +1,113 @@ +import AVFoundation +import Foundation + +final class TtsPlaybackHelper { + private(set) var isPlaying = false + + private var player: AVPlayer? + + private var playerItem: AVPlayerItem? + + private var endObserver: NSObjectProtocol? + + private var failedObserver: NSObjectProtocol? + + private var session = 0 + + func speak(text: String, + voiceId: String = "", + language: String = "", + onStart: (() -> Void)? = nil, + onComplete: (() -> Void)? = nil, + onError: ((String) -> Void)? = nil) { + session += 1 + let currentSession = session + AiMediaProcessManager.convertTextToVoice( + text: text, + voiceId: voiceId, + onSuccess: { [weak self] audioUrl in + guard let self, currentSession == self.session else { return } + guard let url = URL(string: audioUrl) else { + onError?("invalid tts audio url") + return + } + self.startPlayback(currentSession: currentSession, url: url, onStart: onStart, onComplete: onComplete, onError: onError) + }, + onFailure: { [weak self] _, desc in + guard let self, currentSession == self.session else { return } + self.isPlaying = false + onError?(desc) + } + ) + } + + func stop() { + session += 1 + isPlaying = false + teardownPlayer() + } + + deinit { + teardownPlayer() + } + + private func startPlayback(currentSession: Int, + url: URL, + onStart: (() -> Void)?, + onComplete: (() -> Void)?, + onError: ((String) -> Void)?) { + teardownPlayer() + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + onError?(error.localizedDescription) + return + } + let item = AVPlayerItem(url: url) + let avPlayer = AVPlayer(playerItem: item) + avPlayer.actionAtItemEnd = .pause + + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + guard let self, currentSession == self.session else { return } + self.isPlaying = false + self.teardownPlayer() + onComplete?() + } + failedObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: item, + queue: .main + ) { [weak self] notification in + guard let self, currentSession == self.session else { return } + self.isPlaying = false + self.teardownPlayer() + let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error + onError?(error?.localizedDescription ?? "play failed") + } + + playerItem = item + player = avPlayer + isPlaying = true + onStart?() + avPlayer.play() + } + + private func teardownPlayer() { + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + self.endObserver = nil + } + if let failedObserver { + NotificationCenter.default.removeObserver(failedObserver) + self.failedObserver = nil + } + player?.pause() + player = nil + playerItem = nil + } +} diff --git a/chat/uikit/components/ai/tts/TtsTextSanitizer.swift b/chat/uikit/components/ai/tts/TtsTextSanitizer.swift new file mode 100644 index 000000000..23ac34ef3 --- /dev/null +++ b/chat/uikit/components/ai/tts/TtsTextSanitizer.swift @@ -0,0 +1,83 @@ +import Foundation + +final class TtsTextSanitizer { + static let shared = TtsTextSanitizer() + + private let customEmojiRegex: NSRegularExpression? + + private let universalEmojiRegex: NSRegularExpression? + + private static let universalEmojiPattern: String = { + let ri = "[\\x{1F1E6}-\\x{1F1FF}]" + let support = "\\x{A9}|\\x{AE}|\\x{203C}|\\x{2049}|\\x{2122}|\\x{2139}|[\\x{2194}-\\x{2199}]|[\\x{21A9}-\\x{21AA}]" + + "|[\\x{231A}-\\x{231B}]|\\x{2328}|\\x{23CF}|[\\x{23E9}-\\x{23EF}]|[\\x{23F0}-\\x{23F3}]|[\\x{23F8}-\\x{23FA}]|\\x{24C2}" + + "|[\\x{25AA}-\\x{25AB}]|\\x{25B6}|\\x{25C0}|[\\x{25FB}-\\x{25FE}]|[\\x{2600}-\\x{2604}]|\\x{260E}|\\x{2611}|[\\x{2614}-\\x{2615}]" + + "|\\x{2618}|\\x{261D}|\\x{2620}|[\\x{2622}-\\x{2623}]|\\x{2626}|\\x{262A}|[\\x{262E}-\\x{262F}]|[\\x{2638}-\\x{263A}]|\\x{2640}" + + "|\\x{2642}|[\\x{2648}-\\x{264F}]|[\\x{2650}-\\x{2653}]|\\x{265F}|\\x{2660}|\\x{2663}|[\\x{2665}-\\x{2666}]|\\x{2668}|\\x{267B}" + + "|[\\x{267E}-\\x{267F}]|[\\x{2692}-\\x{2697}]|\\x{2699}|[\\x{269B}-\\x{269C}]|[\\x{26A0}-\\x{26A1}]|\\x{26A7}|[\\x{26AA}-\\x{26AB}]" + + "|[\\x{26B0}-\\x{26B1}]|[\\x{26BD}-\\x{26BE}]|[\\x{26C4}-\\x{26C5}]|\\x{26C8}|[\\x{26CE}-\\x{26CF}]|\\x{26D1}|[\\x{26D3}-\\x{26D4}]" + + "|[\\x{26E9}-\\x{26EA}]|[\\x{26F0}-\\x{26F5}]|[\\x{26F7}-\\x{26FA}]|\\x{26FD}|\\x{2702}|\\x{2705}|[\\x{2708}-\\x{270D}]|\\x{270F}|\\x{2712}" + + "|\\x{2714}|\\x{2716}|\\x{271D}|\\x{2721}|\\x{2728}|[\\x{2733}-\\x{2734}]|\\x{2744}|\\x{2747}|\\x{274C}|\\x{274E}|[\\x{2753}-\\x{2755}]" + + "|\\x{2757}|[\\x{2763}-\\x{2764}]|[\\x{2795}-\\x{2797}]|\\x{27A1}|\\x{27B0}|\\x{27BF}|[\\x{2934}-\\x{2935}]|[\\x{2B05}-\\x{2B07}]" + + "|[\\x{2B1B}-\\x{2B1C}]|\\x{2B50}|\\x{2B55}|\\x{3030}|\\x{303D}|\\x{3297}|\\x{3299}|\\x{1F004}|\\x{1F0CF}|[\\x{1F170}-\\x{1F171}]" + + "|[\\x{1F17E}-\\x{1F17F}]|\\x{1F18E}|[\\x{1F191}-\\x{1F19A}]|[\\x{1F1E6}-\\x{1F1FF}]|[\\x{1F201}-\\x{1F202}]" + + "|\\x{1F21A}|\\x{1F22F}|[\\x{1F232}-\\x{1F23A}]|[\\x{1F250}-\\x{1F251}]|[\\x{1F300}-\\x{1F30F}]" + + "|[\\x{1F310}-\\x{1F31F}]|[\\x{1F320}-\\x{1F321}]|[\\x{1F324}-\\x{1F32F}]|[\\x{1F330}-\\x{1F33F}]" + + "|[\\x{1F340}-\\x{1F34F}]|[\\x{1F350}-\\x{1F35F}]|[\\x{1F360}-\\x{1F36F}]|[\\x{1F370}-\\x{1F37F}]" + + "|[\\x{1F380}-\\x{1F38F}]|[\\x{1F390}-\\x{1F393}]|[\\x{1F396}-\\x{1F397}]|[\\x{1F399}-\\x{1F39B}]" + + "|[\\x{1F39E}-\\x{1F39F}]|[\\x{1F3A0}-\\x{1F3AF}]|[\\x{1F3B0}-\\x{1F3BF}]|[\\x{1F3C0}-\\x{1F3CF}]" + + "|[\\x{1F3D0}-\\x{1F3DF}]|[\\x{1F3E0}-\\x{1F3EF}]|\\x{1F3F0}|[\\x{1F3F3}-\\x{1F3F5}]|[\\x{1F3F7}-\\x{1F3FF}]" + + "|[\\x{1F400}-\\x{1F40F}]|[\\x{1F410}-\\x{1F41F}]|[\\x{1F420}-\\x{1F42F}]|[\\x{1F430}-\\x{1F43F}]" + + "|[\\x{1F440}-\\x{1F44F}]|[\\x{1F450}-\\x{1F45F}]|[\\x{1F460}-\\x{1F46F}]|[\\x{1F470}-\\x{1F47F}]" + + "|[\\x{1F480}-\\x{1F48F}]|[\\x{1F490}-\\x{1F49F}]|[\\x{1F4A0}-\\x{1F4AF}]|[\\x{1F4B0}-\\x{1F4BF}]" + + "|[\\x{1F4C0}-\\x{1F4CF}]|[\\x{1F4D0}-\\x{1F4DF}]|[\\x{1F4E0}-\\x{1F4EF}]|[\\x{1F4F0}-\\x{1F4FF}]" + + "|[\\x{1F500}-\\x{1F50F}]|[\\x{1F510}-\\x{1F51F}]|[\\x{1F520}-\\x{1F52F}]|[\\x{1F530}-\\x{1F53D}]" + + "|[\\x{1F549}-\\x{1F54E}]|[\\x{1F550}-\\x{1F55F}]|[\\x{1F560}-\\x{1F56F}]|\\x{1F56F}|\\x{1F570}" + + "|[\\x{1F573}-\\x{1F57A}]|\\x{1F587}|[\\x{1F58A}-\\x{1F58D}]|\\x{1F590}|[\\x{1F595}-\\x{1F596}]" + + "|[\\x{1F5A4}-\\x{1F5A5}]|\\x{1F5A8}|[\\x{1F5B1}-\\x{1F5B2}]|\\x{1F5BC}|[\\x{1F5C2}-\\x{1F5C4}]" + + "|[\\x{1F5D1}-\\x{1F5D3}]|[\\x{1F5DC}-\\x{1F5DE}]|\\x{1F5E1}|\\x{1F5E3}|\\x{1F5E8}|\\x{1F5EF}|\\x{1F5F3}" + + "|[\\x{1F5FA}-\\x{1F5FF}]|[\\x{1F600}-\\x{1F60F}]|[\\x{1F610}-\\x{1F61F}]|[\\x{1F620}-\\x{1F62F}]" + + "|[\\x{1F630}-\\x{1F63F}]|[\\x{1F640}-\\x{1F64F}]|[\\x{1F650}-\\x{1F65F}]|[\\x{1F660}-\\x{1F66F}]" + + "|[\\x{1F670}-\\x{1F67F}]|[\\x{1F680}-\\x{1F68F}]|[\\x{1F690}-\\x{1F69F}]|[\\x{1F6A0}-\\x{1F6AF}]" + + "|[\\x{1F6B0}-\\x{1F6BF}]|[\\x{1F6C0}-\\x{1F6C5}]|[\\x{1F6CB}-\\x{1F6CF}]|[\\x{1F6D0}-\\x{1F6D2}]" + + "|[\\x{1F6D5}-\\x{1F6D7}]|[\\x{1F6DD}-\\x{1F6DF}]|[\\x{1F6E0}-\\x{1F6E5}]|\\x{1F6E9}|[\\x{1F6EB}-\\x{1F6EC}]" + + "|\\x{1F6F0}|[\\x{1F6F3}-\\x{1F6FC}]|[\\x{1F7E0}-\\x{1F7EB}]|\\x{1F7F0}|[\\x{1F90C}-\\x{1F90F}]" + + "|[\\x{1F910}-\\x{1F91F}]|[\\x{1F920}-\\x{1F92F}]|[\\x{1F930}-\\x{1F93A}]|[\\x{1F93C}-\\x{1F93F}]" + + "|[\\x{1F940}-\\x{1F945}]|[\\x{1F947}-\\x{1F94C}]|[\\x{1F94D}-\\x{1F94F}]|[\\x{1F950}-\\x{1F95F}]" + + "|[\\x{1F960}-\\x{1F96F}]|[\\x{1F970}-\\x{1F97F}]|[\\x{1F980}-\\x{1F98F}]|[\\x{1F990}-\\x{1F99F}]" + + "|[\\x{1F9A0}-\\x{1F9AF}]|[\\x{1F9B0}-\\x{1F9BF}]|[\\x{1F9C0}-\\x{1F9CF}]|[\\x{1F9D0}-\\x{1F9DF}]" + + "|[\\x{1F9E0}-\\x{1F9EF}]|[\\x{1F9F0}-\\x{1F9FF}]|[\\x{1FA70}-\\x{1FA74}]|[\\x{1FA78}-\\x{1FA7C}]" + + "|[\\x{1FA80}-\\x{1FA86}]|[\\x{1FA90}-\\x{1FA9F}]|[\\x{1FAA0}-\\x{1FAAC}]|[\\x{1FAB0}-\\x{1FABA}]" + + "|[\\x{1FAC0}-\\x{1FAC5}]|[\\x{1FAD0}-\\x{1FAD9}]|[\\x{1FAE0}-\\x{1FAE7}]|[\\x{1FAF0}-\\x{1FAF6}]" + let keycapBase = "[\\x{0023}\\x{002A}\\x{0030}-\\x{0039}]" + let eMod = "[\\x{1F3FB}-\\x{1F3FF}]" + let variationSelector = "\\x{FE0F}" + let keycap = "\\x{20E3}" + let tags = "[\\x{E0020}-\\x{E007E}]" + let termTag = "\\x{E007F}" + let zwj = "\\x{200D}" + let risequence = ri + ri + let keycapEmoji = keycapBase + variationSelector + "?" + keycap + let element = "(?:" + support + ")(?:" + eMod + "|" + variationSelector + "|" + tags + "+" + termTag + "?)?" + return keycapEmoji + "|" + risequence + "|" + element + "(?:" + zwj + "(?:" + risequence + "|" + element + "))*" + }() + + func sanitize(text: String) -> String { + guard !text.isEmpty else { return text } + var result = text + if let regex = customEmojiRegex { + result = regex.stringByReplacingMatches(in: result, options: [], range: NSRange(result.startIndex..., in: result), withTemplate: "") + } + if let regex = universalEmojiRegex { + result = regex.stringByReplacingMatches(in: result, options: [], range: NSRange(result.startIndex..., in: result), withTemplate: "") + } + return result.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Universal emoji pattern + + private init() { + customEmojiRegex = try? NSRegularExpression(pattern: "\\[[^\\]]+\\]", options: []) + universalEmojiRegex = try? NSRegularExpression(pattern: Self.universalEmojiPattern, options: []) + } +} diff --git a/chat/uikit/components/ai/tts/VoiceItem.swift b/chat/uikit/components/ai/tts/VoiceItem.swift new file mode 100644 index 000000000..2e1896c9a --- /dev/null +++ b/chat/uikit/components/ai/tts/VoiceItem.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct CustomVoiceItem: Equatable, Hashable { + public let voiceId: String + + public let name: String + + let isDefault: Bool + + public init(voiceId: String, name: String, isDefault: Bool) { + self.voiceId = voiceId + self.name = name + self.isDefault = isDefault + } + + public static func == (lhs: CustomVoiceItem, rhs: CustomVoiceItem) -> Bool { + return lhs.voiceId == rhs.voiceId + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(voiceId) + } +} + +enum SystemVoiceID { + static let xiaoxuMale = "male-kefu-xiaoxu" + static let xiaomeiFemale = "female-kefu-xiaomei" + static let xiaoxinFemale = "female-kefu-xiaoxin" + static let xiaoyueFemale = "female-kefu-xiaoyue" +} + +struct DefaultVoiceItem { + public let voiceId: String + let nameKey: String + + init(voiceId: String, nameKey: String) { + self.voiceId = voiceId + self.nameKey = nameKey + } +} + +public enum VoiceItemProvider { + + static let defaultVoiceItems: [DefaultVoiceItem] = [ + DefaultVoiceItem(voiceId: "", nameKey: "voice_message_voice_default"), + DefaultVoiceItem(voiceId: SystemVoiceID.xiaoxuMale, nameKey: "voice_message_voice_xiaoxu"), + DefaultVoiceItem(voiceId: SystemVoiceID.xiaomeiFemale, nameKey: "voice_message_voice_xiaomei"), + DefaultVoiceItem(voiceId: SystemVoiceID.xiaoxinFemale, nameKey: "voice_message_voice_xiaoxin"), + DefaultVoiceItem(voiceId: SystemVoiceID.xiaoyueFemale, nameKey: "voice_message_voice_xiaoyue") + ] + + public static func defaultVoiceList() -> [CustomVoiceItem] { + return defaultVoiceItems.map { item in + CustomVoiceItem(voiceId: item.voiceId, name: LocalizedChatString(item.nameKey), isDefault: true) + } + } +} diff --git a/chat/uikit/components/ai/tts/VoiceMessageConfig.swift b/chat/uikit/components/ai/tts/VoiceMessageConfig.swift new file mode 100644 index 000000000..e5f6bb459 --- /dev/null +++ b/chat/uikit/components/ai/tts/VoiceMessageConfig.swift @@ -0,0 +1,41 @@ +import Foundation +import AtomicXCore + +public final class VoiceMessageConfig { + public static let shared = VoiceMessageConfig() + + private let KeyPrefix = "voice_message_config" + + private let NameSelectedVoiceId = "selectedVoiceId" + + private let NameSelectedVoiceName = "selectedVoiceName" + + public func getSelectedVoiceId() -> String { + return storage().string(forKey: key(NameSelectedVoiceId)) ?? "" + } + + public func getSelectedVoiceName() -> String { + return storage().string(forKey: key(NameSelectedVoiceName)) ?? "" + } + + public func setSelectedVoice(id: String, name: String) { + let storage = storage() + storage.set(id, forKey: key(NameSelectedVoiceId)) + storage.set(name, forKey: key(NameSelectedVoiceName)) + } + + private init() {} + + private func storage() -> UserDefaults { + return UserDefaults.standard + } + + private func key(_ name: String) -> String { + let userId = currentUserId() + return userId.isEmpty ? "\(KeyPrefix).\(name)" : "\(KeyPrefix).\(userId).\(name)" + } + + private func currentUserId() -> String { + return LoginStore.shared.state.value.loginUserInfo?.userID ?? "" + } +} diff --git a/chat/uikit/components/chatsetting/AddContactViewController.swift b/chat/uikit/components/chatsetting/AddContactViewController.swift new file mode 100644 index 000000000..8601ce645 --- /dev/null +++ b/chat/uikit/components/chatsetting/AddContactViewController.swift @@ -0,0 +1,494 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +final class AddContactViewController: ChatSettingBaseViewController { + private enum Step { + case detail + case form + } + + private static let infoCardAvatarSize = ChatAvatarSize.l + + private static let cardHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let toastBottomOffset: CGFloat = 200 + + private static let addFriendNeedConfirmCode = 30539 + + private static let addFriendInvalidParamsCode = 30001 + + private static let addFriendExistFlag = "Err_SNS_FriendAdd_Friend_Exist" + + private static let infoCardVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let infoCardAvatarNameGap: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let infoCardLineGap: CGFloat = 2 + + private static let actionCardHeight: CGFloat = 52 + + private static let sectionSpacerHeight: CGFloat = 10 + + private static let sectionTitleTopPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let sectionTitleBottomPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let inputCardPadding: CGFloat = 14 + + private static let inputCardMinHeight: CGFloat = 120 + + private static let remarkRowHeight: CGFloat = 52 + + private static let remarkLabelValueGap: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let signatureMaxLineCount: Int = 2 + + private static let toastDuration: TimeInterval = 2 + + private let userID: String + + private let onAddFriendSuccess: (() -> Void)? + + private let contactStore = ContactStore.shared + + private var contactInfo: ContactInfo? + + private var currentStep: Step = .detail + + private var cancellables = Set() + + private let contentContainer = UIView() + + private let avatarView: ChatAvatarView = { + let size = AddContactViewController.infoCardAvatarSize + let radius: CGFloat + switch AppBuilderConfig.shared.avatarShape { + case .circular: + radius = size.size / 2 + case .rounded: + radius = size.roundedRectCornerRadius + case .square: + radius = 0 + } + return ChatAvatarView(cornerRadius: radius, fontSize: size.placeholderFontSize) + }() + + private let nameLabel = UILabel() + + private let userIDLabel = UILabel() + + private let signatureLabel = UILabel() + + private let formScrollView = UIScrollView() + + private let formStack = UIStackView() + + private let wordingTextView = UITextView() + + private let remarkTextField = UITextField() + + private var displayName: String { + if let contactInfo = contactInfo { + return chatSettingContactDisplayName(contactInfo) + } + return userID + } + + // MARK: - Layout Constants(对齐 Android dp 值) + + init( + userID: String, + contactInfo: ContactInfo? = nil, + onAddFriendSuccess: (() -> Void)? = nil + ) { + self.userID = userID + self.contactInfo = contactInfo + self.onAddFriendSuccess = onAddFriendSuccess + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("ContactListAddContact")) + view.addSubview(contentContainer) + contentContainer.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + view.backgroundColor = ChatUIKitTheme.colors.bgColorDefault + contentContainer.backgroundColor = ChatUIKitTheme.colors.bgColorDefault + onBack = { [weak self] in + self?.handleStepBack() + } + showDetailStep() + if contactInfo == nil { + fetchUserInfo() + } + subscribeKeyboard() + } + + // MARK: - Keyboard(对齐 Android SOFT_INPUT_ADJUST_RESIZE) + + private func handleStepBack() { + switch currentStep { + case .form: + showDetailStep() + case .detail: + if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } + } + + private func showDetailStep() { + currentStep = .detail + contentContainer.subviews.forEach { $0.removeFromSuperview() } + view.endEditing(true) + + let container = UIStackView() + container.axis = .vertical + container.spacing = 0 + contentContainer.addSubview(container) + container.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + container.addArrangedSubview(makeInfoCard()) + container.addArrangedSubview(makeSectionSpacer()) + container.addArrangedSubview(makeActionCard( + title: LocalizedChatString("ContactListAddContact"), + action: #selector(handleAddContactTapped) + )) + refreshInfoCard() + } + + @objc private func handleAddContactTapped() { + showFormStep() + } + + private func showFormStep() { + currentStep = .form + contentContainer.subviews.forEach { $0.removeFromSuperview() } + + formScrollView.keyboardDismissMode = .onDrag + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + backgroundTap.cancelsTouchesInView = false + formScrollView.addGestureRecognizer(backgroundTap) + remarkTextField.returnKeyType = .done + remarkTextField.delegate = self + + formScrollView.addSubview(formStack) + formStack.axis = .vertical + formStack.spacing = 0 + contentContainer.addSubview(formScrollView) + formScrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + formStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalToSuperview() + } + formStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + formStack.addArrangedSubview(makeInfoCard()) + formStack.addArrangedSubview(makeSectionTitle(LocalizedChatString("ContactListFillValidationMessage"))) + formStack.addArrangedSubview(makeWordingInputCard()) + formStack.addArrangedSubview(makeSectionSpacer()) + formStack.addArrangedSubview(makeRemarkRow()) + formStack.addArrangedSubview(makeSectionSpacer()) + formStack.addArrangedSubview(makeActionCard( + title: LocalizedChatString("Send"), + action: #selector(handleSendTapped) + )) + refreshInfoCard() + applyFormDefaults() + } + + private func applyFormDefaults() { + let selfInfo = LoginStore.shared.state.value.loginUserInfo + let selfDisplayName = (selfInfo?.nickname?.isEmpty == false ? selfInfo?.nickname : selfInfo?.userID) ?? "" + wordingTextView.text = String(format: LocalizedChatString("ContactListAddWordingIAm"), selfDisplayName) + remarkTextField.text = displayName + } + + @objc private func handleBackgroundTapped() { + view.endEditing(true) + } + + @objc private func handleSendTapped() { + let wording = wordingTextView.text ?? "" + var remark = (remarkTextField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if remark.isEmpty { + remark = contactInfo?.nickname ?? userID + } + contactStore.addFriend( + userID: userID, + remark: remark, + addWording: wording.isEmpty ? nil : wording + ) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success: + WindowToastManager.shared.show( + LocalizedChatString("ContactListAddFriendSuccess"), + type: .success, + duration: Self.toastDuration, + position: .bottom(Self.toastBottomOffset), + trailingIcon: UIImage(named: "AppIcon") + ) + self.onAddFriendSuccess?() + if let navigationController = self.navigationController { + navigationController.popViewController(animated: true) + } else { + self.dismiss(animated: true) + } + case .failure(let error): + if error.code == Self.addFriendNeedConfirmCode { + WindowToastManager.shared.show( + LocalizedChatString("FriendRequestSent"), + type: .success, + duration: Self.toastDuration, + position: .bottom(Self.toastBottomOffset), + trailingIcon: UIImage(named: "AppIcon") + ) + self.onAddFriendSuccess?() + if let navigationController = self.navigationController { + navigationController.popViewController(animated: true) + } else { + self.dismiss(animated: true) + } + } else if error.code == Self.addFriendInvalidParamsCode && error.message == Self.addFriendExistFlag { + WindowToastManager.shared.show( + LocalizedChatString("AlreadyFriendTip"), + type: .info, + duration: Self.toastDuration, + position: .bottom(Self.toastBottomOffset), + trailingIcon: UIImage(named: "AppIcon") + ) + } else { + WindowToastManager.shared.show( + LocalizedChatString("ContactListAddFriendFailed"), + type: .error, + duration: Self.toastDuration, + position: .bottom(Self.toastBottomOffset) + ) + } + } + } + } + } + + private func makeInfoCard() -> UIView { + let card = UIView() + card.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + card.addSubview(avatarView) + let textStack = UIStackView(arrangedSubviews: [nameLabel, userIDLabel, signatureLabel]) + textStack.axis = .vertical + textStack.spacing = 0 + textStack.setCustomSpacing(Self.infoCardLineGap, after: nameLabel) + textStack.setCustomSpacing(Self.infoCardLineGap, after: userIDLabel) + card.addSubview(textStack) + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.cardHorizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.infoCardAvatarSize.size) + make.top.greaterThanOrEqualToSuperview().offset(Self.infoCardVerticalPadding) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.infoCardVerticalPadding) + } + textStack.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.infoCardAvatarNameGap) + make.trailing.equalToSuperview().offset(-Self.cardHorizontalPadding) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(Self.infoCardVerticalPadding) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.infoCardVerticalPadding) + } + let colors = ChatUIKitTheme.colors + nameLabel.font = FontScheme.caption1Bold + nameLabel.textColor = colors.textColorPrimary + nameLabel.lineBreakMode = .byTruncatingTail + userIDLabel.font = FontScheme.caption3Regular + userIDLabel.textColor = colors.textColorSecondary + userIDLabel.lineBreakMode = .byTruncatingTail + signatureLabel.font = FontScheme.caption3Regular + signatureLabel.textColor = colors.textColorSecondary + signatureLabel.numberOfLines = Self.signatureMaxLineCount + signatureLabel.lineBreakMode = .byTruncatingTail + return card + } + + private func refreshInfoCard() { + avatarView.configure(avatarURL: contactInfo?.avatarURL, fallbackName: displayName) + nameLabel.text = displayName + userIDLabel.text = labelValueText(LocalizedChatString("ProfileUserID"), userID) + let aboutMe = contactInfo?.aboutMe?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let signatureText = aboutMe.isEmpty ? LocalizedChatString("ContactListNoContent") : aboutMe + signatureLabel.text = labelValueText(LocalizedChatString("ProfileSignature"), signatureText) + } + + private func labelValueText(_ label: String, _ value: String) -> String { + let isChinese = LanguageHelper.getCurrentLanguage().hasPrefix("zh") + return isChinese ? "\(label):\(value)" : "\(label): \(value)" + } + + private func makeSectionSpacer() -> UIView { + let spacer = UIView() + spacer.backgroundColor = ChatUIKitTheme.colors.bgColorDefault + spacer.snp.makeConstraints { make in + make.height.equalTo(Self.sectionSpacerHeight) + } + return spacer + } + + private func makeSectionTitle(_ text: String) -> UIView { + let container = UIView() + container.backgroundColor = ChatUIKitTheme.colors.bgColorDefault + let label = UILabel() + label.text = text + label.font = FontScheme.caption2Regular + label.textColor = ChatUIKitTheme.colors.textColorSecondary + container.addSubview(label) + label.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: Self.sectionTitleTopPadding, + left: Self.cardHorizontalPadding, + bottom: Self.sectionTitleBottomPadding, + right: Self.cardHorizontalPadding + )) + } + return container + } + + private func makeActionCard(title: String, action: Selector) -> UIView { + let row = UIControl() + row.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + let label = UILabel() + label.text = title + label.font = FontScheme.caption1Regular + label.textColor = ChatUIKitTheme.colors.textColorLink + label.textAlignment = .center + row.addSubview(label) + label.snp.makeConstraints { make in + make.center.equalToSuperview() + } + row.snp.makeConstraints { make in + make.height.equalTo(Self.actionCardHeight) + } + row.addTarget(self, action: action, for: .touchUpInside) + return row + } + + private func makeWordingInputCard() -> UIView { + let card = UIView() + card.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + card.addSubview(wordingTextView) + wordingTextView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.inputCardPadding) + make.height.greaterThanOrEqualTo(Self.inputCardMinHeight - Self.inputCardPadding * 2) + } + wordingTextView.font = FontScheme.caption1Regular + wordingTextView.textColor = ChatUIKitTheme.colors.textColorPrimary + wordingTextView.backgroundColor = .clear + wordingTextView.textContainer.lineFragmentPadding = 0 + wordingTextView.textContainerInset = .zero + return card + } + + private func makeRemarkRow() -> UIView { + let row = UIView() + row.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + let label = UILabel() + label.text = LocalizedChatString("ContactListRemark") + label.font = FontScheme.caption1Regular + label.textColor = ChatUIKitTheme.colors.textColorPrimary + row.addSubview(label) + row.addSubview(remarkTextField) + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.cardHorizontalPadding) + make.centerY.equalToSuperview() + } + remarkTextField.snp.makeConstraints { make in + make.leading.equalTo(label.snp.trailing).offset(Self.remarkLabelValueGap) + make.trailing.equalToSuperview().offset(-Self.cardHorizontalPadding) + make.centerY.equalToSuperview() + } + row.snp.makeConstraints { make in + make.height.equalTo(Self.remarkRowHeight) + } + label.setContentCompressionResistancePriority(.required, for: .horizontal) + remarkTextField.font = FontScheme.caption1Regular + remarkTextField.textColor = ChatUIKitTheme.colors.textColorPrimary + remarkTextField.textAlignment = LanguageHelper.isRTL ? .left : .right + return row + } + + private func fetchUserInfo() { + contactStore.getContactInfo( + userIDList: [userID], + completion: AddContactInfoHandler( + onSuccess: { [weak self] contactInfoList in + DispatchQueue.main.async { + guard let self = self else { return } + self.contactInfo = contactInfoList.first + self.refreshInfoCard() + if self.currentStep == .form { + self.applyFormDefaults() + } + } + }, + onFailure: { _, _ in } + ) + ) + } + + private func subscribeKeyboard() { + NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.adjustScrollInsetForKeyboard(notification) + } + .store(in: &cancellables) + } + + private func adjustScrollInsetForKeyboard(_ notification: Notification) { + guard currentStep == .form, + let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let keyboardTop = view.convert(frame, from: nil).origin.y + let bottomOverlap = max(0, view.bounds.height - keyboardTop) + formScrollView.contentInset.bottom = bottomOverlap + formScrollView.verticalScrollIndicatorInsets.bottom = bottomOverlap + } +} + +extension AddContactViewController: UITextFieldDelegate { + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + +private final class AddContactInfoHandler: GetContactInfoCompletionHandler { + private let onSuccessBlock: ([ContactInfo]) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping ([ContactInfo]) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(contactInfoList: [ContactInfo]) { + onSuccessBlock(contactInfoList) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/chatsetting/AvatarPickerSheetController.swift b/chat/uikit/components/chatsetting/AvatarPickerSheetController.swift new file mode 100644 index 000000000..7eca093a1 --- /dev/null +++ b/chat/uikit/components/chatsetting/AvatarPickerSheetController.swift @@ -0,0 +1,211 @@ +import UIKit +import SnapKit +import Kingfisher + +final class AvatarPickerSheetController: UIViewController { + private static let panelHeightRatio: CGFloat = 0.6 + + private static let panelCornerRadius: CGFloat = CGFloat(RadiusScheme.largeRadius) + + private static let headerHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerVerticalPadding: CGFloat = 14 + + private static let titleFontSize: CGFloat = 16 + + private static let closeButtonSize: CGFloat = 32 + + private static let closeSymbolFontSize: CGFloat = 18 + + private static let gridPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + fileprivate static let cellAvatarSize: CGFloat = 64 + + private static let cellAvatarPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + fileprivate static let cellAvatarCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let dimAlpha: CGFloat = 0.4 + + private let imageUrlList: [String] + + private let columnCount: Int + + private let onImageSelected: (String) -> Void + + private let dimView = UIControl() + + private let panelView = UIView() + + private let titleLabel = UILabel() + + private let closeButton = UIButton(type: .custom) + + private var collectionView: UICollectionView? + + init(title: String, imageUrlList: [String], columnCount: Int = 4, onImageSelected: @escaping (String) -> Void) { + self.imageUrlList = imageUrlList + self.columnCount = max(1, columnCount) + self.onImageSelected = onImageSelected + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + titleLabel.text = title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + private func constructViewHierarchy() { + view.addSubview(dimView) + view.addSubview(panelView) + panelView.addSubview(titleLabel) + panelView.addSubview(closeButton) + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = 0 + layout.sectionInset = UIEdgeInsets( + top: 0, + left: Self.gridPadding, + bottom: Self.gridPadding, + right: Self.gridPadding + ) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(AvatarPickerSheetCell.self, forCellWithReuseIdentifier: AvatarPickerSheetCell.reuseIdentifier) + collectionView.dataSource = self + collectionView.delegate = self + self.collectionView = collectionView + panelView.addSubview(collectionView) + } + + private func activateConstraints() { + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + panelView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(UIScreen.main.bounds.height * Self.panelHeightRatio) + } + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalToSuperview().offset(Self.headerVerticalPadding) + } + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.headerHorizontalPadding) + make.centerY.equalTo(titleLabel) + make.width.height.equalTo(Self.closeButtonSize) + } + collectionView?.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.headerVerticalPadding) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func bindInteraction() { + dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) + closeButton.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(Self.dimAlpha) + panelView.backgroundColor = colors.bgColorOperate + panelView.layer.cornerRadius = Self.panelCornerRadius + panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + panelView.layer.masksToBounds = true + + titleLabel.font = UIFont.systemFont(ofSize: Self.titleFontSize, weight: .bold) + titleLabel.textColor = colors.textColorPrimary + + closeButton.setTitle("✕", for: .normal) + closeButton.setTitleColor(colors.textColorSecondary, for: .normal) + closeButton.titleLabel?.font = UIFont.systemFont(ofSize: Self.closeSymbolFontSize) + + collectionView?.backgroundColor = colors.bgColorOperate + } + + @objc private func handleDismiss() { + dismiss(animated: true) + } +} + +extension AvatarPickerSheetController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return imageUrlList.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: AvatarPickerSheetCell.reuseIdentifier, + for: indexPath + ) as? AvatarPickerSheetCell else { + return UICollectionViewCell() + } + cell.configure(imageUrl: imageUrlList[indexPath.item]) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let imageUrl = imageUrlList[indexPath.item] + dismiss(animated: true) { [onImageSelected] in + onImageSelected(imageUrl) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let totalPadding = Self.gridPadding * 2 + let itemWidth = floor((collectionView.bounds.width - totalPadding) / CGFloat(columnCount)) + let itemHeight = Self.cellAvatarSize + Self.cellAvatarPadding * 2 + return CGSize(width: itemWidth, height: itemHeight) + } +} + +private final class AvatarPickerSheetCell: UICollectionViewCell { + static let reuseIdentifier = "AvatarPickerSheetCell" + + private let avatarImageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(avatarImageView) + avatarImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(AvatarPickerSheetController.cellAvatarSize) + } + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.layer.cornerRadius = AvatarPickerSheetController.cellAvatarCornerRadius + avatarImageView.layer.masksToBounds = true + avatarImageView.backgroundColor = ChatUIKitTheme.colors.bgColorInput + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + avatarImageView.kf.cancelDownloadTask() + avatarImageView.image = nil + } + + func configure(imageUrl: String) { + avatarImageView.kf.setImage(with: URL(string: imageUrl)) + } +} diff --git a/chat/uikit/components/chatsetting/AvatarSelectorViewController.swift b/chat/uikit/components/chatsetting/AvatarSelectorViewController.swift new file mode 100644 index 000000000..2d856c5b9 --- /dev/null +++ b/chat/uikit/components/chatsetting/AvatarSelectorViewController.swift @@ -0,0 +1,203 @@ +import Kingfisher +import SnapKit +import UIKit + +public final class AvatarSelectorViewController: ChatSettingBaseViewController { + private static let gridPadding: CGFloat = 10 + + private static let cellSpacing: CGFloat = 10 + + fileprivate static let cellCornerRadius: CGFloat = 10 + + fileprivate static let selectedBorderWidth: CGFloat = 3 + + fileprivate static let checkmarkSize: CGFloat = 24 + + fileprivate static let checkmarkInset: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let contentTopSpacing: CGFloat = 10 + + private static let contentBottomSpacing: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private let imageUrlList: [String] + + private let column: Int + + private let onComplete: (String?) -> Void + + private var selectedImageUrl: String? { + didSet { doneButton.isEnabled = selectedImageUrl != nil } + } + + private let doneButton = UIButton(type: .custom) + + private var collectionView: UICollectionView? + + public init(imageUrlList: [String], column: Int = 3, onComplete: @escaping (String?) -> Void) { + self.imageUrlList = imageUrlList + self.column = max(1, column) + self.onComplete = onComplete + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("ChooseAvatar")) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + doneButton.addTarget(self, action: #selector(handleDoneTapped), for: .touchUpInside) + } + + private func constructViewHierarchy() { + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = Self.cellSpacing + layout.minimumLineSpacing = Self.cellSpacing + layout.sectionInset = UIEdgeInsets( + top: Self.contentTopSpacing, + left: Self.gridPadding, + bottom: Self.contentBottomSpacing, + right: Self.gridPadding + ) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(AvatarSelectorCell.self, forCellWithReuseIdentifier: AvatarSelectorCell.reuseIdentifier) + collectionView.dataSource = self + collectionView.delegate = self + self.collectionView = collectionView + view.addSubview(collectionView) + view.addSubview(doneButton) + } + + private func activateConstraints() { + collectionView?.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + collectionView?.backgroundColor = colors.bgColorOperate + doneButton.setTitle(LocalizedChatString("Done"), for: .normal) + doneButton.setTitleColor(colors.textColorLink, for: .normal) + doneButton.setTitleColor(colors.textColorTertiary, for: .disabled) + doneButton.titleLabel?.font = FontScheme.caption1Regular + doneButton.isEnabled = false + setNavTrailingView(doneButton) + } + + @objc private func handleDoneTapped() { + onComplete(selectedImageUrl) + if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +} + +extension AvatarSelectorViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return imageUrlList.count + } + + public func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: AvatarSelectorCell.reuseIdentifier, + for: indexPath + ) as? AvatarSelectorCell else { + return UICollectionViewCell() + } + let imageUrl = imageUrlList[indexPath.item] + cell.configure(imageUrl: imageUrl, isSelected: imageUrl == selectedImageUrl) + return cell + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + selectedImageUrl = imageUrlList[indexPath.item] + collectionView.reloadData() + } + + public func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let totalSpacing = Self.gridPadding * 2 + Self.cellSpacing * CGFloat(column - 1) + let itemWidth = floor((collectionView.bounds.width - totalSpacing) / CGFloat(column)) + return CGSize(width: itemWidth, height: itemWidth) + } +} + +private final class AvatarSelectorCell: UICollectionViewCell { + static let reuseIdentifier = "AvatarSelectorCell" + + private let avatarImageView = UIImageView() + + private let selectionBorderView = UIView() + + private let checkmarkImageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + avatarImageView.kf.cancelDownloadTask() + avatarImageView.image = nil + } + + func configure(imageUrl: String, isSelected: Bool) { + avatarImageView.kf.setImage(with: URL(string: imageUrl)) + selectionBorderView.isHidden = !isSelected + checkmarkImageView.isHidden = !isSelected + } + + private func constructViewHierarchy() { + contentView.addSubview(avatarImageView) + contentView.addSubview(selectionBorderView) + contentView.addSubview(checkmarkImageView) + } + + private func activateConstraints() { + avatarImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + selectionBorderView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + checkmarkImageView.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(AvatarSelectorViewController.checkmarkInset) + make.width.height.equalTo(AvatarSelectorViewController.checkmarkSize) + } + } + + private func setupViewStyle() { + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.layer.cornerRadius = AvatarSelectorViewController.cellCornerRadius + avatarImageView.layer.masksToBounds = true + avatarImageView.backgroundColor = ChatUIKitTheme.colors.bgColorInput + selectionBorderView.layer.cornerRadius = AvatarSelectorViewController.cellCornerRadius + selectionBorderView.layer.borderWidth = AvatarSelectorViewController.selectedBorderWidth + selectionBorderView.layer.borderColor = ChatUIKitTheme.colors.textColorLink.cgColor + checkmarkImageView.image = UIImage(systemName: "checkmark.circle.fill") + checkmarkImageView.tintColor = ChatUIKitTheme.colors.textColorLink + } +} diff --git a/chat/uikit/components/chatsetting/C2CChatSettingViewController.swift b/chat/uikit/components/chatsetting/C2CChatSettingViewController.swift new file mode 100644 index 000000000..179a323e7 --- /dev/null +++ b/chat/uikit/components/chatsetting/C2CChatSettingViewController.swift @@ -0,0 +1,490 @@ +import AtomicXCore +import Combine +import SnapKit +import TUICallKit_Swift +import UIKit + +public final class C2CChatSettingViewController: ChatSettingBaseViewController { + private static let headerAvatarSize = ChatAvatarSize.l + + private static let headerSpacing: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let headerSubFontSize: CGFloat = 13 + + private static let headerIDTopSpacing: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let headerSignatureTopSpacing: CGFloat = 2 + + private static let toastDuration: TimeInterval = 2 + + private let userID: String + + private let contactStore: ContactStore + + private let conversationStore: ConversationListStore + + private let onSendMessageClick: (() -> Void)? + + private let onContactDelete: (() -> Void)? + + private var remark: String = "" + + private var nick: String = "" + + private var avatar: String = "" + + private var aboutMe: String = "" + + private var isNotDisturb: Bool = false + + private var isPinned: Bool = false + + private var isInBlacklist: Bool = false + + private var chatBackgroundURI: String? + + private var cancellables = Set() + + private let scrollView = UIScrollView() + + private let stackView = UIStackView() + + private let headerView = UIView() + + private let avatarView = ChatAvatarView(size: .l, isRound: false) + + private let nameLabel = UILabel() + + private let userIDLabel = UILabel() + + private let signatureLabel = UILabel() + + private let headerTextStack = UIStackView() + + private lazy var remarkRow = ChatSettingRowView(title: LocalizedChatString("ProfileAlia"), accessory: .arrow) + + private lazy var doNotDisturbRow = ChatSettingToggleRowView(title: LocalizedChatString("ProfileMessageDoNotDisturb")) + + private lazy var pinRow = ChatSettingToggleRowView(title: LocalizedChatString("ProfileStickyonTop")) + + private lazy var backgroundRow = ChatSettingRowView(title: LocalizedChatString("ChatBackgroundSetting"), accessory: .arrow) + + private lazy var blacklistRow = ChatSettingToggleRowView(title: LocalizedChatString("ProfileBlocked")) + + private var conversationID: String { + return ChatUtil.getC2CConversationID(userID) + } + + private var displayName: String { + return nick.isEmpty ? userID : nick + } + + public init( + userID: String, + onSendMessageClick: (() -> Void)? = nil, + onContactDelete: (() -> Void)? = nil + ) { + self.userID = userID + self.contactStore = ContactStore.shared + self.conversationStore = ConversationListStore.create() + self.onSendMessageClick = onSendMessageClick + self.onContactDelete = onContactDelete + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("ProfileContactInfo")) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + subscribeStores() + fetchInitialInfo() + chatBackgroundURI = ChatBackgroundStore.shared.imageURI(forConversationID: conversationID) + refreshUI() + } + + // MARK: - Actions + + private func constructViewHierarchy() { + view.addSubview(scrollView) + scrollView.addSubview(stackView) + headerView.addSubview(avatarView) + headerTextStack.addArrangedSubview(nameLabel) + headerTextStack.addArrangedSubview(userIDLabel) + headerTextStack.addArrangedSubview(signatureLabel) + headerView.addSubview(headerTextStack) + + stackView.addArrangedSubview(headerView) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(remarkRow) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + let switchSection = makeSectionContainer(arrangedSubviews: [ + doNotDisturbRow, + makeChatSettingRowDivider(), + pinRow, + ]) + stackView.addArrangedSubview(switchSection) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(backgroundRow) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(blacklistRow) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(makeActionSection()) + } + + private func makeSectionContainer(arrangedSubviews: [UIView]) -> UIStackView { + let section = UIStackView(arrangedSubviews: arrangedSubviews) + section.axis = .vertical + section.spacing = 0 + section.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + return section + } + + private func makeActionRow(titleKey: String, textColor: UIColor, action: Selector) -> ChatSettingActionRowView { + let row = ChatSettingActionRowView(title: LocalizedChatString(titleKey), textColor: textColor) + row.addTarget(self, action: action, for: .touchUpInside) + return row + } + + private func makeActionSection() -> UIStackView { + let colors = ChatUIKitTheme.colors + return makeSectionContainer(arrangedSubviews: [ + makeActionRow(titleKey: "ProfileSendMessages", textColor: colors.textColorLink, action: #selector(handleSendMessageTapped)), + makeChatSettingRowDivider(), + makeActionRow(titleKey: "MoreVoiceCall", textColor: colors.textColorLink, action: #selector(handleVoiceCallTapped)), + makeChatSettingRowDivider(), + makeActionRow(titleKey: "MoreVideoCall", textColor: colors.textColorLink, action: #selector(handleVideoCallTapped)), + makeChatSettingRowDivider(), + makeActionRow(titleKey: "ClearAllChatHistory", textColor: colors.textColorError, action: #selector(handleClearHistoryTapped)), + makeChatSettingRowDivider(), + makeActionRow(titleKey: "ProfileDeleteFirend", textColor: colors.textColorError, action: #selector(handleDeleteFriendTapped)), + ]) + } + + private func activateConstraints() { + scrollView.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalToSuperview() + } + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.headerHorizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.headerAvatarSize.size) + } + headerTextStack.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.headerSpacing) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.headerHorizontalPadding) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(Self.headerVerticalPadding) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.headerVerticalPadding) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorTopBar + scrollView.backgroundColor = colors.bgColorTopBar + stackView.axis = .vertical + stackView.spacing = 0 + headerView.backgroundColor = colors.bgColorOperate + headerTextStack.axis = .vertical + headerTextStack.spacing = 0 + nameLabel.font = FontScheme.body4Regular + nameLabel.textColor = colors.textColorPrimary + nameLabel.lineBreakMode = .byTruncatingTail + userIDLabel.font = .systemFont(ofSize: Self.headerSubFontSize) + userIDLabel.textColor = colors.textColorTertiary + userIDLabel.lineBreakMode = .byTruncatingTail + signatureLabel.font = .systemFont(ofSize: Self.headerSubFontSize) + signatureLabel.textColor = colors.textColorTertiary + signatureLabel.lineBreakMode = .byTruncatingTail + headerTextStack.setCustomSpacing(Self.headerIDTopSpacing, after: nameLabel) + headerTextStack.setCustomSpacing(Self.headerSignatureTopSpacing, after: userIDLabel) + } + + private func bindInteraction() { + remarkRow.addTarget(self, action: #selector(handleRemarkTapped), for: .touchUpInside) + backgroundRow.addTarget(self, action: #selector(handleBackgroundTapped), for: .touchUpInside) + doNotDisturbRow.onToggle = { [weak self] value in self?.setDoNotDisturb(value) } + pinRow.onToggle = { [weak self] value in self?.setPinned(value) } + blacklistRow.onToggle = { [weak self] value in self?.setBlacklist(value) } + } + + private func subscribeStores() { + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] friendList in + guard let self = self else { return } + if let contactInfo = friendList.first(where: { $0.userID == self.userID }) { + self.applyContactInfo(contactInfo) + } + } + .store(in: &cancellables) + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.blackList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] blackList in + guard let self = self else { return } + self.isInBlacklist = blackList.contains(where: { $0.userID == self.userID }) + self.blacklistRow.setOn(self.isInBlacklist) + } + .store(in: &cancellables) + conversationStore.state + .subscribe(StatePublisherSelector(keyPath: \ConversationListState.conversationList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] conversationList in + guard let self = self else { return } + if let conversationInfo = conversationList.first(where: { $0.conversationID == self.conversationID }) { + self.applyConversationInfo(conversationInfo) + } + } + .store(in: &cancellables) + } + + private func fetchInitialInfo() { + contactStore.loadFriends(completion: nil) + contactStore.loadBlackList(completion: nil) + contactStore.getContactInfo( + userIDList: [userID], + completion: C2CSettingContactInfoHandler( + onSuccess: { [weak self] contactInfoList in + DispatchQueue.main.async { + if let contactInfo = contactInfoList.first { + self?.applyContactInfo(contactInfo) + } + } + }, + onFailure: { _, _ in } + ) + ) + conversationStore.getConversationInfo( + conversationID: conversationID, + completion: C2CSettingConversationInfoHandler( + onSuccess: { [weak self] conversationInfo in + DispatchQueue.main.async { + self?.applyConversationInfo(conversationInfo) + } + }, + onFailure: { _, _ in } + ) + ) + } + + private func applyContactInfo(_ contactInfo: ContactInfo) { + if let friendRemark = contactInfo.friendRemark { + remark = friendRemark + } + if let nickname = contactInfo.nickname { + nick = nickname + } + if let avatarURL = contactInfo.avatarURL { + avatar = avatarURL + } + if let signature = contactInfo.aboutMe { + aboutMe = signature + } + refreshUI() + } + + private func applyConversationInfo(_ conversationInfo: ConversationInfo) { + isNotDisturb = conversationInfo.receiveOption != .receive + isPinned = conversationInfo.isPinned + refreshUI() + } + + private func refreshUI() { + avatarView.configure(avatarURL: avatar, fallbackName: displayName) + nameLabel.text = displayName + userIDLabel.text = "\(LocalizedChatString("ProfileUserID")): \(userID)" + signatureLabel.text = aboutMe.isEmpty ? nil : LocalizedChatString("ProfileSignaturePrefix") + aboutMe + signatureLabel.isHidden = aboutMe.isEmpty + remarkRow.update(value: remark.isEmpty ? displayName : remark, accessory: .arrow) + doNotDisturbRow.setOn(isNotDisturb) + pinRow.setOn(isPinned) + blacklistRow.setOn(isInBlacklist) + backgroundRow.update( + value: chatBackgroundURI == nil + ? LocalizedChatString("ChatBackgroundDefault") + : LocalizedChatString("ChatBackgroundCustom"), + accessory: .arrow + ) + } + + @objc private func handleRemarkTapped() { + let dialog = TextInputDialogViewController( + title: LocalizedChatString("ProfileEditAlia"), + initialText: remark + ) { [weak self] text in + self?.saveRemark(text) + } + present(dialog, animated: true) + } + + @objc private func handleBackgroundTapped() { + let picker = ChatBackgroundPickerViewController(conversationID: conversationID) { [weak self] uri in + guard let self = self else { return } + ChatBackgroundStore.shared.setImageURI(uri, forConversationID: self.conversationID) + self.chatBackgroundURI = uri + self.refreshUI() + } + present(picker, animated: true) + } + + @objc private func handleSendMessageTapped() { + onSendMessageClick?() + } + + @objc private func handleVoiceCallTapped() { + startCall(mediaType: .audio) + } + + @objc private func handleVideoCallTapped() { + startCall(mediaType: .video) + } + + @objc private func handleClearHistoryTapped() { + let alert = UIAlertController( + title: LocalizedChatString("ClearContactHistoryTips"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("AlertConfirm"), style: .default) { [weak self] _ in + self?.clearHistory() + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + @objc private func handleDeleteFriendTapped() { + let alert = UIAlertController( + title: LocalizedChatString("DeleteFriendConfirmTips"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("AlertConfirm"), style: .destructive) { [weak self] _ in + self?.deleteFriend() + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func startCall(mediaType: CallMediaType) { + DataReport.reportInteractionMetrics(.chatInvokeCall) + TUICallKit.createInstance().calls( + userIdList: [userID], + mediaType: mediaType, + params: nil, + completion: nil + ) + } + + private func setDoNotDisturb(_ value: Bool) { + let opt: ReceiveMessageOption = value ? .notNotify : .receive + conversationStore.setReceiveMessageOpt(conversationID: conversationID, opt: opt, completion: nil) + } + + private func setPinned(_ value: Bool) { + conversationStore.pinConversation(conversationID: conversationID, pin: value, completion: nil) + } + + private func setBlacklist(_ value: Bool) { + if value { + contactStore.addToBlacklist(userID: userID) { [weak self] result in + if case .success = result { + self?.contactStore.loadBlackList(completion: nil) + } + } + } else { + contactStore.removeFromBlacklist(userID: userID) { [weak self] result in + if case .success = result { + self?.contactStore.loadBlackList(completion: nil) + } + } + } + } + + private func saveRemark(_ text: String) { + contactStore.setFriendRemark(userID: userID, remark: text, completion: nil) + } + + private func deleteFriend() { + contactStore.deleteFriend(userID: userID) { [weak self] result in + guard let self = self else { return } + DispatchQueue.main.async { + if case .success = result { + self.conversationStore.deleteConversation(conversationID: self.conversationID, completion: nil) + } + if let onContactDelete = self.onContactDelete { + onContactDelete() + } else if let navigationController = self.navigationController { + navigationController.popViewController(animated: true) + } else { + self.dismiss(animated: true) + } + } + } + } + + private func clearHistory() { + conversationStore.clearConversationMessages(conversationID: conversationID) { result in + if case .success = result { + DispatchQueue.main.async { + WindowToastManager.shared.show(LocalizedChatString("ClearAllChatHistory"), type: .success, duration: Self.toastDuration) + } + } + } + } +} + +private final class C2CSettingContactInfoHandler: GetContactInfoCompletionHandler { + private let onSuccessBlock: ([ContactInfo]) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping ([ContactInfo]) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(contactInfoList: [ContactInfo]) { + onSuccessBlock(contactInfoList) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} + +private final class C2CSettingConversationInfoHandler: GetConversationInfoCompletionHandler { + private let onSuccessBlock: (ConversationInfo) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (ConversationInfo) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(conversationInfo: ConversationInfo) { + onSuccessBlock(conversationInfo) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/chatsetting/ChatBackgroundPickerViewController.swift b/chat/uikit/components/chatsetting/ChatBackgroundPickerViewController.swift new file mode 100644 index 000000000..a2da1743c --- /dev/null +++ b/chat/uikit/components/chatsetting/ChatBackgroundPickerViewController.swift @@ -0,0 +1,235 @@ +import UIKit +import SnapKit +import Kingfisher + +final class ChatBackgroundPickerViewController: UIViewController { + private static let panelHeightRatio: CGFloat = 0.65 + + private static let panelCornerRadius: CGFloat = CGFloat(RadiusScheme.largeRadius) + + private static let headerHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerVerticalPadding: CGFloat = 14 + + private static let closeButtonSize: CGFloat = 32 + + private static let columnCount: CGFloat = 2 + + private static let gridPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let cellSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let previewHeight: CGFloat = 120 + + private static let dimAlpha: CGFloat = 0.4 + + private let conversationID: String + + private let onSelected: (String?) -> Void + + private let items = ChatBackgroundPresetProvider.presetItems() + + private let selectedURI: String? + + private let dimView = UIControl() + + private let panelView = UIView() + + private let titleLabel = UILabel() + + private let closeButton = UIButton(type: .custom) + + private var collectionView: UICollectionView? + + init(conversationID: String, onSelected: @escaping (String?) -> Void) { + self.conversationID = conversationID + self.onSelected = onSelected + self.selectedURI = ChatBackgroundStore.shared.imageURI(forConversationID: conversationID) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + private func constructViewHierarchy() { + view.addSubview(dimView) + view.addSubview(panelView) + panelView.addSubview(titleLabel) + panelView.addSubview(closeButton) + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = Self.cellSpacing + layout.minimumLineSpacing = Self.cellSpacing + layout.sectionInset = UIEdgeInsets( + top: 0, + left: Self.gridPadding, + bottom: Self.gridPadding, + right: Self.gridPadding + ) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(ChatBackgroundPreviewCell.self, forCellWithReuseIdentifier: ChatBackgroundPreviewCell.reuseIdentifier) + collectionView.dataSource = self + collectionView.delegate = self + self.collectionView = collectionView + panelView.addSubview(collectionView) + } + + private func activateConstraints() { + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + panelView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(UIScreen.main.bounds.height * Self.panelHeightRatio) + } + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalToSuperview().offset(Self.headerVerticalPadding) + } + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.headerHorizontalPadding) + make.centerY.equalTo(titleLabel) + make.width.height.equalTo(Self.closeButtonSize) + } + collectionView?.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.headerVerticalPadding) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func bindInteraction() { + dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) + closeButton.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(Self.dimAlpha) + panelView.backgroundColor = colors.bgColorOperate + panelView.layer.cornerRadius = Self.panelCornerRadius + panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + panelView.layer.masksToBounds = true + titleLabel.text = LocalizedChatString("SelectChatBackground") + titleLabel.font = FontScheme.caption1Bold + titleLabel.textColor = colors.textColorPrimary + closeButton.setTitle("×", for: .normal) + closeButton.setTitleColor(colors.textColorSecondary, for: .normal) + closeButton.titleLabel?.font = FontScheme.body3Regular + collectionView?.backgroundColor = colors.bgColorOperate + } + + @objc private func handleDismiss() { + dismiss(animated: true) + } + + private func isItemSelected(_ item: ChatBackgroundPresetItem) -> Bool { + if item.isDefault { + return selectedURI == nil + } + return item.imageURI == selectedURI + } +} + +extension ChatBackgroundPickerViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ChatBackgroundPreviewCell.reuseIdentifier, + for: indexPath + ) as? ChatBackgroundPreviewCell else { + return UICollectionViewCell() + } + cell.configure(with: items[indexPath.item], isSelected: isItemSelected(items[indexPath.item])) + return cell + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let totalSpacing = Self.gridPadding * 2 + Self.cellSpacing * (Self.columnCount - 1) + let width = (collectionView.bounds.width - totalSpacing) / Self.columnCount + return CGSize(width: width, height: Self.previewHeight) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + onSelected(items[indexPath.item].imageURI) + dismiss(animated: true) + } +} + +final class ChatBackgroundPreviewCell: UICollectionViewCell { + static let reuseIdentifier = "ChatBackgroundPreviewCell" + + private static let contentCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let selectedBorderWidth: CGFloat = 2 + + private let previewImageView = UIImageView() + + private let defaultLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(previewImageView) + contentView.addSubview(defaultLabel) + previewImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + defaultLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + let colors = ChatUIKitTheme.colors + contentView.backgroundColor = colors.bgColorTopBar + contentView.layer.cornerRadius = Self.contentCornerRadius + contentView.layer.masksToBounds = true + previewImageView.contentMode = .scaleAspectFill + previewImageView.clipsToBounds = true + defaultLabel.font = FontScheme.caption2Regular + defaultLabel.textColor = colors.textColorSecondary + defaultLabel.text = LocalizedChatString("ChatBackgroundDefault") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with item: ChatBackgroundPresetItem, isSelected: Bool) { + let colors = ChatUIKitTheme.colors + contentView.layer.borderWidth = isSelected ? Self.selectedBorderWidth : 0 + contentView.layer.borderColor = isSelected ? colors.buttonColorPrimaryDefault.cgColor : nil + if item.isDefault { + previewImageView.isHidden = true + previewImageView.image = nil + defaultLabel.isHidden = false + } else { + defaultLabel.isHidden = true + previewImageView.isHidden = false + if let thumbnail = item.thumbnailURI { + previewImageView.kf.setImage(with: URL(string: thumbnail)) + } + } + } + + override func prepareForReuse() { + super.prepareForReuse() + previewImageView.kf.cancelDownloadTask() + previewImageView.image = nil + contentView.layer.borderWidth = 0 + } +} diff --git a/chat/uikit/components/chatsetting/ChatBackgroundStore.swift b/chat/uikit/components/chatsetting/ChatBackgroundStore.swift new file mode 100644 index 000000000..f1f59878a --- /dev/null +++ b/chat/uikit/components/chatsetting/ChatBackgroundStore.swift @@ -0,0 +1,67 @@ +import Foundation + +final class ChatBackgroundStore { + static let shared = ChatBackgroundStore() + + static let didChangeNotification = Notification.Name("ChatBackgroundDidChangeNotification") + + static let conversationIDUserInfoKey = "conversationID" + + private static let keyPrefix = "chat_background::" + + private let defaults = UserDefaults.standard + + func imageURI(forConversationID conversationID: String) -> String? { + return normalize(defaults.string(forKey: Self.keyPrefix + conversationID)) + } + + func setImageURI(_ uri: String?, forConversationID conversationID: String) { + let key = Self.keyPrefix + conversationID + if let normalized = normalize(uri) { + defaults.set(normalized, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + NotificationCenter.default.post( + name: Self.didChangeNotification, + object: nil, + userInfo: [Self.conversationIDUserInfoKey: conversationID] + ) + } + + private init() {} + + private func normalize(_ uri: String?) -> String? { + guard let trimmed = uri?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +struct ChatBackgroundPresetItem { + let imageURI: String? + let thumbnailURI: String? + + var isDefault: Bool { + return imageURI == nil + } +} + +enum ChatBackgroundPresetProvider { + + private static let fullImageURLTemplate = "https://im.sdk.qcloud.com/download/tuikit-resource/conversation-backgroundImage/backgroundImage_%d_full.png" + private static let thumbnailURLTemplate = "https://im.sdk.qcloud.com/download/tuikit-resource/conversation-backgroundImage/backgroundImage_%d.png" + private static let presetCount = 7 + + static func presetItems() -> [ChatBackgroundPresetItem] { + var items = [ChatBackgroundPresetItem(imageURI: nil, thumbnailURI: nil)] + for index in 1 ... presetCount { + items.append(ChatBackgroundPresetItem( + imageURI: String(format: fullImageURLTemplate, index), + thumbnailURI: String(format: thumbnailURLTemplate, index) + )) + } + return items + } +} diff --git a/chat/uikit/components/chatsetting/ChatSettingViews.swift b/chat/uikit/components/chatsetting/ChatSettingViews.swift new file mode 100644 index 000000000..5c546f149 --- /dev/null +++ b/chat/uikit/components/chatsetting/ChatSettingViews.swift @@ -0,0 +1,403 @@ +import UIKit +import SnapKit + +public class ChatSettingBaseViewController: UIViewController { + var onBack: (() -> Void)? + + var contentTopItem: ConstraintItem { + return divider.snp.bottom + } + + private static let navBarHeight: CGFloat = 56 + + private static let navHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let backButtonSize: CGFloat = 28 + + private static let backIconPointSize: CGFloat = 18 + + private static let titleFontSize: CGFloat = 16 + + private static let dividerHeight: CGFloat = 0.5 + + private let navigationBar = UIView() + + private let statusBarBackgroundView = UIView() + + private let backButton = UIButton(type: .custom) + + private let titleLabel = UILabel() + + private let divider = UIView() + + private var navLeadingView: UIView? + + private var navTrailingView: UIView? + + public override func viewDidLoad() { + super.viewDidLoad() + constructNavigationBar() + activateNavigationBarConstraints() + setupNavigationBarStyle() + backButton.addTarget(self, action: #selector(handleBackTapped), for: .touchUpInside) + } + + func setNavTitle(_ text: String) { + titleLabel.text = text + } + + func setNavBackHidden(_ hidden: Bool) { + backButton.isHidden = hidden + } + + func setNavLeadingView(_ leadingView: UIView) { + navLeadingView?.removeFromSuperview() + navLeadingView = leadingView + navigationBar.addSubview(leadingView) + leadingView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.navHorizontalInset) + make.centerY.equalToSuperview() + } + updateTitleConstraints() + } + + func setNavTrailingView(_ trailingView: UIView) { + navTrailingView?.removeFromSuperview() + navTrailingView = trailingView + navigationBar.addSubview(trailingView) + trailingView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.navHorizontalInset) + make.centerY.equalToSuperview() + } + updateTitleConstraints() + } + + func pushOrPresent(_ viewController: UIViewController) { + if let navigationController = navigationController { + navigationController.pushViewController(viewController, animated: true) + } else { + present(viewController, animated: true) + } + } + + private func updateTitleConstraints() { + titleLabel.snp.remakeConstraints { make in + make.center.equalToSuperview() + if let navLeadingView = navLeadingView { + make.leading.greaterThanOrEqualTo(navLeadingView.snp.trailing).offset(Self.navHorizontalInset) + } else { + make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(Self.navHorizontalInset) + } + if let navTrailingView = navTrailingView { + make.trailing.lessThanOrEqualTo(navTrailingView.snp.leading).offset(-Self.navHorizontalInset) + } else { + make.trailing.lessThanOrEqualToSuperview().offset(-Self.navHorizontalInset) + } + } + } + + private func constructNavigationBar() { + view.addSubview(statusBarBackgroundView) + view.addSubview(navigationBar) + navigationBar.addSubview(backButton) + navigationBar.addSubview(titleLabel) + view.addSubview(divider) + } + + private func activateNavigationBarConstraints() { + + statusBarBackgroundView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(navigationBar.snp.top) + } + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.navBarHeight) + } + backButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.navHorizontalInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.backButtonSize) + } + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(Self.navHorizontalInset) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.navHorizontalInset) + } + divider.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.dividerHeight) + } + } + + private func setupNavigationBarStyle() { + let colors = ChatUIKitTheme.colors + statusBarBackgroundView.backgroundColor = colors.bgColorOperate + navigationBar.backgroundColor = colors.bgColorOperate + divider.backgroundColor = colors.strokeColorSecondary + if let backImage = AtomicXChatResources.image(named: "contact_info_back") { + backButton.setImage(backImage.withRenderingMode(.alwaysTemplate), for: .normal) + } else { + backButton.setImage( + UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.backIconPointSize, weight: .semibold)), + for: .normal + ) + } + backButton.tintColor = colors.textColorPrimary + backButton.contentHorizontalAlignment = .leading + titleLabel.font = .systemFont(ofSize: Self.titleFontSize, weight: .semibold) + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + } + + @objc private func handleBackTapped() { + if let onBack = onBack { + onBack() + } else if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +} + +// MARK: - Row Views(规格对齐 Android SettingRowNavigate / SettingRowToggle / ActionItem 行) + +final class ChatSettingRowView: UIControl { + enum Accessory { + case none + case arrow + case edit + } + + override var isEnabled: Bool { + didSet { isUserInteractionEnabled = isEnabled } + } + + private static let horizontalPadding: CGFloat = 14 + + private static let verticalPadding: CGFloat = 12 + + private static let rowMinHeight: CGFloat = 48 + + private static let accessorySize: CGFloat = 16 + + private static let accessoryLeadingGap: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let titleTrailingGap: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private let titleLabel = UILabel() + + private let valueLabel = UILabel() + + private let accessoryImageView = UIImageView() + + private var valueTrailingToAccessory: Constraint? + + private var valueTrailingToSuperview: Constraint? + + init(title: String, value: String = "", accessory: Accessory = .arrow) { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + titleLabel.text = title + update(value: value, accessory: accessory) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTitle(_ title: String) { + titleLabel.text = title + } + + func update(value: String, accessory: Accessory) { + valueLabel.text = value + if accessory == .none { + valueTrailingToAccessory?.deactivate() + valueTrailingToSuperview?.activate() + } else { + valueTrailingToSuperview?.deactivate() + valueTrailingToAccessory?.activate() + } + switch accessory { + case .none: + accessoryImageView.isHidden = true + case .arrow: + accessoryImageView.isHidden = false + accessoryImageView.image = AtomicXChatResources.image(named: "contact_info_arrow_right")? + .withRenderingMode(.alwaysTemplate) + case .edit: + accessoryImageView.isHidden = false + accessoryImageView.image = AtomicXChatResources.image(named: "group_setting_edit_icon")? + .withRenderingMode(.alwaysTemplate) + } + } + + private func constructViewHierarchy() { + addSubview(titleLabel) + addSubview(valueLabel) + addSubview(accessoryImageView) + } + + private func activateConstraints() { + snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.rowMinHeight) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + make.centerY.equalToSuperview() + } + accessoryImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.accessorySize) + } + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.titleTrailingGap) + valueTrailingToAccessory = make.trailing.equalTo(accessoryImageView.snp.leading).offset(-Self.accessoryLeadingGap).constraint + valueTrailingToSuperview = make.trailing.equalToSuperview().offset(-Self.horizontalPadding).constraint + make.centerY.equalToSuperview() + } + valueTrailingToSuperview?.deactivate() + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorSecondary + valueLabel.font = FontScheme.caption1Regular + valueLabel.textColor = colors.textColorPrimary + valueLabel.textAlignment = LanguageHelper.isRTL ? .left : .right + valueLabel.lineBreakMode = .byTruncatingTail + accessoryImageView.tintColor = colors.textColorTertiary + accessoryImageView.contentMode = .scaleAspectFit + } +} + +final class ChatSettingToggleRowView: UIView { + var onToggle: ((Bool) -> Void)? + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let verticalPadding: CGFloat = 10 + + private let titleLabel = UILabel() + + private let toggle = UISwitch() + + init(title: String, isOn: Bool = false) { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + titleLabel.text = title + toggle.isOn = isOn + toggle.addTarget(self, action: #selector(handleToggleChanged), for: .valueChanged) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setOn(_ isOn: Bool) { + toggle.setOn(isOn, animated: false) + } + + private func constructViewHierarchy() { + addSubview(titleLabel) + addSubview(toggle) + } + + private func activateConstraints() { + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + make.centerY.equalToSuperview() + } + toggle.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.horizontalPadding) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorSecondary + toggle.onTintColor = colors.switchColorOn + } + + @objc private func handleToggleChanged() { + onToggle?(toggle.isOn) + } +} + +final class ChatSettingActionRowView: UIControl { + private static let verticalPadding: CGFloat = 15 + + private static let rowMinHeight: CGFloat = 56 + + private static let fontSize: CGFloat = 17 + + private let titleLabel = UILabel() + + init(title: String, textColor: UIColor) { + super.init(frame: .zero) + addSubview(titleLabel) + snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.rowMinHeight) + } + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + } + backgroundColor = ChatUIKitTheme.colors.bgColorOperate + titleLabel.font = .systemFont(ofSize: Self.fontSize) + titleLabel.textColor = textColor + titleLabel.text = title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Section Helpers + +private let chatSettingSectionSpacerHeight: CGFloat = 10 + +private let chatSettingRowDividerHeight: CGFloat = 0.5 + +func makeChatSettingSectionSpacer() -> UIView { + let spacer = UIView() + spacer.backgroundColor = ChatUIKitTheme.colors.bgColorTopBar + spacer.snp.makeConstraints { make in + make.height.equalTo(chatSettingSectionSpacerHeight) + } + return spacer +} + +func makeChatSettingRowDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary + divider.snp.makeConstraints { make in + make.height.equalTo(chatSettingRowDividerHeight) + } + return divider +} diff --git a/chat/uikit/components/chatsetting/GroupChatSettingViewController.swift b/chat/uikit/components/chatsetting/GroupChatSettingViewController.swift new file mode 100644 index 000000000..ac549fa5d --- /dev/null +++ b/chat/uikit/components/chatsetting/GroupChatSettingViewController.swift @@ -0,0 +1,969 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +func chatSettingMemberDisplayName(_ member: GroupMember) -> String { + if let nameCard = member.nameCard, !nameCard.isEmpty { + return nameCard + } else if let friendRemark = member.friendRemark, !friendRemark.isEmpty { + return friendRemark + } else if let nickname = member.nickname, !nickname.isEmpty { + return nickname + } else { + return member.userID + } +} + +func chatSettingContactDisplayName(_ contact: ContactInfo) -> String { + if let friendRemark = contact.friendRemark, !friendRemark.isEmpty { + return friendRemark + } else if let nickname = contact.nickname, !nickname.isEmpty { + return nickname + } else { + return contact.userID + } +} + +public final class GroupChatSettingViewController: ChatSettingBaseViewController { + private static let headerAvatarSize = ChatAvatarSize.l + + private static let headerSpacing: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let headerIDFontSize: CGFloat = 13 + + private static let headerIDTopSpacing: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let memberGridColumns = 5 + + private static let memberGridMaxCount = 10 + + private static let memberGridItemWidth: CGFloat = 40 + + private static let memberGridAvatarSize = ChatAvatarSize.m + + private static let memberGridRowSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let memberGridHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let memberGridBottomPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let memberGridNameTopSpacing: CGFloat = 3 + + private static let memberSymbolFontSize: CGFloat = 22 + + private static let memberSymbolCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let presetAvatarCount: Int = 24 + + private static let avatarPickerColumnCount: Int = 4 + + private static let noticeMaxInputLength: Int = 300 + + private static let toastDuration: TimeInterval = 2 + + private let groupID: String + + private let groupStore: GroupStore + + private let memberStore: GroupMemberStore + + private let conversationStore: ConversationListStore + + private let onSendMessageClick: (() -> Void)? + + private let onGroupDelete: (() -> Void)? + + private let onGroupMemberClick: ((String) -> Void)? + + private var groupName: String = "" + + private var avatar: String = "" + + private var notice: String = "" + + private var groupType: GroupType = .work + + private var memberCount: Int = 0 + + private var currentUserRole: GroupMemberRole = .member + + private var selfNameCard: String? + + private var joinGroupApprovalType: GroupJoinOption = .forbid + + private var inviteToGroupApprovalType: GroupInviteOption = .forbid + + private var allMembers: [GroupMember] = [] + + private var currentUserID: String = "" + + private var isNotDisturb: Bool = false + + private var isPinned: Bool = false + + private var chatBackgroundURI: String? + + private var cancellables = Set() + + private let scrollView = UIScrollView() + + private let stackView = UIStackView() + + private let headerView = UIView() + + private let avatarButton = UIButton(type: .custom) + + private let headerAvatarView = ChatAvatarView(size: .l, isRound: false) + + private let nameLabel = UILabel() + + private let groupIDLabel = UILabel() + + private let headerTextStack = UIStackView() + + private let memberSection = UIStackView() + + private lazy var memberHeaderRow = ChatSettingRowView( + title: LocalizedChatString("GroupMember"), + accessory: .arrow + ) + + private let gridContainer = UIView() + + private let gridRow1 = UIStackView() + + private let gridRow2 = UIStackView() + + private let settingsSection = UIStackView() + + private lazy var noticeRow = ChatSettingRowView(title: LocalizedChatString("GroupNotice"), accessory: .none) + + private lazy var manageRow = ChatSettingRowView(title: LocalizedChatString("GroupProfileManage"), accessory: .arrow) + + private lazy var typeRow = ChatSettingRowView(title: LocalizedChatString("GroupProfileType"), accessory: .none) + + private lazy var joinRow = ChatSettingRowView(title: LocalizedChatString("GroupProfileJoinType"), accessory: .none) + + private lazy var inviteRow = ChatSettingRowView(title: LocalizedChatString("GroupProfileInviteType"), accessory: .none) + + private lazy var aliasRow = ChatSettingRowView(title: LocalizedChatString("GroupProfileAlias"), accessory: .none) + + private let switchSection = UIStackView() + + private lazy var doNotDisturbRow = ChatSettingToggleRowView(title: LocalizedChatString("ProfileMessageDoNotDisturb")) + + private lazy var pinRow = ChatSettingToggleRowView(title: LocalizedChatString("ProfileStickyonTop")) + + private lazy var backgroundRow = ChatSettingRowView(title: LocalizedChatString("ChatBackgroundSetting"), accessory: .arrow) + + private let actionSection = UIStackView() + + private let actionSpacer = makeChatSettingSectionSpacer() + + private var conversationID: String { + return ChatUtil.getGroupConversationID(groupID) + } + + private var headerDisplayName: String { + return groupName.isEmpty ? groupID : groupName + } + + private var showAddMemberButton: Bool { + return canPerformAction(.addGroupMember) && inviteToGroupApprovalType != .forbid + } + + private var showRemoveMemberButton: Bool { + return canPerformAction(.removeGroupMember) + } + + private var groupTypeDisplayName: String { + switch groupType { + case .work: return LocalizedChatString("CreatGroupType_Work") + case .publicGroup: return LocalizedChatString("PublicGroup") + case .meeting: return LocalizedChatString("MeetingGroup") + case .community: return LocalizedChatString("Community") + case .avChatRoom: return LocalizedChatString("LiveGroup") + } + } + + public init( + groupID: String, + onSendMessageClick: (() -> Void)? = nil, + onGroupDelete: (() -> Void)? = nil, + onGroupMemberClick: ((String) -> Void)? = nil + ) { + self.groupID = groupID + self.groupStore = GroupStore.shared + self.memberStore = GroupMemberStore.create(groupID: groupID) + self.conversationStore = ConversationListStore.create() + self.onSendMessageClick = onSendMessageClick + self.onGroupDelete = onGroupDelete + self.onGroupMemberClick = onGroupMemberClick + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("GroupChatInfoTitle")) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + subscribeStores() + currentUserID = LoginStore.shared.state.value.loginUserInfo?.userID ?? "" + chatBackgroundURI = ChatBackgroundStore.shared.imageURI(forConversationID: conversationID) + fetchInitialInfo() + ContactStore.shared.loadFriends(completion: nil) + refreshUI() + } + + // MARK: - Actions + + func openMemberDetail(userID: String) { + if let onGroupMemberClick = onGroupMemberClick { + onGroupMemberClick(userID) + } else { + UserProfileRouter.open(userID: userID, from: self) + } + } + + // MARK: - Helpers + + private func constructViewHierarchy() { + view.addSubview(scrollView) + scrollView.addSubview(stackView) + + headerView.addSubview(headerAvatarView) + headerView.addSubview(avatarButton) + headerTextStack.addArrangedSubview(nameLabel) + headerTextStack.addArrangedSubview(groupIDLabel) + headerView.addSubview(headerTextStack) + + gridContainer.addSubview(gridRow1) + gridContainer.addSubview(gridRow2) + memberSection.addArrangedSubview(memberHeaderRow) + memberSection.addArrangedSubview(gridContainer) + + stackView.addArrangedSubview(headerView) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(memberSection) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(settingsSection) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(aliasRow) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(switchSection) + stackView.addArrangedSubview(makeChatSettingSectionSpacer()) + stackView.addArrangedSubview(backgroundRow) + stackView.addArrangedSubview(actionSpacer) + stackView.addArrangedSubview(actionSection) + } + + private func activateConstraints() { + scrollView.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalToSuperview() + } + headerAvatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.headerHorizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.headerAvatarSize.size) + } + avatarButton.snp.makeConstraints { make in + make.edges.equalTo(headerAvatarView) + } + headerTextStack.snp.makeConstraints { make in + make.leading.equalTo(headerAvatarView.snp.trailing).offset(Self.headerSpacing) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.headerHorizontalPadding) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(Self.headerVerticalPadding) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.headerVerticalPadding) + } + gridRow1.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + gridRow2.snp.makeConstraints { make in + make.top.equalTo(gridRow1.snp.bottom).offset(Self.memberGridRowSpacing) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorTopBar + scrollView.backgroundColor = colors.bgColorTopBar + stackView.axis = .vertical + stackView.spacing = 0 + headerView.backgroundColor = colors.bgColorOperate + headerTextStack.axis = .vertical + headerTextStack.spacing = Self.headerIDTopSpacing + headerTextStack.alignment = .leading + nameLabel.font = FontScheme.body4Regular + nameLabel.textColor = colors.textColorPrimary + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.isUserInteractionEnabled = true + groupIDLabel.font = .systemFont(ofSize: Self.headerIDFontSize) + groupIDLabel.textColor = colors.textColorTertiary + groupIDLabel.lineBreakMode = .byTruncatingTail + groupIDLabel.isUserInteractionEnabled = true + memberSection.axis = .vertical + memberSection.spacing = 0 + memberSection.backgroundColor = colors.bgColorOperate + gridContainer.backgroundColor = colors.bgColorOperate + gridContainer.layoutMargins = UIEdgeInsets( + top: 0, + left: Self.memberGridHorizontalPadding, + bottom: Self.memberGridBottomPadding, + right: Self.memberGridHorizontalPadding + ) + for row in [gridRow1, gridRow2] { + row.axis = .horizontal + row.distribution = .fillEqually + row.alignment = .top + row.spacing = 0 + } + settingsSection.axis = .vertical + settingsSection.spacing = 0 + settingsSection.backgroundColor = colors.bgColorOperate + switchSection.axis = .vertical + switchSection.spacing = 0 + switchSection.backgroundColor = colors.bgColorOperate + actionSection.axis = .vertical + actionSection.spacing = 0 + actionSection.backgroundColor = colors.bgColorOperate + } + + private func bindInteraction() { + avatarButton.addTarget(self, action: #selector(handleAvatarTapped), for: .touchUpInside) + nameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleNameTapped))) + groupIDLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleGroupIDTapped))) + memberHeaderRow.addTarget(self, action: #selector(handleMemberListTapped), for: .touchUpInside) + noticeRow.addTarget(self, action: #selector(handleNoticeTapped), for: .touchUpInside) + manageRow.addTarget(self, action: #selector(handleManageTapped), for: .touchUpInside) + joinRow.addTarget(self, action: #selector(handleJoinTapped), for: .touchUpInside) + inviteRow.addTarget(self, action: #selector(handleInviteTapped), for: .touchUpInside) + aliasRow.addTarget(self, action: #selector(handleAliasTapped), for: .touchUpInside) + backgroundRow.addTarget(self, action: #selector(handleBackgroundTapped), for: .touchUpInside) + doNotDisturbRow.onToggle = { [weak self] value in + guard let self = self else { return } + let opt: ReceiveMessageOption = value ? .notNotify : .receive + self.conversationStore.setReceiveMessageOpt(conversationID: self.conversationID, opt: opt, completion: nil) + } + pinRow.onToggle = { [weak self] value in + guard let self = self else { return } + self.conversationStore.pinConversation(conversationID: self.conversationID, pin: value, completion: nil) + } + } + + private func subscribeStores() { + groupStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.joinedGroupList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] joinedGroupList in + guard let self = self else { return } + if let groupInfo = joinedGroupList.first(where: { $0.groupID == self.groupID }) { + self.applyGroupInfo(groupInfo) + } + } + .store(in: &cancellables) + memberStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupMemberState.memberList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] memberList in + self?.applyMemberList(memberList) + } + .store(in: &cancellables) + conversationStore.state + .subscribe(StatePublisherSelector(keyPath: \ConversationListState.conversationList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] conversationList in + guard let self = self else { return } + if let info = conversationList.first(where: { $0.conversationID == self.conversationID }) { + self.isNotDisturb = info.receiveOption != .receive + self.isPinned = info.isPinned + self.refreshUI() + } + } + .store(in: &cancellables) + } + + private func fetchInitialInfo() { + groupStore.getGroupInfo( + groupID: groupID, + completion: GroupSettingGroupInfoHandler( + onSuccess: { [weak self] groupInfo in + DispatchQueue.main.async { + self?.applyGroupInfo(groupInfo) + } + }, + onFailure: { _, _ in } + ) + ) + conversationStore.getConversationInfo( + conversationID: conversationID, + completion: GroupSettingConversationInfoHandler( + onSuccess: { [weak self] info in + DispatchQueue.main.async { + guard let self = self else { return } + self.isNotDisturb = info.receiveOption != .receive + self.isPinned = info.isPinned + self.refreshUI() + } + }, + onFailure: { _, _ in } + ) + ) + memberStore.loadMembers(roleList: [.all], completion: nil) + groupStore.loadJoinedGroups(completion: nil) + } + + private func applyGroupInfo(_ groupInfo: GroupInfo) { + groupName = groupInfo.groupName ?? "" + avatar = groupInfo.avatarURL ?? "" + notice = groupInfo.notification ?? "" + groupType = groupInfo.groupType ?? .work + memberCount = groupInfo.memberCount ?? 0 + currentUserRole = groupInfo.selfRole ?? currentUserRole + joinGroupApprovalType = groupInfo.joinOption ?? .forbid + inviteToGroupApprovalType = groupInfo.inviteOption ?? .forbid + refreshUI() + } + + private func applyMemberList(_ memberList: [GroupMember]) { + allMembers = memberList + memberCount = memberList.isEmpty ? memberCount : memberList.count + if let selfMember = memberList.first(where: { $0.userID == currentUserID }) { + selfNameCard = selfMember.nameCard + currentUserRole = selfMember.role + } + refreshUI() + } + + private func canPerformAction(_ permission: GroupPermission) -> Bool { + return GroupPermissionManager.hasPermission( + groupType: groupType, + memberRole: currentUserRole, + permission: permission + ) + } + + private func refreshUI() { + headerAvatarView.configure(avatarURL: avatar, fallbackName: headerDisplayName) + nameLabel.text = headerDisplayName + groupIDLabel.text = "\(LocalizedChatString("GroupID")): \(groupID)" + memberHeaderRow.update(value: "\(memberCount)", accessory: .arrow) + rebuildMemberGrid() + rebuildSettingsSection() + rebuildSwitchSection() + rebuildActionSection() + let aliasValue: String + if let nameCard = selfNameCard, !nameCard.isEmpty { + aliasValue = nameCard + } else { + aliasValue = LocalizedChatString("Unsetted") + } + aliasRow.update( + value: aliasValue, + accessory: canPerformAction(.setGroupRemark) ? .edit : .none + ) + aliasRow.isEnabled = canPerformAction(.setGroupRemark) + doNotDisturbRow.setOn(isNotDisturb) + pinRow.setOn(isPinned) + backgroundRow.update( + value: chatBackgroundURI == nil + ? LocalizedChatString("ChatBackgroundDefault") + : LocalizedChatString("ChatBackgroundCustom"), + accessory: .arrow + ) + } + + private func rebuildMemberGrid() { + gridRow1.arrangedSubviews.forEach { $0.removeFromSuperview() } + gridRow2.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let buttonCount = (showAddMemberButton ? 1 : 0) + (showRemoveMemberButton ? 1 : 0) + let previewMembers = Array(allMembers.prefix(max(0, Self.memberGridMaxCount - buttonCount))) + var items: [UIView] = previewMembers.map { makeMemberGridItem($0) } + if showAddMemberButton { + items.append(makeSymbolGridItem("+", action: #selector(handleAddMemberTapped))) + } + if showRemoveMemberButton { + items.append(makeSymbolGridItem("−", action: #selector(handleRemoveMemberTapped))) + } + for (index, item) in items.enumerated() { + let slot = UIView() + slot.addSubview(item) + item.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.bottom.lessThanOrEqualToSuperview() + make.width.equalTo(Self.memberGridItemWidth) + } + if index < Self.memberGridColumns { + gridRow1.addArrangedSubview(slot) + } else { + gridRow2.addArrangedSubview(slot) + } + } + let firstRowCount = min(items.count, Self.memberGridColumns) + for _ in 0 ..< (Self.memberGridColumns - firstRowCount) { + gridRow1.addArrangedSubview(UIView()) + } + let secondRowCount = max(0, items.count - Self.memberGridColumns) + if secondRowCount > 0 { + gridRow2.isHidden = false + for _ in 0 ..< (Self.memberGridColumns - secondRowCount) { + gridRow2.addArrangedSubview(UIView()) + } + } else { + gridRow2.isHidden = true + } + } + + private func gridDisplayName(for member: GroupMember) -> String { + if let nameCard = member.nameCard, !nameCard.isEmpty { return nameCard } + if let nickname = member.nickname, !nickname.isEmpty { return nickname } + return member.userID + } + + private func makeMemberGridItem(_ member: GroupMember) -> UIView { + let container = UIControl() + let avatarView = ChatAvatarView(size: Self.memberGridAvatarSize, isRound: false) + avatarView.configure(avatarURL: member.avatarURL, fallbackName: gridDisplayName(for: member)) + avatarView.isUserInteractionEnabled = false + let nameLabel = UILabel() + nameLabel.text = gridDisplayName(for: member) + nameLabel.font = FontScheme.caption3Regular + nameLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + nameLabel.textAlignment = .center + nameLabel.lineBreakMode = .byTruncatingTail + container.addSubview(avatarView) + container.addSubview(nameLabel) + avatarView.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.width.height.equalTo(Self.memberGridAvatarSize.size) + } + nameLabel.snp.makeConstraints { make in + make.top.equalTo(avatarView.snp.bottom).offset(Self.memberGridNameTopSpacing) + make.leading.trailing.bottom.equalToSuperview() + } + container.tag = allMembers.firstIndex(where: { $0.userID == member.userID }) ?? 0 + container.addTarget(self, action: #selector(handleMemberItemTapped(_:)), for: .touchUpInside) + return container + } + + private func makeSymbolGridItem(_ symbol: String, action: Selector) -> UIView { + let container = UIControl() + let symbolBox = UILabel() + symbolBox.text = symbol + symbolBox.font = .systemFont(ofSize: Self.memberSymbolFontSize) + symbolBox.textColor = ChatUIKitTheme.colors.textColorSecondary + symbolBox.textAlignment = .center + symbolBox.backgroundColor = ChatUIKitTheme.colors.bgColorInput + symbolBox.layer.cornerRadius = Self.memberSymbolCornerRadius + symbolBox.layer.masksToBounds = true + symbolBox.isUserInteractionEnabled = false + let placeholderLabel = UILabel() + placeholderLabel.text = " " + placeholderLabel.font = FontScheme.caption3Regular + container.addSubview(symbolBox) + container.addSubview(placeholderLabel) + symbolBox.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.width.height.equalTo(Self.memberGridAvatarSize.size) + } + placeholderLabel.snp.makeConstraints { make in + make.top.equalTo(symbolBox.snp.bottom).offset(Self.memberGridNameTopSpacing) + make.leading.trailing.bottom.equalToSuperview() + } + container.addTarget(self, action: action, for: .touchUpInside) + return container + } + + private func rebuildSettingsSection() { + settingsSection.arrangedSubviews.forEach { $0.removeFromSuperview() } + let canEditNotice = canPerformAction(.setGroupNotice) + let trimmedNotice = notice.trimmingCharacters(in: .whitespacesAndNewlines) + noticeRow.update( + value: trimmedNotice.isEmpty ? LocalizedChatString("GroupNoticeNull") : trimmedNotice, + accessory: canEditNotice ? .edit : .none + ) + noticeRow.isEnabled = canEditNotice + settingsSection.addArrangedSubview(noticeRow) + + if canPerformAction(.setGroupManagement) { + settingsSection.addArrangedSubview(makeChatSettingRowDivider()) + settingsSection.addArrangedSubview(manageRow) + } + + settingsSection.addArrangedSubview(makeChatSettingRowDivider()) + typeRow.update(value: groupTypeDisplayName, accessory: .none) + typeRow.isEnabled = false + settingsSection.addArrangedSubview(typeRow) + + settingsSection.addArrangedSubview(makeChatSettingRowDivider()) + let canEditJoin = canPerformAction(.setJoinGroupApprovalType) + joinRow.update(value: joinGroupDisplayName(joinGroupApprovalType), accessory: canEditJoin ? .arrow : .none) + joinRow.isEnabled = canEditJoin + settingsSection.addArrangedSubview(joinRow) + + settingsSection.addArrangedSubview(makeChatSettingRowDivider()) + let canEditInvite = canPerformAction(.setInviteToGroupApprovalType) + inviteRow.update(value: inviteOptionDisplayName(inviteToGroupApprovalType), accessory: canEditInvite ? .arrow : .none) + inviteRow.isEnabled = canEditInvite + settingsSection.addArrangedSubview(inviteRow) + } + + private func rebuildSwitchSection() { + switchSection.arrangedSubviews.forEach { $0.removeFromSuperview() } + let showDoNotDisturb = canPerformAction(.setDoNotDisturb) + let showPin = canPerformAction(.pinGroup) + if showDoNotDisturb { + switchSection.addArrangedSubview(doNotDisturbRow) + } + if showDoNotDisturb && showPin { + switchSection.addArrangedSubview(makeChatSettingRowDivider()) + } + if showPin { + switchSection.addArrangedSubview(pinRow) + } + switchSection.isHidden = !showDoNotDisturb && !showPin + } + + private func rebuildActionSection() { + actionSection.arrangedSubviews.forEach { $0.removeFromSuperview() } + let colors = ChatUIKitTheme.colors + var rows: [UIView] = [] + if canPerformAction(.transferOwner) { + rows.append(makeActionRow( + titleKey: "GroupTransferOwner", + textColor: colors.textColorLink, + action: #selector(handleTransferTapped) + )) + } + if canPerformAction(.clearHistoryMessages) { + rows.append(makeActionRow( + titleKey: "ClearAllChatHistory", + textColor: colors.textColorError, + action: #selector(handleClearHistoryTapped) + )) + } + if canPerformAction(.deleteAndQuit) { + rows.append(makeActionRow( + titleKey: "GroupProfileDeleteAndExit", + textColor: colors.textColorError, + action: #selector(handleQuitTapped) + )) + } + if canPerformAction(.dismissGroup) { + rows.append(makeActionRow( + titleKey: "GroupProfileDissolve", + textColor: colors.textColorError, + action: #selector(handleDismissTapped) + )) + } + for (index, row) in rows.enumerated() { + if index > 0 { + actionSection.addArrangedSubview(makeChatSettingRowDivider()) + } + actionSection.addArrangedSubview(row) + } + actionSection.isHidden = rows.isEmpty + actionSpacer.isHidden = rows.isEmpty + } + + private func makeActionRow(titleKey: String, textColor: UIColor, action: Selector) -> ChatSettingActionRowView { + let row = ChatSettingActionRowView(title: LocalizedChatString(titleKey), textColor: textColor) + row.addTarget(self, action: action, for: .touchUpInside) + return row + } + + private func inviteOptionDisplayName(_ option: GroupInviteOption) -> String { + switch option { + case .forbid: return LocalizedChatString("GroupProfileInviteDisable") + case .auth: return LocalizedChatString("GroupProfileAdminApprove") + case .any: return LocalizedChatString("GroupProfileAutoApproval") + } + } + + private func joinGroupDisplayName(_ option: GroupJoinOption) -> String { + switch option { + case .forbid: return LocalizedChatString("GroupProfileJoinDisable") + case .auth: return LocalizedChatString("GroupProfileAdminApprove") + case .any: return LocalizedChatString("GroupProfileAutoApproval") + } + } + + @objc private func handleAvatarTapped() { + guard canPerformAction(.setGroupAvatar) else { return } + let picker = AvatarPickerSheetController( + title: LocalizedChatString("ChooseAvatar"), + imageUrlList: (1 ... Self.presetAvatarCount).map { + "https://im.sdk.qcloud.com/download/tuikit-resource/group-avatar/group_avatar_\($0).png" + }, + columnCount: Self.avatarPickerColumnCount, + onImageSelected: { [weak self] selectedImageUrl in + self?.updateGroupProfile(name: nil, notice: nil, avatar: selectedImageUrl) + } + ) + present(picker, animated: true) + } + + @objc private func handleNameTapped() { + guard canPerformAction(.setGroupName) else { return } + let dialog = TextInputDialogViewController( + title: LocalizedChatString("GroupProfileEditGroupName"), + initialText: groupName + ) { [weak self] text in + guard !text.isEmpty else { return } + self?.updateGroupProfile(name: text, notice: nil, avatar: nil) + } + present(dialog, animated: true) + } + + @objc private func handleGroupIDTapped() { + UIPasteboard.general.string = groupID + WindowToastManager.shared.show(LocalizedChatString("Copied"), type: .success, duration: Self.toastDuration) + } + + @objc private func handleMemberListTapped() { + guard canPerformAction(.getGroupMemberList) else { return } + let memberListPage = GroupMemberListViewController( + groupStore: groupStore, + memberStore: memberStore, + onGroupMemberClick: onGroupMemberClick + ) + pushOrPresent(memberListPage) + } + + @objc private func handleMemberItemTapped(_ sender: UIControl) { + let member = allMembers[sender.tag] + guard canPerformAction(.getGroupMemberInfo), member.userID != currentUserID else { return } + openMemberDetail(userID: member.userID) + } + + @objc private func handleAddMemberTapped() { + let picker = GroupMemberPickerViewController( + mode: .addMember(memberStore: memberStore) + ) + pushOrPresent(picker) + } + + @objc private func handleRemoveMemberTapped() { + let picker = GroupMemberPickerViewController( + mode: .removeMember(memberStore: memberStore, currentUserRole: currentUserRole) + ) + pushOrPresent(picker) + } + + @objc private func handleNoticeTapped() { + guard canPerformAction(.setGroupNotice) else { return } + let currentNotice = notice + let dialog = TextInputDialogViewController( + title: LocalizedChatString("GroupNotice"), + initialText: currentNotice, + maxLength: Self.noticeMaxInputLength, + multiline: true + ) { [weak self] text in + if text != currentNotice { + self?.updateGroupProfile(name: nil, notice: text, avatar: nil) + } + } + present(dialog, animated: true) + } + + @objc private func handleManageTapped() { + let managementPage = GroupManagementViewController(groupID: groupID, groupStore: groupStore, memberStore: memberStore) + pushOrPresent(managementPage) + } + + @objc private func handleJoinTapped() { + presentPermissionActionSheet(isJoinOption: true) + } + + @objc private func handleInviteTapped() { + presentPermissionActionSheet(isJoinOption: false) + } + + @objc private func handleAliasTapped() { + guard canPerformAction(.setGroupRemark) else { return } + let dialog = TextInputDialogViewController( + title: LocalizedChatString("GroupEditNameCard"), + initialText: selfNameCard ?? "" + ) { [weak self] text in + self?.memberStore.setSelfNameCard(nameCard: text, completion: nil) + } + present(dialog, animated: true) + } + + @objc private func handleBackgroundTapped() { + let picker = ChatBackgroundPickerViewController(conversationID: conversationID) { [weak self] uri in + guard let self = self else { return } + ChatBackgroundStore.shared.setImageURI(uri, forConversationID: self.conversationID) + self.chatBackgroundURI = uri + self.refreshUI() + } + present(picker, animated: true) + } + + @objc private func handleTransferTapped() { + let picker = GroupMemberPickerViewController( + mode: .transferOwner(groupID: groupID, groupStore: groupStore, memberStore: memberStore) + ) + pushOrPresent(picker) + } + + @objc private func handleClearHistoryTapped() { + let alert = UIAlertController( + title: LocalizedChatString("ClearAllChatHistory"), + message: LocalizedChatString("ClearAllChatHistoryTips"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Clear"), style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.conversationStore.clearConversationMessages(conversationID: self.conversationID, completion: nil) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + @objc private func handleQuitTapped() { + let alert = UIAlertController( + title: LocalizedChatString("DeleteAndQuitConfirmTitle"), + message: LocalizedChatString("DeleteAndQuitConfirmMessage"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("DeleteAndQuit"), style: .destructive) { [weak self] _ in + self?.quitGroup() + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + @objc private func handleDismissTapped() { + let alert = UIAlertController( + title: LocalizedChatString("DismissGroupConfirmTitle"), + message: LocalizedChatString("DismissGroupConfirmMessage"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("DismissGroup"), style: .destructive) { [weak self] _ in + self?.dismissGroup() + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func presentPermissionActionSheet(isJoinOption: Bool) { + let alert = UIAlertController( + title: nil, + message: nil, + preferredStyle: .actionSheet + ) + let forbidKey = isJoinOption ? "GroupProfileJoinDisable" : "GroupProfileInviteDisable" + alert.addAction(UIAlertAction(title: LocalizedChatString(forbidKey), style: .default) { [weak self] _ in + self?.setGroupPermission(.forbid, isJoinOption: isJoinOption) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("GroupProfileAdminApprove"), style: .default) { [weak self] _ in + self?.setGroupPermission(.auth, isJoinOption: isJoinOption) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("GroupProfileAutoApproval"), style: .default) { [weak self] _ in + self?.setGroupPermission(.any, isJoinOption: isJoinOption) + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func setGroupPermission(_ option: GroupJoinOption, isJoinOption: Bool) { + if isJoinOption { + groupStore.setJoinOption(groupID: groupID, option: option, completion: nil) + } else { + let inviteOption = GroupInviteOption(rawValue: option.rawValue) ?? .forbid + groupStore.setInviteOption(groupID: groupID, option: inviteOption, completion: nil) + } + } + + private func updateGroupProfile(name: String?, notice: String?, avatar: String?) { + var groupInfo = GroupInfo(groupID: groupID) + groupInfo.groupName = name + groupInfo.notification = notice + groupInfo.avatarURL = avatar + groupStore.updateProfile(groupInfo: groupInfo, completion: nil) + } + + private func quitGroup() { + groupStore.quitGroup(groupID: groupID) { [weak self] _ in + DispatchQueue.main.async { + self?.exitAfterGroupGone() + } + } + } + + private func dismissGroup() { + groupStore.dismissGroup(groupID: groupID) { [weak self] _ in + DispatchQueue.main.async { + self?.exitAfterGroupGone() + } + } + } + + private func exitAfterGroupGone() { + conversationStore.deleteConversation(conversationID: conversationID, completion: nil) + if let onGroupDelete = onGroupDelete { + onGroupDelete() + } else if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +} + +final class GroupSettingGroupInfoHandler: GetGroupInfoCompletionHandler { + private let onSuccessBlock: (GroupInfo) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (GroupInfo) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(groupInfo: GroupInfo) { + onSuccessBlock(groupInfo) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} + +final class GroupSettingConversationInfoHandler: GetConversationInfoCompletionHandler { + private let onSuccessBlock: (ConversationInfo) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (ConversationInfo) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(conversationInfo: ConversationInfo) { + onSuccessBlock(conversationInfo) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/chatsetting/GroupManagementViewController.swift b/chat/uikit/components/chatsetting/GroupManagementViewController.swift new file mode 100644 index 000000000..9c44d6a8b --- /dev/null +++ b/chat/uikit/components/chatsetting/GroupManagementViewController.swift @@ -0,0 +1,485 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +final class GroupManagementViewController: ChatSettingBaseViewController { + fileprivate static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let sectionSpacerHeight: CGFloat = 10 + + private static let adminTitleTopSpacing: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let adminTitleBottomSpacing: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let adminRowVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let adminItemWidth: CGFloat = 40 + + private static let adminItemSpacing: CGFloat = 20 + + private static let adminAvatarSize = ChatAvatarSize.m + + private static let adminNameTopMargin: CGFloat = 3 + + private static let adminActionSymbolFontSize: CGFloat = 22 + + private static let adminTileCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let adminActionBottomSpacerHeight: CGFloat = 20 + + private static let descriptionPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let descriptionTopPadding = CGFloat(SpacingScheme.iconTextSpacing) + + private static let addRowSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let addRowVerticalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let mutedRowHeight: CGFloat = 56 + + fileprivate static let mutedAvatarSize = ChatAvatarSize.m + + fileprivate static let mutedNameSpacing: CGFloat = 13 + + private let groupID: String + + private let groupStore: GroupStore + + private let memberStore: GroupMemberStore + + private var mutedMembers: [GroupMember] = [] + + private var adminMembers: [GroupMember] = [] + + private var isAllMuted: Bool = false + + private var groupType: GroupType = .work + + private var currentUserRole: GroupMemberRole = .member + + private var cancellables = Set() + + private let headerStack = UIStackView() + + private let adminSectionSpacer = UIView() + + private let adminSection = UIView() + + private let adminTitleLabel = UILabel() + + private let adminScrollView = UIScrollView() + + private let adminRowStack = UIStackView() + + private let muteSectionSpacer = UIView() + + private lazy var muteAllRow = ChatSettingToggleRowView(title: LocalizedChatString("AllMembersMuted")) + + private let descriptionContainer = UIView() + + private let descriptionLabel = UILabel() + + private let addMutedRow = UIControl() + + private let addMutedPlusLabel = UILabel() + + private let addMutedLabel = UILabel() + + private let tableView = UITableView(frame: .zero, style: .plain) + + private var canManageAdmins: Bool { + return GroupPermissionManager.hasPermission( + groupType: groupType, + memberRole: currentUserRole, + permission: .setGroupMemberRole + ) + } + + init(groupID: String, groupStore: GroupStore, memberStore: GroupMemberStore) { + self.groupID = groupID + self.groupStore = groupStore + self.memberStore = memberStore + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("GroupProfileManage")) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + subscribeStores() + memberStore.loadMembers(roleList: [.all], completion: nil) + } + + private func constructViewHierarchy() { + view.addSubview(headerStack) + headerStack.addArrangedSubview(adminSectionSpacer) + headerStack.addArrangedSubview(adminSection) + adminSection.addSubview(adminTitleLabel) + adminSection.addSubview(adminScrollView) + adminScrollView.addSubview(adminRowStack) + headerStack.addArrangedSubview(muteSectionSpacer) + headerStack.addArrangedSubview(muteAllRow) + descriptionContainer.addSubview(descriptionLabel) + headerStack.addArrangedSubview(descriptionContainer) + headerStack.addArrangedSubview(addMutedRow) + addMutedRow.addSubview(addMutedPlusLabel) + addMutedRow.addSubview(addMutedLabel) + view.addSubview(tableView) + } + + private func activateConstraints() { + headerStack.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.equalToSuperview() + } + adminSectionSpacer.snp.makeConstraints { make in + make.height.equalTo(Self.sectionSpacerHeight) + } + adminTitleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.adminTitleTopSpacing) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalPadding) + } + adminScrollView.snp.makeConstraints { make in + make.top.equalTo(adminTitleLabel.snp.bottom).offset(Self.adminTitleBottomSpacing) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() + make.height.equalTo(adminRowStack).offset(Self.adminRowVerticalPadding * 2) + } + adminRowStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: Self.adminRowVerticalPadding, + left: Self.horizontalPadding, + bottom: Self.adminRowVerticalPadding, + right: Self.horizontalPadding + )) + } + muteSectionSpacer.snp.makeConstraints { make in + make.height.equalTo(Self.sectionSpacerHeight) + } + descriptionLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: Self.descriptionTopPadding, + left: Self.horizontalPadding, + bottom: Self.descriptionPadding, + right: Self.horizontalPadding + )) + } + addMutedPlusLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + } + addMutedLabel.snp.makeConstraints { make in + make.leading.equalTo(addMutedPlusLabel.snp.trailing).offset(Self.addRowSpacing) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(Self.addRowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.addRowVerticalPadding) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalPadding) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorTopBar + headerStack.axis = .vertical + headerStack.spacing = 0 + adminSectionSpacer.backgroundColor = colors.bgColorTopBar + adminSection.backgroundColor = colors.bgColorOperate + muteSectionSpacer.backgroundColor = colors.bgColorTopBar + muteAllRow.backgroundColor = colors.bgColorOperate + descriptionContainer.backgroundColor = colors.bgColorOperate + addMutedRow.backgroundColor = colors.bgColorOperate + + adminTitleLabel.text = LocalizedChatString("GroupManageAdmins") + adminTitleLabel.font = FontScheme.caption1Regular + adminTitleLabel.textColor = colors.textColorSecondary + + adminScrollView.showsHorizontalScrollIndicator = false + adminScrollView.alwaysBounceVertical = false + adminRowStack.axis = .horizontal + adminRowStack.alignment = .top + adminRowStack.spacing = Self.adminItemSpacing + + descriptionLabel.text = LocalizedChatString("AllMembersMutedDescription") + descriptionLabel.font = FontScheme.caption2Regular + descriptionLabel.textColor = colors.textColorTertiary + descriptionLabel.numberOfLines = 0 + + addMutedPlusLabel.text = "+" + addMutedPlusLabel.font = FontScheme.body3Regular + addMutedPlusLabel.textColor = colors.textColorLink + addMutedLabel.text = LocalizedChatString("GroupManageSelectMuted") + addMutedLabel.font = FontScheme.caption3Regular + addMutedLabel.textColor = colors.textColorLink + + tableView.backgroundColor = colors.bgColorOperate + tableView.separatorStyle = .none + tableView.rowHeight = Self.mutedRowHeight + tableView.register(MutedMemberCell.self, forCellReuseIdentifier: MutedMemberCell.reuseIdentifier) + tableView.dataSource = self + tableView.delegate = self + } + + private func bindInteraction() { + muteAllRow.onToggle = { [weak self] value in + guard let self = self else { return } + self.groupStore.muteAllMembers(groupID: self.groupID, isMuted: value, completion: nil) + } + addMutedRow.addTarget(self, action: #selector(handleAddMutedTapped), for: .touchUpInside) + } + + private func subscribeStores() { + groupStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.joinedGroupList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] joinedGroupList in + guard let self = self, + let info = joinedGroupList.first(where: { $0.groupID == self.groupID }) else { return } + self.isAllMuted = info.isAllMuted ?? false + self.groupType = info.groupType ?? .work + self.currentUserRole = info.selfRole ?? .member + self.muteAllRow.setOn(self.isAllMuted) + self.updateMuteSectionVisibility() + self.updateAdminSectionVisibility() + } + .store(in: &cancellables) + memberStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupMemberState.memberList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] memberList in + guard let self = self else { return } + self.mutedMembers = memberList.filter { $0.isMuted } + self.adminMembers = memberList.filter { $0.role == .admin } + self.tableView.reloadData() + self.renderAdminItems() + } + .store(in: &cancellables) + } + + private func updateAdminSectionVisibility() { + let visible = canManageAdmins + adminSectionSpacer.isHidden = !visible + adminSection.isHidden = !visible + if visible { + renderAdminItems() + } + } + + private func updateMuteSectionVisibility() { + addMutedRow.isHidden = isAllMuted + tableView.isHidden = isAllMuted + } + + private func renderAdminItems() { + guard canManageAdmins else { return } + adminRowStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (index, member) in adminMembers.enumerated() { + adminRowStack.addArrangedSubview(makeAdminMemberItem(member, index: index)) + } + adminRowStack.addArrangedSubview(makeAdminActionItem(isAdd: true)) + if !adminMembers.isEmpty { + adminRowStack.addArrangedSubview(makeAdminActionItem(isAdd: false)) + } + } + + private func makeAdminMemberItem(_ member: GroupMember, index: Int) -> UIView { + let container = UIStackView() + container.axis = .vertical + container.alignment = .center + container.spacing = Self.adminNameTopMargin + container.tag = index + + let displayName = chatSettingMemberDisplayName(member) + let avatarView = ChatAvatarView(size: Self.adminAvatarSize, isRound: false) + avatarView.configure(avatarURL: member.avatarURL, fallbackName: displayName) + avatarView.snp.makeConstraints { make in + make.width.height.equalTo(Self.adminAvatarSize.size) + } + + let nameLabel = UILabel() + nameLabel.font = FontScheme.caption3Regular + nameLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + nameLabel.textAlignment = .center + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.text = displayName + + container.addArrangedSubview(avatarView) + container.addArrangedSubview(nameLabel) + container.snp.makeConstraints { make in + make.width.equalTo(Self.adminItemWidth) + } + container.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleAdminItemTapped(_:)))) + return container + } + + private func makeAdminActionItem(isAdd: Bool) -> UIView { + let container = UIStackView() + container.axis = .vertical + container.alignment = .center + + let tile = UIControl() + tile.backgroundColor = ChatUIKitTheme.colors.bgColorInput + tile.layer.cornerRadius = Self.adminTileCornerRadius + tile.snp.makeConstraints { make in + make.width.height.equalTo(Self.adminAvatarSize.size) + } + + let symbolLabel = UILabel() + symbolLabel.text = isAdd ? "+" : "−" + symbolLabel.font = .systemFont(ofSize: Self.adminActionSymbolFontSize) + symbolLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + symbolLabel.textAlignment = .center + tile.addSubview(symbolLabel) + symbolLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + tile.addTarget( + self, + action: isAdd ? #selector(handleAddAdminTapped) : #selector(handleRemoveAdminTapped), + for: .touchUpInside + ) + + let bottomSpacer = UIView() + bottomSpacer.snp.makeConstraints { make in + make.height.equalTo(Self.adminActionBottomSpacerHeight) + } + + container.addArrangedSubview(tile) + container.addArrangedSubview(bottomSpacer) + container.snp.makeConstraints { make in + make.width.equalTo(Self.adminItemWidth) + } + return container + } + + @objc private func handleAdminItemTapped(_ gesture: UITapGestureRecognizer) { + guard let index = gesture.view?.tag, index >= 0, index < adminMembers.count else { return } + let member = adminMembers[index] + presentActionSheet( + actionTitle: LocalizedChatString("GroupManageRemoveAdmin"), + anchorView: gesture.view + ) { [weak self] in + self?.memberStore.setMemberRole(userID: member.userID, role: .member, completion: nil) + } + } + + @objc private func handleAddAdminTapped() { + pushOrPresent(GroupMemberPickerViewController(mode: .setAdmin(memberStore: memberStore))) + } + + @objc private func handleRemoveAdminTapped() { + pushOrPresent(GroupMemberPickerViewController(mode: .removeAdmin(memberStore: memberStore))) + } + + @objc private func handleAddMutedTapped() { + pushOrPresent(GroupMemberPickerViewController(mode: .muteMembers(memberStore: memberStore))) + } + + private func presentUnmuteActionSheet(for member: GroupMember, anchorView: UIView?) { + presentActionSheet( + actionTitle: LocalizedChatString("GroupManageCancelMute"), + anchorView: anchorView + ) { [weak self] in + self?.memberStore.muteMember(userID: member.userID, time: 0, completion: nil) + } + } + + private func presentActionSheet(actionTitle: String, anchorView: UIView?, action: @escaping () -> Void) { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: actionTitle, style: .default) { _ in action() }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = anchorView ?? view + popover.sourceRect = anchorView?.bounds ?? view.bounds + } + present(alert, animated: true) + } +} + +extension GroupManagementViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return mutedMembers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: MutedMemberCell.reuseIdentifier, + for: indexPath + ) as? MutedMemberCell else { + return UITableViewCell() + } + let member = mutedMembers[indexPath.row] + cell.configure(member: member) + cell.onLongPress = { [weak self] in + self?.presentUnmuteActionSheet(for: member, anchorView: cell) + } + return cell + } +} + +private final class MutedMemberCell: UITableViewCell { + static let reuseIdentifier = "MutedMemberCell" + + var onLongPress: (() -> Void)? + + private let avatarView = ChatAvatarView( + size: GroupManagementViewController.mutedAvatarSize, + isRound: false + ) + + private let nameLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(GroupManagementViewController.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(GroupManagementViewController.mutedAvatarSize.size) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(GroupManagementViewController.mutedNameSpacing) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().offset(-GroupManagementViewController.horizontalPadding) + } + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + nameLabel.font = FontScheme.caption2Regular + nameLabel.textColor = colors.textColorPrimary + nameLabel.lineBreakMode = .byTruncatingTail + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) + contentView.addGestureRecognizer(longPress) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(member: GroupMember) { + let displayName = chatSettingMemberDisplayName(member) + nameLabel.text = displayName + avatarView.configure(avatarURL: member.avatarURL, fallbackName: displayName) + } + + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + onLongPress?() + } +} diff --git a/chat/uikit/components/chatsetting/GroupMemberListViewController.swift b/chat/uikit/components/chatsetting/GroupMemberListViewController.swift new file mode 100644 index 000000000..5b0ab6d59 --- /dev/null +++ b/chat/uikit/components/chatsetting/GroupMemberListViewController.swift @@ -0,0 +1,303 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +final class GroupMemberListViewController: ChatSettingBaseViewController { + fileprivate static let rowHeight: CGFloat = 60 + + fileprivate static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + fileprivate static let avatarSize = ChatAvatarSize.m + + fileprivate static let avatarNameSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + fileprivate static let roleTagLeadingSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + fileprivate static let roleTagHorizontalPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + fileprivate static let roleTagVerticalPadding: CGFloat = 2 + + fileprivate static let roleTagCornerRadius: CGFloat = 3 + + private let groupStore: GroupStore + + private let memberStore: GroupMemberStore + + private let onGroupMemberClick: ((String) -> Void)? + + private var allMembers: [GroupMember] = [] + + private var currentUserRole: GroupMemberRole = .member + + private var currentUserID: String = "" + + private var cancellables = Set() + + private let tableView = UITableView(frame: .zero, style: .plain) + + init( + groupStore: GroupStore, + memberStore: GroupMemberStore, + onGroupMemberClick: ((String) -> Void)? = nil + ) { + self.groupStore = groupStore + self.memberStore = memberStore + self.onGroupMemberClick = onGroupMemberClick + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(String(format: LocalizedChatString("GroupMemberCountFormat"), 0)) + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + tableView.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + tableView.separatorStyle = .none + tableView.rowHeight = Self.rowHeight + tableView.register(GroupMemberListCell.self, forCellReuseIdentifier: GroupMemberListCell.reuseIdentifier) + tableView.dataSource = self + tableView.delegate = self + currentUserID = LoginStore.shared.state.value.loginUserInfo?.userID ?? "" + subscribeStores() + memberStore.loadMembers(roleList: [.all], completion: nil) + ContactStore.shared.loadFriends(completion: nil) + } + + // MARK: - Permission(对齐 Android GroupMemberActionPolicy) + + private func subscribeStores() { + memberStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupMemberState.memberList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] memberList in + guard let self = self else { return } + self.allMembers = memberList + if let selfMember = memberList.first(where: { $0.userID == self.currentUserID }) { + self.currentUserRole = selfMember.role + } + self.setNavTitle(String(format: LocalizedChatString("GroupMemberCountFormat"), memberList.count)) + self.tableView.reloadData() + } + .store(in: &cancellables) + groupStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.joinedGroupList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] joinedGroupList in + guard let self = self else { return } + if let info = joinedGroupList.first(where: { $0.groupID == self.memberStore.groupID }) { + self.currentUserRole = info.selfRole ?? self.currentUserRole + self.tableView.reloadData() + } + } + .store(in: &cancellables) + } + + private func hasActionPermission(for member: GroupMember) -> Bool { + switch currentUserRole { + case .owner: + return member.role != .owner + case .admin: + return member.role == .member + default: + return false + } + } + + private func canSetAdmin(for member: GroupMember) -> Bool { + return currentUserRole == .owner && member.role == .member + } + + private func canRemoveAdmin(for member: GroupMember) -> Bool { + return currentUserRole == .owner && member.role == .admin + } + + private func canRemoveMember(_ member: GroupMember) -> Bool { + switch currentUserRole { + case .owner: + return member.role != .owner + case .admin: + return member.role == .member + default: + return false + } + } + + private func handleMemberTap(_ member: GroupMember) { + if member.userID == currentUserID { + return + } + if hasActionPermission(for: member) { + presentMemberActionSheet(member) + } else { + openMemberDetail(member) + } + } + + private func openMemberDetail(_ member: GroupMember) { + if let onGroupMemberClick = onGroupMemberClick { + onGroupMemberClick(member.userID) + } else { + UserProfileRouter.open(userID: member.userID, from: self) + } + } + + private func presentMemberActionSheet(_ member: GroupMember) { + let alert = UIAlertController( + title: nil, + message: nil, + preferredStyle: .actionSheet + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("GroupMemberDetail"), style: .default) { [weak self] _ in + self?.openMemberDetail(member) + }) + if canRemoveMember(member) { + alert.addAction(UIAlertAction(title: LocalizedChatString("RemoveMember"), style: .default) { [weak self] _ in + self?.memberStore.deleteMember(userIDList: [member.userID], completion: nil) + }) + } + if canSetAdmin(for: member) { + alert.addAction(UIAlertAction(title: LocalizedChatString("SetAsAdmin"), style: .default) { [weak self] _ in + self?.memberStore.setMemberRole(userID: member.userID, role: .admin, completion: nil) + }) + } + if canRemoveAdmin(for: member) { + alert.addAction(UIAlertAction(title: LocalizedChatString("CancelAdmin"), style: .default) { [weak self] _ in + self?.memberStore.setMemberRole(userID: member.userID, role: .member, completion: nil) + }) + } + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } +} + +extension GroupMemberListViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return allMembers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: GroupMemberListCell.reuseIdentifier, + for: indexPath + ) as? GroupMemberListCell else { + return UITableViewCell() + } + cell.configure(member: allMembers[indexPath.row]) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + handleMemberTap(allMembers[indexPath.row]) + } +} + +// MARK: - Cell + +private final class GroupMemberListCell: UITableViewCell { + static let reuseIdentifier = "GroupMemberListCell" + + private let avatarView: ChatAvatarView = { + let size = GroupMemberListViewController.avatarSize + let radius: CGFloat + switch AppBuilderConfig.shared.avatarShape { + case .circular: + radius = size.size / 2 + case .rounded: + radius = size.roundedRectCornerRadius + case .square: + radius = 0 + } + return ChatAvatarView(cornerRadius: radius, fontSize: size.placeholderFontSize) + }() + + private let nameLabel = UILabel() + + private let roleTagLabel = UILabel() + + private let roleTagContainer = UIView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(member: GroupMember) { + let displayName = chatSettingMemberDisplayName(member) + nameLabel.text = displayName + avatarView.configure(avatarURL: member.avatarURL, fallbackName: displayName) + switch member.role { + case .owner: + roleTagLabel.text = LocalizedChatString("GroupOwnerLabel") + roleTagContainer.isHidden = false + case .admin: + roleTagLabel.text = LocalizedChatString("AdminLabel") + roleTagContainer.isHidden = false + default: + roleTagContainer.isHidden = true + } + } + + private func constructViewHierarchy() { + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + roleTagContainer.addSubview(roleTagLabel) + contentView.addSubview(roleTagContainer) + } + + private func activateConstraints() { + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(GroupMemberListViewController.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(GroupMemberListViewController.avatarSize.size) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(GroupMemberListViewController.avatarNameSpacing) + make.centerY.equalToSuperview() + } + roleTagContainer.snp.makeConstraints { make in + make.leading.equalTo(nameLabel.snp.trailing).offset(GroupMemberListViewController.roleTagLeadingSpacing) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().offset(-GroupMemberListViewController.horizontalPadding) + } + roleTagLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: GroupMemberListViewController.roleTagVerticalPadding, + left: GroupMemberListViewController.roleTagHorizontalPadding, + bottom: GroupMemberListViewController.roleTagVerticalPadding, + right: GroupMemberListViewController.roleTagHorizontalPadding + )) + } + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + nameLabel.font = FontScheme.body4Regular + nameLabel.textColor = colors.textColorPrimary + nameLabel.lineBreakMode = .byTruncatingTail + roleTagLabel.font = FontScheme.caption4Regular + roleTagLabel.textColor = colors.textColorLink + roleTagContainer.backgroundColor = colors.buttonColorPrimaryDisabled + roleTagContainer.layer.cornerRadius = GroupMemberListViewController.roleTagCornerRadius + roleTagContainer.layer.masksToBounds = true + } +} diff --git a/chat/uikit/components/chatsetting/GroupMemberPickerViewController.swift b/chat/uikit/components/chatsetting/GroupMemberPickerViewController.swift new file mode 100644 index 000000000..6487c5ab3 --- /dev/null +++ b/chat/uikit/components/chatsetting/GroupMemberPickerViewController.swift @@ -0,0 +1,297 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +final class GroupMemberPickerViewController: ChatSettingBaseViewController { + enum Mode { + case addMember(memberStore: GroupMemberStore) + case removeMember(memberStore: GroupMemberStore, currentUserRole: GroupMemberRole) + case transferOwner(groupID: String, groupStore: GroupStore, memberStore: GroupMemberStore) + case muteMembers(memberStore: GroupMemberStore) + case setAdmin(memberStore: GroupMemberStore) + case removeAdmin(memberStore: GroupMemberStore) + } + + private static let muteDurationSeconds: Int64 = 7 * 24 * 60 * 60 + + private let mode: Mode + + private var currentUserID: String = "" + + private var allMembers: [GroupMember] = [] + + private var friendList: [ContactInfo] = [] + + private var currentSelection: [UserPickerItem] = [] + + private var cancellables = Set() + + private let pickerView = UserPickerView() + + private let cancelButton = UIButton(type: .custom) + + private let confirmButton = UIButton(type: .custom) + + private var usesNavConfirm: Bool { + switch mode { + case .addMember, .removeMember, .transferOwner, .muteMembers, .setAdmin, .removeAdmin: + return true + default: + return false + } + } + + init(mode: Mode) { + self.mode = mode + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + currentUserID = LoginStore.shared.state.value.loginUserInfo?.userID ?? "" + pickerView.onSelectionChanged = { [weak self] selectedItems in + self?.handleSelectionChanged(selectedItems) + } + if usesNavConfirm { + setupNavActions() + } + switch mode { + case .addMember(let memberStore): + setNavTitle(LocalizedChatString("GroupAddMembers")) + subscribeFriendList() + subscribeMemberList(memberStore) + memberStore.loadMembers(roleList: [.all], completion: nil) + ContactStore.shared.loadFriends(completion: nil) + case .removeMember(let memberStore, _): + setNavTitle(LocalizedChatString("GroupRemoveMembers")) + subscribeMemberList(memberStore) + memberStore.loadMembers(roleList: [.all], completion: nil) + case .transferOwner(_, _, let memberStore): + setNavTitle(LocalizedChatString("GroupTransferOwner")) + subscribeMemberList(memberStore) + memberStore.loadMembers(roleList: [.all], completion: nil) + case .muteMembers(let memberStore): + setNavTitle(LocalizedChatString("GroupManageSelectMuted")) + subscribeMemberList(memberStore) + memberStore.loadMembers(roleList: [.all], completion: nil) + case .setAdmin(let memberStore): + setNavTitle(LocalizedChatString("GroupManageSelectAdmin")) + subscribeMemberList(memberStore) + memberStore.loadMembers(roleList: [.all], completion: nil) + case .removeAdmin(let memberStore): + setNavTitle(LocalizedChatString("GroupManageRemoveAdmin")) + subscribeMemberList(memberStore) + memberStore.loadMembers(roleList: [.all], completion: nil) + } + refreshPicker() + updateConfirmButton() + } + + private func constructViewHierarchy() { + view.addSubview(pickerView) + } + + private func activateConstraints() { + pickerView.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func setupViewStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } + + private func setupNavActions() { + let colors = ChatUIKitTheme.colors + setNavBackHidden(true) + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.titleLabel?.font = FontScheme.caption1Regular + cancelButton.setTitleColor(colors.textColorLink, for: .normal) + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + setNavLeadingView(cancelButton) + confirmButton.setTitle(LocalizedChatString("Confirm"), for: .normal) + confirmButton.titleLabel?.font = FontScheme.caption1Regular + confirmButton.addTarget(self, action: #selector(handleConfirm), for: .touchUpInside) + setNavTrailingView(confirmButton) + } + + private func subscribeFriendList() { + ContactStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] friendList in + self?.friendList = friendList + self?.refreshPicker() + } + .store(in: &cancellables) + } + + private func subscribeMemberList(_ memberStore: GroupMemberStore) { + memberStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupMemberState.memberList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] memberList in + self?.allMembers = memberList + self?.refreshPicker() + } + .store(in: &cancellables) + } + + private func refreshPicker() { + switch mode { + case .addMember: + let memberIDs = Set(allMembers.map { $0.userID }) + let items = friendList.compactMap { contact -> UserPickerItem? in + guard !memberIDs.contains(contact.userID) else { return nil } + return UserPickerItem( + userID: contact.userID, + avatarURL: contact.avatarURL, + title: chatSettingContactDisplayName(contact) + ) + } + pickerView.configure(userList: items, maxCount: 0) + case .removeMember(_, let currentUserRole): + let items = allMembers.compactMap { member -> UserPickerItem? in + guard Self.canRemove(memberRole: member.role, currentUserRole: currentUserRole) else { return nil } + let subtitle = member.role == .admin ? LocalizedChatString("MembersRoleAdmin") : nil + return UserPickerItem( + userID: member.userID, + avatarURL: member.avatarURL, + title: chatSettingMemberDisplayName(member), + subtitle: subtitle + ) + } + pickerView.configure(userList: items, maxCount: 0) + case .transferOwner: + let items = allMembers.compactMap { member -> UserPickerItem? in + if member.userID == currentUserID { return nil } + let subtitle = member.role == .admin ? LocalizedChatString("MembersRoleAdmin") : nil + return UserPickerItem( + userID: member.userID, + avatarURL: member.avatarURL, + title: chatSettingMemberDisplayName(member), + subtitle: subtitle + ) + } + pickerView.configure(userList: items, maxCount: 1) + case .muteMembers: + let items = allMembers.compactMap { member -> UserPickerItem? in + if member.role == .owner || member.isMuted { return nil } + let subtitle = member.role == .admin ? LocalizedChatString("MembersRoleAdmin") : nil + return UserPickerItem( + userID: member.userID, + avatarURL: member.avatarURL, + title: chatSettingMemberDisplayName(member), + subtitle: subtitle + ) + } + pickerView.configure(userList: items, maxCount: 0) + case .setAdmin: + let items = allMembers.compactMap { member -> UserPickerItem? in + guard member.role == .member else { return nil } + return UserPickerItem( + userID: member.userID, + avatarURL: member.avatarURL, + title: chatSettingMemberDisplayName(member) + ) + } + pickerView.configure(userList: items, maxCount: 0) + case .removeAdmin: + let items = allMembers.compactMap { member -> UserPickerItem? in + guard member.role == .admin else { return nil } + return UserPickerItem( + userID: member.userID, + avatarURL: member.avatarURL, + title: chatSettingMemberDisplayName(member), + subtitle: LocalizedChatString("MembersRoleAdmin") + ) + } + pickerView.configure(userList: items, maxCount: 0) + } + } + + private static func canRemove(memberRole: GroupMemberRole, currentUserRole: GroupMemberRole) -> Bool { + switch currentUserRole { + case .owner: + return memberRole != .owner + case .admin: + return memberRole == .member + default: + return false + } + } + + private func handleSelectionChanged(_ selectedItems: [UserPickerItem]) { + currentSelection = selectedItems + updateConfirmButton() + } + + private func updateConfirmButton() { + guard usesNavConfirm else { return } + let colors = ChatUIKitTheme.colors + let hasSelection = !currentSelection.isEmpty + confirmButton.setTitleColor(hasSelection ? colors.textColorLink : colors.textColorDisable, for: .normal) + } + + @objc private func handleCancel() { + closePage() + } + + @objc private func handleConfirm() { + let selectedIDs = currentSelection.map { $0.userID } + guard !selectedIDs.isEmpty else { return } + switch mode { + case .addMember(let memberStore): + memberStore.addMember(userIDList: selectedIDs, completion: nil) + case .removeMember(let memberStore, _): + memberStore.deleteMember(userIDList: selectedIDs, completion: nil) + case .transferOwner(let groupID, let groupStore, _): + presentTransferOwnerConfirmation(groupID: groupID, groupStore: groupStore, newOwnerID: selectedIDs[0]) + return + case .muteMembers(let memberStore): + for userID in selectedIDs { + memberStore.muteMember(userID: userID, time: Self.muteDurationSeconds, completion: nil) + } + case .setAdmin(let memberStore): + for userID in selectedIDs { + memberStore.setMemberRole(userID: userID, role: .admin, completion: nil) + } + case .removeAdmin(let memberStore): + for userID in selectedIDs { + memberStore.setMemberRole(userID: userID, role: .member, completion: nil) + } + } + closePage() + } + + private func presentTransferOwnerConfirmation(groupID: String, groupStore: GroupStore, newOwnerID: String) { + let alert = UIAlertController( + title: LocalizedChatString("GroupTransferOwnerConfirmTips"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Confirm"), style: .destructive) { [weak self] _ in + groupStore.changeOwner(groupID: groupID, newOwnerID: newOwnerID, completion: nil) + self?.closePage() + }) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func closePage() { + if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +} diff --git a/chat/uikit/components/chatsetting/GroupPermissionManager.swift b/chat/uikit/components/chatsetting/GroupPermissionManager.swift new file mode 100644 index 000000000..c5eca7bf8 --- /dev/null +++ b/chat/uikit/components/chatsetting/GroupPermissionManager.swift @@ -0,0 +1,430 @@ +import AtomicXCore +import Foundation + +// MARK: - Group Permission Enums + +enum GroupPermission: CaseIterable { + case setGroupName + case setGroupAvatar + case sendMessage + case setDoNotDisturb + case pinGroup + case setGroupNotice + case setGroupManagement + case getGroupType + case setJoinGroupApprovalType + case setInviteToGroupApprovalType + case setGroupRemark + case setBackground + case getGroupMemberList + case setGroupMemberRole + case getGroupMemberInfo + case removeGroupMember + case addGroupMember + case muteMember + case clearHistoryMessages + case deleteAndQuit + case transferOwner + case dismissGroup + case reportGroup +} + +class GroupPermissionManager { + private static let permissionMatrix: [GroupType: [GroupMemberRole: [GroupPermission: Bool]]] = [ + .work: [ + .owner: [ + .setGroupName: true, + .setGroupAvatar: true, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: true, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: true, + .dismissGroup: false, + .reportGroup: true, + ], + .admin: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: true, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: false, + .addGroupMember: true, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + .member: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: false, + .addGroupMember: true, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + ], + .publicGroup: [ + .owner: [ + .setGroupName: true, + .setGroupAvatar: true, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: true, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: true, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: false, + .transferOwner: true, + .dismissGroup: true, + .reportGroup: true, + ], + .admin: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: true, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + .member: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: false, + .addGroupMember: true, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + ], + .meeting: [ + .owner: [ + .setGroupName: true, + .setGroupAvatar: true, + .sendMessage: true, + .setDoNotDisturb: false, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: true, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: false, + .transferOwner: true, + .dismissGroup: true, + .reportGroup: true, + ], + .admin: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: false, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + .member: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: false, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: false, + .addGroupMember: true, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + ], + .community: [ + .owner: [ + .setGroupName: true, + .setGroupAvatar: true, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: true, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: true, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: false, + .transferOwner: true, + .dismissGroup: true, + .reportGroup: true, + ], + .admin: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: true, + .setInviteToGroupApprovalType: true, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: true, + .addGroupMember: true, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + .member: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: true, + .setGroupMemberRole: false, + .getGroupMemberInfo: true, + .removeGroupMember: false, + .addGroupMember: true, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + ], + .avChatRoom: [ + .owner: [ + .setGroupName: true, + .setGroupAvatar: true, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: true, + .setGroupManagement: true, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: false, + .setGroupMemberRole: false, + .getGroupMemberInfo: false, + .removeGroupMember: false, + .addGroupMember: false, + .muteMember: true, + .clearHistoryMessages: true, + .deleteAndQuit: false, + .transferOwner: false, + .dismissGroup: true, + .reportGroup: true, + ], + .admin: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: false, + .setGroupMemberRole: false, + .getGroupMemberInfo: false, + .removeGroupMember: false, + .addGroupMember: false, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + .member: [ + .setGroupName: false, + .setGroupAvatar: false, + .sendMessage: true, + .setDoNotDisturb: true, + .pinGroup: true, + .setGroupNotice: false, + .setGroupManagement: false, + .getGroupType: true, + .setJoinGroupApprovalType: false, + .setInviteToGroupApprovalType: false, + .setGroupRemark: true, + .setBackground: true, + .getGroupMemberList: false, + .setGroupMemberRole: false, + .getGroupMemberInfo: false, + .removeGroupMember: false, + .addGroupMember: false, + .muteMember: false, + .clearHistoryMessages: true, + .deleteAndQuit: true, + .transferOwner: false, + .dismissGroup: false, + .reportGroup: true, + ], + ], + ] + + // MARK: - Public Methods + + static func hasPermission( + groupType: GroupType, + memberRole: GroupMemberRole, + permission: GroupPermission + ) -> Bool { + return permissionMatrix[groupType]?[memberRole]?[permission] ?? false + } +} diff --git a/chat/uikit/components/chatsetting/TextInputDialogViewController.swift b/chat/uikit/components/chatsetting/TextInputDialogViewController.swift new file mode 100644 index 000000000..fba55eadd --- /dev/null +++ b/chat/uikit/components/chatsetting/TextInputDialogViewController.swift @@ -0,0 +1,292 @@ +import UIKit +import SnapKit + +public final class TextInputDialogViewController: UIViewController { + private static let dimAlpha: CGFloat = 0.4 + + private static let panelCornerRadius: CGFloat = CGFloat(RadiusScheme.alertRadius) + + private static let panelHorizontalMargin: CGFloat = CGFloat(SpacingScheme.cardSpacing) + + private static let panelMaxWidth: CGFloat = 360 + + private static let contentPadding: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let inputCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let inputBorderWidth: CGFloat = 1 + + private static let inputHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let inputVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let singleLineInputHeight: CGFloat = 40 + + private static let multiLineMinHeight: CGFloat = 160 + + private static let multiLineMaxHeight: CGFloat = 280 + + private static let counterTopSpacing: CGFloat = 6 + + private static let inputBottomSpacing: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let confirmButtonHeight: CGFloat = 36 + + private static let keyboardBottomSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private let dialogTitle: String + + private let initialText: String + + private let maxLength: Int + + private let multiline: Bool + + private let onConfirm: (String) -> Void + + private let dimView = UIControl() + + private let panelView = UIView() + + private let titleLabel = UILabel() + + private let textField = UITextField() + + private let textView = UITextView() + + private let counterLabel = UILabel() + + private let confirmButton = UIButton(type: .custom) + + private var showsCounter: Bool { + return multiline && maxLength > 0 + } + + public init( + title: String, + initialText: String = "", + maxLength: Int = 0, + multiline: Bool = false, + onConfirm: @escaping (String) -> Void + ) { + self.dialogTitle = title + self.initialText = initialText + self.maxLength = maxLength + self.multiline = multiline + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + applyInitialContent() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if multiline { + textView.becomeFirstResponder() + } else { + textField.becomeFirstResponder() + } + } + + private func constructViewHierarchy() { + view.addSubview(dimView) + view.addSubview(panelView) + panelView.addSubview(titleLabel) + if multiline { + panelView.addSubview(textView) + } else { + panelView.addSubview(textField) + } + if showsCounter { + panelView.addSubview(counterLabel) + } + panelView.addSubview(confirmButton) + } + + private func activateConstraints() { + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + panelView.snp.makeConstraints { make in + make.center.equalToSuperview().priority(.high) + make.leading.greaterThanOrEqualToSuperview().offset(Self.panelHorizontalMargin) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.panelHorizontalMargin) + make.width.lessThanOrEqualTo(Self.panelMaxWidth) + make.width.equalTo(UIScreen.main.bounds.width - Self.panelHorizontalMargin * 2).priority(.high) + make.bottom.lessThanOrEqualTo(view.keyboardLayoutGuide.snp.top).offset(-Self.keyboardBottomSpacing) + } + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(Self.contentPadding) + } + let inputView = multiline ? textView : textField + inputView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.contentPadding) + make.leading.trailing.equalToSuperview().inset(Self.contentPadding) + } + if multiline { + textView.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.multiLineMinHeight) + make.height.lessThanOrEqualTo(Self.multiLineMaxHeight) + } + } else { + textField.snp.makeConstraints { make in + make.height.equalTo(Self.singleLineInputHeight) + } + } + if showsCounter { + counterLabel.snp.makeConstraints { make in + make.top.equalTo(inputView.snp.bottom).offset(Self.counterTopSpacing) + make.leading.trailing.equalToSuperview().inset(Self.contentPadding) + } + } + confirmButton.snp.makeConstraints { make in + if showsCounter { + make.top.equalTo(counterLabel.snp.bottom).offset(Self.inputBottomSpacing) + } else { + make.top.equalTo(inputView.snp.bottom).offset(Self.inputBottomSpacing) + } + make.leading.trailing.bottom.equalToSuperview().inset(Self.contentPadding) + make.height.equalTo(Self.confirmButtonHeight) + } + } + + private func bindInteraction() { + dimView.addTarget(self, action: #selector(handleDimTap), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(handleConfirmTap), for: .touchUpInside) + textView.delegate = self + } + + private func setupViewStyle() { + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(Self.dimAlpha) + panelView.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + panelView.layer.cornerRadius = Self.panelCornerRadius + panelView.layer.masksToBounds = true + titleLabel.font = FontScheme.caption1Bold + titleLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + titleLabel.textAlignment = .center + let inputFont = FontScheme.caption1Regular + textView.font = inputFont + textField.font = inputFont + textView.textColor = ChatUIKitTheme.colors.textColorPrimary + textField.textColor = ChatUIKitTheme.colors.textColorPrimary + textView.backgroundColor = ChatUIKitTheme.colors.bgColorDialog + textField.backgroundColor = ChatUIKitTheme.colors.bgColorDialog + textView.layer.cornerRadius = Self.inputCornerRadius + textField.layer.cornerRadius = Self.inputCornerRadius + textView.layer.borderWidth = Self.inputBorderWidth + textField.layer.borderWidth = Self.inputBorderWidth + textView.layer.borderColor = ChatUIKitTheme.colors.strokeColorPrimary.cgColor + textField.layer.borderColor = ChatUIKitTheme.colors.strokeColorPrimary.cgColor + if multiline { + textView.textContainerInset = UIEdgeInsets( + top: Self.inputVerticalPadding, + left: Self.inputHorizontalPadding - textView.textContainer.lineFragmentPadding, + bottom: Self.inputVerticalPadding, + right: Self.inputHorizontalPadding - textView.textContainer.lineFragmentPadding + ) + textView.isScrollEnabled = false + } else { + let paddingView = UIView(frame: CGRect( + x: 0, + y: 0, + width: Self.inputHorizontalPadding, + height: Self.singleLineInputHeight + )) + textField.leftView = paddingView + textField.leftViewMode = .always + let rightPaddingView = UIView(frame: CGRect( + x: 0, + y: 0, + width: Self.inputHorizontalPadding, + height: Self.singleLineInputHeight + )) + textField.rightView = rightPaddingView + textField.rightViewMode = .always + } + counterLabel.font = FontScheme.caption3Regular + counterLabel.textColor = ChatUIKitTheme.colors.textColorTertiary + counterLabel.textAlignment = LanguageHelper.isRTL ? .left : .right + confirmButton.setTitle(LocalizedChatString("Confirm"), for: .normal) + confirmButton.setTitleColor(ChatUIKitTheme.colors.textColorButton, for: .normal) + confirmButton.titleLabel?.font = FontScheme.caption1Regular + confirmButton.backgroundColor = ChatUIKitTheme.colors.buttonColorPrimaryDefault + confirmButton.layer.cornerRadius = Self.confirmButtonHeight / 2 + confirmButton.layer.masksToBounds = true + } + + private func applyInitialContent() { + titleLabel.text = dialogTitle + if multiline { + textView.text = initialText + } else { + textField.text = initialText + } + updateCounter() + } + + private func currentInputText() -> String { + return multiline ? (textView.text ?? "") : (textField.text ?? "") + } + + private func updateCounter() { + guard showsCounter else { return } + counterLabel.text = "\(currentInputText().count)/\(maxLength)" + } + + private func updateTextViewScrollability() { + guard multiline else { return } + let fittingHeight = textView.sizeThatFits(CGSize( + width: textView.bounds.width, + height: .greatestFiniteMagnitude + )).height + let shouldScroll = fittingHeight >= Self.multiLineMaxHeight + if textView.isScrollEnabled != shouldScroll { + textView.isScrollEnabled = shouldScroll + } + } + + @objc private func handleDimTap() { + dismiss(animated: true) + } + + @objc private func handleConfirmTap() { + let text = currentInputText().trimmingCharacters(in: .whitespacesAndNewlines) + dismiss(animated: true) { + self.onConfirm(text) + } + } +} + +extension TextInputDialogViewController: UITextViewDelegate { + + public func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard maxLength > 0 else { return true } + let currentText = textView.text ?? "" + guard let stringRange = Range(range, in: currentText) else { return false } + let updatedText = currentText.replacingCharacters(in: stringRange, with: text) + return updatedText.count <= maxLength + } + + public func textViewDidChange(_ textView: UITextView) { + updateCounter() + updateTextViewScrollability() + } +} diff --git a/chat/uikit/components/chatsetting/UserProfileRouter.swift b/chat/uikit/components/chatsetting/UserProfileRouter.swift new file mode 100644 index 000000000..0c52888db --- /dev/null +++ b/chat/uikit/components/chatsetting/UserProfileRouter.swift @@ -0,0 +1,158 @@ +import AtomicXCore +import UIKit + +public enum UserProfileRouter { + + private static var pendingRouteKeys = Set() + + public static func open( + userID: String, + from source: UIViewController, + onSendMessageClick: (() -> Void)? = nil, + onContactDelete: (() -> Void)? = nil + ) { + if isFriendInMemory(userID: userID) { + push( + from: source, + destination: makeC2CPage( + userID: userID, + onSendMessageClick: onSendMessageClick, + onContactDelete: onContactDelete + ) + ) + return + } + let routeKey = "\(ObjectIdentifier(source)):\(userID)" + guard !pendingRouteKeys.contains(routeKey) else { return } + pendingRouteKeys.insert(routeKey) + ContactStore.shared.loadFriends { result in + switch result { + case .success: + DispatchQueue.main.async { + pendingRouteKeys.remove(routeKey) + routeByLoadedFriendList( + userID: userID, + from: source, + onSendMessageClick: onSendMessageClick, + onContactDelete: onContactDelete + ) + } + case .failure: + fallbackToContactInfo( + userID: userID, + from: source, + routeKey: routeKey, + onSendMessageClick: onSendMessageClick, + onContactDelete: onContactDelete + ) + } + } + } + + // MARK: - Private + + private static func isFriendInMemory(userID: String) -> Bool { + return ContactStore.shared.state.value.friendList.contains(where: { $0.userID == userID }) + } + + private static func routeByLoadedFriendList( + userID: String, + from source: UIViewController, + onSendMessageClick: (() -> Void)?, + onContactDelete: (() -> Void)? + ) { + if isFriendInMemory(userID: userID) { + push( + from: source, + destination: makeC2CPage( + userID: userID, + onSendMessageClick: onSendMessageClick, + onContactDelete: onContactDelete + ) + ) + } else { + push(from: source, destination: AddContactViewController(userID: userID)) + } + } + + private static func fallbackToContactInfo( + userID: String, + from source: UIViewController, + routeKey: String, + onSendMessageClick: (() -> Void)?, + onContactDelete: (() -> Void)? + ) { + ContactStore.shared.getContactInfo( + userIDList: [userID], + completion: UserProfileRouteContactInfoHandler( + onSuccess: { contactInfoList in + DispatchQueue.main.async { + pendingRouteKeys.remove(routeKey) + let contactInfo = contactInfoList.first + if contactInfo?.isFriend == true { + push( + from: source, + destination: makeC2CPage( + userID: userID, + onSendMessageClick: onSendMessageClick, + onContactDelete: onContactDelete + ) + ) + } else { + push( + from: source, + destination: AddContactViewController(userID: userID, contactInfo: contactInfo) + ) + } + } + }, + onFailure: { _, _ in + DispatchQueue.main.async { + pendingRouteKeys.remove(routeKey) + push(from: source, destination: AddContactViewController(userID: userID)) + } + } + ) + ) + } + + private static func makeC2CPage( + userID: String, + onSendMessageClick: (() -> Void)?, + onContactDelete: (() -> Void)? + ) -> UIViewController { + return C2CChatSettingViewController( + userID: userID, + onSendMessageClick: onSendMessageClick, + onContactDelete: onContactDelete + ) + } + + private static func push(from source: UIViewController, destination: UIViewController) { + destination.hidesBottomBarWhenPushed = true + if let navigationController = source.navigationController { + navigationController.pushViewController(destination, animated: true) + } else { + source.present(destination, animated: true) + } + } +} + +private final class UserProfileRouteContactInfoHandler: GetContactInfoCompletionHandler { + private let onSuccessBlock: ([ContactInfo]) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping ([ContactInfo]) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(contactInfoList: [ContactInfo]) { + onSuccessBlock(contactInfoList) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/common/BubbleMenuViewController.swift b/chat/uikit/components/common/BubbleMenuViewController.swift new file mode 100644 index 000000000..c0ae06759 --- /dev/null +++ b/chat/uikit/components/common/BubbleMenuViewController.swift @@ -0,0 +1,313 @@ +import UIKit +import SnapKit + +final class BubbleMenuViewController: UIViewController { + struct Item { + let icon: UIImage? + let title: String + let action: () -> Void + } + + private static let bubbleCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let arrowWidth: CGFloat = 12 + + private static let arrowHeight: CGFloat = 6 + + private static let shadowOpacity: Float = 0.2 + + private static let rowHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let rowVerticalPadding: CGFloat = 10 + + private static let iconSize: CGFloat = 20 + + private static let iconTextSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let dividerHorizontalMargin: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let dividerHeight: CGFloat = 1 + + private static let minContentWidth: CGFloat = 72 + + private static let maxWidthScreenInset: CGFloat = CGFloat(SpacingScheme.titleSpacing) + + private static let screenMargin: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let anchorGap: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let titleFontSize: CGFloat = 16 + + private static let appearDuration: TimeInterval = 0.15 + + private static let rowHeight: CGFloat = titleFontSize * 1.2 + rowVerticalPadding * 2 + + private let anchorView: UIView + + private let items: [Item] + + private let bubbleView = BubbleMenuBubbleView() + + private let stackView = UIStackView() + + init(anchorView: UIView, items: [Item]) { + self.anchorView = anchorView + self.items = items + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + bindInteraction() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIView.animate(withDuration: Self.appearDuration) { + self.view.alpha = 1 + } + } + + private func constructViewHierarchy() { + view.alpha = 0 + view.addSubview(bubbleView) + stackView.axis = .vertical + stackView.spacing = 0 + for (index, item) in items.enumerated() { + stackView.addArrangedSubview(makeRow(item: item)) + if index < items.count - 1 { + stackView.addArrangedSubview(makeDivider()) + } + } + bubbleView.contentView.addSubview(stackView) + } + + private func activateConstraints() { + let contentWidth = Self.contentWidth(for: items) + let contentHeight = Self.contentHeight(for: items) + let anchorFrame = anchorView.convert(anchorView.bounds, to: nil) + let screenWidth = UIScreen.main.bounds.width + + let isRTL = view.effectiveUserInterfaceLayoutDirection == .rightToLeft + let preferredX = isRTL ? Self.screenMargin : screenWidth - contentWidth - Self.screenMargin + let maxX = screenWidth - contentWidth - Self.screenMargin + let bubbleX = max(Self.screenMargin, min(preferredX, maxX)) + let bubbleY = anchorFrame.maxY + Self.anchorGap + let arrowCenterX = (anchorFrame.midX - bubbleX) + .clamped(to: Self.bubbleCornerRadius ... contentWidth - Self.bubbleCornerRadius) + + bubbleView.arrowCenterX = arrowCenterX + bubbleView.frame = CGRect( + x: bubbleX, + y: bubbleY, + width: contentWidth, + height: Self.arrowHeight + contentHeight + ) + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(contentWidth) + make.height.equalTo(contentHeight) + } + } + + private func bindInteraction() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackdropTap(_:))) + tap.delegate = self + view.addGestureRecognizer(tap) + } + + private static func contentWidth(for items: [Item]) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: titleFontSize)] + let maxTextWidth = items + .map { ceil(($0.title as NSString).size(withAttributes: attributes).width) } + .max() ?? 0 + let hasAnyIcon = items.contains { $0.icon != nil } + let iconSpace = hasAnyIcon ? iconSize + iconTextSpacing : 0 + let raw = maxTextWidth + iconSpace + rowHorizontalPadding * 2 + let maxWidth = UIScreen.main.bounds.width - maxWidthScreenInset + return min(max(raw, minContentWidth), maxWidth) + } + + private static func contentHeight(for items: [Item]) -> CGFloat { + let dividers = CGFloat(max(items.count - 1, 0)) * dividerHeight + return CGFloat(items.count) * rowHeight + dividers + } + + private func makeRow(item: Item) -> UIControl { + let colors = ChatUIKitTheme.colors + let control = MenuItemControl { [weak self] in + self?.dismiss(animated: false, completion: { + item.action() + }) + } + let iconView = UIImageView(image: item.icon) + iconView.tintColor = colors.textColorPrimary + iconView.contentMode = .scaleAspectFit + let titleLabel = UILabel() + titleLabel.text = item.title + titleLabel.font = .systemFont(ofSize: Self.titleFontSize) + titleLabel.textColor = colors.textColorPrimary + control.addSubview(iconView) + control.addSubview(titleLabel) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.rowHorizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.iconSize) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(Self.iconTextSpacing) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.rowHorizontalPadding) + make.centerY.equalToSuperview() + } + + control.snp.makeConstraints { make in + make.height.equalTo(Self.rowHeight) + } + return control + } + + private func makeDivider() -> UIView { + let wrapper = UIView() + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorSecondary + wrapper.addSubview(divider) + divider.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.equalToSuperview().offset(Self.dividerHorizontalMargin) + make.trailing.equalToSuperview().offset(-Self.dividerHorizontalMargin) + make.height.equalTo(Self.dividerHeight) + } + return wrapper + } + + @objc private func handleBackdropTap(_ gesture: UITapGestureRecognizer) { + dismiss(animated: false) + } +} + +extension BubbleMenuViewController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if let touchedView = touch.view, touchedView.isDescendant(of: bubbleView) { + return false + } + return true + } +} + +private final class MenuItemControl: UIControl { + private static let highlightAlpha: CGFloat = 0.1 + + override var isHighlighted: Bool { + didSet { + backgroundColor = isHighlighted + ? ChatUIKitTheme.colors.textColorPrimary.withAlphaComponent(Self.highlightAlpha) + : .clear + } + } + + private let handler: () -> Void + + init(handler: @escaping () -> Void) { + self.handler = handler + super.init(frame: .zero) + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func handleTap() { + handler() + } +} + +final class BubbleMenuBubbleView: UIView { + var arrowCenterX: CGFloat = 0 { + didSet { setNeedsDisplay() } + } + + let contentView = UIView() + + private static let cornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let arrowWidth: CGFloat = 12 + + private static let arrowHeight: CGFloat = 6 + + private static let shadowOpacity: Float = 0.2 + + private static let shadowRadius: CGFloat = 8 + + private static let shadowOffset = CGSize(width: 0, height: 2) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + contentView.backgroundColor = ChatUIKitTheme.colors.bgColorDialog + contentView.layer.cornerRadius = Self.cornerRadius + contentView.layer.masksToBounds = true + addSubview(contentView) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Self.shadowOpacity + layer.shadowRadius = Self.shadowRadius + layer.shadowOffset = Self.shadowOffset + layer.masksToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + contentView.frame = CGRect( + x: 0, + y: Self.arrowHeight, + width: bounds.width, + height: bounds.height - Self.arrowHeight + ) + layer.shadowPath = bubblePath().cgPath + } + + override func draw(_ rect: CGRect) { + ChatUIKitTheme.colors.bgColorDialog.setFill() + bubblePath().fill() + } + + private func bubblePath() -> UIBezierPath { + let arrowHalfWidth = Self.arrowWidth / 2 + let bodyRect = CGRect( + x: 0, + y: Self.arrowHeight, + width: bounds.width, + height: bounds.height - Self.arrowHeight + ) + let clampedCenterX = min( + max(arrowCenterX, Self.cornerRadius), + bodyRect.width - Self.cornerRadius + ) + let path = UIBezierPath() + path.move(to: CGPoint(x: clampedCenterX - arrowHalfWidth, y: bodyRect.minY)) + path.addLine(to: CGPoint(x: clampedCenterX, y: 0)) + path.addLine(to: CGPoint(x: clampedCenterX + arrowHalfWidth, y: bodyRect.minY)) + path.close() + path.append(UIBezierPath(roundedRect: bodyRect, cornerRadius: Self.cornerRadius)) + return path + } +} + +private extension Comparable { + + func clamped(to range: ClosedRange) -> Self { + return min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/chat/uikit/components/common/CallMessageParser.swift b/chat/uikit/components/common/CallMessageParser.swift new file mode 100644 index 000000000..648245bb2 --- /dev/null +++ b/chat/uikit/components/common/CallMessageParser.swift @@ -0,0 +1,286 @@ +import Foundation +import AtomicXCore + +enum CallProtocolType { + case unknown + case send + case accept + case reject + case cancel + case hangup + case timeout + case lineBusy + case switchToAudio + case switchToAudioConfirm +} + +enum CallStreamMediaType { + case unknown + case voice + case video +} + +enum CallParticipantType { + case unknown + case c2c + case group +} + +enum CallParticipantRole { + case unknown + case caller + case callee +} + +struct CallMessageModel { + let protocolType: CallProtocolType + let streamMediaType: CallStreamMediaType + let participantType: CallParticipantType + let participantRole: CallParticipantRole + let caller: String + let inviteeList: [String] + let duration: Int + let isExcludeFromHistory: Bool + + var isCaller: Bool { participantRole == .caller } + var isGroup: Bool { participantType == .group } +} + +enum CallMessageParser { + + private static let businessIDKey = "businessID" + private static let businessIDAVCall = "av_call" + private static let businessIDRTCCall = "rtc_call" + private static let businessIDTimeout = 1.0 + + private static let actionInvite = 1 + private static let actionCancelInvite = 2 + private static let actionAcceptInvite = 3 + private static let actionRejectInvite = 4 + private static let actionInviteTimeout = 5 + + private static let floatComparisonEpsilon = 0.000001 + + static func parse(_ message: MessageInfo) -> CallMessageModel? { + guard message.messageType == .custom, + case .custom(let payload) = message.messagePayload, + let customDataMap = parseJsonMap(payload.customData), + let signalDataMap = parseSignalDataMap(customDataMap) else { + return nil + } + + guard isKnownBusinessID(signalDataMap[businessIDKey]) else { + return nil + } + + guard let actionType = anyToInt(customDataMap["actionType"]) else { + return nil + } + let protocolType = parseProtocolType(actionType, signalDataMap) + guard protocolType != .unknown else { + return nil + } + + let caller = parseCaller(signalDataMap) + let excludeFromHistory = anyToBool(customDataMap["isExcludedFromLastMessage"]) + && anyToBool(customDataMap["isExcludedFromUnreadCount"]) + + return CallMessageModel( + protocolType: protocolType, + streamMediaType: parseStreamMediaType(protocolType, signalDataMap), + participantType: parseParticipantType(customDataMap), + participantRole: parseParticipantRole(caller), + caller: caller, + inviteeList: parseInviteeList(customDataMap), + duration: parseDuration(protocolType, signalDataMap), + isExcludeFromHistory: excludeFromHistory + ) + } + + + // MARK: - Parse Helpers + + private static func parseJsonMap(_ json: String?) -> [String: Any]? { + guard let json, !json.isEmpty, + let data = json.data(using: .utf8), + let map = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return map + } + + private static func parseSignalDataMap(_ customDataMap: [String: Any]) -> [String: Any]? { + if let dataString = customDataMap["data"] as? String { + return parseJsonMap(dataString) + } + return customDataMap["data"] as? [String: Any] + } + + private static func isKnownBusinessID(_ value: Any?) -> Bool { + if let string = value as? String { + return string == businessIDAVCall || string == businessIDRTCCall + } + if let number = value as? NSNumber { + return abs(number.doubleValue - businessIDTimeout) < floatComparisonEpsilon + } + return false + } + + private static func parseProtocolType(_ actionType: Int, _ signalDataMap: [String: Any]) -> CallProtocolType { + switch actionType { + case actionInvite: + if let data = signalDataMap["data"] as? [String: Any] { + switch data["cmd"] as? String { + case "switchToAudio": return .switchToAudio + case "hangup": return .hangup + case "videoCall", "audioCall": return .send + default: return .unknown + } + } + return signalDataMap["call_end"] != nil ? .hangup : .send + case actionCancelInvite: + return .cancel + case actionAcceptInvite: + let data = signalDataMap["data"] as? [String: Any] + return data?["cmd"] as? String == "switchToAudio" ? .switchToAudioConfirm : .accept + case actionRejectInvite: + return signalDataMap["line_busy"] != nil ? .lineBusy : .reject + case actionInviteTimeout: + return .timeout + default: + return .unknown + } + } + + private static func parseStreamMediaType(_ protocolType: CallProtocolType, _ signalDataMap: [String: Any]) -> CallStreamMediaType { + var type = CallStreamMediaType.unknown + if let callType = anyToInt(signalDataMap["call_type"]) { + switch callType { + case 1: type = .voice + case 2: type = .video + default: break + } + } + if protocolType == .send { + let data = signalDataMap["data"] as? [String: Any] + switch data?["cmd"] as? String { + case "audioCall": type = .voice + case "videoCall": type = .video + default: break + } + } else if protocolType == .switchToAudio || protocolType == .switchToAudioConfirm { + type = .video + } + return type + } + + private static func parseParticipantType(_ customDataMap: [String: Any]) -> CallParticipantType { + let groupID = customDataMap["groupID"] as? String ?? "" + return groupID.isEmpty ? .c2c : .group + } + + private static func parseCaller(_ signalDataMap: [String: Any]) -> String { + let data = signalDataMap["data"] as? [String: Any] + if let inviter = data?["inviter"] as? String, !inviter.isEmpty { + return inviter + } + return LoginStore.shared.state.value.loginUserInfo?.userID ?? "" + } + + private static func parseParticipantRole(_ caller: String) -> CallParticipantRole { + guard let loginUser = LoginStore.shared.state.value.loginUserInfo?.userID, + !loginUser.isEmpty else { + return .callee + } + return caller == loginUser ? .caller : .callee + } + + private static func parseInviteeList(_ customDataMap: [String: Any]) -> [String] { + guard let list = customDataMap["inviteeList"] as? [Any] else { return [] } + return list.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + private static func parseDuration(_ protocolType: CallProtocolType, _ signalDataMap: [String: Any]) -> Int { + guard protocolType == .hangup else { return 0 } + return anyToInt(signalDataMap["call_end"]) ?? 0 + } + + private static func anyToInt(_ value: Any?) -> Int? { + if let number = value as? NSNumber { return number.intValue } + if let string = value as? String { return Int(string) } + return nil + } + + private static func anyToBool(_ value: Any?) -> Bool { + if let bool = value as? Bool { return bool } + if let string = value as? String { return string.lowercased() == "true" } + if let number = value as? NSNumber { return number.intValue != 0 } + return false + } +} + +// MARK: - Display String(对齐 Android getCallMessageDisplayString) + +extension CallMessageModel { + func displayString(senderShowName: String) -> String { + if isGroup { + return groupDisplayString(senderShowName: senderShowName) + } + return c2cDisplayString() + } + + private func c2cDisplayString() -> String { + switch protocolType { + case .reject: + return LocalizedChatString(isCaller ? "CallRejectCaller" : "CallRejectCallee") + case .cancel: + return LocalizedChatString(isCaller ? "CallCancelCaller" : "CallCancelCallee") + case .hangup: + return String(format: LocalizedChatString("CallDurationFormat"), Self.formatDuration(duration)) + case .timeout: + return LocalizedChatString(isCaller ? "CallTimeoutCaller" : "CallTimeoutCallee") + case .lineBusy: + return LocalizedChatString(isCaller ? "CallLineBusyCaller" : "CallLineBusyCallee") + case .send: + return LocalizedChatString("CallStart") + case .accept: + return LocalizedChatString("CallAccept") + case .switchToAudio: + return LocalizedChatString("CallSwitchToAudio") + case .switchToAudioConfirm: + return LocalizedChatString("CallSwitchToAudioAccept") + default: + return LocalizedChatString("CallInvalidCommand") + } + } + + private func groupDisplayString(senderShowName: String) -> String { + switch protocolType { + case .send: + return String(format: LocalizedChatString("CallGroupSendFormat"), senderShowName) + case .cancel, .hangup: + return LocalizedChatString("CallGroupEnd") + case .timeout, .lineBusy: + let names = inviteeList.joined(separator: "、") + let suffix = protocolType == .lineBusy + ? LocalizedChatString("CallLineBusyCallee") + : LocalizedChatString("CallGroupNoAnswer") + return names.isEmpty ? suffix : "\(names) \(suffix)" + case .reject: + return String(format: LocalizedChatString("CallGroupRejectFormat"), senderShowName) + case .accept: + return String(format: LocalizedChatString("CallGroupAcceptFormat"), senderShowName) + case .switchToAudio: + return String(format: LocalizedChatString("CallGroupSwitchToAudioFormat"), senderShowName) + case .switchToAudioConfirm: + return String(format: LocalizedChatString("CallGroupConfirmSwitchToAudioFormat"), senderShowName) + default: + return LocalizedChatString("CallInvalidCommand") + } + } + + private static func formatDuration(_ seconds: Int) -> String { + return DateHelper.formatCallDuration(seconds) + } +} diff --git a/chat/uikit/components/common/ChatAvatarView.swift b/chat/uikit/components/common/ChatAvatarView.swift new file mode 100644 index 000000000..7d11d5e06 --- /dev/null +++ b/chat/uikit/components/common/ChatAvatarView.swift @@ -0,0 +1,117 @@ +import UIKit +import SnapKit +import Kingfisher + +public enum ChatAvatarSize { + case xxs, xs, s, m, l, xl, xxl + + public var size: CGFloat { + switch self { + case .xxs: return 16 + case .xs: return 24 + case .s: return 32 + case .m: return 40 + case .l: return 48 + case .xl: return 64 + case .xxl: return 96 + } + } + + public var placeholderFontSize: CGFloat { + switch self { + case .xxs: return 10 + case .xs: return 12 + case .s: return 14 + case .m: return 16 + case .l: return 18 + case .xl: return 28 + case .xxl: return 36 + } + } + + public var roundedRectCornerRadius: CGFloat { + switch self { + case .xxs, .xs, .s, .m: return 4 + case .l, .xl, .xxl: return 8 + } + } +} + +public final class ChatAvatarView: UIView { + private let imageView = UIImageView() + + private let textLabel = UILabel() + + // MARK: - Init + + public convenience init(size: ChatAvatarSize, isRound: Bool) { + let radius = isRound ? size.size / 2 : size.roundedRectCornerRadius + self.init(cornerRadius: radius, fontSize: size.placeholderFontSize) + } + + public init(cornerRadius: CGFloat = 4, fontSize: CGFloat = 16) { + super.init(frame: .zero) + backgroundColor = ChatUIKitTheme.colors.bgColorAvatar + layer.cornerRadius = cornerRadius + layer.masksToBounds = true + + imageView.contentMode = .scaleAspectFill + imageView.isHidden = true + textLabel.font = .systemFont(ofSize: fontSize, weight: .medium) + textLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + textLabel.textAlignment = .center + + addSubview(imageView) + addSubview(textLabel) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + textLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + public func configure(avatarURL: String?, fallbackName: String) { + imageView.kf.cancelDownloadTask() + let urlString = avatarURL ?? "" + if urlString.isEmpty { + showTextAvatar(name: fallbackName) + } else { + loadImageAvatar(urlString: urlString, fallbackName: fallbackName) + } + } + + func reset() { + imageView.kf.cancelDownloadTask() + imageView.image = nil + imageView.isHidden = true + textLabel.text = nil + textLabel.isHidden = false + } + + // MARK: - Private + + private func showTextAvatar(name: String) { + imageView.isHidden = true + imageView.image = nil + textLabel.isHidden = false + textLabel.text = name.first.map { String($0).uppercased() } ?? "" + } + + private func loadImageAvatar(urlString: String, fallbackName: String) { + textLabel.isHidden = true + imageView.isHidden = false + imageView.kf.setImage(with: URL(string: urlString)) { [weak self] result in + guard let self else { return } + if case .failure = result { + self.showTextAvatar(name: fallbackName) + } + } + } +} diff --git a/chat/uikit/components/common/ChatUIKitLayoutDirection.swift b/chat/uikit/components/common/ChatUIKitLayoutDirection.swift new file mode 100644 index 000000000..0d6b1bff5 --- /dev/null +++ b/chat/uikit/components/common/ChatUIKitLayoutDirection.swift @@ -0,0 +1,23 @@ +import UIKit + +enum ChatUIKitLayoutDirection { + private static let installOnce: Void = { + guard let originalMethod = class_getInstanceMethod(UIViewController.self, #selector(UIViewController.viewDidLoad)), + let swizzledMethod = class_getInstanceMethod(UIViewController.self, #selector(UIViewController.chatUIKitLayoutDirection_viewDidLoad)) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + static func install() { + _ = installOnce + } +} + +extension UIViewController { + @objc fileprivate dynamic func chatUIKitLayoutDirection_viewDidLoad() { + chatUIKitLayoutDirection_viewDidLoad() + guard Bundle(for: type(of: self)) == Bundle(for: AtomicXChatResources.self) else { return } + LanguageHelper.applyLayoutDirection(to: view) + } +} diff --git a/chat/uikit/components/common/ContactSearchBarView.swift b/chat/uikit/components/common/ContactSearchBarView.swift new file mode 100644 index 000000000..c84083e89 --- /dev/null +++ b/chat/uikit/components/common/ContactSearchBarView.swift @@ -0,0 +1,168 @@ +import UIKit +import SnapKit + +final internal class ContactSearchBarView: UIView { + var onQueryChange: ((String) -> Void)? + + var onSubmit: (() -> Void)? + + var currentText: String { + return textField.text ?? "" + } + + var placeholder: String = LocalizedChatString("Search") { + didSet { applyPlaceholder() } + } + + var containerInsets = UIEdgeInsets( + top: CGFloat(SpacingScheme.bubbleSpacing), + left: CGFloat(SpacingScheme.bubbleSpacing), + bottom: CGFloat(SpacingScheme.bubbleSpacing), + right: CGFloat(SpacingScheme.bubbleSpacing) + ) { + didSet { + inputContainer.snp.remakeConstraints { make in + make.edges.equalToSuperview().inset(containerInsets) + make.height.equalTo(Self.inputHeight) + } + } + } + + private static let inputHeight: CGFloat = 36 + + private static let inputCornerRadius: CGFloat = 10 + + private static let searchIconSize: CGFloat = 15 + + private static let searchIconLeading: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let textHorizontalPadding: CGFloat = 36 + + private static let clearButtonSize: CGFloat = 16 + + private static let clearButtonTrailing: CGFloat = 10 + + private static let debounceInterval: TimeInterval = 0.3 + + private let inputContainer = UIView() + + private let searchIconView = UIImageView() + + private let textField = UITextField() + + private let clearButton = UIButton(type: .custom) + + private var debounceWorkItem: DispatchWorkItem? + + private static let searchIconAspect: CGFloat = 29.6484 / 27.889 + + override init(frame: CGRect) { + super.init(frame: frame) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func hideKeyboard() { + textField.resignFirstResponder() + } + + private func constructViewHierarchy() { + addSubview(inputContainer) + inputContainer.addSubview(searchIconView) + inputContainer.addSubview(textField) + inputContainer.addSubview(clearButton) + } + + private func activateConstraints() { + inputContainer.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(containerInsets) + make.height.equalTo(Self.inputHeight) + } + searchIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.searchIconLeading) + make.centerY.equalToSuperview() + make.width.equalTo(Self.searchIconSize) + make.height.equalTo(Self.searchIconSize * Self.searchIconAspect) + } + textField.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: 0, + left: Self.textHorizontalPadding, + bottom: 0, + right: Self.textHorizontalPadding + )) + } + clearButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.clearButtonTrailing) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.clearButtonSize) + } + } + + private func bindInteraction() { + textField.delegate = self + textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged) + clearButton.addTarget(self, action: #selector(handleClearTapped), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + inputContainer.backgroundColor = colors.bgColorInput + inputContainer.layer.cornerRadius = Self.inputCornerRadius + + searchIconView.image = AtomicXChatResources.image(named:"contact_search") + searchIconView.tintColor = colors.textColorTertiary + searchIconView.contentMode = .scaleAspectFit + + textField.font = FontScheme.caption2Regular + textField.textColor = colors.textColorPrimary + applyPlaceholder() + textField.clearButtonMode = .never + textField.returnKeyType = .search + + clearButton.setImage(AtomicXChatResources.image(named:"contact_search_clear"), for: .normal) + clearButton.tintColor = colors.textColorPrimary + clearButton.isHidden = true + } + + @objc private func textDidChange() { + let text = textField.text ?? "" + clearButton.isHidden = text.isEmpty + debounceWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.onQueryChange?(text) + } + debounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.debounceInterval, execute: workItem) + } + + @objc private func handleClearTapped() { + textField.text = "" + clearButton.isHidden = true + debounceWorkItem?.cancel() + onQueryChange?("") + } + + private func applyPlaceholder() { + textField.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [.foregroundColor: ChatUIKitTheme.colors.textColorTertiary] + ) + } +} + +extension ContactSearchBarView: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + onSubmit?() + return true + } +} diff --git a/chat/uikit/components/common/CustomMessageSummaryRegistry.swift b/chat/uikit/components/common/CustomMessageSummaryRegistry.swift new file mode 100644 index 000000000..a90c69557 --- /dev/null +++ b/chat/uikit/components/common/CustomMessageSummaryRegistry.swift @@ -0,0 +1,23 @@ +import Foundation +import AtomicXCore + +final class CustomMessageSummaryRegistry { + static let shared = CustomMessageSummaryRegistry() + + private var providers: [String: (CustomMessagePayload) -> String?] = [:] + + private init() {} + + func register(businessID: String, summaryProvider: @escaping (CustomMessagePayload) -> String?) { + providers[businessID] = summaryProvider + } + + func summary(for payload: CustomMessagePayload) -> String? { + guard let data = payload.customData.data(using: .utf8), + let customInfo = ChatUtil.jsonData2Dictionary(jsonData: data), + let businessID = customInfo["businessID"] as? String else { + return nil + } + return providers[businessID]?(payload) + } +} diff --git a/chat/uikit/components/common/MessageInfo+Content.swift b/chat/uikit/components/common/MessageInfo+Content.swift new file mode 100644 index 000000000..839b93e87 --- /dev/null +++ b/chat/uikit/components/common/MessageInfo+Content.swift @@ -0,0 +1,34 @@ +import AtomicXCore +import Foundation +import UIKit + +extension MessageInfo { + var contentText: String? { + if case .text(let payload) = messagePayload { + return payload.text + } + return nil + } + + var imageInfo: (image: UIImage?, size: CGSize?) { + guard case .image(let payload) = messagePayload else { + return (nil, nil) + } + let size = CGSize(width: payload.originalImageWidth, height: payload.originalImageHeight) + if let imagePath = payload.originalImagePath, let image = UIImage(contentsOfFile: imagePath) { + return (image, size) + } + return (nil, size) + } + + var videoInfo: (snapshot: UIImage?, duration: Int?) { + guard case .video(let payload) = messagePayload else { + return (nil, nil) + } + if let snapshotPath = payload.videoSnapshotPath { + return (UIImage(contentsOfFile: snapshotPath), payload.videoDuration) + } else { + return (nil, payload.videoDuration) + } + } +} diff --git a/chat/uikit/components/common/MessageListHelper.swift b/chat/uikit/components/common/MessageListHelper.swift new file mode 100644 index 000000000..684970199 --- /dev/null +++ b/chat/uikit/components/common/MessageListHelper.swift @@ -0,0 +1,283 @@ +import AtomicXCore +import Foundation + +class MessageListHelper { + private static let mergedTitleMaxSenderCount = 2 + + private static let mergedAbstractMaxCount = 4 + + static func getGroupTipsDisplayString(_ groupTips: [GroupTipsInfo]?) -> String { + guard let groupTips = groupTips, !groupTips.isEmpty else { + return "" + } + + let parts = groupTips.compactMap { info -> String? in + let result = getGroupTipDisplayString(info) + return result.isEmpty ? nil : result + } + + return parts.isEmpty ? LocalizedChatString("unknown") : parts.joined(separator: LocalizedChatString("MessageTipsSeparator")) + } + + static func getGroupTipDisplayString(_ groupTip: GroupTipsInfo) -> String { + switch groupTip { + case .unknown: + return "" + + case .joinGroup(let joinMember): + return String(format: LocalizedChatString("MessageTipsJoinGroupFormat"), displayName(joinMember)) + + case .inviteToGroup(let inviter, let invitees): + let inviteesShowName = invitees.map { displayName($0) }.joined(separator: ", ") + return String(format: LocalizedChatString("MessageTipsInviteJoinGroupFormat"), displayName(inviter), inviteesShowName) + + case .quitGroup(let quitMember): + return String(format: LocalizedChatString("MessageTipsLeaveGroupFormat"), displayName(quitMember)) + + case .kickedFromGroup(let opUser, let kickedMembers): + let kickedMembersShowName = kickedMembers.map { displayName($0) }.joined(separator: ", ") + return String(format: LocalizedChatString("MessageTipsKickoffGroupFormat"), displayName(opUser), kickedMembersShowName) + + case .setGroupAdmin(_, let setAdminMembers): + let setAdminMembersShowName = setAdminMembers.map { displayName($0) }.joined(separator: ", ") + return String(format: LocalizedChatString("MessageTipsSettAdminFormat"), setAdminMembersShowName) + + case .cancelGroupAdmin(_, let cancelAdminMembers): + let cancelAdminMembersShowName = cancelAdminMembers.map { displayName($0) }.joined(separator: ", ") + return String(format: LocalizedChatString("MessageTipsCancelAdminFormat"), cancelAdminMembersShowName) + + case .muteGroupMember(_, let isSelfMuted, let mutedGroupMembers, let muteTime): + let mutedGroupMembersShowName = mutedGroupMembers.map { displayName($0) }.joined(separator: ", ") + let actualShowName = isSelfMuted ? LocalizedChatString("You") : mutedGroupMembersShowName + return "\(actualShowName) \(muteTime == 0 ? LocalizedChatString("MessageTipsUnmute") : LocalizedChatString("MessageTipsMute"))" + + case .pinGroupMessage(let opUser): + return String(format: LocalizedChatString("MessageTipsGroupPinMessage"), displayName(opUser)) + + case .unpinGroupMessage(let opUser): + return String(format: LocalizedChatString("MessageTipsGroupUnPinMessage"), displayName(opUser)) + + case .changeGroupName(let opUser, let groupName): + return String(format: LocalizedChatString("MessageTipsEditGroupNameFormat"), displayName(opUser), groupName) + + case .changeGroupIntroduction(let opUser, let groupIntroduction): + return String(format: LocalizedChatString("MessageTipsEditGroupIntroFormat"), displayName(opUser), groupIntroduction) + + case .changeGroupNotification(let opUser, let groupNotification): + let format = groupNotification.isEmpty ? LocalizedChatString("MessageTipsDeleteGroupAnnounceFormat") : LocalizedChatString("MessageTipsEditGroupAnnounceFormat") + return String(format: format, displayName(opUser), groupNotification) + + case .changeGroupAvatar(let opUser, _): + return String(format: LocalizedChatString("MessageTipsEditGroupAvatarFormat"), displayName(opUser)) + + case .changeGroupOwner(let opUser, let groupOwner): + return String(format: LocalizedChatString("MessageTipsEditGroupOwnerFormat"), displayName(opUser), groupOwner) + + case .changeGroupMuteAll(let opUser, let isMuteAll): + let format = isMuteAll ? LocalizedChatString("SetShutupAllFormatString") : LocalizedChatString("CancelShutupAllFormatString") + return String(format: format, displayName(opUser)) + + case .changeJoinGroupApproval(let opUser, let groupJoinOption): + var desc = "" + switch groupJoinOption { + case .forbid: + desc = LocalizedChatString("GroupProfileJoinDisable") + case .auth: + desc = LocalizedChatString("GroupProfileAdminApprove") + case .any: + desc = LocalizedChatString("GroupProfileAutoApproval") + } + return String(format: LocalizedChatString("MessageTipsEditGroupAddOptFormat"), displayName(opUser), desc) + + case .changeInviteToGroupApproval(let opUser, let groupInviteOption): + var desc = "" + switch groupInviteOption { + case .forbid: + desc = LocalizedChatString("GroupProfileInviteDisable") + case .auth: + desc = LocalizedChatString("GroupProfileAdminApprove") + case .any: + desc = LocalizedChatString("GroupProfileAutoApproval") + } + return String(format: LocalizedChatString("MessageTipsEditGroupInviteOptFormat"), displayName(opUser), desc) + } + } + + static func getMessageAbstract(_ messageInfo: MessageInfo?, showMergedTitle: Bool = false) -> String { + guard let messageInfo = messageInfo else { return "" } + + if messageInfo.status == .revoked { + if messageInfo.isSentBySelf { + return LocalizedChatString("MessageTipsYouRecallMessage") + } else if messageInfo.conversationType == .c2c { + return LocalizedChatString("MessageTipsOthersRecallMessage") + } else { + return String(format: LocalizedChatString("MessageTipsRecallMessageFormat"), messageInfo.from.userID) + } + } + + switch messageInfo.messageType { + case .text: + if case .text(let payload) = messageInfo.messagePayload { + return payload.text + } + return "" + + case .image: + return LocalizedChatString("MessageTypeImage") + + case .audio: + if case .audio(let payload) = messageInfo.messagePayload { + let voiceLabel = LocalizedChatString("MessageTypeVoice") + let duration = payload.audioDuration + if duration > 0 { + return "\(voiceLabel) \(duration)\"" + } + return voiceLabel + } + return LocalizedChatString("MessageTypeVoice") + + case .file: + return LocalizedChatString("MessageTypeFile") + + case .video: + return LocalizedChatString("MessageTypeVideo") + + case .face: + return LocalizedChatString("MessageTypeAnimateEmoji") + + case .custom: + + if let callModel = CallMessageParser.parse(messageInfo) { + return callModel.displayString(senderShowName: Self.senderShowName(of: messageInfo)) + } + if case .custom(let payload) = messageInfo.messagePayload, + let data = payload.customData.data(using: .utf8), + let customInfo = ChatUtil.jsonData2Dictionary(jsonData: data), + let businessID = customInfo["businessID"] as? String, + businessID == "group_create" + { + let sender = customInfo["opUser"] as? String ?? "" + let cmd = customInfo["cmd"] as? Int ?? 0 + + return String(format: cmd == 1 ? LocalizedChatString("TUICommunityCreateTipsMessage") : LocalizedChatString("TUIGroupCreateTipsMessage"), sender) + } + if case .custom(let payload) = messageInfo.messagePayload, + let summary = CustomMessageSummaryRegistry.shared.summary(for: payload), + !summary.isEmpty + { + return summary + } + return LocalizedChatString("MessageUnsupportedType") + + case .tips: + if case .tips(let payload) = messageInfo.messagePayload { + return getGroupTipsDisplayString(payload.groupTips) + } + return "" + + case .merged: + if showMergedTitle, case .merged(let payload) = messageInfo.messagePayload, !payload.title.isEmpty { + let title = payload.title + return title + } + return LocalizedChatString("MessageTypeMergedHistory") + + default: + return "" + } + } + + static func shouldShowReadReceipt(message: MessageInfo, isInMergedDetailView: Bool = false) -> Bool { + return !isInMergedDetailView && + AppBuilderConfig.shared.enableReadReceipt && + message.isSentBySelf && + message.needReadReceipt && + message.status == .sendSuccess && + message.messageType != .tips + } + + + // MARK: - Message Forwarding + + static func getMessageAbstractForForward(_ message: MessageInfo) -> String { + let senderName: String + if let nickname = message.from.nickname, !nickname.isEmpty { + senderName = nickname + } else { + senderName = message.from.userID + } + let content = getMessageAbstract(message) + return alignEmojiString(userName: senderName, text: content) + } + + static func generateMergedTitle(messages: [MessageInfo], conversationID: String) -> String { + + let isGroupChat = conversationID.hasPrefix("group_") + + if isGroupChat { + + return LocalizedChatString("RelayGroupChatHistory") + } else { + + var senderNames: [String] = [] + var seenSenders: Set = [] + + for message in messages { + let sender = message.from.userID + if !seenSenders.contains(sender) { + seenSenders.insert(sender) + + let name = message.from.nickname ?? sender + senderNames.append(name) + } + + if senderNames.count >= mergedTitleMaxSenderCount { + break + } + } + + if senderNames.count == mergedTitleMaxSenderCount { + + return String(format: LocalizedChatString("RelayChatHistoryForSomebodyFormat"), senderNames[0], senderNames[1]) + } else if senderNames.count == 1 { + + return String(format: LocalizedChatString("RelayC2CChatHistoryFormat"), senderNames[0]) + } else { + + return LocalizedChatString("RelayChatHistory") + } + } + } + + static func generateAbstractList(messages: [MessageInfo]) -> [String] { + return messages.prefix(mergedAbstractMaxCount).map { message in + getMessageAbstractForForward(message) + } + } + + private static func alignEmojiString(userName: String, text: String) -> String { + return "\(userName): \(text)" + } + + private static func displayName(_ member: GroupMember) -> String { + if let nameCard = member.nameCard, !nameCard.isEmpty { + return nameCard + } + if let remark = member.friendRemark, !remark.isEmpty { + return remark + } + if let nickname = member.nickname, !nickname.isEmpty { + return nickname + } + return member.userID + } + + static func senderShowName(of message: MessageInfo) -> String { + let sender = message.from + if let nameCard = sender.nameCard, !nameCard.isEmpty { return nameCard } + if let remark = sender.friendRemark, !remark.isEmpty { return remark } + if let nickname = sender.nickname, !nickname.isEmpty { return nickname } + return sender.userID + } +} diff --git a/chat/uikit/components/common/RTCBaseView.swift b/chat/uikit/components/common/RTCBaseView.swift new file mode 100644 index 000000000..b12ca7d4b --- /dev/null +++ b/chat/uikit/components/common/RTCBaseView.swift @@ -0,0 +1,37 @@ +import UIKit + +open class RTCBaseView: UIView { + private var isViewReady = false + + public override init(frame: CGRect) { + super.init(frame: frame) + } + + @available(*, unavailable, message: "Loading this view from a nib is unsupported") + + public required init?(coder aDecoder: NSCoder) { + fatalError("Loading this view from a nib is unsupported") + } + + open override func didMoveToWindow() { + super.didMoveToWindow() + guard !isViewReady else { return } + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + isViewReady = true + } + + open func constructViewHierarchy() { + assertionFailure("RTCBaseView constructViewHierarchy function can not be called") + } + + open func activateConstraints() { + assertionFailure("RTCBaseView activateConstraints function can not be called") + } + + open func bindInteraction() {} + + open func setupViewStyle() {} +} diff --git a/chat/uikit/components/common/SelectionCheckBox.swift b/chat/uikit/components/common/SelectionCheckBox.swift new file mode 100644 index 000000000..893a85900 --- /dev/null +++ b/chat/uikit/components/common/SelectionCheckBox.swift @@ -0,0 +1,76 @@ +import UIKit + +final class SelectionCheckBox: UIView { + private static let iconRadiusRatio: CGFloat = 0.375 + + private static let checkmarkHorizontalRatio: CGFloat = 0.5 + + private static let checkmarkJointXRatio: CGFloat = 0.1 + + private static let checkmarkVerticalRatio: CGFloat = 0.35 + + private static let strokeLineWidth: CGFloat = 1.5 + + private static let crossRadiusRatio: CGFloat = 0.4 + + var isChecked: Bool = false { + didSet { setNeedsDisplay() } + } + + var isLocked: Bool = false { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + let colors = ChatUIKitTheme.colors + let centerX = bounds.width / 2 + let centerY = bounds.height / 2 + let radius = bounds.width / 2 + let iconRadius = bounds.width * Self.iconRadiusRatio + + if isChecked { + (isLocked ? colors.textColorLinkDisabled : colors.textColorLink).setFill() + UIBezierPath(ovalIn: bounds).fill() + + let checkPath = UIBezierPath() + checkPath.move(to: CGPoint(x: centerX - iconRadius * Self.checkmarkHorizontalRatio, y: centerY)) + checkPath.addLine(to: CGPoint(x: centerX - iconRadius * Self.checkmarkJointXRatio, y: centerY + iconRadius * Self.checkmarkVerticalRatio)) + checkPath.addLine(to: CGPoint(x: centerX + iconRadius * Self.checkmarkHorizontalRatio, y: centerY - iconRadius * Self.checkmarkVerticalRatio)) + checkPath.lineWidth = Self.strokeLineWidth + checkPath.lineCapStyle = .round + checkPath.lineJoinStyle = .round + (isLocked ? colors.textColorButtonDisabled : colors.textColorButton).setStroke() + checkPath.stroke() + } else { + let strokeWidth: CGFloat = 1 + let ringRect = bounds.insetBy(dx: strokeWidth / 2, dy: strokeWidth / 2) + let ringPath = UIBezierPath(ovalIn: ringRect) + ringPath.lineWidth = strokeWidth + colors.scrollbarColorDefault.setStroke() + ringPath.stroke() + + if isLocked { + let crossRadius = iconRadius * Self.crossRadiusRatio + let crossPath = UIBezierPath() + crossPath.move(to: CGPoint(x: centerX - crossRadius, y: centerY - crossRadius)) + crossPath.addLine(to: CGPoint(x: centerX + crossRadius, y: centerY + crossRadius)) + crossPath.move(to: CGPoint(x: centerX + crossRadius, y: centerY - crossRadius)) + crossPath.addLine(to: CGPoint(x: centerX - crossRadius, y: centerY + crossRadius)) + crossPath.lineWidth = Self.strokeLineWidth + crossPath.lineCapStyle = .round + colors.textColorButtonDisabled.setStroke() + crossPath.stroke() + } + } + } +} diff --git a/chat/uikit/components/common/SystemNavigationBarPage.swift b/chat/uikit/components/common/SystemNavigationBarPage.swift new file mode 100644 index 000000000..21b4d0c9a --- /dev/null +++ b/chat/uikit/components/common/SystemNavigationBarPage.swift @@ -0,0 +1,3 @@ +import UIKit + +public protocol SystemNavigationBarPage {} diff --git a/chat/uikit/components/common/UIViewController+ClosePage.swift b/chat/uikit/components/common/UIViewController+ClosePage.swift new file mode 100644 index 000000000..eed26f734 --- /dev/null +++ b/chat/uikit/components/common/UIViewController+ClosePage.swift @@ -0,0 +1,13 @@ +import UIKit + +extension UIViewController { + + func closePage(animated: Bool = true, completion: (() -> Void)? = nil) { + if let navigationController = navigationController, navigationController.viewControllers.count > 1 { + navigationController.popViewController(animated: animated) + completion?() + } else { + dismiss(animated: animated, completion: completion) + } + } +} diff --git a/chat/uikit/components/common/UserPickerItem.swift b/chat/uikit/components/common/UserPickerItem.swift new file mode 100644 index 000000000..97cbc313b --- /dev/null +++ b/chat/uikit/components/common/UserPickerItem.swift @@ -0,0 +1,23 @@ +import UIKit + +struct UserPickerItem: Identifiable { + public var id: String { userID } + public let userID: String + public let avatarURL: String? + public let title: String + let subtitle: String? + let isDisabled: Bool + init( + userID: String, + avatarURL: String? = nil, + title: String, + subtitle: String? = nil, + isDisabled: Bool = false + ) { + self.userID = userID + self.avatarURL = avatarURL + self.title = title + self.subtitle = subtitle + self.isDisabled = isDisabled + } +} diff --git a/chat/uikit/components/common/UserPickerView.swift b/chat/uikit/components/common/UserPickerView.swift new file mode 100644 index 000000000..d10c0014d --- /dev/null +++ b/chat/uikit/components/common/UserPickerView.swift @@ -0,0 +1,347 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class UserPickerView: UIView { + var onSelectionChanged: (([UserPickerItem]) -> Void)? + + var onReachEnd: (() -> Void)? + + var onUserInteraction: (() -> Void)? + + var selectedItems: [UserPickerItem] { + selectedOrder.compactMap { allKnownItems[$0] } + } + + private static let rowHeight: CGFloat = 60 + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let indexBarReserved: CGFloat = 26 + + private static let sectionHeaderHeight: CGFloat = 32 + + private static let indexBarEndPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let indexBarVerticalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let centerBubbleSize: CGFloat = 80 + + private static let centerBubbleDismissDelay: TimeInterval = 0.15 + + private static let centerBubbleBackgroundAlpha: CGFloat = 0.9 + + private var userList: [UserPickerItem] = [] + + private var maxCount: Int = 1 + + private var preselectedIDs: Set = [] + + private var sectionedItems: [(letter: String, items: [UserPickerItem])] = [] + + private var allKnownItems: [String: UserPickerItem] = [:] + + private var selectedIDs: Set = [] + + private var selectedOrder: [String] = [] + + private var bubbleDismissWorkItem: DispatchWorkItem? + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = Self.rowHeight + table.keyboardDismissMode = .onDrag + table.register(UserPickerRowCell.self, forCellReuseIdentifier: UserPickerRowCell.reuseIdentifier) + table.dataSource = self + table.delegate = self + return table + }() + + private let indexBar = AtomicIndexBarView() + + private let centerBubbleLabel = UILabel() + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindIndexBar() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(userList: [UserPickerItem], maxCount: Int = 1, defaultSelectedItems: Set = []) { + self.userList = userList + self.maxCount = maxCount + self.preselectedIDs = defaultSelectedItems + userList.forEach { allKnownItems[$0.userID] = $0 } + rebuildSections() + tableView.reloadData() + } + + func setInitialSelectedIDs(_ ids: Set) { + let editableIDs = ids.filter { allKnownItems[$0] != nil && !preselectedIDs.contains($0) } + selectedIDs = Set(editableIDs) + selectedOrder = userList.map(\.userID).filter { selectedIDs.contains($0) } + tableView.reloadData() + } + + private func constructViewHierarchy() { + addSubview(tableView) + addSubview(indexBar) + addSubview(centerBubbleLabel) + } + + private func activateConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + indexBar.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.indexBarEndPadding) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(Self.indexBarVerticalPadding) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.indexBarVerticalPadding) + } + centerBubbleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(Self.centerBubbleSize) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + tableView.backgroundColor = colors.bgColorOperate + + indexBar.isHidden = true + + centerBubbleLabel.font = FontScheme.body1Bold + centerBubbleLabel.textColor = colors.textColorPrimary + centerBubbleLabel.textAlignment = .center + centerBubbleLabel.backgroundColor = colors.bgColorBubbleReciprocal.withAlphaComponent(Self.centerBubbleBackgroundAlpha) + centerBubbleLabel.layer.cornerRadius = Self.centerBubbleSize / 2 + centerBubbleLabel.layer.masksToBounds = true + centerBubbleLabel.isHidden = true + } + + private func bindIndexBar() { + indexBar.onLetterSelected = { [weak self] letter in + guard let self = self else { return } + if let section = self.sectionedItems.firstIndex(where: { $0.letter == letter }), + !self.sectionedItems[section].items.isEmpty { + self.tableView.scrollToRow(at: IndexPath(row: 0, section: section), at: .top, animated: false) + } + self.indexBar.setCurrentLetter(letter) + self.showCenterBubble(letter) + } + indexBar.onDragStart = { [weak self] in + self?.bubbleDismissWorkItem?.cancel() + } + indexBar.onDragEnd = { [weak self] in + guard let self = self else { return } + let workItem = DispatchWorkItem { [weak self] in + self?.centerBubbleLabel.isHidden = true + } + self.bubbleDismissWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.centerBubbleDismissDelay, execute: workItem) + } + } + + private func showCenterBubble(_ letter: String) { + bubbleDismissWorkItem?.cancel() + centerBubbleLabel.text = letter + centerBubbleLabel.isHidden = false + } + + private func rebuildSections() { + let azItems = userList.map { + AZOrderedListItem(userID: $0.userID, avatarURL: $0.avatarURL, title: $0.title, extraData: $0) + } + let grouped = AZOrderedListSorter.group(azItems) + sectionedItems = grouped.sections.map { section in + (section.sectionTitle, section.items.compactMap { $0.extraData as? UserPickerItem }) + } + let letters = sectionedItems.map { $0.letter } + indexBar.setLetters(letters) + indexBar.isHidden = letters.isEmpty + } + + private func isSelected(_ item: UserPickerItem) -> Bool { + selectedIDs.contains(item.userID) || preselectedIDs.contains(item.userID) + } + + private func isLocked(_ item: UserPickerItem) -> Bool { + item.isDisabled || preselectedIDs.contains(item.userID) + } + + private func toggle(_ item: UserPickerItem) { + guard !isLocked(item) else { return } + let willSelect = !selectedIDs.contains(item.userID) + if maxCount == 1 { + selectedIDs.removeAll() + selectedOrder.removeAll() + if willSelect { + selectedIDs.insert(item.userID) + selectedOrder.append(item.userID) + } + } else { + if willSelect { + selectedIDs.insert(item.userID) + selectedOrder.append(item.userID) + } else { + selectedIDs.remove(item.userID) + selectedOrder.removeAll { $0 == item.userID } + } + } + tableView.reloadData() + onSelectionChanged?(selectedItems) + } +} + +// MARK: - UITableViewDataSource / Delegate + +extension UserPickerView: UITableViewDataSource, UITableViewDelegate { + + func numberOfSections(in tableView: UITableView) -> Int { + sectionedItems.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sectionedItems[section].items.count + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + Self.sectionHeaderHeight + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let header = UIView() + header.backgroundColor = ChatUIKitTheme.colors.bgColorDialog + let label = UILabel() + label.text = sectionedItems[section].letter + label.font = FontScheme.caption2Bold + label.textColor = ChatUIKitTheme.colors.textColorPrimary + header.addSubview(label) + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + } + return header + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: UserPickerRowCell.reuseIdentifier, + for: indexPath + ) as? UserPickerRowCell else { + return UITableViewCell() + } + let item = sectionedItems[indexPath.section].items[indexPath.row] + cell.configure(item: item, isSelected: isSelected(item), isLocked: isLocked(item)) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + onUserInteraction?() + toggle(sectionedItems[indexPath.section].items[indexPath.row]) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + onUserInteraction?() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let firstVisible = tableView.indexPathsForVisibleRows?.first, + sectionedItems.indices.contains(firstVisible.section) else { return } + indexBar.setCurrentLetter(sectionedItems[firstVisible.section].letter) + + guard let lastSection = sectionedItems.indices.last, + let lastRowCount = sectionedItems.last?.items.count, + lastRowCount > 0, + let lastVisible = tableView.indexPathsForVisibleRows?.last, + lastVisible.section == lastSection, + lastVisible.row >= lastRowCount - 1 else { return } + onReachEnd?() + } +} + +// MARK: - UserPickerRowCell + +private final class UserPickerRowCell: UITableViewCell { + static let reuseIdentifier = "UserPickerRowCell" + + private static let checkboxSize: CGFloat = 16 + + private static let checkboxAvatarSpacing: CGFloat = 10 + + private static let avatarTextSpacing: CGFloat = 12 + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let indexBarReserved: CGFloat = 26 + + private let checkboxView = SelectionCheckBox() + + private let avatarView = ChatAvatarView(size: .m, isRound: false) + + private let nameLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(item: UserPickerItem, isSelected: Bool, isLocked: Bool) { + let colors = ChatUIKitTheme.colors + checkboxView.isChecked = isSelected + checkboxView.isLocked = isLocked + nameLabel.text = item.title + nameLabel.textColor = isLocked ? colors.textColorSecondary : colors.textColorPrimary + avatarView.configure(avatarURL: item.avatarURL, fallbackName: item.title) + } + + private func constructViewHierarchy() { + contentView.addSubview(checkboxView) + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + } + + private func activateConstraints() { + checkboxView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.checkboxSize) + } + avatarView.snp.makeConstraints { make in + make.leading.equalTo(checkboxView.snp.trailing).offset(Self.checkboxAvatarSpacing) + make.centerY.equalToSuperview() + make.width.height.equalTo(ChatAvatarSize.m.size) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarTextSpacing) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().offset(-Self.indexBarReserved) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + nameLabel.font = FontScheme.body4Regular + nameLabel.numberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + } +} diff --git a/chat/uikit/components/common/azorderedlist/AZOrderedListCell.swift b/chat/uikit/components/common/azorderedlist/AZOrderedListCell.swift new file mode 100644 index 000000000..cbb271390 --- /dev/null +++ b/chat/uikit/components/common/azorderedlist/AZOrderedListCell.swift @@ -0,0 +1,141 @@ +import UIKit +import SnapKit +import Kingfisher + +final class AZOrderedListCell: UITableViewCell { + static let reuseIdentifier = "AZOrderedListCell" + + var titleFontSize: CGFloat = 14 { + didSet { titleLabel.font = .systemFont(ofSize: titleFontSize) } + } + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let avatarSize: CGFloat = 40 + + private static let avatarCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let avatarTextSpacing: CGFloat = 12 + + private static let dividerHeight: CGFloat = 0.5 + + private static let dividerLeadingInset: CGFloat = 68 + + private let avatarContainer = UIView() + + private let avatarImageView = UIImageView() + + private let avatarTextLabel = UILabel() + + private let titleLabel = UILabel() + + private let dividerView = UIView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with item: AZOrderedListItem, showsDivider: Bool = false) { + let displayName = item.title ?? item.userID + titleLabel.text = displayName + titleLabel.font = .systemFont(ofSize: titleFontSize) + dividerView.isHidden = !showsDivider + let urlString = item.avatarURL ?? "" + if urlString.isEmpty { + showTextAvatar(name: displayName) + } else { + loadImageAvatar(urlString: urlString, fallbackName: displayName) + } + } + + override func prepareForReuse() { + super.prepareForReuse() + avatarImageView.kf.cancelDownloadTask() + avatarImageView.image = nil + titleLabel.text = nil + dividerView.isHidden = true + } + + private func constructViewHierarchy() { + selectionStyle = .none + contentView.addSubview(avatarContainer) + avatarContainer.addSubview(avatarImageView) + avatarContainer.addSubview(avatarTextLabel) + contentView.addSubview(titleLabel) + contentView.addSubview(dividerView) + } + + private func activateConstraints() { + avatarContainer.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.avatarSize) + } + avatarImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + avatarTextLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarContainer.snp.trailing).offset(Self.avatarTextSpacing) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + dividerView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.dividerLeadingInset) + make.trailing.bottom.equalToSuperview() + make.height.equalTo(Self.dividerHeight) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + + avatarContainer.backgroundColor = colors.bgColorAvatar + avatarContainer.layer.cornerRadius = Self.avatarCornerRadius + avatarContainer.layer.masksToBounds = true + + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.isHidden = true + + avatarTextLabel.font = FontScheme.caption1Medium + avatarTextLabel.textColor = colors.textColorPrimary + avatarTextLabel.textAlignment = .center + + titleLabel.font = .systemFont(ofSize: titleFontSize) + titleLabel.textColor = colors.textColorPrimary + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + + dividerView.backgroundColor = colors.strokeColorSecondary + dividerView.isHidden = true + } + + private func showTextAvatar(name: String) { + avatarImageView.isHidden = true + avatarImageView.image = nil + avatarTextLabel.isHidden = false + avatarTextLabel.text = name.first.map { String($0).uppercased() } ?? "" + } + + private func loadImageAvatar(urlString: String, fallbackName: String) { + avatarTextLabel.isHidden = true + avatarImageView.isHidden = false + avatarImageView.kf.setImage(with: URL(string: urlString)) { [weak self] result in + guard let self else { return } + if case .failure = result { + self.showTextAvatar(name: fallbackName) + } + } + } +} diff --git a/chat/uikit/components/common/azorderedlist/AZOrderedListSorter.swift b/chat/uikit/components/common/azorderedlist/AZOrderedListSorter.swift new file mode 100644 index 000000000..5a0ef2bee --- /dev/null +++ b/chat/uikit/components/common/azorderedlist/AZOrderedListSorter.swift @@ -0,0 +1,95 @@ +import Foundation + +struct AZGroupedSection { + let sectionTitle: String + let items: [AZOrderedListItem] +} + +enum AZOrderedListSorter { + private static var pinyinCache: [Character: String] = [:] + private static let maxCacheSize = 1000 + private static let cjkUnifiedIdeographRangeStart: UInt32 = 0x4E00 + private static let cjkUnifiedIdeographRangeEnd: UInt32 = 0x9FFF + private static let pinyinFirstLetters: [Character] = Array( + "ydkqsxnwzssxjbymgcczqpssqbycdscdqldylybssjgyqzjjfgcclzznwdwzjljpfyynnjjtmynzwzhflzppqhgccyynmjqyxxgdnnsnsjnjnsnnmlnrxyfsngnnnnqzggllyjlnyzssecykyyhqwjssggyxyqyjtwktjhychmnxjtlhjyqbyxdldwrrjnwysrldzjpcbzjjbrcfslnczstzfxxchtrqggddlyccssymmrjcyqzpwwjjyfcrwfdfzqpyddwyxkyjawjffxjbcftzyhhycyswccyxsclcxxwzcxnbgnnxbxlzsqsbsjpysazdhmdzbqbscwdzzyytzhbtsyyfzgntnxjywqnknphhlxgybfmjnbjhhgqtjcysxstkzglyckglysmzxyalmeldccxgzyrjxjzlnjzcqkcnnjwhjczccqljststbnhbtyxceqxkkwjyflzqlyhjxspsfxlmpbysxxxytccnylllsjxfhjxpjbtffyabyxbcczbzyclwlczggbtssmdtjcxpthyqtgjjxcjfzkjzjqnlzwlslhdzbwjncjzyzsqnycqynzcjjwybrtwpyftwexcskdzctbyhyzqyyjxzcfbzzmjyxxsdczottbzljwfckscsxfyrlrygmbdthjxsqjccsbxyytswfbjdztnbcnzlcyzzpsacyzzsqqcshzqydxlbpjllmqxqydzxsqjtzpxlcglqdcwzfhctdjjsfxjejjtlbgxsxjmyjjqpfzasyjnsydjxkjcdjsznbartcclnjqmwnqnclllkbdbzzsyhqcltwlccrshllzntylnewyzyxczxxgdkdmtcedejtsyyssdqdfmxdbjlkrwnqlybglxnlgtgxbqjdznyjsjyjcjmrnymgrcjczgjmzmgxmmryxkjnymsgmzzymknfxmbdtgfbhcjhkylpfmdxlxjjsmsqgzsjlqdldgjycalcmzcsdjllnxdjffffjcnfnnffpfkhkgdpqxktacjdhhzdddrrcfqyjkqccwjdxhwjlyllzgcfcqjsmlzpbjjblsbcjggdckkdezsqcckjgcgkdjtjllzycxklqccgjcltfpcqczgwbjdqyzjjbyjhsjddwgfsjgzkcjctllfspkjgqjhzzljplgjgjjthjjyjzccmlzlyqbgjwmljkxzdznjqsyzmljlljkywxmkjlhskjhbmclyymkxjqlbmllkmdxxkwyxwslmlpsjqqjqxyqfjtjdxmxxllcrqbsyjbgwynnggbcnxpjtgpapfgdjqbhbncfjyzjkjkhxqfgqckfhygkhdkllsdjqxpqyaybnqsxqnszswhbsxwhxwbzzxdmndjbsbkbbzklylxgwxjjwaqzmywsjqlsjxxjqwjeqxnchetlzalyyyszzpnkyzcptlshtzcfycyxyljsdcjqagyslcllyyysslqqqnldxzsccscadycjysfsgbfrsszqsbxjpsjysdrckgjlgtkzjzbdktcsyqpyhstcldjnhmymcgxyzhjdctmhltxzhylamoxyjcltyfbqqjpfbdfehthsqhzywwcncxcdwhowgyjlegmdqcwgfjhcsntmydolbygnqwesqpwnmlrydzszzlyqpzgcwxhnxpyxshmdqjgztdppbfbhzhhjyfdzwkgkzbldnzsxhqeegzxylzmmzyjzgszxkhkhtxexxgylyapsthxdwhzydpxagkydxbhnhnkdnjnmyhylpmgecslnzhkxxlbzzlbmlsfbhhgsgyyggbhscyajtxglxtzmcwzydqdqmngdnllszhngjzwfyhqswscelqajynytlsxthaznkzzsdhlaxxtwwcjhqqtddwzbcchyqzflxpslzqgpzsznglydqtbdlxntctajdkywnsyzljhhdzckryyzywmhychhhxhjkzwsxhdnxlyscqydpslyzwmypnkxyjlkchtyhaxqsyshxasmchkdscrsgjpwqsgzjlwwschsjhsqnhnsngndantbaalczmsstdqjcjktscjnxplggxhhgoxzcxpdmmhldgtybynjmxhmrzplxjzckzxshflqxxcdhxwzpckczcdytcjyxqhlxdhypjqxnlsyydzozjnhhqezysjyayxkypdgxddnsppyzndhthrhxydpcjjhtcnnctlhbynyhmhzllnnxmylllmdcppxhmxdkycyrdltxjchhznxclcclylnzsxnjzzlnnnnwhyqsnjhxynttdkyjpychhyegkcwtwlgjrlggtgtygyhpyhylqyqgcwyqkpyyettttlhyylltyttsylnyzwgywgpydqqzzdqnnkcqnmjjzzbxtqfjkdffbtkhzkbxdjjkdjjtlbwfzpptkqtztgpdwntpjyfalqmkgxbcclzfhzcllllanpnxtjklcclgyhdzfgyddgcyyfgydxkssendhykdndknnaxxhbpbyyhxccgapfqyjjdmlxcsjzllpcnbsxgjyndybwjspcwjlzkzddtacsbkzdyzypjzqsjnkktknjdjgyepgtlnyqnacdntcyhblgdzhbbydmjregkzyheyybjmcdtafzjzhgcjnlghldwxjjkytcyksssmtwcttqzlpbszdtwcxgzagyktywxlnlcpbclloqmmzsslcmbjcsdzkydczjgqjdsmcytzqqlnzqzxssbpkdfqmddzzsddtdmfhtdycnaqjqkypbdjyyxtljhdrqxlmhkydhrnlklytwhllrllrcxylbnsrnzzsymqzzhhkyhxksmzsyzgcxfbnbsqlfzxxnnxkxwymsddyqnggqmmyhcdzttfgyyhgsbttybykjdnkyjbelhdypjqnfxfdnkzhqksbyjtzbxhfdsbdaswpawajldyjsfhblcnndnqjtjnchxfjsrfwhzfmdrfjyxwzpdjkzyjympcyznynxfbytfyfwygdbnzzzdnytxzemmqbsqehxfznbmflzzsrsyqjgsxwzjsprytjsjgskjjgljjynzjjxhgjkymlpyyycxycgqzswhwlyrjlpxslcxmnsmwklcdnknynpsjszhdzeptxmwywxyysywlxjqcqxzdclaeelmcpjpclwbxsqhfwrtfnjtnqjhjqdxhwlbyccfjlylkyynldxnhycstyywncjtxywtrmdrqnwqcmfjdxzmhmayxnwmyzqtxtlmrspwwjhanbxtgzypxyyrrclmpamgkqjszycymyjsnxtplnbappypylxmyzkynldgyjzcchnlmzhhanqnbgwqtzmxxmllhgdzxnhxhrxycjmffxywcfsbssqlhnndycannmtcjcypnxnytycnnymnmsxndlylysljnlxyssqmllyzlzjjjkyzzcsfbzxxmstbjgnxnchlsnmcjscyznfzlxbrnnnylmnrtgzqysatswryhyjzmgdhzgzdwybsscskxsyhytsxgcqgxzzbhyxjscrhmkkbsczjyjymkqhzjfnbhmqhysnjnzybknqmcjgqhwlsnzswxkhljhyybqcbfcdsxdldspfzfskjjzwzxsddxjseeegjscssygclxxnwwyllymwwwgydkzjggggggsycknjwnjpcxbjjtqtjwdsspjxcxnzxnmelptfsxtllxcljxjjljsxctnswxlennlyqrwhsycsqnybyaywjejqfwqcqqcjqgxaldbzzyjgkgxbltqyfxjltpydkyqhpmatlcndnkxmtxynhklefxdllegqtymsawhzmljtkynxlyjzljeeyybqqffnlyxhdsctgjhxywlkllxqkcctnhjlqmkkzgcyygllljdcgydhzwypysjbzjdzgyzzhywyfqdtyzszyezklymgjjhtsmqwyzljyywzcsrkqyqltdxwcdrjalwsqzwbdcqyncjnnszjlncdcdtlzzzacqqzzddxyblxcbqjylzllljddzjgyqyjzyxnyyyexjxksdaznyrdlzyyynjlslldyxjcykywnqcclddnyyynycgczhjxcclgzqjgnwnncqqjysbzzxyjxjnxjfzbsbdsfnsfpzxhdwztdmpptflzzbzdmyypqjrsdzsqzsqxbdgcpzswdwcsqzgmdhzxmwwfybpngphdmjthzsmmbgzmbzjcfzhfcbbnmqdfmbcmcjxlgpnjbbxgyhyyjgptzgzmqbqdcgybjxlwnkydpdymgcftpfxyztzxdzxtgkptybbclbjaskytssqyymscxfjhhlsllsjpqjjqaklyldlycctsxmcwfgngbqxllllnyxtyltyxytdpjhnhgnkbyqnfjyyzbyyessessgdyhfhwtcqbsdzjtfdmxhcnjzymqwsrxjdzjqbdqbbsdjgnfbknbxdkqhmkwjjjgdllthzhhyyyyhhsxztyyyccbdbpypzyccztjpzywcbdlfwzcwjdxxhyhlhwczxjtcnlcdpxnqczczlyxjjcjbhfxwpywxzpcdzzbdccjwjhmlxbqxxbylrddgjrrctttgqdczwmxfytmmzcwjwxyywzzkybzcccttqnhxnwxxkhkfhtswoccjybcmpzzykbnnzpbthhjdlszddytyfjpxyngfxbyqxzbhxcpxxtnzdnnycnxsxlhkmzxlthdhkghxxsshqyhhcjyxglhzxcxnhekdtgqxqypkdhentykcnymyyjmkqyyyjxzlthhqtbyqhxbmyhsqckwwyllhcyylnneqxqwmcfbdccmljggxdqktlxkknqcdgcjwyjjlyhhqyttnwchhxcxwherzjydjccdbqcdgdnyxzdhcqrxcbhztqcbxwgqwyybxhmbymykdyecmqkyaqyngyzslfnkkqgyssqyshngjctxkzycssbkyxhyylstycxqthysmnscpmmgcccccmnztasmgqzjhklosjylswtmqzyqkdzljqqyplzycztcqqpbbcjzclpkhqcyyxxdtdddsjcxffllchqxmjlwcjcxtspycxndtjshjwhdqqqckxyamylsjhmlalygxcyydmamdqmlmcznnyybzxkyflmcncmlhxrcjjhsylnmtjggzgywjxsrxcwjgjqhqzdqjdcjjskjkgdzcgjjyjylxzxxcdqhhheslmhlfsbdjsyyshfyssczqlpbdrfnztzdkykhsccgkwtqzckmsynbcrxqbjyfaxpzzedzcjykbcjwhyjbqzzywnyszptdkzpfpbaztklqnhbbzptpptyzzybhnydcpzmmcycqmcjfzzdcmnlfpbplngqjtbttajzpzbbdnjkljqylnbzqhksjznggqstzkcxchpzsnbcgzkddzqanzgjkdrtlzldwjnjzlywtxndjzjhxnatncbgtzcsskmljpjytsnwxcfjwjjtkhtzplbhsnjssyjbhbjyzlstlsbjhdnwqpslmmfbjdwajyzccjtbnnnzwxxcdslqgdsdpdzgjtqqpsqlyyjzlgyhsdlctcbjtktyczjtqkbsjlgnnzdncsgpynjzjjyyknhrpwszxmtncszzyshbyhyzaxywkcjtllckjjtjhgcssxyqyczbynnlwqcglzgjgqyqcczssbcrbcskydznxjsqgxssjmecnstjtpbdlthzwxqwqczexnqczgwesgssbybstscslccgbfsdqnzlccglllzghzcthcnmjgyzazcmsksstzmmzckbjygqljyjppldxrkzyxccsnhshhdznlzhzjjcddcbcjxlbfqbczztpqdnnxljcthqzjgylklszzpcjdscqjhjqkdxgpbajynnsmjtzdxlcjyryynhjbngzjkmjxltbsllrzpylssznxjhllhyllqqzqlsymrcncxsljmlzltzldwdjjllnzggqxppskyggggbfzbdkmwggcxmcgdxjmcjsdycabxjdlnbcddygskydqdxdjjyxhsaqazdzfslqxxjnqzylblxxwxqqzbjzlfbblylwdsljhxjyzjwtdjcyfqzqzzdzsxzzqlzcdzfxhwspynpqzmlpplffxjjnzzylsjnyqzfpfzgsywjjjhrdjzzxtxxglghtdxcskyswmmtcwybazbjkshfhgcxmhfqhyxxyzftsjyzbxyxpzlchmzmbxhzzssyfdmncwdabazlxktcshhxkxjjzjsthygxsxyyhhhjwxkzxssbzzwhhhcwtzzzpjxsyxqqjgzyzawllcwxznxgyxyhfmkhydwsqmnjnaycyspmjkgwcqhylajgmzxhmmcnzhbhxclxdjpltxyjkdyylttxfqzhyxxsjbjnayrsmxyplckdnyhlxrlnllstycyyqygzhhsccsmcctzcxhyqfpyyrpbflfqnntszlljmhwtcjqyzwtlnmlmdwmbzzsnzrbpdddlqjjbxtcsnzqqygwcsxfwzlxccrszdzmcyggdyqsgtnnnlsmymmsyhfbjdgyxccpshxczcsbsjyygjmpbwaffyfnxhydxzylremzgzzyndsznlljcsqfnxxkptxzgxjjgbmyyssnbtylbnlhbfzdcyfbmgqrrmzszxysjtznnydzzcdgnjafjbdknzblczszpsgcycjszlmnrznbzzldlnllysxsqzqlcxzlsgkbrxbrbzcycxzjzeeyfgklzlnyhgzcgzlfjhgtgwkraajyzkzqtsshjjxdzyznynnzyrzdqqhgjzxsszbtkjbbfrtjxllfqwjgclqtymblpzdxtzagbdhzzrbgjhwnjtjxlkscfsmwlldcysjtxkzscfwjlbnntzlljzllqblcqmqqcgcdfpbphzczjlpyyghdtgwdxfczqyyyqysrclqzfklzzzgffcqnwglhjycjjczlqzzyjbjzzbpdcsnnjgxdqnknlznnnnpsntsdyfwwdjzjysxyyczcyhzwbbyhxrylybhkjksfxtjjmmchhlltnyymsxxyzpdjjycsycwmdjjkqyrhllngpngtlyycljnnnxjyzfnmlrgjjtyzbsyzmsjyjhgfzqmsyxrszcytlrtqzsstkxgqkgsptgxdnjsgcqcqhmxggztqydjjznlbznxqlhyqgggthqscbyhjhhkyygkggcmjdzllcclxqsftgjslllmlcskctbljszszmmnytpzsxqhjcnnqnyexzqzcpshkzzyzxxdfgmwqrllqxrfztlystctmjcsjjthjnxtnrztzfqrhcgllgcnnnnjdnlnnytsjtlnyxsszxcgjzyqpylfhdjsbbdczgjjjqzjqdybssllcmyttmqnbhjqmnygjyeqyqmzgcjkpdcnmyzgqllslnclmholzgdylfzslncnzlylzcjeshnyllnxnjxlyjyyyxnbcljsswcqqnnyllzldjnllzllbnylnqchxyyqoxccqkyjxxxyklksxeyqhcqkkkkcsnyxxyqxygwtjohthxpxxhsnlcykychzzcbwqbbwjqcscszsslcylgddsjzmmymcytsdsxxscjpqqsqylyfzychdjynywcbtjsydchcyddjlbdjjsodzyqyskkyxdhhgqjyohdyxwgmmmazdybbbppbcmnnpnjzsmtxerxjmhqdntpjdcbsnmssythjtslmltrcplzszmlqdsdmjmqpnqdxcfrnnfsdqqyxhyaykqyddlqyyysszbydslntfgtzqbzmchdhczcwfdxtmqqsphqwwxsrgjcwnntzcqmgwqjrjhtqjbbgwzfxjhnqfxxqywyyhyscdydhhqmrmtmwctbszppzzglmzfollcfwhmmsjzttdhlmyffytzzgzyskjjxqyjzqbhmbzclyghgfmshpcfzsnclpbqsnjyzslxxfpmtyjygbxlldlxpzjyzjyhhzcywhjylsjexfszzywxkzjlnadymlymqjpwxxhxsktqjezrpxxzghmhwqpwqlyjjqjjzszcnhjlchhnxjlqwzjhbmzyxbdhhypylhlhlgfwlcfyytlhjjcwmscpxstkpnhjxsntyxxtestjctlsslstdlllwwyhdnrjzsfgxssyczykwhtdhwjglhtzdqdjzxxqgghltzphcsqfclnjtclzpfstpdynylgmjllycqhynspchylhqyqtmzymbywrfqykjsyslzdnjmpxyyssrhzjnyqtqdfzbwwdwwrxcwggyhxmkmyyyhmxmzhnksepmlqqmtcwctmxmxjpjjhfxyyzsjzhtybmstsyjznqjnytlhynbyqclcycnzwsmylknjxlggnnpjgtysylymzskttwlgsmzsylmpwlcwxwqcssyzsyxyrhssntsrwpccpwcmhdhhxzdzyfjhgzttsbjhgyglzysmyclllxbtyxhbbzjkssdmalhhycfygmqypjyjqxjllljgclzgqlycjcctotyxmtmshllwlqfxymzmklpszzcxhkjyclctyjcyhxsgyxnnxlzwpyjpxhjwpjpwxqqxlxsdhmrslzzydwdtcxknstzshbsccstplwsscjchjlcgchssphylhfhhxjsxallnylmzdhzxylsxlmzykcldyahlcmddyspjtqjzlngjfsjshctsdszlblmssmnyymjqbjhrzwtyydchjljapzwbgqxbkfnbjdllllyylsjydwhxpsbcmljpscgbhxlqhyrljxyswxhhzlldfhlnnymjljyflyjycdrjlfsyzfsllcqyqfgqyhnszlylmdtdjcnhbzllnwlqxygyyhbmgdhxxnhlzzjzxczzzcyqzfngwpylcpkpykpmclgkdgxzgxwqbdxzzkzfbddlzxjtpjpttbythzzdwslcpnhsltjxxqlhyxxxywzyswttzkhlxzxzpyhgzhknfsyhntjrnxfjcpjztwhplshfcrhnslxxjxxyhzqdxqwnnhyhmjdbflkhcxcwhjfyjcfpqcxqxzyyyjygrpynscsnnnnchkzdyhflxxhjjbyzwttxnncyjjymswyxqrmhxzwfqsylznggbhyxnnbwttcsybhxxwxyhhxyxnknyxmlywrnnqlxbbcljsylfsytjzyhyzawlhorjmnsczjxxxyxchcyqryxqzddsjfslyltsffyxlmtyjmnnyyyxltzcsxqclhzxlwyxzhnnlrxkxjcdyhlbrlmbrdlaxksnlljlyxxlynrylcjtgncmtlzllcyzlpzpzyawnjjfybdyyzsepckzzqdqpbpsjpdyttbdbbbyndycncpjmtmlrmfmmrwyfbsjgygsmdqqqztxmkqwgxllpjgzbqrdjjjfpkjkcxbljmswldtsjxldlppbxcwkcqqbfqbccajzgmykbhyhhzykndqzybpjnspxthlfpnsygyjdbgxnhhjhzjhstrstldxskzysybmxjlxyslbzyslzxjhfybqnbylljqkygzmcyzzymccslnlhzhwfwyxzmwyxtynxjhbyymcysbmhysmydyshnyzchmjjmzcaahcbjbbhblytylsxsnxgjdhkxxtxxnbhnmlngsltxmrhnlxqqxmzllyswqgdlbjhdcgjyqyymhwfmjybbbyjyjwjmdpwhxqldyapdfxxbcgjspckrssyzjmslbzzjfljjjlgxzgyxyxlszqkxbexyxhgcxbpndyhwectwwcjmbtxchxyqqllxflyxlljlssnwdbzcmyjclwswdczpchqekcqbwlcgydblqppqzqfnqdjhymmcxtxdrmzwrhxcjzylqxdyynhyyhrslnrsywwjjymtltllgtqcjzyabtckzcjyccqlysqxalmzynywlwdnzxqdllqshgpjfjljnjabcqzdjgthhsstnyjfbswzlxjxrhgldlzrlzqzgsllllzlymxxgdzhgbdphzpbrlwnjqbpfdwonnnhlypcnjccndmbcpbzzncyqxldomzblzwpdwyygdstthcsqsccrsssyslfybnntyjszdfndpdhtqzmbqlxlcmyffgtjjqwftmnpjwdnlbzcmmcngbdzlqlpnfhyymjylsdchdcjwjcctljcldtljjcbddpndsszycndbjlggjzxsxnlycybjjxxcbylzcfzppgkcxqdzfztjjfjdjxzbnzyjqctyjwhdyczhymdjxttmpxsplzcdwslshxypzgtfmlcjtacbbmgdewycyzxdszjyhflystygwhkjyylsjcxgywjcbllcsnddbtzbsclyzczzssqdllmjyyhfllqllxfdyhabxggnywyypllsdldllbjcyxjznlhljdxyyqytdlllbngpfdfbbqbzzmdpjhgclgmjjpgaehhbwcqxajhhhzchxyphjaxhlphjpgpzjqcqzgjjzzgzdmqyybzzphyhybwhazyjhykfgdpfqsdlzmljxjpgalxzdaglmdgxmmzqwtxdxxpfdmmssympfmdmmkxksyzyshdzkjsysmmzzzmdydyzzczxbmlstmdyemxckjmztyymzmzzmsshhdccjewxxkljsthwlsqlyjzllsjssdppmhnlgjczyhmxxhgncjmdhxtkgrmxfwmckmwkdcksxqmmmszzydkmsclcmpcjmhrpxqpzdsslcxkyxtwlkjyahzjgzjwcjnxyhmmbmlgjxmhlmlgmxctkzmjlyscjsyszhsyjzjcdajzhbsdqjzgwtkqxfkdmsdjlfmnhkzqkjfeypzyszcdpynffmzqykttdzzefmzlbnpplplpbpszalltnlkckqzkgenjlwalkxydpxnhsxqnwqnkxqclhyxxmlnccwlymqyckynnlcjnszkpyzkcqzqljbdmdjhlasqlbydwqlwdgbqcryddztjybkbwszdxdtnpjdtcnqnfxqqmgnseclstbhpwslctxxlpwydzklnqgzcqapllkqcylbqmqczqcnjslqzdjxlddhpzqdljjxzqdjyzhhzlkcjqdwjppypqakjyrmpzbnmcxkllzllfqpylllmbsglzysslrsysqtmxyxzqzbscnysyztffmzzsmzqhzssccmlyxwtpzgxzjgzgsjzgkddhtqggzllbjdzlsbzhyxyzhzfywxytymsdnzzyjgtcmtnxqyxjscxhslnndlrytzlryylxqhtxsrtzcgyxbnqqzfhykmzjbzymkbpnlyzpblmcnqyzzzsjztjctzhhyzzjrdyzhnfxklfzslkgjtctssyllgzrzbbjzzklpkbczysnnyxbjfbnjzzxcdwlzyjxzzdjjgggrsnjkmsmzjlsjywqsnyhqjsxpjztnlsnshrnynjtwchglbnrjlzxwjqxqkysjycztlqzybbybyzjqdwgyzcytjcjxckcwdkkzxsnkdnywwyyjqyytlytdjlxwkcjnklccpzcqqdzzqlcsfqchqqgssmjzzllbjjzysjhtsjdysjqjpdszcdchjkjzzlpycgmzndjxbsjzzsyzyhgxcpbjydssxdzncglqmbtsfcbfdzdlznfgfjgfsmpnjqlnblgqcyyxbqgdjjqsrfkztjdhczklbsdzcfytplljgjhtxzcsszzxstjygkgckgynqxjplzbbbgcgyjzgczqszlbjlsjfzgkqqjcgycjbzqtldxrjnbsxxpzshszycfwdsjjhxmfczpfzhqhqmqnknlyhtycgfrzgnqxcgpdlbzcsczqlljblhbdcypscppdymzzxgyhckcpzjgslzlnscnsldlxbmsdlddfjmkdqdhslzxlsznpqpgjdlybdskgqlbzlnlkyyhzttmcjnqtzzfszqktlljtyyllnllqyzqlbdzlslyyzxmdfszsnxlxznczqnbbwskrfbcylctnblgjpmczzlstlxshtzcyzlzbnfmqnlxflcjlyljqcbclzjgnsstbrmhxzhjzclxfnbgxgtqncztmsfzkjmssncljkbhszjntnlzdntlmmjxgzjyjczxyhyhwrwwqnztnfjscpyshzjfyrdjsfscjzbjfzqzchzlxfxsbzqlzsgyftzdcszxzjbjpszkjrhxjzcgbjkhcggtxkjqglxbxfgtrtylxqxhdtsjxhjzjjcmzlcqsbtxwqgxtxxhxftsdkfjhzyjfjxnzldlllcqsqqzqwqxswqtwgwbzcgcllqzbclmqjtzgzyzxljfrmyzflxnsnxxjkxrmjdzdmmyxbsqbhgzmwfwygmjlzbyytgzyccdjyzxsngnyjyznbgpzjcqsyxsxrtfyzgrhztxszzthcbfclsyxzlzqmzlmplmxzjssfsbysmzqhxxnxrxhqzzzsslyflczjrcrxhhzxqndshxsjjhqcjjbcynsysxjbqjpxzqplmlxzkyxlxcnlcycxxzzlxdlllmjyhzxhyjwkjrwyhcpsgnrzlfzwfzznsxgxflzsxzzzbfcsyjdbrjkrdhhjxjljjtgxjxxstjtjxlyxqfcsgswmsbctlqzzwlzzkxjmltmjyhsddbxgzhdlbmyjfrzfcgclyjbpmlysmsxlszjqqhjzfxgfqfqbphngyyqxgztnqwyltlgwgwwhnlfmfgzjmgmgbgtjflyzzgzyzaflsspmlbflcwbjztljjmzlpjjlymqtmyyyfbgygqzglyzdxqyxrqqqhsxyyqxygjtyxfsfsllgnqcygycwfhcccfxpylypllzqxxxxxqqhhsshjzcftsczjxspzwhhhhhapylqnlpqafyhxdylnkmzqgggddesrenzltzgchyppcsqjjhclljtolnjpzljlhymhezdydsqycddhgznndzclzywllznteydgnlhslpjjbdgwxpcnntycklkclwkllcasstknzdnnjttlyyzssysszzryljqkcgdhhyrxrzydgrgcwcgzqffbppjfzynakrgywyjpqxxfkjtszzxswzddfbbqtbgtzkznpzfpzxzpjszbmqhkyyxyldkljnypkyghgdzjxxeaxpnznctzcmxcxmmjxnkszqnmnlwbwwqjjyhclstmcsxnjcxxtpcnfdtnnpglllzcjlspblpgjcdtnjjlyarscffjfqwdpgzdwmrzzcgodaxnssnyzrestyjwjyjdbcfxnmwttbqlwstszgybljpxglbnclgpcbjftmxzljylzxcltpnclcgxtfzjshcrxsfysgdkntlbyjcyjllstgqcbxnhzxbxklylhzlqzlnzcqwgzlgzjncjgcmnzzgjdzxtzjxycyycxxjyyxjjxsssjstsstdppghtcsxwzdcsynptfbchfbblzjclzzdbxgcjlhpxnfzflsyltnwbmnjhszbmdnbcysccldnycndqlyjjhmqllcsgljjsyfpyyccyltjantjjpwycmmgqyysxdxqmzhszxbftwwzqswqrfkjlzjqqyfbrxjhhfwjgzyqacmyfrhcyybynwlpexcczsyyrlttdmqlrkmpbgmyyjprkznbbsqyxbhyzdjdnghpmfsgbwfzmfqmmbzmzdcgjlnnnxyqgmlrygqccyxzlwdkcjcggmcjjfyzzjhycfrrcmtznzxhkqgdjxccjeascrjthpljlrzdjrbcqhjdnrhylyqjsymhzydwcdfryhbbydtssccwbxglpzmlzjdqsscfjmmxjcxjytycghycjwynsxlfemwjnmkllswtxhyyyncmmcyjdqdjzglljwjnkhpzggflccsczmcbltbhbqjxqdjpdjztghglfjawbzyzjltstdhjhctcbchflqmpwdshyytqwcnntjtlnnmnndyyyxsqkxwyyflxxnzwcxypmaelyhgjwzzjbrxxaqjfllpfhhhytzzxsgqjmhspgdzqwbwpjhzjdyjcqwxkthxsqlzyymysdzgnqckknjlwpnsyscsyzlnmhqsyljxbcxtlhzqzpcycykpppnsxfyzjjrcemhszmnxlxglrwgcstlrsxbygbzgnxcnlnjlclynymdxwtzpalcxpqjcjwtcyyjlblxbzlqmyljbghdslssdmxmbdczsxyhamlczcpjmcnhjyjnsykchskqmczqdllkablwjqsfmocdxjrrlyqchjmybyqlrhetfjzfrfksryxfjdwtsxxywsqjyslyxwjhsdlxyyxhbhawhwjcxlmyljcsqlkydttxbzslfdxgxsjkhsxxybssxdpwncmrptqzczenygcxqfjxkjbdmljzmqqxnoxslyxxlylljdzptymhbfsttqqwlhsgynlzzalzxclhtwrrqhlstmypyxjjxmnsjnnbryxyjllyqyltwylqyfmlkljdnlltfzwkzhljmlhljnljnnlqxylmbhhlnlzxqchxcfxxlhyhjjgbyzzkbxscqdjqdsndzsygzhhmgsxcsymxfepcqwwrbpyyjqryqcyjhqqzyhmwffhgzfrjfcdbxntqyzpcyhhjlfrzgpbxzdbbgrqstlgdgylcqmgchhmfywlzyxkjlypjhsywmqqggzmnzjnsqxlqsyjtcbehsxfszfxzwfllbcyyjdytdthwzsfjmqqyjlmqsxlldttkghybfpwdyysqqrnqwlgwdebzwcyygcnlkjxtmxmyjsxhybrwfymwfrxyymxysctzztfykmldhqdlgyjnlcryjtlpsxxxywlsbrrjwxhqybhtydnhhxmmywytycnnmnssccdalwztcpqpyjllqzyjswjwzzmmglmxclmxnzmxmzsqtzppjqblpgxjzhfljjhycjsrxwcxsncdlxsyjdcqzxslqyclzxlzzxmxqrjmhrhzjbhmfljlmlclqnldxzlllfyprgjynxcqqdcmqjzzxhnpnxzmemmsxykynlxsxtljxyhwdcwdzhqyybgybcyscfgfsjnzdrzzxqxrzrqjjymcanhrjtldbpyzbstjhxxzypbdwfgzzrpymnnkxcqbyxnbnfyckrjjcmjegrzgyclnnzdnkknsjkcljspgyyclqqjybzssqlllkjftbgtylcccdblsppfylgydtzjqjzgkntsfcxbdkdxxhybbfytyhbclnnytgdhryrnjsbtcsnyjqhklllzslydxxwbcjqsbxnpjzjzjdzfbxxbrmladhcsnclbjdstblprznswsbxbcllxxlzdnzsjpynyxxyftnnfbhjjjgbygjpmmmmsszljmtlyzjxswxtyledqpjmpgqzjgdjlqjwjqllsdgjgygmscljjxdtygjqjjjcjzcjgdzdshqgzjggcjhqxsnjlzzbxhsgzxcxyljxyxyydfqqjhjfxdhctxjyrxysqtjxyefyyssyxjxncyzxfxcsxszxyyschshxzzzgzzzgfjdldylnpzgsjaztyqzpbxcbdztzczyxxyhhscjshcggqhjhgxhsctmzmehyxgebtclzkkwytjzrslekestdbcyhqqsayxcjxwwgsphjszsdncsjkqcxswxfctynydpccczjqtcwjqjzzzqzljzhlsbhpydxpsxshhezdxfptjqyzcxhyaxncfzyyhxgnqmywntzsjbnhhgymxmxqcnssbcqsjyxxtyyhybcqlmmszmjzzllcogxzaajzyhjmchhcxzsxsdznleyjjzjbhzwjzsqtzpsxzzdsqjjjlnyazphhyysrnqzthzhnyjyjhdzxzlswclybzyecwcycrylchzhzydzydyjdfrjjhtrsqtxyxjrjhojynxelxsfsfjzghpzsxzszdzcqzbyyklsgsjhczshdgqgxyzgxchxzjwyqwgyhksseqzzndzfkwyssdclzstsymcdhjxxyweyxczaydmpxmdsxybsqmjmzjmtjqlpjyqzcgqhyjhhhqxhlhdldjqcfdwbsxfzzyyschtytyjbhecxhjkgqfxbhyzjfxhwhbdzfyzbchpnpgdydmsxhkhhmamlnbyjtmpxejmcthqbzyfcgtyhwphftgzzezsbzegpbmdskftycmhbllhgpzjxzjgzjyxzsbbqsczzlzscstpgxmjsfdcczjzdjxsybzlfcjsazfgszlwbczzzbyztzynswyjgxzbdsynxlgzbzfygczxbzhzftpbgzgejbstgkdmfhyzzjhzllzzgjqzlsfdjsscbzgpdlfzfzszyzyzsygcxsnxxchczxtzzljfzgqsqqxcjqccccdjcdszzyqjccgxztdlgscxzsyjjqtcclqdqztqchqqyzynzzzpbkhdjfcjfztypqyqttynlmbdktjcpqzjdzfpjsbnjlgyjdxjdcqkzgqkxclbzjtcjdqbxdjjjstcxnxbxqmslyjcxntjqwwcjjnjjlllhjcwqtbzqqczczpzzdzyddcyzdzccjgtjfzdprntctjdcxtqzdtjnplzbcllctdsxkjzqdmzlbznbtjdcxfczdbczjjltqqpldckztbbzjcqdcjwynllzlzccdwllxwzlxrxntqjczxkjlsgdnqtddglnlajjtnnynkqlldzntdnycygjwyxdxfrsqstcdenqmrrqzhhqhdldazfkapbggpzrebzzykyqspeqjjglkqzzzjlysyhyzwfqznlzzlzhwcgkypqgnpgblplrrjyxcccgyhsfzfwbzywtgzxyljczwhncjzplfflgskhyjdeyxhlpllllcygxdrzelrhgklzzyhzlyqszzjzqljzflnbhgwlczcfjwspyxzlzlxgccpzbllcxbbbbnbbcbbcrnnzccnrbbnnldcgqyyqxygmqzwnzytyjhyfwtehznjywlccntzyjjcdedpwdztstnjhtymbjnyjzlxtsstphndjxxbyxqtzqddtjtdyztgwscszqflshlnzbcjbhdlyzjyckwtydylbnydsdsycctyszyyebgexhqddwnygyclxtdcystqnygzascsszzdzlcclzrqxyywljsbymxshzdembbllyyllytdqyshymrqnkfkbfxnnsbychxbwjyhtqbpbsbwdzylkgzskyghqzjxhxjxgnljkzlyycdxlfwfghljgjybxblybxqpqgntzplncybxdjyqydymrbeyjyyhkxxstmxrczzjwxyhybmcflyzhqyzfwxdbxbcwzmslpdmyckfmzklzcyqycclhxfzlydqzpzygyjyzmdxtzfnnyttqtzhgsfcdmlccytzxjcytjmkslpzhysnwllytpzctzccktxdhxxtqcyfksmqccyyazhtjplylzlyjbjxtfnyljyynrxcylmmnxjsmybcsysslzylljjgyldzdlqhfzzblfndsqkczfyhhgqmjdsxycttxnqnjpyybfcjtyyfbnxejdgyqbjrcnfyyqpghyjsyzngrhtknlnndzntsmgklbygbpyszbydjzsstjztsxzbhbscsbzczptqfzlqflypybbjgszmnxdjmtsyskkbjtxhjcegbsmjyjzcstmljyxrczqscxxqpyzhmkyxxxjcljyrmyygadyskqlnadhrskqxzxztcggzdlmlwxybwsyctbhjhcfcwzsxwwtgzlxqshnyczjxemplsrcgltnzntlzjcyjgdtclglbllqpjmzpapxyzlaktkdwczzbncctdqqzqyjgmcdxltgcszlmlhbglkznnwzndxnhlnmkydlgxdtwcfrjerctzhydxykxhwfzcqshknmqqhzhhymjdjskhxzjzbzzxympajnmctbxlsxlzynwrtsqgscbptbsgzwyhtlkssswhzzlyytnxjgmjrnsnnnnlskztxgxlsammlbwldqhylakqcqctmycfjbslxclzjclxxknbnnzlhjphqplsxsckslnhpsfqcytxjjzljldtzjjzdlydjntptnndskjfsljhylzqqzlbthydgdjfdbyadxdzhzjnthqbyknxjjqczmlljzkspldsclbblnnlelxjlbjycxjxgcnlcqplzlznjtsljgyzdzpltqcssfdmnycxgbtjdcznbgbqyqjwgkfhtnbyqzqgbkpbbyzmtjdytblsqmbsxtbnpdxklemyycjynzdtldykzzxtdxhqshygmzsjycctayrzlpwltlkxslzcggexclfxlkjrtlqjaqzncmbqdkkcxglczjzxjhptdjjmzqykqsecqzdshhadmlzfmmzbgntjnnlhbyjbrbtmlbyjdzxlcjlpldlpcqdhlhzlycblcxccjadqlmzmmsshmybhbnkkbhrsxxjmxmdznnpklbbrhgghfchgmnklltsyyycqlcskymyehywxnxqywbawykqldnntndkhqcgdqktgpkxhcpdhtwnmssyhbwcrwxhjmkmzngwtmlkfghkjyldyycxwhyyclqhkqhtdqkhffldxqwytyydesbpkyrzpjfyyzjceqdzzdlattpbfjllcxdlmjsdxegwgsjqxcfbssszpdyzcxznyxppzydlyjccpltxlnxyzyrscyyytylwwndsahjsygyhgywwaxtjzdaxysrltdpssyxfnejdxyzhlxlllzhzsjnyqyqyxyjghzgjcyjchzlycdshhsgczyjscllnxzjjyyxnfsmwfpyllyllabmddhwzxjmcxztzpmlqchsfwzynctlndywlslxhymmylmbwwkyxyaddxylldjpybpwnxjmmmllhafdllaflbnhhbqqjqzjcqjjdjtffkmmmpythygdrjrddwrqjxnbysrmzdbyytbjhpymyjtjxaahggdqtmystqxkbtzbkjlxrbyqqhxmjjbdjntgtbxpgbktlgqxjjjcdhxqdwjlwrfmjgwqhcnrxswgbtgygbwhswdwrfhwytjjxxxjyzyslphyypyyxhydqpxshxyxgskqhywbdddpplcjlhqeewjgsyykdpplfjthkjltcyjhhjttpltzzcdlyhqkcjqysteeyhkyzyxxyysddjkllpymqyhqgxqhzrhbxpllnqydqhxsxxwgdqbshyllpjjjthyjkyphthyyktyezyenmdshlzrpqfbnfxzbsftlgxsjbswyysksflxlpplbbblnsfbfyzbsjssylpbbffffsscjdstjsxtryjcyffsyzyzbjtlctsbsdhrtjjbytcxyyeylycbnebjdsysyhgsjzbxbytfzwgenhhhthjhhxfwgcstbgxklstyymtmbyxjskzscdyjrcythxzfhmymcxlznsdjtxtxrycfyjsbsdyerxhljxbbdeynjghxgckgscymblxjmsznskgxfbnbbthfjyafxwxfbxmyfhdttcxzzpxrsywzdlybbktyqwqjbzypzjznjpzjlztfysbttslmptzrtdxqsjehbnylndxljsqmlhtxtjecxalzzspktlzkqqyfsyjywpcpqfhjhytqxzkrsgtksqczlptxcdyyzsslzslxlzmacpcqbzyxhbsxlzdltztjtylzjyytbzypltxjsjxhlbmytxcqrblzssfjzztnjytxmyjhlhpblcyxqjqqkzzscpzkswalqsplczzjsxgwwwygyatjbbctdkhqhkgtgpbkqyslbxbbckbmllndzstbklggqkqlzbkktfxrmdkbftpzfrtppmferqnxgjpzsstlbztpszqzsjdhljqlzbpmsmmsxlqqnhknblrddnhxdkddjcyyljfqgzlgsygmjqjkhbpmxyxlytqwlwjcpbmjxcyzydrjbhtdjyeqshtmgsfyplwhlzffnynnhxqhpltbqpfbjwjdbygpnxtbfzjgnnntjshxeawtzylltyqbwjpgxghnnkndjtmszsqynzggnwqtfhclssgmnnnnynzqqxncjdqgzdlfnykljcjllzlmzznnnnsshthxjlzjbbhqjwwycrdhlyqqjbeyfsjhthnrnwjhwpslmssgzttygrqqwrnlalhmjtqjsmxqbjjzjqzyzkxbjqxbjxshzssfglxmxnxfghkzszggslcnnarjxhnlllmzxelglxydjytlfbkbpnlyzfbbhptgjkwetzhkjjxzxxglljlstgshjjyqlqzfkcgnndjsszfdbctwwseqfhqjbsaqtgypjlbxbmmywxgslzhglsgnyfljbyfdjfngsfmbyzhqffwjsyfyjjphzbyyzffwotjnlmftwlbzgyzqxcdjygzyyryzynyzwegazyhjjlzrthlrmgrjxzclnnnljjyhtbwjybxxbxjjtjteekhwslnnlbsfazpqqbdlqjjtyyqlyzkdksqjnejzldqcgjqnnjsncmrfqthtejmfctyhypymhydmjncfgyyxwshctxrljgjzhzcyyyjltkttntmjlzclzzayyoczlrlbszywjytsjyhbyshfjlykjxxtmzyyltxxypslqyjzyzyypnhmymdyylblhlsyygqllnjjymsoycbzgdlyxylcqyxtszegxhzglhwbljheyxtwqmakbpqcgyshhegqcmwyywljyjhyyzlljjylhzyhmgsljljxcjjyclycjbcpzjzjmmwlcjlnqljjjlxyjmlszljqlycmmgcfmmfpqqmfxlqmcffqmmmmhnznfhhjgtthxkhslnchhyqzxtmmqdcydyxyqmyqylddcyaytazdcymdydlzfffmmycqcwzzmabtbyctdmndzggdftypcgqyttssffwbdttqssystwnjhjytsxxylbyyhhwhxgzxwznnqzjzjjqjccchykxbzszcnjtllcqxynjnckycynccqnxyewyczdcjycchyjlbtzyycqwlpgpyllgktltlgkgqbgychjxy") + + static func group(_ items: [AZOrderedListItem]) -> (sections: [AZGroupedSection], titles: [String]) { + let sortedItems = items.sorted { item1, item2 in + let title1 = item1.title ?? item1.id + let title2 = item2.title ?? item2.id + return title1.localizedCaseInsensitiveCompare(title2) == .orderedAscending + } + var groupedDict: [String: [AZOrderedListItem]] = [:] + for item in sortedItems { + let title = item.title ?? item.id + let firstChar = getFirstCharacter(title) + let sectionTitle = getSectionTitle(firstChar) + groupedDict[sectionTitle, default: []].append(item) + } + let sortedSections = groupedDict.keys.sorted { sortSections($0, $1) } + let sections = sortedSections.map { title in + AZGroupedSection(sectionTitle: title, items: groupedDict[title] ?? []) + } + return (sections, sortedSections) + } + + private static func getFirstCharacter(_ text: String) -> String { + guard let firstChar = text.first else { return "#" } + let unicodeValue = firstChar.unicodeScalars.first?.value ?? 0 + if unicodeValue >= cjkUnifiedIdeographRangeStart && unicodeValue <= cjkUnifiedIdeographRangeEnd { + return getChinesePinyinFirstLetter(String(firstChar)) + } + if (firstChar >= "A" && firstChar <= "Z") || (firstChar >= "a" && firstChar <= "z") { + return String(firstChar).uppercased() + } + return "#" + } + + private static func getSectionTitle(_ firstChar: String) -> String { + if firstChar.rangeOfCharacter(from: .letters) != nil { + return firstChar.uppercased() + } + return "#" + } + + private static func sortSections(_ section1: String, _ section2: String) -> Bool { + if section1 == "#" && section2 != "#" { + return false + } else if section1 != "#" && section2 == "#" { + return true + } else { + return section1 < section2 + } + } + + private static func getChinesePinyinFirstLetter(_ chinese: String) -> String { + guard let firstChar = chinese.first else { return "#" } + if let cached = pinyinCache[firstChar] { + return cached + } + let hanziStart: UInt32 = 19968 + let hanziCount = pinyinFirstLetters.count + let unicodeValue = firstChar.unicodeScalars.first?.value ?? 0 + if unicodeValue >= hanziStart && unicodeValue < hanziStart + UInt32(hanziCount) { + let index = Int(unicodeValue - hanziStart) + if index >= 0 && index < hanziCount { + let result = String(pinyinFirstLetters[index]).uppercased() + addToPinyinCache(firstChar, result) + return result + } + } + let result = "#" + addToPinyinCache(firstChar, result) + return result + } + + private static func addToPinyinCache(_ char: Character, _ pinyin: String) { + if pinyinCache.count >= maxCacheSize { + let keysToRemove = Array(pinyinCache.keys.prefix(maxCacheSize / 2)) + for key in keysToRemove { + pinyinCache.removeValue(forKey: key) + } + } + pinyinCache[char] = pinyin + } +} diff --git a/chat/uikit/components/common/azorderedlist/AZOrderedListView.swift b/chat/uikit/components/common/azorderedlist/AZOrderedListView.swift new file mode 100644 index 000000000..92736ae5b --- /dev/null +++ b/chat/uikit/components/common/azorderedlist/AZOrderedListView.swift @@ -0,0 +1,275 @@ +import UIKit +import SnapKit + +public struct AZOrderedListItem: Identifiable, Equatable { + public var id: String { userID } + public let userID: String + public let avatarURL: String? + public let title: String? + let extraData: Any? + public init(userID: String, avatarURL: String? = nil, title: String? = nil, extraData: Any? = nil) { + self.userID = userID + self.avatarURL = avatarURL + self.title = title + self.extraData = extraData + } + + public static func == (lhs: AZOrderedListItem, rhs: AZOrderedListItem) -> Bool { + return lhs.id == rhs.id + } +} + +final class AZOrderedListView: RTCBaseView { + var onUserInteraction: (() -> Void)? + + var headerView: UIView? { + didSet { refreshHeaderVisibility() } + } + + var isHeaderHidden = false { + didSet { refreshHeaderVisibility() } + } + + var itemTitleFontSize: CGFloat = 18 + + var emptyText: String = LocalizedChatString("NoUser") { + didSet { emptyLabel.text = emptyText } + } + + private static let rowHeight: CGFloat = 60 + + private static let sectionHeaderHeight: CGFloat = 32 + + private static let sectionHeaderHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let indexBarTrailingPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let emptyLabelHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let emptyIllustrationWidth: CGFloat = 88 + + private static let emptyIllustrationAspectRatio: CGFloat = 300.0 / 338.0 + + private static let emptyTextTopSpacing: CGFloat = 14 + + private let showIndexBar: Bool + + private let onItemClick: (AZOrderedListItem) -> Void + + private var sections: [AZGroupedSection] = [] + + private var sectionTitles: [String] = [] + + private var isEmpty: Bool { sections.isEmpty } + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = Self.rowHeight + table.sectionFooterHeight = 0 + table.sectionHeaderTopPadding = 0 + table.keyboardDismissMode = .onDrag + table.register(AZOrderedListCell.self, forCellReuseIdentifier: AZOrderedListCell.reuseIdentifier) + return table + }() + + private lazy var indexBar = AtomicIndexBarView() + + private lazy var emptyView: UIView = { + let view = UIView() + view.isHidden = true + return view + }() + + private lazy var emptyIllustrationView: UIImageView = { + let imageView = UIImageView(image: AtomicXChatResources.image(named: "uikit_empty_illustration")) + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private lazy var emptyLabel: UILabel = { + let label = UILabel() + label.text = LocalizedChatString("NoUser") + label.font = FontScheme.caption2Regular + label.textColor = ChatUIKitTheme.colors.textColorTertiary + label.textAlignment = .center + return label + }() + + // MARK: - Init + + init(showIndexBar: Bool = true, onItemClick: @escaping (AZOrderedListItem) -> Void) { + self.showIndexBar = showIndexBar + self.onItemClick = onItemClick + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - RTCBaseView Lifecycle + + public override func constructViewHierarchy() { + addSubview(tableView) + addSubview(emptyView) + emptyView.addSubview(emptyIllustrationView) + emptyView.addSubview(emptyLabel) + addSubview(indexBar) + } + + public override func activateConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + emptyView.snp.makeConstraints { make in + make.center.equalToSuperview() + } + emptyIllustrationView.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.width.equalTo(Self.emptyIllustrationWidth) + make.height.equalTo(Self.emptyIllustrationWidth).multipliedBy(Self.emptyIllustrationAspectRatio) + } + emptyLabel.snp.makeConstraints { make in + make.top.equalTo(emptyIllustrationView.snp.bottom).offset(Self.emptyTextTopSpacing) + make.leading.trailing.bottom.equalToSuperview() + } + indexBar.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.indexBarTrailingPadding) + make.centerY.equalToSuperview() + } + } + + public override func bindInteraction() { + tableView.dataSource = self + tableView.delegate = self + indexBar.onLetterSelected = { [weak self] letter in + self?.scrollToSection(letter) + } + indexBar.onDragStart = { [weak self] in + guard let self else { return } + self.tableView.setContentOffset(self.tableView.contentOffset, animated: false) + } + } + + public override func setupViewStyle() { + let color = ChatUIKitTheme.colors.bgColorOperate + backgroundColor = color + tableView.backgroundColor = color + } + + override func layoutSubviews() { + super.layoutSubviews() + sizeHeaderToFit() + } + + // MARK: - Public + + func setItems(_ items: [AZOrderedListItem]) { + let grouped = AZOrderedListSorter.group(items) + sections = grouped.sections + sectionTitles = grouped.titles + emptyView.isHidden = !isEmpty + indexBar.setLetters(sectionTitles) + indexBar.isHidden = !showIndexBar || isEmpty + tableView.reloadData() + refreshHeaderVisibility() + } + + // MARK: - Header Layout + + private func scrollToSection(_ letter: String) { + guard let section = sectionTitles.firstIndex(of: letter), + section < sections.count, + !sections[section].items.isEmpty else { return } + tableView.scrollToRow(at: IndexPath(row: 0, section: section), at: .top, animated: false) + } + + private func refreshHeaderVisibility() { + tableView.tableHeaderView = isHeaderHidden ? nil : headerView + sizeHeaderToFit() + } + + private func sizeHeaderToFit() { + guard let header = tableView.tableHeaderView else { return } + let targetWidth = tableView.bounds.width + guard targetWidth > 0 else { return } + let fitting = header.systemLayoutSizeFitting( + CGSize(width: targetWidth, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + if header.frame.height != fitting.height || header.frame.width != targetWidth { + header.frame = CGRect(x: 0, y: 0, width: targetWidth, height: fitting.height) + tableView.tableHeaderView = header + } + } +} + +// MARK: - UITableViewDataSource + +extension AZOrderedListView: UITableViewDataSource { + + public func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].items.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: AZOrderedListCell.reuseIdentifier, + for: indexPath + ) as? AZOrderedListCell else { + return UITableViewCell() + } + let items = sections[indexPath.section].items + cell.titleFontSize = itemTitleFontSize + cell.configure(with: items[indexPath.row], showsDivider: indexPath.row < items.count - 1) + return cell + } + +} + +// MARK: - UITableViewDelegate + +extension AZOrderedListView: UITableViewDelegate { + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return showIndexBar ? Self.sectionHeaderHeight : 0 + } + + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard showIndexBar else { return nil } + let colors = ChatUIKitTheme.colors + let container = UIView() + container.backgroundColor = colors.bgColorInput + let label = UILabel() + label.text = sections[section].sectionTitle + label.font = FontScheme.caption2Bold + label.textColor = colors.textColorPrimary + container.addSubview(label) + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.sectionHeaderHorizontalPadding) + make.centerY.equalToSuperview() + } + return container + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + onUserInteraction?() + onItemClick(sections[indexPath.section].items[indexPath.row]) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + onUserInteraction?() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let firstVisible = tableView.indexPathsForVisibleRows?.first, + firstVisible.section < sectionTitles.count else { return } + indexBar.setCurrentLetter(sectionTitles[firstVisible.section]) + } +} diff --git a/chat/uikit/components/common/azorderedlist/AtomicIndexBarView.swift b/chat/uikit/components/common/azorderedlist/AtomicIndexBarView.swift new file mode 100644 index 000000000..8253cd670 --- /dev/null +++ b/chat/uikit/components/common/azorderedlist/AtomicIndexBarView.swift @@ -0,0 +1,145 @@ +import UIKit + +final class AtomicIndexBarView: UIView { + var onLetterSelected: ((String) -> Void)? + + var onDragStart: (() -> Void)? + + var onDragEnd: (() -> Void)? + + override var intrinsicContentSize: CGSize { + if letters.isEmpty { + return CGSize(width: Self.barWidth, height: 0) + } + let contentHeight = CGFloat(letters.count) * Self.letterSlotHeight + + CGFloat(letters.count - 1) * Self.letterSpacing + + Self.verticalPadding * 2 + return CGSize(width: Self.barWidth, height: contentHeight) + } + + private static let barWidth: CGFloat = 24 + + private static let letterSlotHeight: CGFloat = 16 + + private static let letterSpacing: CGFloat = 1 + + private static let verticalPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let highlightedDiameter: CGFloat = 20 + + private var letters: [String] = [] + + private var currentLetter: String? + + private var draggedLetter: String? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isUserInteractionEnabled = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setLetters(_ letters: [String]) { + self.letters = letters + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + + func setCurrentLetter(_ letter: String?) { + if currentLetter != letter { + currentLetter = letter + setNeedsDisplay() + } + } + + override func draw(_ rect: CGRect) { + guard !letters.isEmpty, let context = UIGraphicsGetCurrentContext() else { return } + let colors = ChatUIKitTheme.colors + let centerX = bounds.width / 2 + + for (index, letter) in letters.enumerated() { + let slotTop = Self.verticalPadding + CGFloat(index) * (Self.letterSlotHeight + Self.letterSpacing) + let centerY = slotTop + Self.letterSlotHeight / 2 + let isDragged = draggedLetter == letter + let isCurrent = draggedLetter == nil && currentLetter == letter + let isHighlighted = isDragged || isCurrent + + let textColor = isHighlighted ? colors.textColorButton : colors.textColorLink + if isHighlighted { + context.setFillColor(colors.textColorLink.cgColor) + let circleRect = CGRect( + x: centerX - Self.highlightedDiameter / 2, + y: centerY - Self.highlightedDiameter / 2, + width: Self.highlightedDiameter, + height: Self.highlightedDiameter + ) + context.fillEllipse(in: circleRect) + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: FontScheme.caption3Regular, + .foregroundColor: textColor, + ] + let textSize = (letter as NSString).size(withAttributes: attributes) + let textRect = CGRect( + x: centerX - textSize.width / 2, + y: centerY - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + (letter as NSString).draw(in: textRect, withAttributes: attributes) + } + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + onDragStart?() + updateDraggedLetter(at: touch.location(in: self).y, notify: true) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + updateDraggedLetter(at: touch.location(in: self).y, notify: true) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + draggedLetter = nil + setNeedsDisplay() + onDragEnd?() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + draggedLetter = nil + setNeedsDisplay() + onDragEnd?() + } + + private func updateDraggedLetter(at y: CGFloat, notify: Bool) { + guard let letter = letter(at: y) else { return } + if draggedLetter != letter { + draggedLetter = letter + setNeedsDisplay() + if notify { + onLetterSelected?(letter) + } + } + } + + private func letter(at y: CGFloat) -> String? { + guard !letters.isEmpty else { return nil } + let step = Self.letterSlotHeight + Self.letterSpacing + let relativeY = y - Self.verticalPadding + if relativeY <= 0 { + return letters.first + } + let index = Int(relativeY / step) + if index >= letters.count { + return letters.last + } + return letters[index] + } +} diff --git a/chat/uikit/components/common/theme/ChatUIKitTheme.swift b/chat/uikit/components/common/theme/ChatUIKitTheme.swift new file mode 100644 index 000000000..f4a9d8d76 --- /dev/null +++ b/chat/uikit/components/common/theme/ChatUIKitTheme.swift @@ -0,0 +1,96 @@ +import UIKit + +public enum ChatUIKitTheme { + + public static var colors: ChatUIKitColorScheme { + return ChatUIKitColorScheme() + } +} + +public struct ChatUIKitColorScheme { + public var bgColorOperate: UIColor { dynamic(\.bgColorOperate) } + + public var bgColorInput: UIColor { dynamic(\.bgColorInput) } + + public var bgColorTopBar: UIColor { dynamic(\.bgColorTopBar) } + + public var bgColorDefault: UIColor { dynamic(\.bgColorDefault) } + + public var bgColorEntryCard: UIColor { dynamic(\.bgColorEntryCard) } + + public var bgColorBottomBar: UIColor { dynamic(\.bgColorBottomBar) } + + public var bgColorBubbleOwn: UIColor { dynamic(\.bgColorBubbleOwn) } + + public var bgColorBubbleReciprocal: UIColor { dynamic(\.bgColorBubbleReciprocal) } + + public var bgColorAvatar: UIColor { dynamic(\.bgColorAvatar) } + + public var bgColorDialog: UIColor { dynamic(\.bgColorDialog) } + + public var textColorPrimary: UIColor { dynamic(\.textColorPrimary) } + + public var textColorSecondary: UIColor { dynamic(\.textColorSecondary) } + + public var textColorTertiary: UIColor { dynamic(\.textColorTertiary) } + + public var textColorAntiPrimary: UIColor { dynamic(\.textColorAntiPrimary) } + + public var textColorAntiSecondary: UIColor { dynamic(\.textColorAntiSecondary) } + + public var textColorLink: UIColor { dynamic(\.textColorLink) } + + public var textColorButton: UIColor { dynamic(\.textColorButton) } + + public var textColorError: UIColor { dynamic(\.textColorError) } + + public var textColorDisable: UIColor { dynamic(\.textColorDisable) } + + public var textColorLinkDisabled: UIColor { dynamic(\.textColorLinkDisabled) } + + public var textColorButtonDisabled: UIColor { dynamic(\.textColorButtonDisabled) } + + public var textColorSuccess: UIColor { dynamic(\.textColorSuccess) } + + public var textColorWarning: UIColor { dynamic(\.textColorWarning) } + + public var shadowColor: UIColor { dynamic(\.shadowColor) } + + public var floatingColorDefault: UIColor { dynamic(\.floatingColorDefault) } + + public var dropdownColorDefault: UIColor { dynamic(\.dropdownColorDefault) } + + public var dropdownColorHover: UIColor { dynamic(\.dropdownColorHover) } + + public var buttonColorPrimaryDefault: UIColor { dynamic(\.buttonColorPrimaryDefault) } + + public var buttonColorPrimaryDisabled: UIColor { dynamic(\.buttonColorPrimaryDisabled) } + + public var buttonColorSecondaryDefault: UIColor { dynamic(\.buttonColorSecondaryDefault) } + + public var buttonColorHangupDefault: UIColor { dynamic(\.buttonColorHangupDefault) } + + public var buttonColorOff: UIColor { dynamic(\.buttonColorOff) } + + public var strokeColorPrimary: UIColor { dynamic(\.strokeColorPrimary) } + + public var strokeColorSecondary: UIColor { dynamic(\.strokeColorSecondary) } + + public var strokeColorModule: UIColor { dynamic(\.strokeColorModule) } + + public var scrollbarColorDefault: UIColor { dynamic(\.scrollbarColorDefault) } + + public var listColorDefault: UIColor { dynamic(\.listColorDefault) } + + public var switchColorOn: UIColor { dynamic(\.switchColorOn) } + + public var clearColor: UIColor { dynamic(\.clearColor) } + + public init() {} + + private func dynamic(_ keyPath: KeyPath) -> UIColor { + return UIColor { _ in + ThemeState.shared.colors[keyPath: keyPath] + } + } +} diff --git a/chat/uikit/components/common/uicustom/CustomEditor.swift b/chat/uikit/components/common/uicustom/CustomEditor.swift new file mode 100644 index 000000000..a3ef6758a --- /dev/null +++ b/chat/uikit/components/common/uicustom/CustomEditor.swift @@ -0,0 +1,88 @@ +import Foundation + +public protocol CustomItem { + var ID: String { get } +} + +public final class CustomEditor { + public private(set) var items: [Item] + + private var didBuild = false + + public init(items: [Item]) { + self.items = items + } + + public func add(_ item: Item) { + prepareMutation() + precondition(!item.ID.isEmpty, "CustomEditor: item ID must not be empty") + precondition(!items.contains { $0.ID == item.ID }, "CustomEditor: duplicated item ID \(item.ID)") + items.append(item) + } + + @discardableResult + public func remove(_ ID: String) -> Bool { + prepareMutation() + let count = items.count + items.removeAll { $0.ID == ID } + return items.count != count + } + + public func replace(_ ID: String, _ transform: (Item) -> Item) { + prepareMutation() + guard let index = items.firstIndex(where: { $0.ID == ID }) else { return } + let replacement = transform(items[index]) + precondition(replacement.ID == ID, "CustomEditor: replacement must keep the same ID") + items[index] = replacement + } + + public func insertBefore(_ anchorID: String, _ item: Item) { + prepareMutation() + precondition(!item.ID.isEmpty, "CustomEditor: item ID must not be empty") + precondition(!items.contains { $0.ID == item.ID }, "CustomEditor: duplicated item ID \(item.ID)") + guard let index = items.firstIndex(where: { $0.ID == anchorID }) else { return } + items.insert(item, at: index) + } + + public func insertAfter(_ anchorID: String, _ item: Item) { + prepareMutation() + precondition(!item.ID.isEmpty, "CustomEditor: item ID must not be empty") + precondition(!items.contains { $0.ID == item.ID }, "CustomEditor: duplicated item ID \(item.ID)") + guard let index = items.firstIndex(where: { $0.ID == anchorID }) else { return } + items.insert(item, at: index + 1) + } + + public func moveBefore(_ ID: String, _ anchorID: String) { + prepareMutation() + guard ID != anchorID, + let from = items.firstIndex(where: { $0.ID == ID }), + let to = items.firstIndex(where: { $0.ID == anchorID }) else { return } + let item = items.remove(at: from) + let target = items.firstIndex(where: { $0.ID == anchorID }) ?? to + items.insert(item, at: target) + } + + public func moveAfter(_ ID: String, _ anchorID: String) { + prepareMutation() + guard ID != anchorID, + let from = items.firstIndex(where: { $0.ID == ID }), + let to = items.firstIndex(where: { $0.ID == anchorID }) else { return } + let item = items.remove(at: from) + let target = items.firstIndex(where: { $0.ID == anchorID }) ?? (to - 1) + items.insert(item, at: target + 1) + } + + public func clear() { + prepareMutation() + items.removeAll() + } + + public func build() -> [Item] { + didBuild = true + return items + } + + private func prepareMutation() { + precondition(!didBuild, "CustomEditor: cannot mutate after build()") + } +} diff --git a/chat/uikit/components/contactlist/AddFriendViewController.swift b/chat/uikit/components/contactlist/AddFriendViewController.swift new file mode 100644 index 000000000..0171f17ba --- /dev/null +++ b/chat/uikit/components/contactlist/AddFriendViewController.swift @@ -0,0 +1,32 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class AddFriendViewController: ChatSettingBaseViewController { + private let impl = AddFriendViewControllerImpl() + + override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("ContactListAddContact")) + view.addSubview(impl) + impl.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + impl.onAddContact = { [weak self] contact in + self?.pushAddContact(contact) + } + } + + private func pushAddContact(_ contact: ContactInfo) { + let detail = AddContactViewController( + userID: contact.userID, + contactInfo: contact, + onAddFriendSuccess: { [weak self] in + guard let self = self, let navigationController = self.navigationController else { return } + navigationController.viewControllers.removeAll { $0 === self } + } + ) + navigationController?.pushViewController(detail, animated: true) + } +} diff --git a/chat/uikit/components/contactlist/ContactListView.swift b/chat/uikit/components/contactlist/ContactListView.swift new file mode 100644 index 000000000..2c09fe147 --- /dev/null +++ b/chat/uikit/components/contactlist/ContactListView.swift @@ -0,0 +1,141 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +protocol ContactListConfigProtocol { + var showNewContacts: Bool { get } + var showGroupApplications: Bool { get } + var showMyGroups: Bool { get } + var showBlacklist: Bool { get } + var showSearchBar: Bool { get } + var itemCustomizer: ContactListItemCustomizer? { get } +} + +extension ContactListConfigProtocol { + var showNewContacts: Bool { true } + var showGroupApplications: Bool { true } + var showMyGroups: Bool { true } + var showBlacklist: Bool { true } + var showSearchBar: Bool { true } + var itemCustomizer: ContactListItemCustomizer? { nil } +} + +struct ChatContactListConfig: ContactListConfigProtocol { + var showNewContacts: Bool { + return userShowNewContacts ?? true + } + + var showGroupApplications: Bool { + return userShowGroupApplications ?? true + } + + var showMyGroups: Bool { + return userShowMyGroups ?? true + } + + var showBlacklist: Bool { + return userShowBlacklist ?? true + } + + var showSearchBar: Bool { + return userShowSearchBar ?? true + } + + var itemCustomizer: ContactListItemCustomizer? { + return userItemCustomizer + } + + private let userShowNewContacts: Bool? + + private let userShowGroupApplications: Bool? + + private let userShowMyGroups: Bool? + + private let userShowBlacklist: Bool? + + private let userShowSearchBar: Bool? + + private let userItemCustomizer: ContactListItemCustomizer? + + init() { + self.userShowNewContacts = nil + self.userShowGroupApplications = nil + self.userShowMyGroups = nil + self.userShowBlacklist = nil + self.userShowSearchBar = nil + self.userItemCustomizer = nil + } + + init(showNewContacts: Bool? = nil, + showGroupApplications: Bool? = nil, + showMyGroups: Bool? = nil, + showBlacklist: Bool? = nil, + showSearchBar: Bool? = nil, + itemCustomizer: ContactListItemCustomizer? = nil) { + self.userShowNewContacts = showNewContacts + self.userShowGroupApplications = showGroupApplications + self.userShowMyGroups = showMyGroups + self.userShowBlacklist = showBlacklist + self.userShowSearchBar = showSearchBar + self.userItemCustomizer = itemCustomizer + } +} + +typealias ContactListItemCustomizer = (CustomEditor) -> Void + +struct ContactCustomItem: CustomItem { + var ID: String + + let title: String + + let iconName: String + + let badgeCount: AnyPublisher? + + let onClick: () -> Void + + init(ID: String, + title: String, + iconName: String, + badgeCount: AnyPublisher? = nil, + onClick: @escaping () -> Void) { + self.ID = ID + self.title = title + self.iconName = iconName + self.badgeCount = badgeCount + self.onClick = onClick + } +} + +enum ContactDisplayFormatter { + static func name(for contact: ContactInfo) -> String { + if let remark = contact.friendRemark, !remark.isEmpty { return remark } + if let nickname = contact.nickname, !nickname.isEmpty { return nickname } + return contact.userID + } + + static func name(for group: GroupInfo) -> String { + if let groupName = group.groupName, !groupName.isEmpty { return groupName } + return group.groupID + } +} + +final class ContactListView: UIView { + private let impl: ContactListViewImpl + + init(onContactClick: @escaping (AZOrderedListItem) -> Void, + onGroupClick: @escaping (AZOrderedListItem) -> Void, + config: ContactListConfigProtocol = ChatContactListConfig()) { + impl = ContactListViewImpl(onContactClick: onContactClick, onGroupClick: onGroupClick, config: config) + super.init(frame: .zero) + addSubview(impl) + impl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/chat/uikit/components/contactlist/JoinGroupViewController.swift b/chat/uikit/components/contactlist/JoinGroupViewController.swift new file mode 100644 index 000000000..d06b1e75e --- /dev/null +++ b/chat/uikit/components/contactlist/JoinGroupViewController.swift @@ -0,0 +1,42 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class JoinGroupViewController: ChatSettingBaseViewController { + var onEnterGroupChat: ((GroupInfo) -> Void)? + + private let impl = JoinGroupViewControllerImpl() + + override func viewDidLoad() { + super.viewDidLoad() + setNavTitle(LocalizedChatString("ContactsJoinGroupTitle")) + view.addSubview(impl) + impl.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + impl.onEnterGroupChat = { [weak self] group in + self?.onEnterGroupChat?(group) + } + impl.onJoinGroup = { [weak self] group in + self?.pushJoinGroupDetail(group) + } + } + + private func pushJoinGroupDetail(_ group: GroupInfo) { + let detail = JoinGroupDetailViewController(groupInfo: group) { [weak self] in + self?.dismissFlow() + } + navigationController?.pushViewController(detail, animated: true) + } + + private func dismissFlow() { + guard let navigationController = navigationController, + let index = navigationController.viewControllers.firstIndex(of: self), + index > 0 else { + dismiss(animated: true) + return + } + navigationController.popToViewController(navigationController.viewControllers[index - 1], animated: true) + } +} diff --git a/chat/uikit/components/contactlist/utils/ContactDisplayNameFormatter.swift b/chat/uikit/components/contactlist/utils/ContactDisplayNameFormatter.swift new file mode 100644 index 000000000..26b2e3dc8 --- /dev/null +++ b/chat/uikit/components/contactlist/utils/ContactDisplayNameFormatter.swift @@ -0,0 +1,39 @@ +import Foundation +import AtomicXCore + +enum ContactDisplayNameFormatter { + + static func name(for contact: ContactInfo) -> String { + if let remark = contact.friendRemark, !remark.isEmpty { + return remark + } + if let nickname = contact.nickname, !nickname.isEmpty { + return nickname + } + return contact.userID + } + + static func name(for group: GroupInfo) -> String { + if let groupName = group.groupName, !groupName.isEmpty { + return groupName + } + return group.groupID + } + + static func name(for application: FriendApplicationInfo) -> String { + if let title = application.title, !title.isEmpty { + return title + } + return application.userID + } + + static func name(for application: GroupApplicationInfo) -> String { + if let nickname = application.fromUserNickname, !nickname.isEmpty { + return nickname + } + if let fromUser = application.fromUser, !fromUser.isEmpty { + return fromUser + } + return application.applicationID + } +} diff --git a/chat/uikit/components/contactlist/view/AddFriendViewControllerImpl.swift b/chat/uikit/components/contactlist/view/AddFriendViewControllerImpl.swift new file mode 100644 index 000000000..e05a2f84b --- /dev/null +++ b/chat/uikit/components/contactlist/view/AddFriendViewControllerImpl.swift @@ -0,0 +1,181 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class AddFriendViewControllerImpl: RTCBaseView { + var onAddContact: ((ContactInfo) -> Void)? + + private static let myIDTopSpacing: CGFloat = 80 + + private static let searchBarContainerInsets = UIEdgeInsets( + top: CGFloat(SpacingScheme.iconIconSpacing), + left: CGFloat(SpacingScheme.bubbleSpacing), + bottom: CGFloat(SpacingScheme.iconIconSpacing), + right: CGFloat(SpacingScheme.bubbleSpacing) + ) + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let loadingTopSpacing: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let emptyFontSize: CGFloat = 17 + + private let searchBar = ContactSearchBarView() + + private lazy var resultCell = ContactSearchResultCell { [weak self] in + self?.handleResultTapped() + } + + private let myIDLabel = UILabel() + + private let emptyLabel = UILabel() + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private var foundContact: ContactInfo? + + private var hasSearched = false + + init() { + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func constructViewHierarchy() { + addSubview(searchBar) + addSubview(myIDLabel) + addSubview(resultCell) + addSubview(emptyLabel) + addSubview(loadingIndicator) + } + + public override func activateConstraints() { + searchBar.containerInsets = Self.searchBarContainerInsets + searchBar.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + myIDLabel.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Self.myIDTopSpacing) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + } + resultCell.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Self.myIDTopSpacing) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + } + loadingIndicator.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Self.loadingTopSpacing) + make.centerX.equalToSuperview() + } + } + + public override func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + searchBar.placeholder = LocalizedChatString("ProfileUserID") + myIDLabel.font = FontScheme.caption2Regular + myIDLabel.textColor = colors.textColorSecondary + myIDLabel.textAlignment = .center + emptyLabel.text = LocalizedChatString("ContactNoInformation") + emptyLabel.font = .systemFont(ofSize: Self.emptyFontSize) + emptyLabel.textColor = colors.textColorSecondary + emptyLabel.textAlignment = .center + resultCell.isHidden = true + emptyLabel.isHidden = true + refreshMyIDLabel() + } + + public override func bindInteraction() { + searchBar.onSubmit = { [weak self] in + self?.searchUser() + } + searchBar.onQueryChange = { [weak self] _ in + guard let self = self else { return } + if self.searchBar.currentText.isEmpty { + self.hasSearched = false + self.foundContact = nil + self.resultCell.isHidden = true + self.emptyLabel.isHidden = true + self.refreshMyIDLabel() + } + } + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + backgroundTap.cancelsTouchesInView = false + addGestureRecognizer(backgroundTap) + ContactStore.shared.loadFriends(completion: nil) + } + + private func refreshMyIDLabel() { + let showMyID = !hasSearched + myIDLabel.isHidden = !showMyID + if showMyID { + let myUserID = LoginStore.shared.state.value.loginUserInfo?.userID ?? "" + myIDLabel.text = String( + format: LocalizedChatString("ContactLabelValueFormat"), + LocalizedChatString("ContactMyUserID"), + myUserID + ) + } + } + + private func searchUser() { + let keyword = searchBar.currentText.trimmingCharacters(in: .whitespaces) + guard !keyword.isEmpty else { return } + hasSearched = true + refreshMyIDLabel() + foundContact = nil + resultCell.isHidden = true + emptyLabel.isHidden = true + loadingIndicator.startAnimating() + ContactStore.shared.getContactInfo( + userIDList: [keyword], + completion: ContactLookupHandler( + onSuccess: { [weak self] list in + DispatchQueue.main.async { + self?.loadingIndicator.stopAnimating() + self?.applySearchResult(list.first) + } + }, + onFailure: { [weak self] _, _ in + DispatchQueue.main.async { + self?.loadingIndicator.stopAnimating() + self?.applySearchResult(nil) + } + } + ) + ) + } + + private func applySearchResult(_ contact: ContactInfo?) { + foundContact = contact + guard let contact = contact else { + resultCell.isHidden = true + emptyLabel.isHidden = false + return + } + emptyLabel.isHidden = true + resultCell.isHidden = false + resultCell.configure( + avatarURL: contact.avatarURL, + name: ContactDisplayFormatter.name(for: contact), + identityLabelText: LocalizedChatString("ProfileUserID"), + identityValue: contact.userID, + tip: contact.isFriend ? LocalizedChatString("AlreadyFriendTip") : nil + ) + } + + private func handleResultTapped() { + guard let contact = foundContact, !contact.isFriend else { return } + onAddContact?(contact) + } + + @objc private func handleBackgroundTapped() { + searchBar.hideKeyboard() + } +} diff --git a/chat/uikit/components/contactlist/view/ContactAddSupport.swift b/chat/uikit/components/contactlist/view/ContactAddSupport.swift new file mode 100644 index 000000000..cfbe6e20f --- /dev/null +++ b/chat/uikit/components/contactlist/view/ContactAddSupport.swift @@ -0,0 +1,137 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class ContactSearchResultCell: UIControl { + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let verticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let avatarSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private let avatar = ChatAvatarView(size: .l, isRound: false) + + private let textColumn = UIStackView() + + private let nameLabel = UILabel() + + private let identityLabel = UILabel() + + private let tipLabel = UILabel() + + private let handler: () -> Void + + init(handler: @escaping () -> Void) { + self.handler = handler + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(avatarURL: String?, name: String, identityLabelText: String, identityValue: String, tip: String? = nil) { + avatar.configure(avatarURL: avatarURL, fallbackName: name) + nameLabel.text = name + let colors = ChatUIKitTheme.colors + let fullText = String(format: LocalizedChatString("ContactLabelValueFormat"), identityLabelText, identityValue) + let attributed = NSMutableAttributedString( + string: fullText, + attributes: [.foregroundColor: colors.textColorSecondary] + ) + if let valueRange = fullText.range(of: identityValue) { + attributed.addAttribute(.foregroundColor, value: colors.textColorLink, range: NSRange(valueRange, in: fullText)) + } + identityLabel.attributedText = attributed + tipLabel.text = tip + tipLabel.isHidden = tip == nil + } + + private func constructViewHierarchy() { + textColumn.axis = .vertical + textColumn.spacing = 0 + textColumn.addArrangedSubview(nameLabel) + textColumn.addArrangedSubview(identityLabel) + addSubview(avatar) + addSubview(textColumn) + addSubview(tipLabel) + } + + private func activateConstraints() { + avatar.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + make.width.height.equalTo(ChatAvatarSize.l.size) + } + textColumn.snp.makeConstraints { make in + make.leading.equalTo(avatar.snp.trailing).offset(Self.avatarSpacing) + make.centerY.equalTo(avatar) + make.trailing.lessThanOrEqualTo(tipLabel.snp.leading).offset(-Self.avatarSpacing) + } + tipLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + tipLabel.setContentHuggingPriority(.required, for: .horizontal) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + nameLabel.font = FontScheme.caption1Regular + nameLabel.textColor = colors.textColorPrimary + identityLabel.font = FontScheme.caption3Regular + identityLabel.textColor = colors.textColorSecondary + tipLabel.font = FontScheme.caption3Regular + tipLabel.textColor = colors.textColorSecondary + } + + @objc private func handleTap() { + handler() + } +} + +// MARK: - Store Completion Handlers + +final class ContactLookupHandler: GetContactInfoCompletionHandler { + private let onSuccessBlock: ([ContactInfo]) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping ([ContactInfo]) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(contactInfoList: [ContactInfo]) { + onSuccessBlock(contactInfoList) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} + +final class GroupLookupHandler: GetGroupInfoCompletionHandler { + private let onSuccessBlock: (GroupInfo) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (GroupInfo) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(groupInfo: GroupInfo) { + onSuccessBlock(groupInfo) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/contactlist/view/ContactEntryIcons.swift b/chat/uikit/components/contactlist/view/ContactEntryIcons.swift new file mode 100644 index 000000000..e69de29bb diff --git a/chat/uikit/components/contactlist/view/ContactListViewImpl.swift b/chat/uikit/components/contactlist/view/ContactListViewImpl.swift new file mode 100644 index 000000000..5efd5e1e0 --- /dev/null +++ b/chat/uikit/components/contactlist/view/ContactListViewImpl.swift @@ -0,0 +1,228 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +enum ContactListItemIDs { + static let newContacts = "contactList.newContacts" + static let groupApplications = "contactList.groupApplications" + static let myGroups = "contactList.myGroups" + static let blacklist = "contactList.blacklist" +} + +final class ContactListViewImpl: RTCBaseView { + private static let itemTitleFontSize: CGFloat = 18 + + private let onContactClick: (AZOrderedListItem) -> Void + + private let onGroupClick: (AZOrderedListItem) -> Void + + private let config: ContactListConfigProtocol + + private let viewModel = ContactListViewModel() + + private var cancellables = Set() + + private var allContacts: [ContactInfo] = [] + + private var searchQuery = "" + + private lazy var listView: AZOrderedListView = { + let listView = AZOrderedListView(showIndexBar: true) { [weak self] item in + self?.onContactClick(item) + } + listView.itemTitleFontSize = Self.itemTitleFontSize + return listView + }() + + private let searchBar = ContactSearchBarView() + + private let headerView = ContactNavigationHeaderView() + + init(onContactClick: @escaping (AZOrderedListItem) -> Void, + onGroupClick: @escaping (AZOrderedListItem) -> Void, + config: ContactListConfigProtocol = ChatContactListConfig()) { + self.onContactClick = onContactClick + self.onGroupClick = onGroupClick + self.config = config + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func constructViewHierarchy() { + addSubview(searchBar) + addSubview(listView) + } + + public override func activateConstraints() { + searchBar.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + listView.snp.makeConstraints { make in + if config.showSearchBar { + make.top.equalTo(searchBar.snp.bottom) + } else { + make.top.equalToSuperview() + } + make.leading.trailing.bottom.equalToSuperview() + } + searchBar.isHidden = !config.showSearchBar + } + + public override func bindInteraction() { + listView.headerView = headerView + searchBar.onQueryChange = { [weak self] query in + self?.searchQuery = query + self?.renderFriendList() + } + listView.onUserInteraction = { [weak self] in + self?.searchBar.hideKeyboard() + } + headerView.setItems(buildHeaderItems()) + bindViewModel() + viewModel.loadData() + } + + public override func setupViewStyle() { + backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } + + private func buildHeaderItems() -> [ContactCustomItem] { + var defaults: [ContactCustomItem] = [] + if config.showNewContacts { + defaults.append(ContactCustomItem( + ID: ContactListItemIDs.newContacts, + title: LocalizedChatString("ContactsNewFriends"), + iconName: "contact_new_contacts", + badgeCount: viewModel.$friendApplicationUnreadCount.eraseToAnyPublisher(), + onClick: { [weak self] in self?.presentFriendApplications() } + )) + } + if config.showGroupApplications { + defaults.append(ContactCustomItem( + ID: ContactListItemIDs.groupApplications, + title: LocalizedChatString("ContactsGroupApplications"), + iconName: "contact_group_notification", + badgeCount: viewModel.$groupApplicationUnreadCount.eraseToAnyPublisher(), + onClick: { [weak self] in self?.presentGroupApplications() } + )) + } + if config.showMyGroups { + defaults.append(ContactCustomItem( + ID: ContactListItemIDs.myGroups, + title: LocalizedChatString("ContactsGroupChats"), + iconName: "contact_my_group", + onClick: { [weak self] in self?.presentGroupList() } + )) + } + if config.showBlacklist { + defaults.append(ContactCustomItem( + ID: ContactListItemIDs.blacklist, + title: LocalizedChatString("ContactsBlackList"), + iconName: "contact_blacklist", + onClick: { [weak self] in self?.presentBlackList() } + )) + } + guard let customizer = config.itemCustomizer else { return defaults } + let editor = CustomEditor(items: defaults) + customizer(editor) + return editor.build() + } + + private func bindViewModel() { + viewModel.$friendList + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.allContacts = list + self?.renderFriendList() + } + .store(in: &cancellables) + } + + private func renderFriendList() { + let isSearching = !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty + listView.isHeaderHidden = isSearching + let filtered = allContacts.filter { contact in + matchesSearchQuery(contact, query: searchQuery) + } + let items = filtered.map { contact in + AZOrderedListItem( + userID: contact.userID, + avatarURL: contact.avatarURL, + title: ContactDisplayNameFormatter.name(for: contact) + ) + } + listView.setItems(items) + } + + private func matchesSearchQuery(_ contact: ContactInfo, query: String) -> Bool { + let keyword = query.trimmingCharacters(in: .whitespaces) + if keyword.isEmpty { + return true + } + let candidates = [ + ContactDisplayNameFormatter.name(for: contact), + contact.userID, + contact.nickname, + contact.friendRemark, + ] + var seen = Set() + return candidates.compactMap { $0 }.filter { !$0.isEmpty }.contains { value in + if !seen.insert(value).inserted { + return false + } + return value.range(of: keyword, options: .caseInsensitive) != nil + } + } + + private func presentFriendApplications() { + present(FriendApplicationListViewController()) + } + + private func presentGroupApplications() { + present(GroupApplicationListViewController()) + } + + private func presentGroupList() { + let controller = GroupListViewController { [weak self] item in + self?.onGroupClick(item) + } + controller.hidesBottomBarWhenPushed = true + if let navigationController = findViewController()?.navigationController { + navigationController.pushViewController(controller, animated: true) + } else { + present(controller) + } + } + + private func presentBlackList() { + let controller = BlackListViewController { [weak self] item in + self?.onContactClick(item) + } + controller.hidesBottomBarWhenPushed = true + if let navigationController = findViewController()?.navigationController { + navigationController.pushViewController(controller, animated: true) + } else { + present(controller) + } + } + + private func present(_ controller: UIViewController) { + controller.modalPresentationStyle = .fullScreen + findViewController()?.present(controller, animated: true) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } +} diff --git a/chat/uikit/components/contactlist/view/ContactNavigationHeaderView.swift b/chat/uikit/components/contactlist/view/ContactNavigationHeaderView.swift new file mode 100644 index 000000000..e109aef74 --- /dev/null +++ b/chat/uikit/components/contactlist/view/ContactNavigationHeaderView.swift @@ -0,0 +1,183 @@ +import UIKit +import Combine +import SnapKit + +final class ContactNavigationRowView: UIView { + var onTap: (() -> Void)? + + private static let rowHeight: CGFloat = 60 + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let iconSize: CGFloat = 40 + + private static let iconTextSpacing: CGFloat = 14 + + private static let badgeSize: CGFloat = 18 + + private static let badgeMaxCount = 99 + + private static let badgeFontSize: CGFloat = 11 + + private let iconView = UIImageView() + + private let titleLabel = UILabel() + + private let badgeLabel = UILabel() + + init(title: String, icon: UIImage?) { + super.init(frame: .zero) + iconView.image = icon + titleLabel.text = title + constructViewHierarchy() + activateConstraints(hasIcon: icon != nil) + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateBadge(_ count: Int) { + if count > 0 { + badgeLabel.text = count > Self.badgeMaxCount ? "\(Self.badgeMaxCount)+" : "\(count)" + badgeLabel.isHidden = false + } else { + badgeLabel.isHidden = true + } + } + + private func constructViewHierarchy() { + addSubview(iconView) + addSubview(titleLabel) + addSubview(badgeLabel) + } + + private func activateConstraints(hasIcon: Bool) { + snp.makeConstraints { make in + make.height.equalTo(Self.rowHeight) + } + if hasIcon { + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.iconSize) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(Self.iconTextSpacing) + make.centerY.equalToSuperview() + } + } else { + iconView.isHidden = true + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + } + } + badgeLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.badgeSize) + } + } + + private func bindInteraction() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorPrimary + badgeLabel.font = .systemFont(ofSize: Self.badgeFontSize) + badgeLabel.textColor = .white + badgeLabel.backgroundColor = colors.textColorError + badgeLabel.textAlignment = .center + badgeLabel.layer.cornerRadius = Self.badgeSize / 2 + badgeLabel.layer.masksToBounds = true + badgeLabel.isHidden = true + } + + @objc private func handleTap() { + onTap?() + } +} + +final class ContactNavigationHeaderView: UIView { + private static let dividerHeight: CGFloat = 0.5 + + private static let dividerLeadingInset: CGFloat = 68 + + private let stackView = UIStackView() + + private var badgeCancellables = Set() + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setItems(_ items: [ContactCustomItem]) { + badgeCancellables.removeAll() + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (index, item) in items.enumerated() { + let row = ContactNavigationRowView( + title: item.title, + icon: AtomicXChatResources.image(named: item.iconName) + ) + row.onTap = item.onClick + if let badgeCount = item.badgeCount { + badgeCount + .receive(on: DispatchQueue.main) + .sink { [weak row] count in + row?.updateBadge(count) + } + .store(in: &badgeCancellables) + } + stackView.addArrangedSubview(row) + if index < items.count - 1 { + stackView.addArrangedSubview(makeDivider()) + } + } + } + + private func constructViewHierarchy() { + stackView.axis = .vertical + stackView.spacing = 0 + addSubview(stackView) + } + + private func makeDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorSecondary + divider.snp.makeConstraints { make in + make.height.equalTo(Self.dividerHeight) + } + let insetWrapper = UIView() + insetWrapper.addSubview(divider) + divider.snp.makeConstraints { make in + make.top.bottom.trailing.equalToSuperview() + make.leading.equalToSuperview().offset(Self.dividerLeadingInset) + } + return insetWrapper + } + + private func activateConstraints() { + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func setupViewStyle() { + backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } +} diff --git a/chat/uikit/components/contactlist/view/JoinGroupDetailViewController.swift b/chat/uikit/components/contactlist/view/JoinGroupDetailViewController.swift new file mode 100644 index 000000000..84daf9f34 --- /dev/null +++ b/chat/uikit/components/contactlist/view/JoinGroupDetailViewController.swift @@ -0,0 +1,246 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class JoinGroupDetailViewController: ChatSettingBaseViewController { + private static let cardHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let cardVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let avatarTextSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let nameIDSpacing: CGFloat = 2 + + private static let identityFontSize: CGFloat = 12 + + private static let sectionTitleTop: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let sectionTitleBottom: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let inputCardPadding: CGFloat = 14 + + private static let inputCardMinHeight: CGFloat = 120 + + private static let sectionSpacerHeight: CGFloat = 10 + + private static let actionRowHeight: CGFloat = 52 + + private static let toastDuration: TimeInterval = 2 + + private let groupInfo: GroupInfo + + private let dismissAll: () -> Void + + private let scrollView = UIScrollView() + + private let contentStack = UIStackView() + + private let infoCard = UIView() + + private let avatar = ChatAvatarView(size: .l, isRound: false) + + private let nameLabel = UILabel() + + private let idLabel = UILabel() + + private let sectionTitleContainer = UIView() + + private let sectionTitleLabel = UILabel() + + private let inputCard = UIView() + + private let wordingTextView = UITextView() + + private let spacerView = UIView() + + private let sendButton = UIButton(type: .custom) + + private var isSending = false + + init(groupInfo: GroupInfo, dismissAll: @escaping () -> Void) { + self.groupInfo = groupInfo + self.dismissAll = dismissAll + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + // MARK: - Send + + private func constructViewHierarchy() { + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + infoCard.addSubview(avatar) + infoCard.addSubview(nameLabel) + infoCard.addSubview(idLabel) + inputCard.addSubview(wordingTextView) + sectionTitleContainer.addSubview(sectionTitleLabel) + contentStack.addArrangedSubview(infoCard) + contentStack.addArrangedSubview(sectionTitleContainer) + contentStack.addArrangedSubview(inputCard) + contentStack.addArrangedSubview(spacerView) + contentStack.addArrangedSubview(sendButton) + } + + private func activateConstraints() { + scrollView.snp.makeConstraints { make in + make.top.equalTo(contentTopItem) + make.leading.trailing.bottom.equalToSuperview() + } + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + } + avatar.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.cardHorizontalPadding) + make.top.equalToSuperview().offset(Self.cardVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.cardVerticalPadding) + make.width.height.equalTo(ChatAvatarSize.l.size) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatar.snp.trailing).offset(Self.avatarTextSpacing) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.cardHorizontalPadding) + make.centerY.equalTo(avatar).offset(-(Self.identityFontSize + Self.nameIDSpacing) / 2) + } + idLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.top.equalTo(nameLabel.snp.bottom).offset(Self.nameIDSpacing) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.cardHorizontalPadding) + } + wordingTextView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.inputCardPadding) + } + inputCard.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(Self.inputCardMinHeight) + } + sectionTitleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(Self.cardHorizontalPadding) + make.top.equalToSuperview().offset(Self.sectionTitleTop) + make.bottom.equalToSuperview().offset(-Self.sectionTitleBottom) + } + spacerView.snp.makeConstraints { make in + make.height.equalTo(Self.sectionSpacerHeight) + } + sendButton.snp.makeConstraints { make in + make.height.equalTo(Self.actionRowHeight) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorDefault + setNavTitle(LocalizedChatString("GroupDetailTitle")) + contentStack.axis = .vertical + contentStack.spacing = 0 + contentStack.alignment = .fill + + infoCard.backgroundColor = colors.bgColorOperate + let name = ContactDisplayFormatter.name(for: groupInfo) + avatar.configure(avatarURL: groupInfo.avatarURL, fallbackName: name) + nameLabel.text = name + nameLabel.font = FontScheme.caption1Bold + nameLabel.textColor = colors.textColorPrimary + idLabel.text = String( + format: LocalizedChatString("ContactLabelValueFormat"), + LocalizedChatString("GroupID"), + groupInfo.groupID + ) + idLabel.font = .systemFont(ofSize: Self.identityFontSize) + idLabel.textColor = colors.textColorSecondary + + sectionTitleLabel.text = LocalizedChatString("FillVerificationInfo") + sectionTitleLabel.font = FontScheme.caption2Regular + sectionTitleLabel.textColor = colors.textColorSecondary + + inputCard.backgroundColor = colors.bgColorOperate + wordingTextView.backgroundColor = .clear + wordingTextView.font = FontScheme.caption1Regular + wordingTextView.textColor = colors.textColorPrimary + wordingTextView.textContainer.lineFragmentPadding = 0 + wordingTextView.text = Self.defaultWording() + + spacerView.backgroundColor = colors.bgColorDefault + + sendButton.setTitle(LocalizedChatString("Send"), for: .normal) + sendButton.setTitleColor(colors.textColorLink, for: .normal) + sendButton.titleLabel?.font = FontScheme.caption1Regular + sendButton.backgroundColor = colors.bgColorOperate + } + + private func bindInteraction() { + sendButton.addTarget(self, action: #selector(handleSend), for: .touchUpInside) + scrollView.keyboardDismissMode = .onDrag + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + backgroundTap.cancelsTouchesInView = false + scrollView.addGestureRecognizer(backgroundTap) + } + + private static func defaultWording() -> String { + let loginInfo = LoginStore.shared.state.value.loginUserInfo + let nickname = loginInfo?.nickname ?? "" + let displayName = !nickname.isEmpty ? nickname : loginInfo?.userID ?? "" + return String(format: LocalizedChatString("ContactListAddWordingIAm"), displayName) + } + + @objc private func handleSend() { + guard !isSending else { return } + if GroupStore.shared.state.value.joinedGroupList.contains(where: { $0.groupID == groupInfo.groupID }) { + WindowToastManager.shared.show(LocalizedChatString("AlreadyGroupMember"), type: .error, duration: Self.toastDuration) + return + } + setSending(true) + GroupStore.shared.joinGroup( + groupID: groupInfo.groupID, + message: wordingTextView.text, + completion: { [weak self] result in + DispatchQueue.main.async { + self?.handleJoinResult(result) + } + } + ) + } + + private func handleJoinResult(_ result: Result) { + setSending(false) + switch result { + case .success: + WindowToastManager.shared.show(LocalizedChatString("GroupJoinRequestSent"), type: .success, duration: Self.toastDuration) + dismissAll() + case .failure(let error): + WindowToastManager.shared.show(groupErrorText(code: error.code), type: .error, duration: Self.toastDuration) + } + } + + private func groupErrorText(code: Int) -> String { + switch code { + case 10013: return LocalizedChatString("AlreadyGroupMember") + case 10010: return LocalizedChatString("GroupNotFound") + case 10015: return LocalizedChatString("GroupJoinRequestAlreadySent") + case 10016: return LocalizedChatString("GroupJoinForbidden") + default: return LocalizedChatString("GroupJoinRequestFailed") + } + } + + private func setSending(_ sending: Bool) { + isSending = sending + sendButton.isEnabled = !sending + sendButton.setTitle( + sending ? LocalizedChatString("Joining") : LocalizedChatString("Send"), + for: .normal + ) + } + + @objc private func handleBackgroundTapped() { + view.endEditing(true) + } +} diff --git a/chat/uikit/components/contactlist/view/JoinGroupViewControllerImpl.swift b/chat/uikit/components/contactlist/view/JoinGroupViewControllerImpl.swift new file mode 100644 index 000000000..51d736451 --- /dev/null +++ b/chat/uikit/components/contactlist/view/JoinGroupViewControllerImpl.swift @@ -0,0 +1,149 @@ +import AtomicXCore +import SnapKit +import UIKit + +final class JoinGroupViewControllerImpl: RTCBaseView { + var onEnterGroupChat: ((GroupInfo) -> Void)? + + var onJoinGroup: ((GroupInfo) -> Void)? + + private static let emptyTopMargin: CGFloat = CGFloat(SpacingScheme.titleSpacing) + + private static let loadingTopSpacing: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private let searchBar = ContactSearchBarView() + + private lazy var resultCell = ContactSearchResultCell { [weak self] in + self?.handleResultTapped() + } + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private let emptyLabel = UILabel() + + private var foundGroup: GroupInfo? + + private var isJoinedFoundGroup = false + + private var isSearching = false + + init() { + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func constructViewHierarchy() { + addSubview(searchBar) + addSubview(resultCell) + addSubview(loadingIndicator) + addSubview(emptyLabel) + } + + public override func activateConstraints() { + searchBar.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + resultCell.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Self.loadingTopSpacing) + make.centerX.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom).offset(Self.emptyTopMargin) + make.leading.trailing.equalToSuperview() + } + } + + public override func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + searchBar.placeholder = LocalizedChatString("GroupID") + resultCell.isHidden = true + emptyLabel.text = LocalizedChatString("ContactNoInformation") + emptyLabel.font = FontScheme.caption1Regular + emptyLabel.textColor = colors.textColorSecondary + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + } + + public override func bindInteraction() { + searchBar.onSubmit = { [weak self] in + self?.searchGroup() + } + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + backgroundTap.cancelsTouchesInView = false + addGestureRecognizer(backgroundTap) + GroupStore.shared.loadJoinedGroups(completion: nil) + } + + private func searchGroup() { + guard let keyword = currentKeyword(), !keyword.isEmpty else { return } + foundGroup = nil + resultCell.isHidden = true + emptyLabel.isHidden = true + isSearching = true + loadingIndicator.startAnimating() + GroupStore.shared.getGroupInfo( + groupID: keyword, + completion: GroupLookupHandler( + onSuccess: { [weak self] group in + DispatchQueue.main.async { + self?.isSearching = false + self?.loadingIndicator.stopAnimating() + self?.applySearchResult(group) + } + }, + onFailure: { [weak self] _, _ in + DispatchQueue.main.async { + self?.isSearching = false + self?.loadingIndicator.stopAnimating() + self?.applySearchResult(nil) + } + } + ) + ) + } + + private func currentKeyword() -> String? { + return searchBar.currentText.trimmingCharacters(in: .whitespaces) + } + + private func applySearchResult(_ group: GroupInfo?) { + foundGroup = group + guard let group = group else { + resultCell.isHidden = true + emptyLabel.isHidden = false + return + } + emptyLabel.isHidden = true + resultCell.isHidden = false + let isJoined = GroupStore.shared.state.value.joinedGroupList.contains { $0.groupID == group.groupID } + isJoinedFoundGroup = isJoined + resultCell.configure( + avatarURL: group.avatarURL, + name: ContactDisplayFormatter.name(for: group), + identityLabelText: LocalizedChatString("GroupID"), + identityValue: group.groupID, + tip: isJoined ? LocalizedChatString("AlreadyInGroupTip") : nil + ) + } + + private func handleResultTapped() { + guard let group = foundGroup else { return } + if isJoinedFoundGroup { + onEnterGroupChat?(group) + } else { + onJoinGroup?(group) + } + } + + @objc private func handleBackgroundTapped() { + endEditing(true) + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/BlackListViewController.swift b/chat/uikit/components/contactlist/view/subpage/BlackListViewController.swift new file mode 100644 index 000000000..0b65742b5 --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/BlackListViewController.swift @@ -0,0 +1,135 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class BlackListViewController: UIViewController { + private static let navigationBarHeight: CGFloat = 44 + + private let onContactClick: (AZOrderedListItem) -> Void + + private let viewModel = BlackListViewModel() + + private var blackList: [ContactInfo] = [] + + private var searchQuery = "" + + private var cancellables = Set() + + private lazy var navigationBar = SubPageNavigationBar(title: LocalizedChatString("ContactsBlackList")) + + private let searchBar = ContactSearchBarView() + + private lazy var listView: AZOrderedListView = { + let view = AZOrderedListView(showIndexBar: true) { [weak self] item in + self?.onContactClick(item) + } + view.emptyText = LocalizedChatString("ContactNoBlacklistUsers") + return view + }() + + init(onContactClick: @escaping (AZOrderedListItem) -> Void) { + self.onContactClick = onContactClick + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindInteraction() + bindViewModel() + viewModel.loadData() + } + + private func setupHierarchy() { + navigationBar.onClose = { [weak self] in + if let navigationController = self?.navigationController { + navigationController.popViewController(animated: true) + } else { + self?.dismiss(animated: true) + } + } + view.addSubview(navigationBar) + view.addSubview(searchBar) + view.addSubview(listView) + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.navigationBarHeight) + } + searchBar.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.equalToSuperview() + } + listView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func setupStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } + + private func bindInteraction() { + searchBar.onQueryChange = { [weak self] query in + self?.searchQuery = query + self?.renderList() + } + listView.onUserInteraction = { [weak self] in + self?.searchBar.hideKeyboard() + } + } + + private func bindViewModel() { + viewModel.$blackList + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.blackList = list + self?.renderList() + } + .store(in: &cancellables) + } + + private func renderList() { + let isSearching = !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty + listView.emptyText = isSearching + ? LocalizedChatString("SearchBlacklistUserNotFound") + : LocalizedChatString("ContactNoBlacklistUsers") + let filtered = blackList.filter { matchesSearchQuery($0, query: searchQuery) } + listView.setItems(filtered.map { item(for: $0) }) + } + + private func matchesSearchQuery(_ contact: ContactInfo, query: String) -> Bool { + let keyword = query.trimmingCharacters(in: .whitespaces) + if keyword.isEmpty { + return true + } + let candidates = [ + ContactDisplayNameFormatter.name(for: contact), + contact.userID, + contact.nickname, + contact.friendRemark, + ] + var seen = Set() + return candidates.compactMap { $0 }.filter { !$0.isEmpty }.contains { value in + if !seen.insert(value).inserted { + return false + } + return value.range(of: keyword, options: .caseInsensitive) != nil + } + } + + private func item(for contact: ContactInfo) -> AZOrderedListItem { + AZOrderedListItem( + userID: contact.userID, + avatarURL: contact.avatarURL, + title: ContactDisplayNameFormatter.name(for: contact) + ) + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/FriendApplicationCell.swift b/chat/uikit/components/contactlist/view/subpage/FriendApplicationCell.swift new file mode 100644 index 000000000..af3e32e57 --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/FriendApplicationCell.swift @@ -0,0 +1,162 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class FriendApplicationCell: UITableViewCell { + static let reuseIdentifier = "FriendApplicationCell" + + private static let rowVerticalPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let avatarTextSpacing: CGFloat = 13 + + private static let buttonWidth: CGFloat = 70 + + private static let buttonHeight: CGFloat = 32 + + private static let buttonCornerRadius: CGFloat = 10 + + private static let buttonSpacing: CGFloat = 10 + + private static let refuseBorderWidth: CGFloat = 1 + + private var onAccept: (() -> Void)? + + private var onRefuse: (() -> Void)? + + private let avatarView = ChatAvatarView(size: .m, isRound: false) + + private let textColumn = UIStackView() + + private let nameLabel = UILabel() + + private let wordingLabel = UILabel() + + private let acceptButton = UIButton(type: .custom) + + private let refuseButton = UIButton(type: .custom) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with application: FriendApplicationInfo, + onAccept: @escaping () -> Void, + onRefuse: @escaping () -> Void) { + self.onAccept = onAccept + self.onRefuse = onRefuse + let displayName = ContactDisplayNameFormatter.name(for: application) + nameLabel.text = displayName + wordingLabel.text = application.addWording ?? "" + avatarView.configure(avatarURL: application.avatarURL, fallbackName: displayName) + } + + override func prepareForReuse() { + super.prepareForReuse() + nameLabel.text = nil + wordingLabel.text = nil + onAccept = nil + onRefuse = nil + } + + private func constructViewHierarchy() { + selectionStyle = .none + textColumn.axis = .vertical + textColumn.spacing = 0 + textColumn.addArrangedSubview(nameLabel) + textColumn.addArrangedSubview(wordingLabel) + contentView.addSubview(avatarView) + contentView.addSubview(textColumn) + contentView.addSubview(acceptButton) + contentView.addSubview(refuseButton) + } + + private func activateConstraints() { + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.rowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.rowVerticalPadding) + make.width.height.equalTo(ChatAvatarSize.m.size) + } + textColumn.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarTextSpacing) + make.centerY.equalTo(avatarView) + make.trailing.lessThanOrEqualTo(acceptButton.snp.leading).offset(-Self.buttonSpacing) + } + acceptButton.snp.makeConstraints { make in + make.trailing.equalTo(refuseButton.snp.leading).offset(-Self.buttonSpacing) + make.centerY.equalTo(avatarView) + make.width.equalTo(Self.buttonWidth) + make.height.equalTo(Self.buttonHeight) + } + refuseButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalTo(avatarView) + make.width.equalTo(Self.buttonWidth) + make.height.equalTo(Self.buttonHeight) + } + } + + private func bindInteraction() { + acceptButton.addTarget(self, action: #selector(handleAccept), for: .touchUpInside) + refuseButton.addTarget(self, action: #selector(handleRefuse), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + nameLabel.font = FontScheme.caption2Regular + nameLabel.textColor = colors.textColorPrimary + wordingLabel.font = FontScheme.caption3Regular + wordingLabel.textColor = colors.textColorSecondary + styleActionButton( + acceptButton, + title: LocalizedChatString("Agree"), + titleColor: colors.textColorButton, + backgroundColor: colors.buttonColorPrimaryDefault, + borderColor: nil + ) + styleActionButton( + refuseButton, + title: LocalizedChatString("Decline"), + titleColor: colors.textColorError, + backgroundColor: .clear, + borderColor: colors.strokeColorPrimary + ) + } + + private func styleActionButton(_ button: UIButton, + title: String, + titleColor: UIColor, + backgroundColor: UIColor, + borderColor: UIColor?) { + button.setTitle(title, for: .normal) + button.setTitleColor(titleColor, for: .normal) + button.titleLabel?.font = FontScheme.caption2Regular + button.backgroundColor = backgroundColor + button.layer.cornerRadius = Self.buttonCornerRadius + button.layer.masksToBounds = true + if let borderColor = borderColor { + button.layer.borderWidth = Self.refuseBorderWidth + button.layer.borderColor = borderColor.cgColor + } + } + + @objc private func handleAccept() { + onAccept?() + } + + @objc private func handleRefuse() { + onRefuse?() + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/FriendApplicationListViewController.swift b/chat/uikit/components/contactlist/view/subpage/FriendApplicationListViewController.swift new file mode 100644 index 000000000..487d5d972 --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/FriendApplicationListViewController.swift @@ -0,0 +1,144 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class FriendApplicationListViewController: UIViewController { + private let viewModel = FriendApplicationListViewModel() + + private var applications: [FriendApplicationInfo] = [] + + private var cancellables = Set() + + private static let listTopInset: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let emptyFontSize: CGFloat = 17 + + private static let estimatedRowHeight: CGFloat = 56 + + private static let navigationBarHeight: CGFloat = 44 + + private static let toastDuration: TimeInterval = 3 + + private lazy var navigationBar = SubPageNavigationBar(title: LocalizedChatString("ContactsFriendApplicationTitle")) + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = Self.estimatedRowHeight + table.contentInset = UIEdgeInsets(top: Self.listTopInset, left: 0, bottom: 0, right: 0) + table.register(FriendApplicationCell.self, forCellReuseIdentifier: FriendApplicationCell.reuseIdentifier) + return table + }() + + private lazy var emptyLabel: UILabel = { + let label = UILabel() + label.text = LocalizedChatString("ContactNoFriendApplication") + label.font = .systemFont(ofSize: Self.emptyFontSize) + label.textColor = ChatUIKitTheme.colors.textColorSecondary + label.textAlignment = .center + label.isHidden = true + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindViewModel() + viewModel.loadData() + } + + private func setupHierarchy() { + navigationBar.onClose = { [weak self] in + self?.dismiss(animated: true) + } + view.addSubview(navigationBar) + view.addSubview(tableView) + view.addSubview(emptyLabel) + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.navigationBarHeight) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + tableView.dataSource = self + } + + private func setupStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + tableView.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } + + private func bindViewModel() { + viewModel.$applications + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + guard let self = self else { return } + self.applications = list + self.emptyLabel.isHidden = !list.isEmpty + self.tableView.isHidden = list.isEmpty + self.tableView.reloadData() + } + .store(in: &cancellables) + } + + private func accept(_ application: FriendApplicationInfo) { + viewModel.accept(application) { result in + DispatchQueue.main.async { + switch result { + case .success: + WindowToastManager.shared.show(LocalizedChatString("FriendRequestAccepted"), type: .success, duration: Self.toastDuration) + case .failure: + WindowToastManager.shared.show(LocalizedChatString("FriendRequestAcceptFailed"), type: .error, duration: Self.toastDuration) + } + } + } + } + + private func refuse(_ application: FriendApplicationInfo) { + viewModel.refuse(application) { result in + DispatchQueue.main.async { + switch result { + case .success: + WindowToastManager.shared.show(LocalizedChatString("FriendRequestDeclined"), type: .info, duration: Self.toastDuration) + case .failure: + WindowToastManager.shared.show(LocalizedChatString("FriendRequestDeclineFailed"), type: .error, duration: Self.toastDuration) + } + } + } + } +} + +// MARK: - UITableViewDataSource + +extension FriendApplicationListViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return applications.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: FriendApplicationCell.reuseIdentifier, + for: indexPath + ) as? FriendApplicationCell else { + return UITableViewCell() + } + let application = applications[indexPath.row] + cell.configure( + with: application, + onAccept: { [weak self] in self?.accept(application) }, + onRefuse: { [weak self] in self?.refuse(application) } + ) + return cell + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/GroupApplicationCell.swift b/chat/uikit/components/contactlist/view/subpage/GroupApplicationCell.swift new file mode 100644 index 000000000..0c890a1e2 --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/GroupApplicationCell.swift @@ -0,0 +1,258 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class GroupApplicationCell: UITableViewCell { + static let reuseIdentifier = "GroupApplicationCell" + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let verticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let avatarSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let personFontSize: CGFloat = 13 + + private static let requestMsgMaxLines = 2 + + private static let buttonWidth: CGFloat = 60 + + private static let buttonHeight: CGFloat = 32 + + private static let buttonCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let buttonSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let refuseBorderWidth: CGFloat = 1 + + private var onAccept: (() -> Void)? + + private var onRefuse: (() -> Void)? + + private let avatarView = ChatAvatarView(size: .m, isRound: false) + + private let textStackView = UIStackView() + + private let typeLabel = UILabel() + + private let personLabel = UILabel() + + private let groupNameLabel = UILabel() + + private let requestMsgLabel = UILabel() + + private let statusLabel = UILabel() + + private let acceptButton = UIButton(type: .custom) + + private let refuseButton = UIButton(type: .custom) + + private let actionStackView = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with application: GroupApplicationInfo, + onAccept: @escaping () -> Void, + onRefuse: @escaping () -> Void) { + self.onAccept = onAccept + self.onRefuse = onRefuse + typeLabel.text = Self.applicationTypeText(application) + personLabel.text = Self.personText(application) + groupNameLabel.text = "\(LocalizedChatString("GroupID")) \u{FF1A}\(application.groupID)" + if let requestMsg = application.requestMsg, !requestMsg.isEmpty { + requestMsgLabel.text = requestMsg + requestMsgLabel.isHidden = false + } else { + requestMsgLabel.text = nil + requestMsgLabel.isHidden = true + } + let displayName = Self.fromUserDisplayName(application) + avatarView.configure(avatarURL: application.fromUserAvatarURL, fallbackName: displayName) + applyHandledState(application) + } + + override func prepareForReuse() { + super.prepareForReuse() + typeLabel.text = nil + personLabel.text = nil + groupNameLabel.text = nil + requestMsgLabel.text = nil + statusLabel.text = nil + onAccept = nil + onRefuse = nil + } + + private func constructViewHierarchy() { + selectionStyle = .none + textStackView.axis = .vertical + textStackView.spacing = 0 + textStackView.addArrangedSubview(typeLabel) + textStackView.addArrangedSubview(personLabel) + textStackView.addArrangedSubview(groupNameLabel) + textStackView.addArrangedSubview(requestMsgLabel) + + actionStackView.axis = .horizontal + actionStackView.spacing = Self.buttonSpacing + actionStackView.addArrangedSubview(acceptButton) + actionStackView.addArrangedSubview(refuseButton) + + contentView.addSubview(avatarView) + contentView.addSubview(textStackView) + contentView.addSubview(actionStackView) + contentView.addSubview(statusLabel) + } + + private func activateConstraints() { + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.verticalPadding) + make.width.height.equalTo(ChatAvatarSize.m.size) + } + textStackView.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarSpacing) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + make.trailing.lessThanOrEqualTo(actionStackView.snp.leading).offset(-Self.buttonSpacing) + } + actionStackView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + acceptButton.snp.makeConstraints { make in + make.width.equalTo(Self.buttonWidth) + make.height.equalTo(Self.buttonHeight) + } + refuseButton.snp.makeConstraints { make in + make.width.equalTo(Self.buttonWidth) + make.height.equalTo(Self.buttonHeight) + } + statusLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + } + + private func bindInteraction() { + acceptButton.addTarget(self, action: #selector(handleAccept), for: .touchUpInside) + refuseButton.addTarget(self, action: #selector(handleRefuse), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + typeLabel.font = FontScheme.caption2Regular + typeLabel.textColor = colors.textColorPrimary + personLabel.font = .systemFont(ofSize: Self.personFontSize) + personLabel.textColor = colors.textColorSecondary + groupNameLabel.font = .systemFont(ofSize: Self.personFontSize) + groupNameLabel.textColor = colors.textColorSecondary + requestMsgLabel.font = FontScheme.caption3Regular + requestMsgLabel.textColor = colors.textColorSecondary + requestMsgLabel.numberOfLines = Self.requestMsgMaxLines + statusLabel.font = FontScheme.caption3Regular + statusLabel.textColor = colors.textColorSecondary + styleActionButton( + acceptButton, + title: LocalizedChatString("Agree"), + titleColor: colors.textColorButton, + backgroundColor: colors.buttonColorPrimaryDefault, + borderColor: nil + ) + styleActionButton( + refuseButton, + title: LocalizedChatString("Decline"), + titleColor: colors.textColorError, + backgroundColor: .clear, + borderColor: colors.strokeColorPrimary + ) + } + + private func styleActionButton(_ button: UIButton, + title: String, + titleColor: UIColor, + backgroundColor: UIColor, + borderColor: UIColor?) { + button.setTitle(title, for: .normal) + button.setTitleColor(titleColor, for: .normal) + button.titleLabel?.font = FontScheme.caption3Regular + button.backgroundColor = backgroundColor + button.layer.cornerRadius = Self.buttonCornerRadius + button.layer.masksToBounds = true + if let borderColor = borderColor { + button.layer.borderWidth = Self.refuseBorderWidth + button.layer.borderColor = borderColor.cgColor + } + } + + private static func applicationTypeText(_ application: GroupApplicationInfo) -> String { + switch application.type { + case .joinApprovedByAdmin: + return LocalizedChatString("GroupApplicationTypeApply") + case .inviteApprovedByInvitee, .inviteApprovedByAdmin: + return LocalizedChatString("GroupApplicationTypeInvite") + } + } + + private static func personText(_ application: GroupApplicationInfo) -> String { + if application.type == .joinApprovedByAdmin { + return "\(LocalizedChatString("GroupApplicationApplicant"))\u{FF1A}\(fromUserDisplayName(application))" + } + return "\(LocalizedChatString("GroupApplicationInvitee"))\u{FF1A}\(application.toUser ?? "")" + } + + private static func fromUserDisplayName(_ application: GroupApplicationInfo) -> String { + if let nickname = application.fromUserNickname, !nickname.isEmpty { + return nickname + } + return application.fromUser ?? "" + } + + private func applyHandledState(_ application: GroupApplicationInfo) { + let canHandle = application.handledStatus == .unhandled || application.handledStatus == nil + actionStackView.isHidden = !canHandle + statusLabel.isHidden = canHandle + if !canHandle { + statusLabel.text = Self.handledStatusText(application) + } + } + + private static func handledStatusText(_ application: GroupApplicationInfo) -> String { + switch application.handledStatus { + case .unhandled: + return LocalizedChatString("GroupApplicationStatusPending") + case .byOther: + return LocalizedChatString("GroupApplicationStatusByOther") + case .byMyself: + switch application.handledResult { + case .refused: + return LocalizedChatString("Disclined") + case .agreed: + return LocalizedChatString("Agreed") + default: + return LocalizedChatString("UserStatusUnknown") + } + default: + return LocalizedChatString("UserStatusUnknown") + } + } + + @objc private func handleAccept() { + onAccept?() + } + + @objc private func handleRefuse() { + onRefuse?() + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/GroupApplicationListViewController.swift b/chat/uikit/components/contactlist/view/subpage/GroupApplicationListViewController.swift new file mode 100644 index 000000000..bf426f093 --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/GroupApplicationListViewController.swift @@ -0,0 +1,144 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class GroupApplicationListViewController: UIViewController { + private let viewModel = GroupApplicationListViewModel() + + private var applications: [GroupApplicationInfo] = [] + + private var cancellables = Set() + + private static let listTopInset: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let emptyFontSize: CGFloat = 17 + + private static let estimatedRowHeight: CGFloat = 80 + + private static let navigationBarHeight: CGFloat = 44 + + private static let toastDuration: TimeInterval = 3 + + private lazy var navigationBar = SubPageNavigationBar(title: LocalizedChatString("ContactsGroupApplicationTitle")) + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = Self.estimatedRowHeight + table.contentInset = UIEdgeInsets(top: Self.listTopInset, left: 0, bottom: 0, right: 0) + table.register(GroupApplicationCell.self, forCellReuseIdentifier: GroupApplicationCell.reuseIdentifier) + return table + }() + + private lazy var emptyLabel: UILabel = { + let label = UILabel() + label.text = LocalizedChatString("ContactNoGroupApplication") + label.font = .systemFont(ofSize: Self.emptyFontSize) + label.textColor = ChatUIKitTheme.colors.textColorSecondary + label.textAlignment = .center + label.isHidden = true + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindViewModel() + viewModel.loadData() + } + + private func setupHierarchy() { + navigationBar.onClose = { [weak self] in + self?.dismiss(animated: true) + } + view.addSubview(navigationBar) + view.addSubview(tableView) + view.addSubview(emptyLabel) + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.navigationBarHeight) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + tableView.dataSource = self + } + + private func setupStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + tableView.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } + + private func bindViewModel() { + viewModel.$applications + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + guard let self = self else { return } + self.applications = list + self.emptyLabel.isHidden = !list.isEmpty + self.tableView.isHidden = list.isEmpty + self.tableView.reloadData() + } + .store(in: &cancellables) + } + + private func accept(_ application: GroupApplicationInfo) { + viewModel.accept(application) { result in + DispatchQueue.main.async { + switch result { + case .success: + WindowToastManager.shared.show(LocalizedChatString("GroupApplicationAccepted"), type: .success, duration: Self.toastDuration) + case .failure: + WindowToastManager.shared.show(LocalizedChatString("GroupApplicationAcceptFailed"), type: .error, duration: Self.toastDuration) + } + } + } + } + + private func refuse(_ application: GroupApplicationInfo) { + viewModel.refuse(application) { result in + DispatchQueue.main.async { + switch result { + case .success: + WindowToastManager.shared.show(LocalizedChatString("GroupApplicationDeclined"), type: .success, duration: Self.toastDuration) + case .failure: + WindowToastManager.shared.show(LocalizedChatString("GroupApplicationDeclineFailed"), type: .error, duration: Self.toastDuration) + } + } + } + } +} + +// MARK: - UITableViewDataSource + +extension GroupApplicationListViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return applications.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: GroupApplicationCell.reuseIdentifier, + for: indexPath + ) as? GroupApplicationCell else { + return UITableViewCell() + } + let application = applications[indexPath.row] + cell.configure( + with: application, + onAccept: { [weak self] in self?.accept(application) }, + onRefuse: { [weak self] in self?.refuse(application) } + ) + return cell + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/GroupListViewController.swift b/chat/uikit/components/contactlist/view/subpage/GroupListViewController.swift new file mode 100644 index 000000000..4dcff70ae --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/GroupListViewController.swift @@ -0,0 +1,126 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class GroupListViewController: UIViewController { + private static let navigationBarHeight: CGFloat = 44 + + private let onGroupClick: (AZOrderedListItem) -> Void + + private let viewModel = GroupListViewModel() + + private var groups: [GroupInfo] = [] + + private var searchQuery = "" + + private var cancellables = Set() + + private lazy var navigationBar = SubPageNavigationBar(title: LocalizedChatString("ContactsGroupChats")) + + private let searchBar = ContactSearchBarView() + + private lazy var listView: AZOrderedListView = { + let view = AZOrderedListView(showIndexBar: true) { [weak self] item in + self?.onGroupClick(item) + } + view.emptyText = LocalizedChatString("ContactNoGroupChats") + return view + }() + + init(onGroupClick: @escaping (AZOrderedListItem) -> Void) { + self.onGroupClick = onGroupClick + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindInteraction() + bindViewModel() + viewModel.loadData() + } + + private func setupHierarchy() { + navigationBar.onClose = { [weak self] in + if let navigationController = self?.navigationController { + navigationController.popViewController(animated: true) + } else { + self?.dismiss(animated: true) + } + } + view.addSubview(navigationBar) + view.addSubview(searchBar) + view.addSubview(listView) + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.navigationBarHeight) + } + searchBar.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.equalToSuperview() + } + listView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func setupStyle() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + } + + private func bindInteraction() { + searchBar.onQueryChange = { [weak self] query in + self?.searchQuery = query + self?.renderList() + } + listView.onUserInteraction = { [weak self] in + self?.searchBar.hideKeyboard() + } + } + + private func bindViewModel() { + viewModel.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.groups = list + self?.renderList() + } + .store(in: &cancellables) + } + + private func renderList() { + let isSearching = !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty + listView.emptyText = isSearching + ? LocalizedChatString("SearchGroupNotFound") + : LocalizedChatString("ContactNoGroupChats") + let filtered = groups.filter { matchesSearchQuery($0, query: searchQuery) } + listView.setItems(filtered.map { item(for: $0) }) + } + + private func matchesSearchQuery(_ group: GroupInfo, query: String) -> Bool { + let keyword = query.trimmingCharacters(in: .whitespaces) + if keyword.isEmpty { + return true + } + let candidates = [ContactDisplayNameFormatter.name(for: group), group.groupID] + return candidates.filter { !$0.isEmpty }.contains { + $0.range(of: keyword, options: .caseInsensitive) != nil + } + } + + private func item(for group: GroupInfo) -> AZOrderedListItem { + AZOrderedListItem( + userID: group.groupID, + avatarURL: group.avatarURL, + title: ContactDisplayNameFormatter.name(for: group) + ) + } +} diff --git a/chat/uikit/components/contactlist/view/subpage/SubPageNavigationBar.swift b/chat/uikit/components/contactlist/view/subpage/SubPageNavigationBar.swift new file mode 100644 index 000000000..e637d994e --- /dev/null +++ b/chat/uikit/components/contactlist/view/subpage/SubPageNavigationBar.swift @@ -0,0 +1,66 @@ +import UIKit +import SnapKit + +final class SubPageNavigationBar: UIView { + private static let closeButtonLeading: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let closeButtonSize: CGFloat = 44 + + private static let backSymbolPointSize: CGFloat = 18 + + var onClose: (() -> Void)? + + private let closeButton = UIButton(type: .system) + + private let titleLabel = UILabel() + + init(title: String) { + super.init(frame: .zero) + titleLabel.text = title + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func constructViewHierarchy() { + addSubview(closeButton) + addSubview(titleLabel) + } + + private func activateConstraints() { + closeButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.closeButtonLeading) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.closeButtonSize) + } + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + private func bindInteraction() { + closeButton.addTarget(self, action: #selector(handleClose), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + titleLabel.font = FontScheme.caption1Medium + titleLabel.textColor = colors.textColorPrimary + let image = AtomicXChatResources.image(named: "contact_info_back")? + .withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left", + withConfiguration: UIImage.SymbolConfiguration(pointSize: Self.backSymbolPointSize, weight: .semibold)) + closeButton.setImage(image, for: .normal) + closeButton.tintColor = colors.textColorPrimary + } + + @objc private func handleClose() { + onClose?() + } +} diff --git a/chat/uikit/components/contactlist/viewmodel/ContactListViewModel.swift b/chat/uikit/components/contactlist/viewmodel/ContactListViewModel.swift new file mode 100644 index 000000000..7cd71dd25 --- /dev/null +++ b/chat/uikit/components/contactlist/viewmodel/ContactListViewModel.swift @@ -0,0 +1,74 @@ +import Foundation +import Combine +import AtomicXCore + +final class ContactListViewModel { + @Published private(set) var friendList: [ContactInfo] = [] + + @Published private(set) var friendApplicationUnreadCount: Int = 0 + + @Published private(set) var groupApplicationUnreadCount: Int = 0 + + private let contactStore: ContactStore + + private let groupStore: GroupStore + + private var cancellables = Set() + + // MARK: - Init + + init(contactStore: ContactStore = ContactStore.shared, + groupStore: GroupStore = GroupStore.shared) { + self.contactStore = contactStore + self.groupStore = groupStore + syncInitialState() + subscribeState() + } + + // MARK: - Business Actions (forwarded to core stores) + + func loadData() { + contactStore.loadFriends(completion: nil) + contactStore.loadFriendApplications(completion: nil) + groupStore.loadApplications(completion: nil) + } + + private func syncInitialState() { + let contactState = contactStore.state.value + let groupState = groupStore.state.value + friendList = contactState.friendList + friendApplicationUnreadCount = contactState.friendApplicationUnreadCount + groupApplicationUnreadCount = groupState.unreadApplicationCount + } + + private func subscribeState() { + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + guard let self else { return } + + if list.isEmpty && !self.friendList.isEmpty { + return + } + self.friendList = list + } + .store(in: &cancellables) + + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendApplicationUnreadCount)) + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + self?.friendApplicationUnreadCount = count + } + .store(in: &cancellables) + + groupStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.unreadApplicationCount)) + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + self?.groupApplicationUnreadCount = count + } + .store(in: &cancellables) + } +} diff --git a/chat/uikit/components/contactlist/viewmodel/ContactSubPageViewModels.swift b/chat/uikit/components/contactlist/viewmodel/ContactSubPageViewModels.swift new file mode 100644 index 000000000..3b72a71c9 --- /dev/null +++ b/chat/uikit/components/contactlist/viewmodel/ContactSubPageViewModels.swift @@ -0,0 +1,117 @@ +import Foundation +import Combine +import AtomicXCore + +final class FriendApplicationListViewModel { + @Published private(set) var applications: [FriendApplicationInfo] = [] + + private let contactStore: ContactStore + + private var cancellables = Set() + + init(contactStore: ContactStore = ContactStore.shared) { + self.contactStore = contactStore + applications = contactStore.state.value.friendApplicationList + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendApplicationList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.applications = list + } + .store(in: &cancellables) + } + + func loadData() { + contactStore.loadFriendApplications(completion: nil) + contactStore.clearFriendApplicationUnreadCount(completion: nil) + } + + func accept(_ application: FriendApplicationInfo, completion: @escaping CompletionClosure) { + contactStore.acceptFriendApplication(info: application, completion: completion) + } + + func refuse(_ application: FriendApplicationInfo, completion: @escaping CompletionClosure) { + contactStore.refuseFriendApplication(info: application, completion: completion) + } +} + +final class GroupApplicationListViewModel { + @Published private(set) var applications: [GroupApplicationInfo] = [] + + private let groupStore: GroupStore + + private var cancellables = Set() + + init(groupStore: GroupStore = GroupStore.shared) { + self.groupStore = groupStore + applications = groupStore.state.value.applicationList + groupStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.applicationList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.applications = list + } + .store(in: &cancellables) + } + + func loadData() { + groupStore.loadApplications(completion: nil) + groupStore.clearApplicationUnreadCount(completion: nil) + } + + func accept(_ application: GroupApplicationInfo, completion: @escaping CompletionClosure) { + groupStore.acceptApplication(info: application, completion: completion) + } + + func refuse(_ application: GroupApplicationInfo, completion: @escaping CompletionClosure) { + groupStore.refuseApplication(info: application, completion: completion) + } +} + +final class GroupListViewModel { + @Published private(set) var groups: [GroupInfo] = [] + + private let groupStore: GroupStore + + private var cancellables = Set() + + init(groupStore: GroupStore = GroupStore.shared) { + self.groupStore = groupStore + groups = groupStore.state.value.joinedGroupList + groupStore.state + .subscribe(StatePublisherSelector(keyPath: \GroupState.joinedGroupList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.groups = list + } + .store(in: &cancellables) + } + + func loadData() { + groupStore.loadJoinedGroups(completion: nil) + } +} + +final class BlackListViewModel { + @Published private(set) var blackList: [ContactInfo] = [] + + private let contactStore: ContactStore + + private var cancellables = Set() + + init(contactStore: ContactStore = ContactStore.shared) { + self.contactStore = contactStore + blackList = contactStore.state.value.blackList + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.blackList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.blackList = list + } + .store(in: &cancellables) + } + + func loadData() { + contactStore.loadBlackList(completion: nil) + } +} diff --git a/chat/uikit/components/conversationlist/ConversationListView.swift b/chat/uikit/components/conversationlist/ConversationListView.swift new file mode 100644 index 000000000..73e27d0e7 --- /dev/null +++ b/chat/uikit/components/conversationlist/ConversationListView.swift @@ -0,0 +1,142 @@ +import AtomicXCore +import SnapKit +import UIKit + +protocol ConversationActionConfigProtocol { + var isSupportDelete: Bool { get } + var isSupportMute: Bool { get } + var isSupportPin: Bool { get } + var isSupportMarkUnread: Bool { get } + var isSupportClearHistory: Bool { get } + var actionCustomizer: ConversationActionCustomizer? { get } +} + +extension ConversationActionConfigProtocol { + var actionCustomizer: ConversationActionCustomizer? { nil } +} + +struct ChatConversationActionConfig: ConversationActionConfigProtocol { + var isSupportDelete: Bool { + if let userIsSupportDelete = userIsSupportDelete { + return userIsSupportDelete + } else { + let config = AppBuilderConfig.shared + return config.conversationActionList.contains(.delete) + } + } + + var isSupportMute: Bool { + if let userIsSupportMute = userIsSupportMute { + return userIsSupportMute + } else { + let config = AppBuilderConfig.shared + return config.conversationActionList.contains(.mute) + } + } + + var isSupportPin: Bool { + if let userIsSupportPin = userIsSupportPin { + return userIsSupportPin + } else { + let config = AppBuilderConfig.shared + return config.conversationActionList.contains(.pin) + } + } + + var isSupportMarkUnread: Bool { + if let userIsSupportMarkUnread = userIsSupportMarkUnread { + return userIsSupportMarkUnread + } else { + let config = AppBuilderConfig.shared + return config.conversationActionList.contains(.markUnread) + } + } + + var isSupportClearHistory: Bool { + if let userIsSupportClearHistory = userIsSupportClearHistory { + return userIsSupportClearHistory + } else { + let config = AppBuilderConfig.shared + return config.conversationActionList.contains(.clearHistory) + } + } + + private let userIsSupportDelete: Bool? + + private let userIsSupportMute: Bool? + + private let userIsSupportPin: Bool? + + private let userIsSupportMarkUnread: Bool? + + var actionCustomizer: ConversationActionCustomizer? { + return userActionCustomizer + } + + private let userIsSupportClearHistory: Bool? + + private let userActionCustomizer: ConversationActionCustomizer? + + init() { + self.userIsSupportDelete = nil + self.userIsSupportMute = nil + self.userIsSupportPin = nil + self.userIsSupportMarkUnread = nil + self.userIsSupportClearHistory = nil + self.userActionCustomizer = nil + } + + init( + isSupportDelete: Bool? = nil, + isSupportMute: Bool? = nil, + isSupportPin: Bool? = nil, + isSupportMarkUnread: Bool? = nil, + isSupportClearHistory: Bool? = nil, + actionCustomizer: ConversationActionCustomizer? = nil + ) { + self.userIsSupportDelete = isSupportDelete + self.userIsSupportMute = isSupportMute + self.userIsSupportPin = isSupportPin + self.userIsSupportMarkUnread = isSupportMarkUnread + self.userIsSupportClearHistory = isSupportClearHistory + self.userActionCustomizer = actionCustomizer + } +} + +typealias ConversationActionCustomizer = (CustomEditor) -> Void + +struct ConversationCustomAction: CustomItem { + public var ID: String + public let title: String + public let dangerous: Bool + public let action: (ConversationInfo) -> Void + + public init(ID: String, title: String, dangerous: Bool = false, action: @escaping (ConversationInfo) -> Void) { + self.ID = ID + self.title = title + self.dangerous = dangerous + self.action = action + } + + init(title: String, action: @escaping (ConversationInfo) -> Void) { + self.init(ID: UUID().uuidString, title: title, dangerous: false, action: action) + } +} + +final class ConversationListView: UIView { + private let impl: ConversationListViewImpl + + init(onConversationClick: @escaping (ConversationInfo) -> Void, + config: ConversationActionConfigProtocol = ChatConversationActionConfig()) { + impl = ConversationListViewImpl(onConversationClick: onConversationClick, config: config) + super.init(frame: .zero) + addSubview(impl) + impl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/chat/uikit/components/conversationlist/CreateC2CConversationViewController.swift b/chat/uikit/components/conversationlist/CreateC2CConversationViewController.swift new file mode 100644 index 000000000..942c646d1 --- /dev/null +++ b/chat/uikit/components/conversationlist/CreateC2CConversationViewController.swift @@ -0,0 +1,53 @@ +import SnapKit +import UIKit + +final class CreateC2CConversationViewController: UIViewController, SystemNavigationBarPage { + private let onUserSelected: (AZOrderedListItem) -> Void + + private let impl = CreateC2CConversationViewControllerImpl() + + init(onUserSelected: @escaping (AZOrderedListItem) -> Void) { + self.onUserSelected = onUserSelected + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViewStyle() + view.addSubview(impl) + impl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + impl.onUserSelected = { [weak self] item in + self?.onUserSelected(item) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + title = LocalizedChatString("ChatsNewChatText") + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: AtomicXChatResources.image(named: "contact_info_back")? + .withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)), + style: .plain, + target: self, + action: #selector(handleCancel) + ) + navigationItem.leftBarButtonItem?.tintColor = colors.textColorPrimary + } + + @objc private func handleCancel() { + if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +} diff --git a/chat/uikit/components/conversationlist/CreateGroupConversationViewController.swift b/chat/uikit/components/conversationlist/CreateGroupConversationViewController.swift new file mode 100644 index 000000000..564eba618 --- /dev/null +++ b/chat/uikit/components/conversationlist/CreateGroupConversationViewController.swift @@ -0,0 +1,65 @@ +import SnapKit +import UIKit + +final class CreateGroupConversationViewController: UIViewController, SystemNavigationBarPage { + private let onComplete: (String?, String?, String?) -> Void + + private let impl = CreateGroupConversationViewControllerImpl() + + init(onComplete: @escaping (String?, String?, String?) -> Void) { + self.onComplete = onComplete + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViewStyle() + view.addSubview(impl) + impl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + impl.onConfirm = { [weak self] members in + self?.pushConfigGroupInfo(members: members) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + title = LocalizedChatString("CreateGroupTitle") + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: AtomicXChatResources.image(named: "contact_info_back") ?? UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(handleCancel) + ) + navigationItem.leftBarButtonItem?.tintColor = colors.textColorPrimary + } + + private func pushConfigGroupInfo(members: [UserPickerItem]) { + guard let navigationController = navigationController else { return } + let config = ConfigGroupInfoViewController( + members: members, + onComplete: { [weak self] createdGroupID, groupName, conversationId in + self?.navigationController?.popViewController(animated: false) + self?.onComplete(createdGroupID, groupName, conversationId) + }, + onBack: { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + ) + navigationController.pushViewController(config, animated: true) + } + + @objc private func handleCancel() { + if let navigationController = navigationController { + navigationController.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } +} diff --git a/chat/uikit/components/conversationlist/utils/ConversationCellFormatter.swift b/chat/uikit/components/conversationlist/utils/ConversationCellFormatter.swift new file mode 100644 index 000000000..6902e61d5 --- /dev/null +++ b/chat/uikit/components/conversationlist/utils/ConversationCellFormatter.swift @@ -0,0 +1,146 @@ +import UIKit +import AtomicXCore + +extension ConversationInfo { + var shouldShowDoNotDisturbIndicator: Bool { + guard receiveOption == .notNotify else { return false } + return groupType != .meeting + } +} + +enum ConversationSendStatusIndicator { + case none + case error + case sending +} + +enum ConversationCellFormatter { + + private static let unreadCountBadgeThreshold = 2 + + // MARK: - Time + + static func timeText(for conversation: ConversationInfo) -> String? { + guard let timestamp = conversation.lastMessage?.timestamp, timestamp > 0 else { + return nil + } + return DateHelper.convertDateToYMDStr(Date(timeIntervalSince1970: TimeInterval(timestamp))) + } + + // MARK: - Send Status + + static func sendStatus(for conversation: ConversationInfo) -> ConversationSendStatusIndicator { + switch conversation.lastMessage?.status { + case .sendFail, .violation: + return .error + case .sending: + return .sending + default: + return .none + } + } + + // MARK: - Subtitle + + static func subtitleAttributedText(for conversation: ConversationInfo) -> NSAttributedString { + let colors = ChatUIKitTheme.colors + + let font = FontScheme.caption2Regular + let secondary = colors.textColorSecondary + let error = colors.textColorError + let countUnit = LocalizedChatString("MessageCount") + let atTag = atTagText(for: conversation) + let result = NSMutableAttributedString() + + if let draft = conversation.draft, !draft.isEmpty { + appendUnreadPrefixIfNeeded(to: result, conversation: conversation, countUnit: countUnit, color: secondary, font: font) + let draftLabel = LocalizedChatString("MessageTypeDraftFormat") + result.append(makeAttr(atTag, error, font)) + result.append(makeAttr(draftLabel, error, font)) + result.append(emojiRenderedAttr(draft, color: secondary, font: font)) + return result + } + + let finalText = finalSubtitleText(for: conversation, countUnit: countUnit) + result.append(makeAttr(atTag, error, font)) + result.append(emojiRenderedAttr(finalText, color: secondary, font: font)) + return result + } + + // MARK: - Private + + private static func appendUnreadPrefixIfNeeded(to result: NSMutableAttributedString, + conversation: ConversationInfo, + countUnit: String, + color: UIColor, + font: UIFont) { + guard conversation.receiveOption == .notNotify, conversation.unreadCount >= unreadCountBadgeThreshold else { + return + } + result.append(makeAttr("[\(conversation.unreadCount)\(countUnit)]", color, font)) + } + + private static func finalSubtitleText(for conversation: ConversationInfo, countUnit: String) -> String { + let subtitle = MessageListHelper.getMessageAbstract(conversation.lastMessage) + let stripped = strippingMentionBlocks(from: subtitle, message: conversation.lastMessage) + if conversation.receiveOption == .notNotify, conversation.unreadCount >= unreadCountBadgeThreshold { + return "[\(conversation.unreadCount)\(countUnit)] \(stripped)" + } + return stripped + } + + private static func emojiRenderedAttr(_ text: String, color: UIColor, font: UIFont) -> NSAttributedString { + guard text.contains("[TUIEmoji_") else { + return makeAttr(text, color, font) + } + return EmojiManager.shared.createStyledAttributedString(fromEmojiCodes: text, font: font, textColor: color) + } + + private static func strippingMentionBlocks(from text: String, message: MessageInfo?) -> String { + guard let message = message, + message.messageType == .text, + !message.atUserList.isEmpty else { + return text + } + return text.replacingOccurrences(of: "@[^\\s@]+\\s", with: "", options: .regularExpression) + } + + private static func atTagText(for conversation: ConversationInfo) -> String { + guard conversation.unreadCount > 0, + conversation.conversationID.hasPrefix("group_"), + let atInfoList = conversation.groupAtInfoList, + !atInfoList.isEmpty else { + return "" + } + + var hasAtAll = false + var hasAtMe = false + for atInfo in atInfoList { + switch atInfo.atType { + case .atMe: + hasAtMe = true + case .atAll: + hasAtAll = true + case .atAllAtMe: + hasAtAll = true + hasAtMe = true + } + } + + var result = "" + if hasAtAll { + result += LocalizedChatString("MentionAtAllTag") + } + if hasAtMe { + result += LocalizedChatString("MentionAtMeTag") + } + return result + } + + private static func makeAttr(_ text: String, _ color: UIColor, _ font: UIFont) -> NSAttributedString { + guard !text.isEmpty else { + return NSAttributedString() + } + return NSAttributedString(string: text, attributes: [.foregroundColor: color, .font: font]) + } +} diff --git a/chat/uikit/components/conversationlist/view/ConfigGroupInfoViewController.swift b/chat/uikit/components/conversationlist/view/ConfigGroupInfoViewController.swift new file mode 100644 index 000000000..2df8e3471 --- /dev/null +++ b/chat/uikit/components/conversationlist/view/ConfigGroupInfoViewController.swift @@ -0,0 +1,864 @@ +import AtomicXCore +import Kingfisher +import SnapKit +import UIKit + +private let groupCreateTipsMessageDelay: TimeInterval = 0.5 + +final class ConfigGroupInfoViewController: UIViewController { + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let rowHeight: CGFloat = 48 + + private static let sectionTitleFontSize: CGFloat = 16 + + private static let sectionTitleTopPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let sectionTitleBottomPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let sectionBottomPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let dividerHeight: CGFloat = 0.5 + + private static let descTopPadding: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let descLineHeightMultiple: CGFloat = 1.25 + + private static let avatarItemSize: CGFloat = 48 + + private static let avatarItemSpacing: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let avatarGridRows = 2 + + private static let memberAvatarSize: CGFloat = 40 + + private static let memberAvatarSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let createButtonWidth: CGFloat = 76 + + private static let createButtonHeight: CGFloat = 30 + + private static let createButtonCornerRadius: CGFloat = 6 + + private static let bottomBarTopPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let bottomBarBottomPadding: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let toastBottomOffset: CGFloat = 200 + + private static let groupNamePreviewCount = 3 + + private static let presetAvatarCount = 24 + + private static let backButtonSymbolPointSize: CGFloat = 18 + + private static let rowContentSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let memberAvatarCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let memberAvatarFontSize: CGFloat = 16 + + private static let createErrorToastDuration: TimeInterval = 3 + + private static let validationErrorToastDuration: TimeInterval = 2 + + private var avatarURLList: [String] { + (1 ... Self.presetAvatarCount).map { "https://im.sdk.qcloud.com/download/tuikit-resource/group-avatar/group_avatar_\($0).png" } + } + + private var members: [UserPickerItem] + + private let onComplete: (String?, String?, String?) -> Void + + private let onBack: () -> Void + + private var groupType: GroupTypeSelection = .work + + private var selectedAvatar: String? + + private let scrollView = UIScrollView() + + private let contentStack = UIStackView() + + private let groupNameField = UITextField() + + private let groupIDField = UITextField() + + private let groupTypeValueLabel = UILabel() + + private let groupTypeDescLabel = UILabel() + + private var avatarItems: [GroupAvatarGridItem] = [] + + private let avatarColumnsStack = UIStackView() + + private let membersStack = UIStackView() + + private let bottomBar = UIView() + + private let createButton = UIButton(type: .custom) + + // MARK: - Init + + init(members: [UserPickerItem], + onComplete: @escaping (String?, String?, String?) -> Void, + onBack: @escaping () -> Void) { + self.members = members + self.onComplete = onComplete + self.onBack = onBack + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupInitialData() + constructViewHierarchy() + activateConstraints() + setupNavigationBar() + setupViewStyle() + refreshGroupTypeDisplay() + refreshMembers() + refreshCreateButtonState() + } + + // MARK: - Create Group + + private func constructViewHierarchy() { + view.addSubview(scrollView) + view.addSubview(bottomBar) + bottomBar.addSubview(createButton) + scrollView.addSubview(contentStack) + + contentStack.addArrangedSubview(makeEditableRow(title: LocalizedChatString("ConfigGroupNameLabel"), + field: groupNameField, + hint: "")) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeEditableRow(title: LocalizedChatString("ConfigGroupIDLabel"), + field: groupIDField, + hint: LocalizedChatString("ConfigGroupIDOptionalHint"))) + contentStack.addArrangedSubview(makeDivider()) + contentStack.addArrangedSubview(makeGroupTypeRow()) + contentStack.addArrangedSubview(makeDescContainer()) + contentStack.addArrangedSubview(makeAvatarSection()) + contentStack.addArrangedSubview(makeMembersSection()) + } + + private func activateConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + createButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.bottomBarTopPadding) + make.bottom.equalTo(bottomBar.safeAreaLayoutGuide).offset(-Self.bottomBarBottomPadding) + make.width.equalTo(Self.createButtonWidth) + make.height.equalTo(Self.createButtonHeight) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + } + } + + private func setupNavigationBar() { + let colors = ChatUIKitTheme.colors + title = LocalizedChatString("CreateGroupTitle") + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: AtomicXChatResources.image(named: "contact_info_back")? + .withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.backButtonSymbolPointSize, weight: .semibold)), + style: .plain, + target: self, + action: #selector(handleCancel) + ) + navigationItem.leftBarButtonItem?.tintColor = colors.textColorPrimary + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + bottomBar.backgroundColor = colors.bgColorOperate + contentStack.axis = .vertical + contentStack.spacing = 0 + contentStack.alignment = .fill + + createButton.setTitle(LocalizedChatString("CreateFinish"), for: .normal) + createButton.setTitleColor(colors.textColorButton, for: .normal) + createButton.titleLabel?.font = FontScheme.caption1Regular + createButton.layer.cornerRadius = Self.createButtonCornerRadius + createButton.layer.masksToBounds = true + + groupNameField.addTarget(self, action: #selector(handleGroupNameChanged), for: .editingChanged) + createButton.addTarget(self, action: #selector(handleCreate), for: .touchUpInside) + scrollView.keyboardDismissMode = .onDrag + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTapped)) + tapGesture.cancelsTouchesInView = false + scrollView.addGestureRecognizer(tapGesture) + } + + @objc private func handleBackgroundTapped() { + view.endEditing(true) + } + + private func setupInitialData() { + groupNameField.text = Self.makeDefaultGroupName(from: members) + } + + private static func makeDefaultGroupName(from members: [UserPickerItem]) -> String { + let names = members.map { $0.title.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + let preview = names.prefix(Self.groupNamePreviewCount) + .joined(separator: LocalizedChatString("ConfigGroupNameSeparator")) + let remaining = names.count - Self.groupNamePreviewCount + if remaining > 0 { + return preview + String(format: LocalizedChatString("ConfigGroupNameSuffix"), remaining) + } + return preview + } + + private func makeEditableRow(title: String, field: UITextField, hint: String) -> UIView { + let colors = ChatUIKitTheme.colors + let row = UIView() + row.backgroundColor = colors.bgColorTopBar + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorPrimary + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + + field.placeholder = hint.isEmpty ? nil : hint + field.font = FontScheme.caption1Regular + field.textColor = colors.textColorPrimary + field.textAlignment = LanguageHelper.isRTL ? .left : .right + field.autocapitalizationType = .none + field.autocorrectionType = .no + field.returnKeyType = .done + field.delegate = self + field.attributedPlaceholder = hint.isEmpty ? nil : NSAttributedString( + string: hint, + attributes: [.foregroundColor: colors.textColorSecondary] + ) + + row.addSubview(titleLabel) + row.addSubview(field) + row.snp.makeConstraints { make in + make.height.equalTo(Self.rowHeight) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + } + field.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(Self.rowContentSpacing) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + return row + } + + private func makeDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary + divider.snp.makeConstraints { make in + make.height.equalTo(Self.dividerHeight) + } + return divider + } + + private func makeGroupTypeRow() -> UIView { + let colors = ChatUIKitTheme.colors + let row = UIControl() + row.backgroundColor = colors.bgColorTopBar + row.addTarget(self, action: #selector(handleSelectGroupType), for: .touchUpInside) + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("CreatGroupType") + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorPrimary + + let chevronLabel = UILabel() + chevronLabel.text = LanguageHelper.isRTL ? "‹" : "›" + chevronLabel.font = FontScheme.body3Regular + chevronLabel.textColor = colors.textColorSecondary + + groupTypeValueLabel.font = FontScheme.caption1Regular + groupTypeValueLabel.textColor = colors.textColorPrimary + groupTypeValueLabel.lineBreakMode = .byTruncatingTail + + row.addSubview(titleLabel) + row.addSubview(groupTypeValueLabel) + row.addSubview(chevronLabel) + row.snp.makeConstraints { make in + make.height.equalTo(Self.rowHeight) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + } + chevronLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + } + groupTypeValueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(Self.rowContentSpacing) + make.trailing.equalTo(chevronLabel.snp.leading).offset(-Self.rowContentSpacing) + make.centerY.equalToSuperview() + } + return row + } + + private func makeDescContainer() -> UIView { + let container = UIView() + groupTypeDescLabel.font = FontScheme.caption3Regular + groupTypeDescLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + groupTypeDescLabel.numberOfLines = 0 + container.addSubview(groupTypeDescLabel) + groupTypeDescLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.descTopPadding) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.bottom.equalToSuperview() + } + return container + } + + private func makeAvatarSection() -> UIView { + let colors = ChatUIKitTheme.colors + let container = UIView() + container.backgroundColor = colors.bgColorTopBar + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("CreatGroupAvatar") + titleLabel.font = .systemFont(ofSize: Self.sectionTitleFontSize, weight: .semibold) + titleLabel.textColor = colors.textColorPrimary + + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.clipsToBounds = false + + avatarColumnsStack.axis = .horizontal + avatarColumnsStack.spacing = Self.avatarItemSpacing + avatarColumnsStack.alignment = .top + + container.addSubview(titleLabel) + container.addSubview(scrollView) + scrollView.addSubview(avatarColumnsStack) + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.sectionTitleTopPadding) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.sectionTitleBottomPadding) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().offset(-Self.sectionBottomPadding) + make.height.equalTo(Self.avatarItemSize * CGFloat(Self.avatarGridRows) + Self.avatarItemSpacing) + } + avatarColumnsStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: Self.horizontalPadding, bottom: 0, right: Self.horizontalPadding)) + make.height.equalToSuperview() + } + + buildAvatarGrid() + return container + } + + private func buildAvatarGrid() { + let candidates: [String?] = [nil] + avatarURLList.map { Optional($0) } + let currentGroupName = groupNameField.text ?? "" + let fallbackName = currentGroupName.isEmpty ? LocalizedChatString("ConfigGroupNameLabel") : currentGroupName + let columns = stride(from: 0, to: candidates.count, by: Self.avatarGridRows).map { + Array(candidates[$0 ..< min($0 + Self.avatarGridRows, candidates.count)]) + } + for columnItems in columns { + let columnStack = UIStackView() + columnStack.axis = .vertical + columnStack.spacing = Self.avatarItemSpacing + for url in columnItems { + let item = GroupAvatarGridItem(url: url, fallbackName: fallbackName) { [weak self] selectedURL in + self?.selectAvatar(selectedURL) + } + item.snp.makeConstraints { make in + make.width.height.equalTo(Self.avatarItemSize) + } + avatarItems.append(item) + columnStack.addArrangedSubview(item) + } + avatarColumnsStack.addArrangedSubview(columnStack) + } + refreshAvatarSelection() + } + + private func selectAvatar(_ url: String?) { + selectedAvatar = url + refreshAvatarSelection() + } + + private func refreshAvatarSelection() { + for item in avatarItems { + item.setSelected(item.url == selectedAvatar) + } + } + + private func makeMembersSection() -> UIView { + let colors = ChatUIKitTheme.colors + let container = UIView() + container.backgroundColor = colors.bgColorTopBar + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("CreateMemebers") + titleLabel.font = .systemFont(ofSize: Self.sectionTitleFontSize, weight: .semibold) + titleLabel.textColor = colors.textColorPrimary + + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + + membersStack.axis = .horizontal + membersStack.spacing = Self.memberAvatarSpacing + membersStack.alignment = .center + + container.addSubview(titleLabel) + container.addSubview(scrollView) + scrollView.addSubview(membersStack) + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.sectionTitleTopPadding) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.sectionTitleBottomPadding) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().offset(-Self.sectionBottomPadding) + make.height.equalTo(Self.memberAvatarSize) + } + membersStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: Self.horizontalPadding, bottom: 0, right: Self.horizontalPadding)) + make.height.equalToSuperview() + } + return container + } + + private func refreshGroupTypeDisplay() { + groupTypeValueLabel.text = groupType.displayName + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Self.descLineHeightMultiple + groupTypeDescLabel.attributedText = NSAttributedString( + string: groupType.typeDescription, + attributes: [.paragraphStyle: paragraphStyle] + ) + } + + private func refreshMembers() { + membersStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for member in members { + let avatar = ChatAvatarView(cornerRadius: Self.memberAvatarCornerRadius, fontSize: Self.memberAvatarFontSize) + avatar.configure(avatarURL: member.avatarURL, fallbackName: member.title) + avatar.snp.makeConstraints { make in + make.width.height.equalTo(Self.memberAvatarSize) + } + membersStack.addArrangedSubview(avatar) + } + } + + private func refreshCreateButtonState() { + let colors = ChatUIKitTheme.colors + let nameEmpty = (groupNameField.text ?? "").isEmpty + let enabled = !nameEmpty && !members.isEmpty + createButton.isEnabled = enabled + createButton.backgroundColor = enabled ? colors.textColorLink : colors.textColorDisable + } + + @objc private func handleGroupNameChanged() { + refreshCreateButtonState() + } + + @objc private func handleSelectGroupType() { + let selector = ChooseGroupTypeViewController(selected: groupType) { [weak self] type in + self?.groupType = type + self?.refreshGroupTypeDisplay() + } + navigationController?.pushViewController(selector, animated: true) + } + + @objc private func handleCancel() { + onBack() + } + + @objc private func handleCreate() { + let groupID = groupIDField.text ?? "" + guard validateGroupID(groupID) else { return } + + var params = GroupCreateParams(groupName: groupNameField.text ?? "") + params.groupType = groupType.coreType + params.groupID = groupID.isEmpty ? nil : groupID + params.avatarURL = selectedAvatar + params.memberList = members.map { $0.userID } + + GroupStore.shared.createGroup( + params: params, + completion: CreateGroupHandler( + onSuccess: { [weak self] createdGroupID in + DispatchQueue.main.async { + self?.handleCreateSuccess(createdGroupID) + } + }, + onFailure: { [weak self] code, _ in + DispatchQueue.main.async { + guard let self = self else { return } + WindowToastManager.shared.show(Self.createGroupErrorText(code: code), type: .error, duration: Self.createErrorToastDuration, position: .bottom(Self.toastBottomOffset)) + } + } + ) + ) + } + + private func validateGroupID(_ groupID: String) -> Bool { + guard !groupID.isEmpty else { return true } + let isCommunity = groupType == .community + let hasCommunityPrefix = groupID.hasPrefix("@TGS#_") + let hasGroupPrefix = groupID.hasPrefix("@TGS#") + if isCommunity && !hasCommunityPrefix { + WindowToastManager.shared.show(LocalizedChatString("TUICommunityCreateTipsMessageRuleError"), type: .error, duration: Self.validationErrorToastDuration, position: .bottom(Self.toastBottomOffset)) + return false + } + if !isCommunity && hasGroupPrefix { + WindowToastManager.shared.show(LocalizedChatString("TUIGroupCreateTipsMessageRuleError"), type: .error, duration: Self.validationErrorToastDuration, position: .bottom(Self.toastBottomOffset)) + return false + } + return true + } + + private static func createGroupErrorText(code: Int) -> String { + switch code { + case 10021, 10025: + return LocalizedChatString("GroupCreateErrorGroupIDUsed") + default: + return LocalizedChatString("GroupCreateFailed") + } + } + + private func handleCreateSuccess(_ createdGroupID: String) { + let groupName = groupNameField.text ?? "" + let conversationId = createdGroupID.isEmpty ? nil : "group_\(createdGroupID)" + onComplete(createdGroupID.isEmpty ? nil : createdGroupID, groupName.isEmpty ? nil : groupName, conversationId) + dismiss(animated: true) + let type = groupType.rawValue + DispatchQueue.main.asyncAfter(deadline: .now() + groupCreateTipsMessageDelay) { + Self.sendGroupCreateTipsMessage(groupID: createdGroupID, groupType: type) + } + } + + private static func sendGroupCreateTipsMessage(groupID: String, groupType: String) { + guard !groupID.isEmpty else { return } + let showName = LoginStore.shared.state.value.loginUserInfo?.userID ?? "用户" + let isCommunity = groupType == "Community" + let content = isCommunity + ? LocalizedChatString("TUICommunityCreateTipsMessage") + : LocalizedChatString("TUIGroupCreateTipsMessage") + let dic: [String: Any] = [ + "version": 1, + "businessID": "group_create", + "opUser": showName, + "content": content, + "cmd": isCommunity ? 1 : 0 + ] + let customData = ChatUtil.dictionary2JsonData(dic) + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + let payload = CustomSendMessagePayload(customData: customData) + let messageInputState = MessageInputStore.create(conversationID: "group_\(groupID)") + messageInputState.sendMessage(payload: .custom(payload), option: nil, completion: nil) + } +} + +extension ConfigGroupInfoViewController: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + +// MARK: - Group Type + +private enum GroupTypeSelection: String, CaseIterable { + case work = "Work" + case publicGroup = "Public" + case meeting = "Meeting" + case community = "Community" + + var displayName: String { + switch self { + case .work: return LocalizedChatString("CreatGroupType_Work") + case .publicGroup: return LocalizedChatString("CreatGroupType_Public") + case .meeting: return LocalizedChatString("CreatGroupType_Meeting") + case .community: return LocalizedChatString("CreatGroupType_Community") + } + } + + var typeDescription: String { + switch self { + case .work: return LocalizedChatString("CreatGroupType_Work_Desc") + case .publicGroup: return LocalizedChatString("CreatGroupType_Public_Desc") + case .meeting: return LocalizedChatString("CreatGroupType_Meeting_Desc") + case .community: return LocalizedChatString("CreatGroupType_Community_Desc") + } + } + + var iconName: String { + switch self { + case .work: return "group_type_work" + case .publicGroup: return "group_type_public" + case .meeting: return "group_type_meeting" + case .community: return "group_type_community" + } + } + + var coreType: AtomicXCore.GroupType { + switch self { + case .work: return .work + case .publicGroup: return .publicGroup + case .meeting: return .meeting + case .community: return .community + } + } +} + +// MARK: - Choose Group Type + +private final class ChooseGroupTypeViewController: UIViewController { + private static let cardSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let containerPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let cardCornerRadius: CGFloat = CGFloat(RadiusScheme.alertRadius) + + private static let cardPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let cardSelectedStrokeWidth: CGFloat = 1.5 + + private static let typeIconSize: CGFloat = 40 + + private static let typeIconTitleGap: CGFloat = 10 + + private static let descTopMargin: CGFloat = 10 + + private static let descFontSize: CGFloat = 13 + + private static let descLineHeightMultiple: CGFloat = 1.3 + + private static let titleFontSize: CGFloat = 16 + + private static let backButtonSymbolPointSize: CGFloat = 18 + + private var selected: GroupTypeSelection + + private let onSelect: (GroupTypeSelection) -> Void + + private let contentStack = UIStackView() + + init(selected: GroupTypeSelection, onSelect: @escaping (GroupTypeSelection) -> Void) { + self.selected = selected + self.onSelect = onSelect + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorTopBar + title = LocalizedChatString("ConfigSelectGroupTypeTitle") + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: AtomicXChatResources.image(named: "contact_info_back")? + .withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.backButtonSymbolPointSize, weight: .semibold)), + style: .plain, + target: self, + action: #selector(handleBack) + ) + navigationItem.leftBarButtonItem?.tintColor = colors.textColorPrimary + + let scrollView = UIScrollView() + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + contentStack.axis = .vertical + contentStack.spacing = Self.cardSpacing + contentStack.snp.makeConstraints { make in + make.top.equalTo(scrollView.contentLayoutGuide).offset(Self.cardSpacing) + make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(Self.containerPadding) + make.bottom.equalTo(scrollView.contentLayoutGuide).offset(-Self.cardSpacing) + } + + for type in GroupTypeSelection.allCases { + contentStack.addArrangedSubview(makeOption(type)) + } + } + + private func makeOption(_ type: GroupTypeSelection) -> UIView { + let colors = ChatUIKitTheme.colors + let isSelected = type == selected + + let card = GroupTypeOptionControl(type: type) { [weak self] selectedType in + self?.onSelect(selectedType) + self?.navigationController?.popViewController(animated: true) + } + card.backgroundColor = colors.bgColorOperate + card.layer.cornerRadius = Self.cardCornerRadius + card.layer.borderWidth = isSelected ? Self.cardSelectedStrokeWidth : 0 + card.layer.borderColor = colors.textColorLink.cgColor + + let iconView = UIImageView(image: AtomicXChatResources.image(named: type.iconName)) + iconView.contentMode = .scaleAspectFit + + let titleLabel = UILabel() + titleLabel.text = type.displayName + titleLabel.font = .systemFont(ofSize: Self.titleFontSize, weight: .semibold) + titleLabel.textColor = colors.textColorPrimary + + let descLabel = UILabel() + descLabel.font = .systemFont(ofSize: Self.descFontSize) + descLabel.textColor = colors.textColorSecondary + descLabel.numberOfLines = 0 + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Self.descLineHeightMultiple + descLabel.attributedText = NSAttributedString( + string: type.typeDescription, + attributes: [.paragraphStyle: paragraphStyle] + ) + + card.addSubview(iconView) + card.addSubview(titleLabel) + card.addSubview(descLabel) + iconView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.cardPadding) + make.leading.equalToSuperview().offset(Self.cardPadding) + make.width.height.equalTo(Self.typeIconSize) + } + titleLabel.snp.makeConstraints { make in + make.centerY.equalTo(iconView) + make.leading.equalTo(iconView.snp.trailing).offset(Self.typeIconTitleGap) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.cardPadding) + } + descLabel.snp.makeConstraints { make in + make.top.equalTo(iconView.snp.bottom).offset(Self.descTopMargin) + make.leading.equalToSuperview().offset(Self.cardPadding) + make.trailing.equalToSuperview().offset(-Self.cardPadding) + make.bottom.equalToSuperview().offset(-Self.cardPadding) + } + return card + } + + @objc private func handleBack() { + navigationController?.popViewController(animated: true) + } +} + +private final class GroupTypeOptionControl: UIControl { + private let type: GroupTypeSelection + + private let handler: (GroupTypeSelection) -> Void + + init(type: GroupTypeSelection, handler: @escaping (GroupTypeSelection) -> Void) { + self.type = type + self.handler = handler + super.init(frame: .zero) + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func handleTap() { + handler(type) + } +} + +// MARK: - Group Avatar Grid Item + +private final class GroupAvatarGridItem: UIControl { + private static let avatarCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let avatarFontSize: CGFloat = 18 + + private static let selectedBorderWidth: CGFloat = 2 + + let url: String? + + private let onTap: (String?) -> Void + + private let avatarView: ChatAvatarView + + init(url: String?, fallbackName: String, onTap: @escaping (String?) -> Void) { + self.url = url + self.onTap = onTap + avatarView = ChatAvatarView(cornerRadius: Self.avatarCornerRadius, fontSize: Self.avatarFontSize) + super.init(frame: .zero) + avatarView.isUserInteractionEnabled = false + if let url = url { + avatarView.configure(avatarURL: url, fallbackName: fallbackName) + } else { + avatarView.configure(avatarURL: nil, fallbackName: fallbackName) + } + addSubview(avatarView) + avatarView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + layer.cornerRadius = Self.avatarCornerRadius + layer.masksToBounds = true + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSelected(_ selected: Bool) { + layer.borderWidth = selected ? Self.selectedBorderWidth : 0 + layer.borderColor = ChatUIKitTheme.colors.textColorLink.cgColor + } + + @objc private func handleTap() { + onTap(url) + } +} + +// MARK: - Create Group Handler + +private final class CreateGroupHandler: CreateGroupCompletionHandler { + private let onSuccessBlock: (String) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (String) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(groupID: String) { + onSuccessBlock(groupID) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/conversationlist/view/ConversationCell.swift b/chat/uikit/components/conversationlist/view/ConversationCell.swift new file mode 100644 index 000000000..d1c487a3a --- /dev/null +++ b/chat/uikit/components/conversationlist/view/ConversationCell.swift @@ -0,0 +1,398 @@ +import UIKit +import SnapKit +import Kingfisher +import AtomicXCore + +final class ConversationCell: UITableViewCell { + static let reuseIdentifier = "ConversationCell" + + private static let avatarSize: CGFloat = 48 + + private static let avatarCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let avatarLeadingPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let avatarTextSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let minTitleSubtitleSpacing: CGFloat = 2 + + private static let textVerticalOffset: CGFloat = 1 + + private static let badgeMaxCount = 99 + + private static let trailingPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let statusIconSize: CGFloat = 14 + + private static let sendingIndicatorSlotSize: CGFloat = 12 + + private static let sendingIndicatorScale: CGFloat = 0.6 + + private static let muteIconSize: CGFloat = 16 + + private static let muteIconTopSpacing: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let separatorHeight: CGFloat = 0.5 + + private static let titleFontSize: CGFloat = 18 + + private static let menuHighlightAlpha: CGFloat = 0.08 + + private static let statusSubtitleSpacing: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private let avatarContainer = UIView() + + private let avatarImageView = UIImageView() + + private let avatarTextLabel = UILabel() + + private let avatarBadgeView = ConversationAvatarBadgeView() + + private let titleLabel = UILabel() + + private let subtitleLabel = UILabel() + + private let rightColumnContainer = UIView() + + private let timeLabel = UILabel() + + private let muteImageView = UIImageView() + + private let statusImageView = UIImageView() + + private let sendingIndicator = UIActivityIndicatorView(style: .medium) + + private let separatorView = UIView() + + private let menuHighlightOverlay = UIView() + + private var subtitleLeadingConstraint: Constraint? + + private var muteHeightConstraint: Constraint? + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + func setMenuHighlighted(_ highlighted: Bool) { + menuHighlightOverlay.isHidden = !highlighted + } + + func setSeparatorHidden(_ hidden: Bool) { + separatorView.isHidden = hidden + } + + func configure(with conversation: ConversationInfo) { + titleLabel.text = conversation.title ?? "" + subtitleLabel.attributedText = ConversationCellFormatter.subtitleAttributedText(for: conversation) + timeLabel.text = ConversationCellFormatter.timeText(for: conversation) + + configureAvatar(with: conversation) + configureStatus(with: conversation) + configureUnreadIndicators(with: conversation) + } + + // MARK: - Reuse + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + subtitleLabel.attributedText = nil + timeLabel.text = nil + muteImageView.isHidden = true + statusImageView.isHidden = true + separatorView.isHidden = false + sendingIndicator.stopAnimating() + avatarImageView.kf.cancelDownloadTask() + avatarImageView.image = nil + avatarBadgeView.setMode(.none) + } + + private func constructViewHierarchy() { + selectionStyle = .none + contentView.clipsToBounds = false + clipsToBounds = false + contentView.addSubview(avatarContainer) + avatarContainer.addSubview(avatarImageView) + avatarContainer.addSubview(avatarTextLabel) + contentView.addSubview(avatarBadgeView) + contentView.addSubview(titleLabel) + contentView.addSubview(rightColumnContainer) + rightColumnContainer.addSubview(timeLabel) + rightColumnContainer.addSubview(muteImageView) + contentView.addSubview(statusImageView) + contentView.addSubview(sendingIndicator) + contentView.addSubview(subtitleLabel) + contentView.addSubview(separatorView) + menuHighlightOverlay.isUserInteractionEnabled = false + contentView.addSubview(menuHighlightOverlay) + } + + private func activateConstraints() { + avatarContainer.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.avatarLeadingPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.avatarSize) + } + avatarImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + avatarTextLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + avatarBadgeView.snp.makeConstraints { make in + make.centerX.equalTo(avatarContainer.snp.trailing) + make.centerY.equalTo(avatarContainer.snp.top) + } + rightColumnContainer.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.trailingPadding) + make.centerY.equalToSuperview() + } + timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + timeLabel.setContentHuggingPriority(.required, for: .horizontal) + timeLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + muteImageView.snp.makeConstraints { make in + make.top.equalTo(timeLabel.snp.bottom).offset(Self.muteIconTopSpacing) + make.trailing.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview() + make.width.equalTo(Self.muteIconSize) + muteHeightConstraint = make.height.equalTo(0).constraint + make.bottom.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarContainer.snp.trailing).offset(Self.avatarTextSpacing) + make.top.equalTo(avatarContainer).offset(Self.textVerticalOffset) + make.trailing.lessThanOrEqualTo(rightColumnContainer.snp.leading).offset(-Self.trailingPadding) + } + statusImageView.snp.makeConstraints { make in + make.leading.equalTo(avatarContainer.snp.trailing).offset(Self.avatarTextSpacing) + make.centerY.equalTo(subtitleLabel) + make.width.height.equalTo(Self.statusIconSize) + } + sendingIndicator.snp.makeConstraints { make in + make.center.equalTo(statusImageView) + } + subtitleLabel.snp.makeConstraints { make in + make.top.greaterThanOrEqualTo(titleLabel.snp.bottom).offset(Self.minTitleSubtitleSpacing) + make.bottom.equalTo(avatarContainer).offset(-Self.textVerticalOffset) + subtitleLeadingConstraint = make.leading.equalTo(statusImageView.snp.trailing).offset(0).constraint + make.trailing.lessThanOrEqualTo(rightColumnContainer.snp.leading).offset(-Self.trailingPadding) + } + separatorView.snp.makeConstraints { make in + make.leading.equalTo(titleLabel) + make.trailing.bottom.equalToSuperview() + make.height.equalTo(Self.separatorHeight) + } + menuHighlightOverlay.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + + avatarContainer.backgroundColor = colors.bgColorAvatar + avatarContainer.layer.cornerRadius = Self.avatarCornerRadius + avatarContainer.layer.masksToBounds = true + + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.isHidden = true + + avatarTextLabel.font = FontScheme.caption1Medium + avatarTextLabel.textColor = colors.textColorPrimary + avatarTextLabel.textAlignment = .center + + titleLabel.font = .systemFont(ofSize: Self.titleFontSize, weight: .regular) + titleLabel.textColor = colors.textColorPrimary + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + + subtitleLabel.font = FontScheme.caption2Regular + subtitleLabel.textColor = colors.textColorSecondary + subtitleLabel.numberOfLines = 1 + subtitleLabel.lineBreakMode = .byTruncatingTail + + timeLabel.font = FontScheme.caption3Regular + timeLabel.textColor = colors.textColorSecondary + timeLabel.textAlignment = LanguageHelper.isRTL ? .left : .right + timeLabel.setContentHuggingPriority(.required, for: .horizontal) + timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + muteImageView.image = UIImage(systemName: "bell.slash.fill") + muteImageView.tintColor = colors.textColorSecondary + muteImageView.contentMode = .scaleAspectFit + + statusImageView.image = UIImage(systemName: "exclamationmark.circle.fill") + statusImageView.tintColor = colors.textColorError + statusImageView.contentMode = .scaleAspectFit + + sendingIndicator.color = colors.textColorAntiSecondary + sendingIndicator.hidesWhenStopped = true + sendingIndicator.transform = CGAffineTransform(scaleX: Self.sendingIndicatorScale, y: Self.sendingIndicatorScale) + + separatorView.backgroundColor = colors.strokeColorSecondary + + menuHighlightOverlay.backgroundColor = UIColor.black.withAlphaComponent(Self.menuHighlightAlpha) + menuHighlightOverlay.isHidden = true + } + + private func configureAvatar(with conversation: ConversationInfo) { + let urlString = conversation.avatarURL ?? "" + let displayName = conversation.title ?? conversation.conversationID + if urlString.isEmpty { + showTextAvatar(name: displayName) + } else { + loadImageAvatar(urlString: urlString, fallbackName: displayName) + } + } + + private func showTextAvatar(name: String) { + avatarImageView.isHidden = true + avatarImageView.image = nil + avatarTextLabel.isHidden = false + avatarTextLabel.text = name.first.map { String($0).uppercased() } ?? "" + } + + private func loadImageAvatar(urlString: String, fallbackName: String) { + avatarTextLabel.isHidden = true + avatarImageView.isHidden = false + avatarImageView.kf.setImage(with: URL(string: urlString)) { [weak self] result in + guard let self else { return } + if case .failure = result { + self.showTextAvatar(name: fallbackName) + } + } + } + + private func configureStatus(with conversation: ConversationInfo) { + switch ConversationCellFormatter.sendStatus(for: conversation) { + case .error: + statusImageView.isHidden = false + sendingIndicator.stopAnimating() + statusImageView.snp.updateConstraints { $0.width.height.equalTo(Self.statusIconSize) } + subtitleLeadingConstraint?.update(offset: Self.statusSubtitleSpacing) + case .sending: + statusImageView.isHidden = true + sendingIndicator.startAnimating() + statusImageView.snp.updateConstraints { $0.width.height.equalTo(Self.sendingIndicatorSlotSize) } + subtitleLeadingConstraint?.update(offset: Self.statusSubtitleSpacing) + case .none: + statusImageView.isHidden = true + sendingIndicator.stopAnimating() + statusImageView.snp.updateConstraints { $0.width.height.equalTo(0) } + subtitleLeadingConstraint?.update(offset: 0) + } + } + + private func configureUnreadIndicators(with conversation: ConversationInfo) { + let isUnread = conversation.unreadCount > 0 || conversation.conversationMarkList.contains(.unread) + + if conversation.shouldShowDoNotDisturbIndicator { + muteImageView.isHidden = false + muteHeightConstraint?.update(offset: Self.muteIconSize) + avatarBadgeView.setMode(isUnread ? .dot : .none) + } else { + muteImageView.isHidden = true + muteHeightConstraint?.update(offset: 0) + if isUnread { + let count = conversation.unreadCount + let text = count > Self.badgeMaxCount ? "\(Self.badgeMaxCount)+" : "\(max(count, 1))" + avatarBadgeView.setMode(.text(text)) + } else { + avatarBadgeView.setMode(.none) + } + } + } +} + +// MARK: - ConversationAvatarBadgeView + +private final class ConversationAvatarBadgeView: UIView { + enum Mode { + case none + case dot + case text(String) + } + + override var intrinsicContentSize: CGSize { + switch mode { + case .none: + return .zero + case .dot: + return CGSize(width: Self.dotSize, height: Self.dotSize) + case .text(let text): + let textWidth = (text as NSString).size(withAttributes: [.font: Self.textFont]).width + return CGSize( + width: ceil(textWidth) + Self.textHorizontalPadding * 2, + height: Self.textHeight + ) + } + } + + private static let textHeight: CGFloat = 16 + + private static let textHorizontalPadding: CGFloat = 5 + + private static let dotSize: CGFloat = 8 + + private static let textFont = FontScheme.caption3Bold + + private let textLabel = UILabel() + + private var mode: Mode = .none + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = ChatUIKitTheme.colors.textColorError + textLabel.font = Self.textFont + textLabel.textColor = ChatUIKitTheme.colors.textColorButton + textLabel.textAlignment = .center + addSubview(textLabel) + textLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: 0, + left: Self.textHorizontalPadding, + bottom: 0, + right: Self.textHorizontalPadding + )).priority(.high) + } + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setMode(_ mode: Mode) { + self.mode = mode + switch mode { + case .none: + isHidden = true + case .dot: + isHidden = false + textLabel.text = nil + layer.cornerRadius = Self.dotSize / 2 + case .text(let text): + isHidden = false + textLabel.text = text + layer.cornerRadius = Self.textHeight / 2 + } + layer.masksToBounds = true + invalidateIntrinsicContentSize() + } +} diff --git a/chat/uikit/components/conversationlist/view/ConversationCreateSupport.swift b/chat/uikit/components/conversationlist/view/ConversationCreateSupport.swift new file mode 100644 index 000000000..a1f066d70 --- /dev/null +++ b/chat/uikit/components/conversationlist/view/ConversationCreateSupport.swift @@ -0,0 +1,32 @@ +import AtomicXCore + +// MARK: - Item Mapping + +enum ConversationCreateSupport { + + static func displayName(for contact: ContactInfo) -> String { + if let remark = contact.friendRemark, !remark.isEmpty { return remark } + if let nickname = contact.nickname, !nickname.isEmpty { return nickname } + return contact.userID + } + + static func orderedListItems(from friendList: [ContactInfo]) -> [AZOrderedListItem] { + friendList.map { contact in + AZOrderedListItem( + userID: contact.userID, + avatarURL: contact.avatarURL, + title: displayName(for: contact) + ) + } + } + + static func userPickerItems(from friendList: [ContactInfo]) -> [UserPickerItem] { + friendList.map { contact in + UserPickerItem( + userID: contact.userID, + avatarURL: contact.avatarURL, + title: displayName(for: contact) + ) + } + } +} diff --git a/chat/uikit/components/conversationlist/view/ConversationListViewImpl.swift b/chat/uikit/components/conversationlist/view/ConversationListViewImpl.swift new file mode 100644 index 000000000..694f6b59f --- /dev/null +++ b/chat/uikit/components/conversationlist/view/ConversationListViewImpl.swift @@ -0,0 +1,298 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +enum ConversationActionIDs { + static let delete = "conversation.delete" + static let mute = "conversation.mute" + static let pin = "conversation.pin" + static let markUnread = "conversation.markUnread" + static let clearHistory = "conversation.clearHistory" +} + +final class ConversationListViewImpl: RTCBaseView { + private static let rowHeight: CGFloat = 72 + + private let onConversationClick: (ConversationInfo) -> Void + + private let config: ConversationActionConfigProtocol + + private let viewModel: ConversationListViewModel + + private var conversations: [ConversationInfo] = [] + + private var cancellables = Set() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = Self.rowHeight + table.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) + return table + }() + + private static let emptyIllustrationWidth: CGFloat = 88 + + private static let emptyIllustrationAspectRatio: CGFloat = 300.0 / 338.0 + + private static let emptyTextTopSpacing: CGFloat = 14 + + private lazy var emptyView: UIView = { + let view = UIView() + view.isHidden = true + return view + }() + + private lazy var emptyIllustrationView: UIImageView = { + let imageView = UIImageView(image: AtomicXChatResources.image(named: "uikit_empty_illustration")) + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private lazy var emptyLabel: UILabel = { + let label = UILabel() + label.text = LocalizedChatString("EmptyContent") + label.font = FontScheme.caption2Regular + label.textColor = ChatUIKitTheme.colors.textColorTertiary + label.textAlignment = .center + return label + }() + + init(onConversationClick: @escaping (ConversationInfo) -> Void, + config: ConversationActionConfigProtocol = ChatConversationActionConfig()) { + self.onConversationClick = onConversationClick + self.config = config + self.viewModel = ConversationListViewModel() + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func constructViewHierarchy() { + addSubview(tableView) + addSubview(emptyView) + emptyView.addSubview(emptyIllustrationView) + emptyView.addSubview(emptyLabel) + } + + public override func activateConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + emptyView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } + emptyIllustrationView.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.width.equalTo(Self.emptyIllustrationWidth) + make.height.equalTo(Self.emptyIllustrationWidth).multipliedBy(Self.emptyIllustrationAspectRatio) + } + emptyLabel.snp.makeConstraints { make in + make.top.equalTo(emptyIllustrationView.snp.bottom).offset(Self.emptyTextTopSpacing) + make.leading.trailing.bottom.equalToSuperview() + } + } + + public override func bindInteraction() { + tableView.dataSource = self + tableView.delegate = self + + viewModel.$conversationList + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.conversations = list + self?.tableView.reloadData() + self?.refreshEmptyViewVisibility() + } + .store(in: &cancellables) + + viewModel.$initialLoadFinished + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshEmptyViewVisibility() + } + .store(in: &cancellables) + + viewModel.loadConversations() + } + + private func refreshEmptyViewVisibility() { + emptyView.isHidden = !(viewModel.initialLoadFinished && conversations.isEmpty) + } + + public override func setupViewStyle() { + let listColor = ChatUIKitTheme.colors.bgColorTopBar + backgroundColor = listColor + tableView.backgroundColor = listColor + } + + private func buildActions(for conversation: ConversationInfo) -> [ConversationCustomAction] { + var defaultActions: [ConversationCustomAction] = [] + if config.isSupportMarkUnread { + let isUnread = viewModel.isUnread(conversation) + let title = isUnread ? LocalizedChatString("MarkAsRead") : LocalizedChatString("MarkAsUnRead") + defaultActions.append(ConversationCustomAction(ID: ConversationActionIDs.markUnread, title: title) { [weak self] _ in + if isUnread { + self?.viewModel.markAsRead(conversation) + } else { + self?.viewModel.markAsUnread(conversation) + } + }) + } + if config.isSupportMute { + let willMute = conversation.receiveOption == .receive + let title = willMute ? LocalizedChatString("ConversationMute") : LocalizedChatString("ConversationUnmute") + defaultActions.append(ConversationCustomAction(ID: ConversationActionIDs.mute, title: title) { [weak self] _ in + self?.viewModel.muteConversation(conversation, mute: willMute) + }) + } + if config.isSupportPin { + let pinned = conversation.isPinned + let title = pinned ? LocalizedChatString("UnPin") : LocalizedChatString("Pin") + defaultActions.append(ConversationCustomAction(ID: ConversationActionIDs.pin, title: title) { [weak self] _ in + self?.viewModel.pinConversation(conversation, pin: !pinned) + }) + } + if config.isSupportClearHistory { + defaultActions.append(ConversationCustomAction(ID: ConversationActionIDs.clearHistory, title: LocalizedChatString("ConversationClearChatHistory")) { [weak self] _ in + self?.viewModel.clearHistory(conversation) + }) + } + if config.isSupportDelete { + defaultActions.append(ConversationCustomAction(ID: ConversationActionIDs.delete, title: LocalizedChatString("Delete"), dangerous: true) { [weak self] _ in + self?.viewModel.deleteConversation(conversation) + }) + } + if let customizer = config.actionCustomizer { + let editor = CustomEditor(items: defaultActions) + customizer(editor) + return editor.build() + } + return defaultActions + } + + private func confirmDeleteConversation(_ conversation: ConversationInfo, action: ConversationCustomAction) { + let alert = UIAlertController( + title: LocalizedChatString("ConversationDeleteChatConfirmTitle"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: LocalizedChatString("Delete"), style: .destructive) { _ in + action.action(conversation) + }) + findViewController()?.present(alert, animated: true) + } + + private func showMoreActionSheet(actions: [ConversationCustomAction], conversation: ConversationInfo) { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + for action in actions { + let style: UIAlertAction.Style = action.dangerous ? .destructive : .default + alert.addAction(UIAlertAction(title: action.title, style: style) { _ in + action.action(conversation) + }) + } + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = self + popover.sourceRect = bounds + } + findViewController()?.present(alert, animated: true) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } +} + +extension ConversationListViewImpl: UITableViewDataSource { + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return conversations.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: ConversationCell.reuseIdentifier, + for: indexPath + ) as? ConversationCell else { + return UITableViewCell() + } + let conversation = conversations[indexPath.row] + cell.configure(with: conversation) + let colors = ChatUIKitTheme.colors + cell.backgroundColor = conversation.isPinned ? colors.bgColorInput : colors.bgColorOperate + cell.setSeparatorHidden(indexPath.row == conversations.count - 1) + return cell + } +} + +extension ConversationListViewImpl: UITableViewDelegate { + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let conversation = conversations[indexPath.row] + onConversationClick(conversation) + } + + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let conversation = conversations[indexPath.row] + let actions = buildActions(for: conversation) + + var swipeActions: [UIContextualAction] = [] + var sheetActions: [ConversationCustomAction] = [] + + var deleteContextualAction: UIContextualAction? + var markContextualAction: UIContextualAction? + + for action in actions { + switch action.ID { + case ConversationActionIDs.delete: + deleteContextualAction = UIContextualAction(style: .destructive, title: action.title) { [weak self] _, _, completion in + self?.confirmDeleteConversation(conversation, action: action) + completion(true) + } + case ConversationActionIDs.markUnread: + let markAction = UIContextualAction(style: .normal, title: action.title) { _, _, completion in + action.action(conversation) + completion(true) + } + markAction.backgroundColor = ChatUIKitTheme.colors.buttonColorPrimaryDefault + markContextualAction = markAction + default: + sheetActions.append(action) + } + } + + if let deleteContextualAction = deleteContextualAction { + swipeActions.append(deleteContextualAction) + } + if let markContextualAction = markContextualAction { + swipeActions.append(markContextualAction) + } + if !sheetActions.isEmpty { + let moreAction = UIContextualAction(style: .normal, title: LocalizedChatString("More")) { [weak self] _, _, completion in + self?.showMoreActionSheet(actions: sheetActions, conversation: conversation) + completion(true) + } + moreAction.backgroundColor = .systemOrange + swipeActions.append(moreAction) + } + + guard !swipeActions.isEmpty else { return nil } + let configuration = UISwipeActionsConfiguration(actions: swipeActions) + configuration.performsFirstActionWithFullSwipe = false + return configuration + } +} diff --git a/chat/uikit/components/conversationlist/view/ConversationPopupMenuController.swift b/chat/uikit/components/conversationlist/view/ConversationPopupMenuController.swift new file mode 100644 index 000000000..c182797dc --- /dev/null +++ b/chat/uikit/components/conversationlist/view/ConversationPopupMenuController.swift @@ -0,0 +1,274 @@ +import UIKit +import SnapKit + +final class ConversationPopupMenuController: UIViewController { + struct MenuItem { + let title: String + let isDangerous: Bool + let action: () -> Void + } + + private static let menuCornerRadius: CGFloat = CGFloat(RadiusScheme.smallRadius) + + private static let menuItemHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let menuItemVerticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let dividerHeight: CGFloat = 0.5 + + private static let dividerHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let hostMargin: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private static let anchorHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let aboveOverlap: CGFloat = 8 + + private static let minBelowOverlap: CGFloat = 24 + + private static let showDuration: TimeInterval = 0.18 + + private static let dismissDuration: TimeInterval = 0.16 + + private static let animationScale: CGFloat = 0.92 + + private static let showTranslation: CGFloat = 8 + + private static let dismissTranslation: CGFloat = 6 + + private static let shadowOpacity: Float = 0.15 + + private static let shadowRadius: CGFloat = 8 + + private static let itemHighlightAlpha: CGFloat = 0.08 + + private static let belowOverlapNumerator: CGFloat = 3 + + private static let belowOverlapDenominator: CGFloat = 5 + + private let items: [MenuItem] + + private let anchorFrameInHost: CGRect + + private let onDismiss: () -> Void + + private var showBelow = true + + private var isDismissing = false + + private let menuContainer = UIView() + + private let menuStack = UIStackView() + + // MARK: - Init + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func show( + items: [MenuItem], + anchorFrameInHost: CGRect, + from hostViewController: UIViewController, + onDismiss: @escaping () -> Void + ) { + guard !items.isEmpty else { return } + let controller = ConversationPopupMenuController( + items: items, + anchorFrameInHost: anchorFrameInHost, + onDismiss: onDismiss + ) + hostViewController.present(controller, animated: false) + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + layoutMenu() + prepareShowAnimationState() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + animateShow() + } + + // MARK: - Actions + + private init(items: [MenuItem], anchorFrameInHost: CGRect, onDismiss: @escaping () -> Void) { + self.items = items + self.anchorFrameInHost = anchorFrameInHost + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + private func constructViewHierarchy() { + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleBackdropTap)) + tapRecognizer.delegate = self + view.addGestureRecognizer(tapRecognizer) + + menuStack.axis = .vertical + menuStack.spacing = 0 + for (index, item) in items.enumerated() { + menuStack.addArrangedSubview(makeMenuItemView(item: item)) + if index < items.count - 1 { + menuStack.addArrangedSubview(makeDivider()) + } + } + menuContainer.addSubview(menuStack) + view.addSubview(menuContainer) + } + + private func activateConstraints() { + menuStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = .clear + menuContainer.backgroundColor = colors.bgColorOperate + menuContainer.layer.cornerRadius = Self.menuCornerRadius + menuContainer.layer.masksToBounds = false + menuContainer.layer.shadowColor = UIColor.black.cgColor + menuContainer.layer.shadowOpacity = Self.shadowOpacity + menuContainer.layer.shadowRadius = Self.shadowRadius + menuContainer.layer.shadowOffset = .zero + } + + private func makeMenuItemView(item: MenuItem) -> UIView { + let colors = ChatUIKitTheme.colors + let control = UIControl() + let label = UILabel() + label.text = item.title + label.font = FontScheme.caption1Regular + label.textColor = item.isDangerous ? colors.textColorError : colors.textColorPrimary + control.addSubview(label) + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.menuItemHorizontalPadding) + make.trailing.equalToSuperview().offset(-Self.menuItemHorizontalPadding) + make.top.equalToSuperview().offset(Self.menuItemVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.menuItemVerticalPadding) + } + control.addAction(UIAction { [weak self] _ in + self?.dismissMenu(afterDismiss: item.action) + }, for: .touchUpInside) + control.addAction(UIAction { [weak control] _ in + control?.backgroundColor = ChatUIKitTheme.colors.textColorPrimary.withAlphaComponent(Self.itemHighlightAlpha) + }, for: .touchDown) + let clearHighlight = UIAction { [weak control] _ in + control?.backgroundColor = .clear + } + control.addAction(clearHighlight, for: .touchUpOutside) + control.addAction(clearHighlight, for: .touchCancel) + return control + } + + private func makeDivider() -> UIView { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorSecondary + divider.snp.makeConstraints { make in + make.height.equalTo(Self.dividerHeight) + } + let insetContainer = UIView() + insetContainer.isUserInteractionEnabled = false + insetContainer.addSubview(divider) + divider.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.dividerHorizontalInset) + make.trailing.equalToSuperview().offset(-Self.dividerHorizontalInset) + make.top.bottom.equalToSuperview() + } + return insetContainer + } + + private func layoutMenu() { + view.layoutIfNeeded() + menuContainer.layoutIfNeeded() + let popupSize = menuStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let hostSize = view.bounds.size + + let isRtl = view.effectiveUserInterfaceLayoutDirection == .rightToLeft + let preferredX: CGFloat + if isRtl { + preferredX = anchorFrameInHost.minX + Self.anchorHorizontalInset + } else { + preferredX = anchorFrameInHost.maxX - popupSize.width - Self.anchorHorizontalInset + } + let maxX = max(hostSize.width - popupSize.width - Self.hostMargin, Self.hostMargin) + let xOffset = min(max(preferredX, Self.hostMargin), maxX) + + let belowOverlap = max(anchorFrameInHost.height * Self.belowOverlapNumerator / Self.belowOverlapDenominator, Self.minBelowOverlap) + let belowY = anchorFrameInHost.maxY - belowOverlap + let aboveY = anchorFrameInHost.minY - popupSize.height + Self.aboveOverlap + showBelow = belowY + popupSize.height <= hostSize.height - Self.hostMargin || aboveY < Self.hostMargin + let yOffset: CGFloat + if showBelow { + yOffset = min(belowY, hostSize.height - popupSize.height - Self.hostMargin) + } else { + yOffset = max(aboveY, Self.hostMargin) + } + + menuContainer.frame = CGRect(origin: CGPoint(x: xOffset, y: yOffset), size: popupSize) + } + + private func prepareShowAnimationState() { + let isRtl = view.effectiveUserInterfaceLayoutDirection == .rightToLeft + menuContainer.layer.anchorPoint = CGPoint( + x: isRtl ? 0 : 1, + y: showBelow ? 0 : 1 + ) + + menuContainer.frame.origin = CGPoint( + x: menuContainer.frame.origin.x + (isRtl ? -menuContainer.frame.width / 2 : menuContainer.frame.width / 2), + y: menuContainer.frame.origin.y + (showBelow ? -menuContainer.frame.height / 2 : menuContainer.frame.height / 2) + ) + let translation: CGFloat = showBelow ? -Self.showTranslation : Self.showTranslation + menuContainer.alpha = 0 + menuContainer.transform = CGAffineTransform(scaleX: Self.animationScale, y: Self.animationScale) + .translatedBy(x: 0, y: translation) + } + + private func animateShow() { + UIView.animate(withDuration: Self.showDuration) { + self.menuContainer.alpha = 1 + self.menuContainer.transform = .identity + } + } + + private func dismissMenu(afterDismiss: (() -> Void)? = nil) { + guard !isDismissing else { return } + isDismissing = true + let translation: CGFloat = showBelow ? -Self.dismissTranslation : Self.dismissTranslation + UIView.animate(withDuration: Self.dismissDuration, animations: { + self.menuContainer.alpha = 0 + self.menuContainer.transform = CGAffineTransform(scaleX: Self.animationScale, y: Self.animationScale) + .translatedBy(x: 0, y: translation) + }, completion: { _ in + self.dismiss(animated: false) { + self.onDismiss() + afterDismiss?() + } + }) + } + + @objc private func handleBackdropTap() { + dismissMenu() + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension ConversationPopupMenuController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let touchView = touch.view else { return true } + return !touchView.isDescendant(of: menuContainer) + } +} diff --git a/chat/uikit/components/conversationlist/view/CreateC2CConversationViewControllerImpl.swift b/chat/uikit/components/conversationlist/view/CreateC2CConversationViewControllerImpl.swift new file mode 100644 index 000000000..0ce00d0ef --- /dev/null +++ b/chat/uikit/components/conversationlist/view/CreateC2CConversationViewControllerImpl.swift @@ -0,0 +1,72 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +final class CreateC2CConversationViewControllerImpl: RTCBaseView { + var onUserSelected: ((AZOrderedListItem) -> Void)? + + private lazy var listView = AZOrderedListView(showIndexBar: true) { [weak self] item in + self?.onUserSelected?(item) + } + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private var cancellables = Set() + + init() { + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func constructViewHierarchy() { + addSubview(listView) + addSubview(loadingIndicator) + } + + public override func activateConstraints() { + listView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + public override func bindInteraction() { + subscribeFriendList() + loadFriendList() + } + + private func subscribeFriendList() { + ContactStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] friendList in + self?.applyFriendList(friendList) + } + .store(in: &cancellables) + } + + private func loadFriendList() { + let current = ContactStore.shared.state.value.friendList + if !current.isEmpty { + applyFriendList(current) + } else { + loadingIndicator.startAnimating() + } + ContactStore.shared.loadFriends(completion: { [weak self] _ in + DispatchQueue.main.async { + self?.loadingIndicator.stopAnimating() + } + }) + } + + private func applyFriendList(_ friendList: [ContactInfo]) { + loadingIndicator.stopAnimating() + listView.setItems(ConversationCreateSupport.orderedListItems(from: friendList)) + } +} diff --git a/chat/uikit/components/conversationlist/view/CreateGroupConversationViewControllerImpl.swift b/chat/uikit/components/conversationlist/view/CreateGroupConversationViewControllerImpl.swift new file mode 100644 index 000000000..95ccf4137 --- /dev/null +++ b/chat/uikit/components/conversationlist/view/CreateGroupConversationViewControllerImpl.swift @@ -0,0 +1,194 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +final class CreateGroupConversationViewControllerImpl: RTCBaseView { + var onConfirm: (([UserPickerItem]) -> Void)? + + private static let bottomBarHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let bottomBarVerticalPadding: CGFloat = 10 + + private static let bottomBarSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let dividerHeight: CGFloat = 0.5 + + private static let previewAvatarSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let confirmButtonHeight: CGFloat = 36 + + private static let confirmButtonCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let confirmButtonHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private let pickerView = UserPickerView() + + private let searchBar = ContactSearchBarView() + + private let bottomBar = UIView() + + private let bottomDivider = UIView() + + private let previewScrollView = UIScrollView() + + private let previewStack = UIStackView() + + private let confirmButton = UIButton(type: .custom) + + private var cancellables = Set() + + private var allItems: [UserPickerItem] = [] + + private var currentQuery = "" + + private var currentSelection: [UserPickerItem] = [] + + init() { + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func constructViewHierarchy() { + addSubview(searchBar) + addSubview(pickerView) + addSubview(bottomBar) + bottomBar.addSubview(bottomDivider) + bottomBar.addSubview(previewScrollView) + previewScrollView.addSubview(previewStack) + bottomBar.addSubview(confirmButton) + } + + public override func activateConstraints() { + searchBar.snp.makeConstraints { make in + make.top.equalTo(safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + bottomDivider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Self.dividerHeight) + } + confirmButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.bottomBarHorizontalPadding) + make.top.equalTo(bottomBar.safeAreaLayoutGuide).offset(Self.bottomBarVerticalPadding) + make.bottom.equalTo(bottomBar.safeAreaLayoutGuide).offset(-Self.bottomBarVerticalPadding) + make.height.equalTo(Self.confirmButtonHeight) + } + previewScrollView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.bottomBarHorizontalPadding) + make.trailing.equalTo(confirmButton.snp.leading).offset(-Self.bottomBarSpacing) + make.centerY.equalTo(confirmButton) + make.height.equalTo(ChatAvatarSize.m.size) + } + previewStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + pickerView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + } + + public override func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + bottomBar.backgroundColor = colors.bgColorOperate + bottomDivider.backgroundColor = colors.strokeColorSecondary + + previewScrollView.showsHorizontalScrollIndicator = false + previewStack.axis = .horizontal + previewStack.spacing = Self.previewAvatarSpacing + previewStack.alignment = .center + + confirmButton.titleLabel?.font = FontScheme.caption2Bold + confirmButton.setTitleColor(colors.textColorButton, for: .normal) + confirmButton.layer.cornerRadius = Self.confirmButtonCornerRadius + confirmButton.layer.masksToBounds = true + confirmButton.contentEdgeInsets = UIEdgeInsets( + top: 0, + left: Self.confirmButtonHorizontalPadding, + bottom: 0, + right: Self.confirmButtonHorizontalPadding + ) + updateConfirmButton() + } + + public override func bindInteraction() { + confirmButton.addTarget(self, action: #selector(handleConfirm), for: .touchUpInside) + searchBar.onQueryChange = { [weak self] query in + self?.currentQuery = query + self?.applyFilter() + } + pickerView.onSelectionChanged = { [weak self] selection in + self?.currentSelection = selection + self?.updateConfirmButton() + self?.refreshPreviews() + } + pickerView.onUserInteraction = { [weak self] in + self?.searchBar.hideKeyboard() + } + subscribeFriendList() + ContactStore.shared.loadFriends(completion: nil) + } + + private func subscribeFriendList() { + ContactStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] friendList in + self?.allItems = ConversationCreateSupport.userPickerItems(from: friendList) + self?.applyFilter() + } + .store(in: &cancellables) + } + + private func applyFilter() { + let keyword = currentQuery.trimmingCharacters(in: .whitespaces) + let filtered: [UserPickerItem] + if keyword.isEmpty { + filtered = allItems + } else { + filtered = allItems.filter { + $0.title.range(of: keyword, options: .caseInsensitive) != nil + || $0.userID.range(of: keyword, options: .caseInsensitive) != nil + } + } + pickerView.configure(userList: filtered, maxCount: 0) + } + + private func updateConfirmButton() { + let colors = ChatUIKitTheme.colors + let count = currentSelection.count + confirmButton.setTitle(String(format: LocalizedChatString("ConfirmSelectionFormat"), count), for: .normal) + confirmButton.backgroundColor = count > 0 ? colors.textColorLink : colors.textColorDisable + } + + private func refreshPreviews() { + previewStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for item in currentSelection { + let avatar = ChatAvatarView(size: .m, isRound: false) + avatar.configure(avatarURL: item.avatarURL, fallbackName: item.title) + avatar.snp.makeConstraints { make in + make.width.height.equalTo(ChatAvatarSize.m.size) + } + previewStack.addArrangedSubview(avatar) + } + previewScrollView.layoutIfNeeded() + let maxOffsetX = max(0, previewScrollView.contentSize.width - previewScrollView.bounds.width) + let targetX = previewScrollView.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 0 : maxOffsetX + previewScrollView.setContentOffset(CGPoint(x: targetX, y: 0), animated: false) + } + + @objc private func handleConfirm() { + guard !currentSelection.isEmpty else { return } + onConfirm?(currentSelection) + } +} diff --git a/chat/uikit/components/conversationlist/viewmodel/ConversationListViewModel.swift b/chat/uikit/components/conversationlist/viewmodel/ConversationListViewModel.swift new file mode 100644 index 000000000..90f8e0980 --- /dev/null +++ b/chat/uikit/components/conversationlist/viewmodel/ConversationListViewModel.swift @@ -0,0 +1,102 @@ +import Foundation +import Combine +import AtomicXCore + +final class ConversationListViewModel { + @Published private(set) var conversationList: [ConversationInfo] = [] + + @Published private(set) var initialLoadFinished = false + + private let store: ConversationListStore + + private var cancellables = Set() + + // MARK: - Init + + init(store: ConversationListStore = ConversationListStore.create()) { + self.store = store + subscribeState() + } + + // MARK: - Business Actions (forwarded to core store) + + func loadConversations(completion: (() -> Void)? = nil) { + store.loadConversations(option: ConversationLoadOption()) { _ in + DispatchQueue.main.async { + self.initialLoadFinished = true + completion?() + } + } + } + + func deleteConversation(_ conversation: ConversationInfo) { + store.deleteConversation(conversationID: conversation.conversationID, completion: nil) + } + + func pinConversation(_ conversation: ConversationInfo, pin: Bool) { + store.pinConversation(conversationID: conversation.conversationID, pin: pin, completion: nil) + } + + func clearHistory(_ conversation: ConversationInfo) { + store.clearConversationMessages(conversationID: conversation.conversationID, completion: nil) + } + + func clearUnreadCount(_ conversation: ConversationInfo) { + store.clearConversationUnreadCount(conversationID: conversation.conversationID, completion: nil) + store.markConversation( + conversationIDList: [conversation.conversationID], + markType: .unread, + enable: false, + completion: nil + ) + } + + func markAsRead(_ conversation: ConversationInfo) { + clearUnreadCount(conversation) + } + + func markAsUnread(_ conversation: ConversationInfo) { + store.markConversation( + conversationIDList: [conversation.conversationID], + markType: .unread, + enable: true, + completion: nil + ) + } + + func muteConversation(_ conversation: ConversationInfo, mute: Bool) { + store.setReceiveMessageOpt( + conversationID: conversation.conversationID, + opt: mute ? .notNotify : .receive, + completion: nil + ) + } + + // MARK: - Derived Helpers + + func isUnread(_ conversation: ConversationInfo) -> Bool { + return conversation.unreadCount > 0 || conversation.conversationMarkList.contains(.unread) + } + + private func subscribeState() { + store.state + .subscribe(StatePublisherSelector(keyPath: \ConversationListState.conversationList)) + .map { Self.sanitize($0) } + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.conversationList = list + } + .store(in: &cancellables) + } + + private static func sanitize(_ list: [ConversationInfo]) -> [ConversationInfo] { + var seenConversationIDs = Set() + var result: [ConversationInfo] = [] + for conversation in list where !conversation.conversationID.isEmpty { + if seenConversationIDs.insert(conversation.conversationID).inserted { + result.append(conversation) + } + } + return result + } +} diff --git a/chat/uikit/components/emoji/EmojiHelper.swift b/chat/uikit/components/emoji/EmojiHelper.swift new file mode 100644 index 000000000..9232bcd0f --- /dev/null +++ b/chat/uikit/components/emoji/EmojiHelper.swift @@ -0,0 +1,301 @@ +import Foundation +import UIKit +import Combine +import Kingfisher + +let defaultEmojiSize = CGSize(width: 23, height: 23) +class EmojiBundleHelper { + static var bundlePath: String = BundleHelper.getBundlePath(bundleName: "EmojiFace", classType: EmojiBundleHelper.self, frameworkName: "ChatUIKitBundle") + static func appendPath(_ path: String) -> String { + return (bundlePath as NSString).appendingPathComponent(path) + } + + static func getLocalizedString(_ key: String) -> String { + return LanguageHelper.getLocalizedString(forKey: key, bundle: "EmojiFace", classType: EmojiBundleHelper.self, frameworkName: "ChatUIKitBundle") + } +} + +class EmojiGroup: NSObject { + private static let littleEmojiRowCount: Int = 4 + + private static let bigEmojiRowCount: Int = 3 + + private static let littleEmojiItemCountPerRow: Int = 8 + + private static let bigEmojiItemCountPerRow: Int = 5 + + var id: String = "" + var groupIndex: Int = 0 + var groupPath: String? + var rowCount: Int = 0 + var itemCountPerRow: Int = 0 + var emojis: [EmojiData]? + var needBackDelete: Bool = false + var menuPath: String? + var recentGroup: EmojiGroup? + var isNeedAddInInputBar: Bool = false + var groupName: String? + var isLittleEmoji: Bool = true + var supportReaction: Bool = true + + override init() { + super.init() + } + + init(id: String, name: String, iconPath: String?, emojis: [EmojiData], isLittleEmoji: Bool, supportReaction: Bool? = nil) { + super.init() + self.id = id + self.groupName = name + self.menuPath = iconPath + self.emojis = emojis + self.isLittleEmoji = isLittleEmoji + self.supportReaction = supportReaction ?? isLittleEmoji + self.rowCount = isLittleEmoji ? Self.littleEmojiRowCount : Self.bigEmojiRowCount + self.itemCountPerRow = isLittleEmoji ? Self.littleEmojiItemCountPerRow : Self.bigEmojiItemCountPerRow + } + var emojisMap: [String: String] { + if _emojisMap == nil || (_emojisMap?.count ?? 0) != (emojis?.count ?? 0) { + var emojiDic: [String: String] = [:] + if let emojis = emojis { + for data in emojis { + if let name = data.name { + emojiDic[name] = data.path + } + } + } + _emojisMap = emojiDic + } + return _emojisMap ?? [:] + } + + private var _emojisMap: [String: String]? +} + +class EmojiCache { + public static let shared = EmojiCache() + + private var emojiCache: [String: UIImage] = [:] + + func addEmojiToCache(_ path: String) { + asyncDecodeImage(path) { [weak self] key, image in + guard let self = self, let key = key, let image = image else { return } + self.emojiCache[key] = image + } + } + + func getImageFromCache(_ path: String) -> UIImage? { + guard !path.isEmpty else { return nil } + if let image = emojiCache[path] { + return image + } + if path.contains(".gif") { + if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { + return UIImage(data: data) + } + return nil + } + if let image = UIImage(contentsOfFile: path) { + return image + } + + let formatPath = path + ".gif" + if let data = try? Data(contentsOf: URL(fileURLWithPath: formatPath)) { + return UIImage(data: data) + } + return nil + } + + func asyncDecodeImage(_ path: String, complete: @escaping (String?, UIImage?) -> Void) { + DispatchQueue.global().async { + if let image = UIImage(contentsOfFile: path) { + DispatchQueue.main.async { + complete(path, image) + } + } else { + DispatchQueue.main.async { + complete(nil, nil) + } + } + } + } + + func setImage(_ image: UIImage, forKey key: String) { + emojiCache[key] = image + } + + func downloadRemoteImage(_ url: String) { + guard !url.isEmpty, emojiCache[url] == nil, let target = URL(string: url) else { return } + KingfisherManager.shared.retrieveImage(with: target) { [weak self] result in + guard let self = self, case .success(let value) = result else { return } + self.emojiCache[url] = value.image + NotificationCenter.default.post(name: EmojiCache.didUpdateNotification, object: url) + } + } + + static let didUpdateNotification = NSNotification.Name("EmojiCacheDidUpdateNotification") + + private init() {} +} + +class EmojiConfig: NSObject { + public static let shared = EmojiConfig() + + static let builtInGroupID = "tui_built_in_little_emoji" + + private static let builtInEmojiRowCount: Int = 4 + + private static let builtInEmojiItemCountPerRow: Int = 8 + + public private(set) var emojiGroups: [EmojiGroup] = [] + var chatPopDetailGroups: [EmojiGroup] = [] + var chatContextEmojiDetailGroups: [EmojiGroup] = [] + + let groupsDidChangePublisher = PassthroughSubject() + + override private init() { + super.init() + if let group = createEmojiGroup() { + emojiGroups.append(group) + } + } + + @discardableResult + func addEmojiGroup(_ group: EmojiGroup) -> EmojiConfig { + if let index = emojiGroups.firstIndex(where: { $0.id == group.id }) { + emojiGroups.remove(at: index) + } + emojiGroups.append(group) + didChangeGroups(prefetch: group) + return self + } + + @discardableResult + func addEmojiGroup(_ group: EmojiGroup, index: Int) -> EmojiConfig { + if let existing = emojiGroups.firstIndex(where: { $0.id == group.id }) { + emojiGroups.remove(at: existing) + } + let safeIndex = max(0, min(index, emojiGroups.count)) + emojiGroups.insert(group, at: safeIndex) + didChangeGroups(prefetch: group) + return self + } + + @discardableResult + func addEmojiGroups(_ groups: [EmojiGroup]) -> EmojiConfig { + for group in groups { + if let existing = emojiGroups.firstIndex(where: { $0.id == group.id }) { + emojiGroups.remove(at: existing) + } + emojiGroups.append(group) + prefetchImages(for: group) + } + groupsDidChangePublisher.send() + return self + } + + @discardableResult + func removeEmojiGroup(id: String) -> EmojiConfig { + emojiGroups.removeAll { $0.id == id } + groupsDidChangePublisher.send() + return self + } + + @discardableResult + func removeEmojiGroups(_ groups: [EmojiGroup]) -> EmojiConfig { + let ids = Set(groups.map { $0.id }) + emojiGroups.removeAll { ids.contains($0.id) } + groupsDidChangePublisher.send() + return self + } + + @discardableResult + func clearEmojiGroups() -> EmojiConfig { + emojiGroups.removeAll() + groupsDidChangePublisher.send() + return self + } + + @discardableResult + func moveEmojiGroup(_ id: String, toIndex: Int) -> EmojiConfig { + guard let from = emojiGroups.firstIndex(where: { $0.id == id }) else { return self } + let group = emojiGroups.remove(at: from) + let safeIndex = max(0, min(toIndex, emojiGroups.count)) + emojiGroups.insert(group, at: safeIndex) + groupsDidChangePublisher.send() + return self + } + + func getEmojiGroup(_ id: String) -> EmojiGroup? { + return emojiGroups.first { $0.id == id } + } + + func containsEmojiGroup(_ id: String) -> Bool { + return emojiGroups.contains { $0.id == id } + } + + private func didChangeGroups(prefetch group: EmojiGroup) { + prefetchImages(for: group) + groupsDidChangePublisher.send() + } + + private func prefetchImages(for group: EmojiGroup) { + for emoji in group.emojis ?? [] { + if let path = emoji.path, !path.isEmpty { + EmojiCache.shared.addEmojiToCache(path) + } else if let url = emoji.url, !url.isEmpty { + EmojiCache.shared.downloadRemoteImage(url) + } + } + if let iconPath = group.menuPath, !iconPath.isEmpty { + if iconPath.hasPrefix("http") { + EmojiCache.shared.downloadRemoteImage(iconPath) + } else { + EmojiCache.shared.addEmojiToCache(iconPath) + } + } + } + + private func createEmojiGroup() -> EmojiGroup? { + var emojiEmojis: [EmojiData] = [] + let plistPath = EmojiBundleHelper.appendPath("emoji/emoji.plist") + if let emojiList = NSArray(contentsOfFile: plistPath) as? [[String: String]] { + for dic in emojiList { + let data = EmojiData() + if let name = dic["face_name"], let fileName = dic["face_file"] { + let path = "emoji/\(fileName)@2x.png" + let localizableName = EmojiBundleHelper.getLocalizedString(name) + data.name = name + data.path = EmojiBundleHelper.appendPath(path) + data.localizableName = localizableName + if let path = data.path { + EmojiCache.shared.addEmojiToCache(path) + } + emojiEmojis.append(data) + } + } + } + if !emojiEmojis.isEmpty { + let emojiGroup = EmojiGroup() + emojiGroup.id = EmojiConfig.builtInGroupID + emojiGroup.emojis = emojiEmojis + emojiGroup.groupIndex = 0 + emojiGroup.groupPath = EmojiBundleHelper.appendPath("emoji/") + emojiGroup.menuPath = EmojiBundleHelper.appendPath("emoji/menu") + emojiGroup.isNeedAddInInputBar = true + emojiGroup.groupName = "All" + emojiGroup.rowCount = Self.builtInEmojiRowCount + emojiGroup.itemCountPerRow = Self.builtInEmojiItemCountPerRow + emojiGroup.needBackDelete = false + if let path = emojiGroup.menuPath { + EmojiCache.shared.addEmojiToCache(path) + } + EmojiCache.shared.addEmojiToCache(EmojiBundleHelper.appendPath("del_normal")) + EmojiCache.shared.addEmojiToCache(EmojiBundleHelper.appendPath("ic_unknown_image@2x")) + return emojiGroup + } + return nil + } + + + +} diff --git a/chat/uikit/components/emoji/EmojiManager.swift b/chat/uikit/components/emoji/EmojiManager.swift new file mode 100644 index 000000000..a374e7120 --- /dev/null +++ b/chat/uikit/components/emoji/EmojiManager.swift @@ -0,0 +1,266 @@ +class EmojiData: NSObject { + public var name: String? + var localizableName: String? + public var path: String? + public var url: String? + + public convenience init(key: String, emojiName: String, url: String) { + self.init() + self.name = key + self.localizableName = emojiName + self.url = url + } +} + +class EmojiTextAttachment: NSTextAttachment { + private static let verticalOffsetRatio: CGFloat = 0.4 + + var faceCellData: EmojiData? + var emojiTag: String? + var emojiSize: CGSize = .zero + override public func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect { + return CGRect(x: 0, y: -Self.verticalOffsetRatio * lineFrag.size.height, width: defaultEmojiSize.width, height: defaultEmojiSize.height) + } +} + +class EmojiManager { + public static let shared = EmojiManager() + + private static let emojiCodeAttachmentSize: CGFloat = 20 + + private static let emojiCodeAttachmentVerticalOffset: CGFloat = -4 + + private static let styledAttachmentSizeRatio: CGFloat = 1.5 + + private static let styledAttachmentVerticalOffsetRatio: CGFloat = -0.2 + func createAttributedStringFromEmojiData(_ emoji: EmojiData) -> NSAttributedString { + let emojiTextAttachment = EmojiTextAttachment() + emojiTextAttachment.faceCellData = emoji + emojiTextAttachment.emojiTag = emoji.name + emojiTextAttachment.image = EmojiManager.cachedImage(for: emoji) ?? UIImage() + emojiTextAttachment.emojiSize = defaultEmojiSize + return NSAttributedString(attachment: emojiTextAttachment) + } + + func createAttributedStringWithTextAndStyle(text: String, withFont textFont: UIFont, textColor: UIColor) -> NSMutableAttributedString { + guard !text.isEmpty else { + print("createAttributedStringWithTextAndStyle failed, current text is nil") + return NSMutableAttributedString(string: "") + } + let attributeString = NSMutableAttributedString(string: text) + let allFaces = getAllEmojis() + guard !allFaces.isEmpty else { + attributeString.addAttribute(.font, value: textFont, range: NSRange(location: 0, length: attributeString.length)) + return attributeString + } + let regexEmoji = EmojiManager.getEmojiRegex() + do { + let regex = try NSRegularExpression(pattern: regexEmoji, options: .caseInsensitive) + let results = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) + var imageArray = [(range: NSRange, imageStr: NSAttributedString)]() + for match in results { + let range = match.range + let subStr = (text as NSString).substring(with: range) + for face in allFaces { + if face.name == subStr || face.localizableName == subStr { + let emojiTextAttachment = EmojiTextAttachment() + emojiTextAttachment.faceCellData = face + emojiTextAttachment.emojiTag = face.name + if let path = face.path { + emojiTextAttachment.image = EmojiCache.shared.getImageFromCache(path) + } else if let url = face.url { + emojiTextAttachment.image = EmojiCache.shared.getImageFromCache(url) + } + emojiTextAttachment.emojiSize = defaultEmojiSize + let imageStr = NSAttributedString(attachment: emojiTextAttachment) + imageArray.append((range, imageStr)) + break + } + } + } + var locations = [(originRange: NSRange, originStr: NSAttributedString, currentStr: NSAttributedString)]() + for item in imageArray.reversed() { + let originRange = item.range + let originStr = attributeString.attributedSubstring(from: originRange) + let currentStr = item.imageStr + locations.insert((originRange, originStr, currentStr), at: 0) + attributeString.replaceCharacters(in: originRange, with: currentStr) + } + var offsetLocation = 0 + for location in locations { + var currentRange = location.originRange + currentRange.location += offsetLocation + currentRange.length = location.currentStr.length + offsetLocation += location.currentStr.length - location.originStr.length + } + attributeString.addAttribute(.font, value: textFont, range: NSRange(location: 0, length: attributeString.length)) + attributeString.addAttribute(.foregroundColor, value: textColor, range: NSRange(location: 0, length: attributeString.length)) + } catch { + print("Regex error: \(error.localizedDescription)") + } + return attributeString + } + + func createAttributedStringFromEmojiCodes(from text: String) -> NSAttributedString { + let pattern = "\\[TUIEmoji_[^\\]]+\\]" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let nsString = text as NSString + let matches = regex?.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) ?? [] + let attributedString = NSMutableAttributedString() + var lastIndex = 0 + for match in matches { + if match.range.location > lastIndex { + let textRange = NSRange(location: lastIndex, length: match.range.location - lastIndex) + let textString = nsString.substring(with: textRange) + attributedString.append(NSAttributedString(string: textString)) + } + let emojiCode = nsString.substring(with: match.range) + if let emojiData = getAllEmojis().first(where: { $0.name == emojiCode }), + let image = EmojiManager.cachedImage(for: emojiData) + { + + let attachment = EmojiTextAttachment() + attachment.image = image + attachment.emojiTag = emojiCode + attachment.faceCellData = emojiData + attachment.emojiSize = defaultEmojiSize + attachment.bounds = CGRect(x: 0, y: Self.emojiCodeAttachmentVerticalOffset, width: Self.emojiCodeAttachmentSize, height: Self.emojiCodeAttachmentSize) + let imageString = NSAttributedString(attachment: attachment) + attributedString.append(imageString) + } else { + attributedString.append(NSAttributedString(string: emojiCode)) + } + lastIndex = match.range.location + match.range.length + } + if lastIndex < nsString.length { + let textRange = NSRange(location: lastIndex, length: nsString.length - lastIndex) + let textString = nsString.substring(with: textRange) + attributedString.append(NSAttributedString(string: textString)) + } + return attributedString + } + + func createStyledAttributedString(fromEmojiCodes text: String, font: UIFont, textColor: UIColor) -> NSAttributedString { + let source = createAttributedStringFromEmojiCodes(from: text) + let mutable = NSMutableAttributedString(attributedString: source) + let fullRange = NSRange(location: 0, length: mutable.length) + let emojiSize = font.pointSize * Self.styledAttachmentSizeRatio + mutable.enumerateAttribute(.attachment, in: fullRange, options: []) { value, range, _ in + if let attachment = value as? NSTextAttachment { + attachment.bounds = CGRect( + x: 0, + y: emojiSize * Self.styledAttachmentVerticalOffsetRatio, + width: emojiSize, + height: emojiSize + ) + } else { + mutable.addAttribute(.font, value: font, range: range) + mutable.addAttribute(.foregroundColor, value: textColor, range: range) + } + } + return mutable + } + + func createLocalizedStringFromEmojiCodes(_ text: String) -> String { + let regex = try? NSRegularExpression(pattern: EmojiManager.getEmojiRegex(), options: []) + let nsString = text as NSString + let matches = regex?.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) ?? [] + var result = text + for match in matches.reversed() { + let emojiCode = nsString.substring(with: match.range) + if let emojiData = getAllEmojis().first(where: { $0.name == emojiCode }), + let localizedName = emojiData.localizableName + { + result = (result as NSString).replacingCharacters(in: match.range, with: localizedName) + } + } + return result + } + + private init() {} + private func getStickerFromCache(_ path: String) -> UIImage { + return EmojiCache.shared.getImageFromCache(path) ?? UIImage() + } + + static func cachedImage(for emoji: EmojiData) -> UIImage? { + if let path = emoji.path, !path.isEmpty { + return EmojiCache.shared.getImageFromCache(path) + } + if let url = emoji.url, !url.isEmpty { + return EmojiCache.shared.getImageFromCache(url) + } + return nil + } + + private static func getEmojiRegex() -> String { + return "\\[[a-zA-Z0-9_\\u4e00-\\u9fa5]+\\]" + } +} + +extension EmojiManager { + private static let kRecentEmojiKey = "recent_emoji_list" + + private static let chineseElementEmojiKeys: Set = [ + "[TUIEmoji_Rich]", "[TUIEmoji_Bless]", "[TUIEmoji_Fortune]", + "[TUIEmoji_Convinced]", "[TUIEmoji_Prohibit]", "[TUIEmoji_666]", "[TUIEmoji_857]" + ] + + private var userDefaults: UserDefaults { UserDefaults.standard } + + private var maxRecentCount: Int { 8 } + + func getAllEmojis() -> [EmojiData] { + return EmojiConfig.shared.emojiGroups.flatMap { $0.emojis ?? [] } + } + + func pickerVisibleEmojis(in group: EmojiGroup?) -> [EmojiData] { + guard let group = group else { return [] } + let emojis = group.emojis ?? [] + guard group.id == EmojiConfig.builtInGroupID, + !LanguageHelper.getCurrentLanguage().hasPrefix("zh") else { return emojis } + return emojis.filter { !EmojiManager.chineseElementEmojiKeys.contains($0.name ?? "") } + } + + func getReactionEmojis() -> [EmojiData] { + let group = EmojiConfig.shared.emojiGroups.first(where: { $0.supportReaction }) + ?? EmojiConfig.shared.emojiGroups.first + return pickerVisibleEmojis(in: group) + } + + func getPickerEmojis() -> [EmojiData] { + return getReactionEmojis() + } + + func getRecentEmojiDataList(groupID: String = EmojiConfig.builtInGroupID) -> [EmojiData] { + let group = EmojiConfig.shared.getEmojiGroup(groupID) + let visible = pickerVisibleEmojis(in: group) + return getRecentEmojis(groupID: groupID).compactMap { id in + visible.first(where: { $0.name == id }) + } + } + + func getRecentEmojis(groupID: String = EmojiConfig.builtInGroupID) -> [String] { + return userDefaults.stringArray(forKey: Self.recentKey(for: groupID)) ?? [] + } + + func addRecentEmoji(_ emoji: EmojiData, groupID: String = EmojiConfig.builtInGroupID) { + guard let id = emoji.name else { return } + var list = getRecentEmojis(groupID: groupID) + list.removeAll { $0 == id } + list.insert(id, at: 0) + if list.count > maxRecentCount { + list = Array(list.prefix(maxRecentCount)) + } + userDefaults.set(list, forKey: Self.recentKey(for: groupID)) + } + + func reactionGroupID() -> String { + return EmojiConfig.shared.emojiGroups.first(where: { $0.supportReaction })?.id + ?? EmojiConfig.builtInGroupID + } + + private static func recentKey(for groupID: String) -> String { + if groupID == EmojiConfig.builtInGroupID { return kRecentEmojiKey } + return "\(kRecentEmojiKey)_\(groupID)" + } +} diff --git a/chat/uikit/components/messageinput/MessageInputView.swift b/chat/uikit/components/messageinput/MessageInputView.swift new file mode 100644 index 000000000..05db5c172 --- /dev/null +++ b/chat/uikit/components/messageinput/MessageInputView.swift @@ -0,0 +1,323 @@ +import AtomicXCore +import Combine +import SnapKit +import UIKit + +public protocol MessageInputConfigProtocol { + var isShowAudioRecorder: Bool { get } + var isShowPhotoTaker: Bool { get } + var isShowMore: Bool { get } + var isShowSendButton: Bool { get } + var isShowEmoji: Bool { get } + var enableReadReceipt: Bool { get } + var enableMention: Bool { get } + var enableLongPressToTalk: Bool { get } + var isShowVideoCall: Bool { get } + var isShowAudioCall: Bool { get } + var enableTyping: Bool { get } + var actionCustomizer: MessageInputActionCustomizer? { get } +} + +extension MessageInputConfigProtocol { + public var isShowVideoCall: Bool { true } + public var isShowAudioCall: Bool { true } + public var enableTyping: Bool { true } + public var actionCustomizer: MessageInputActionCustomizer? { nil } +} + +public struct ChatMessageInputConfig: MessageInputConfigProtocol { + public var isShowAudioRecorder: Bool { + return userIsShowAudioRecorder ?? true + } + + public var isShowPhotoTaker: Bool { + return userIsShowPhotoTaker ?? true + } + + public var isShowMore: Bool { + return userIsShowMore ?? true + } + + public var isShowSendButton: Bool { + if let userIsShowSendButton = userIsShowSendButton { + return userIsShowSendButton + } else { + return !AppBuilderConfig.shared.hideSendButton + } + } + + public var isShowEmoji: Bool { + return userIsShowEmoji ?? true + } + + public var enableReadReceipt: Bool { + if let userEnableReadReceipt = userEnableReadReceipt { + return userEnableReadReceipt + } else { + return AppBuilderConfig.shared.enableReadReceipt + } + } + + public var enableMention: Bool { + return userEnableMention ?? true + } + + public var enableLongPressToTalk: Bool { + return userEnableLongPressToTalk ?? true + } + + public var isShowVideoCall: Bool { + return userIsShowVideoCall ?? true + } + + public var isShowAudioCall: Bool { + return userIsShowAudioCall ?? true + } + + public var enableTyping: Bool { + return userEnableTyping ?? true + } + + public var actionCustomizer: MessageInputActionCustomizer? { + return userActionCustomizer + } + + private let userIsShowAudioRecorder: Bool? + + private let userIsShowPhotoTaker: Bool? + + private let userIsShowMore: Bool? + + private let userIsShowSendButton: Bool? + + private let userIsShowEmoji: Bool? + + private let userEnableReadReceipt: Bool? + + private let userEnableMention: Bool? + + private let userEnableLongPressToTalk: Bool? + + private let userIsShowVideoCall: Bool? + + private let userIsShowAudioCall: Bool? + + private let userEnableTyping: Bool? + + private let userActionCustomizer: MessageInputActionCustomizer? + + public init() { + self.userIsShowAudioRecorder = nil + self.userIsShowPhotoTaker = nil + self.userIsShowMore = nil + self.userIsShowSendButton = nil + self.userIsShowEmoji = nil + self.userEnableReadReceipt = nil + self.userEnableMention = nil + self.userEnableLongPressToTalk = nil + self.userIsShowVideoCall = nil + self.userIsShowAudioCall = nil + self.userEnableTyping = nil + self.userActionCustomizer = nil + } + + public init( + isShowAudioRecorder: Bool? = nil, + isShowPhotoTaker: Bool? = nil, + isShowMore: Bool? = nil, + isShowSendButton: Bool? = nil, + isShowEmoji: Bool? = nil, + enableReadReceipt: Bool? = nil, + enableMention: Bool? = nil, + enableLongPressToTalk: Bool? = nil, + isShowVideoCall: Bool? = nil, + isShowAudioCall: Bool? = nil, + enableTyping: Bool? = nil, + actionCustomizer: MessageInputActionCustomizer? = nil + ) { + self.userIsShowAudioRecorder = isShowAudioRecorder + self.userIsShowPhotoTaker = isShowPhotoTaker + self.userIsShowMore = isShowMore + self.userIsShowSendButton = isShowSendButton + self.userIsShowEmoji = isShowEmoji + self.userEnableReadReceipt = enableReadReceipt + self.userEnableMention = enableMention + self.userEnableLongPressToTalk = enableLongPressToTalk + self.userIsShowVideoCall = isShowVideoCall + self.userIsShowAudioCall = isShowAudioCall + self.userEnableTyping = enableTyping + self.userActionCustomizer = actionCustomizer + } +} + +public enum MessageInputActionIDs { + public static let album = "messageInput.album" + public static let takePhoto = "messageInput.takePhoto" + public static let recordVideo = "messageInput.recordVideo" + public static let file = "messageInput.file" + public static let videoCall = "messageInput.videoCall" + public static let audioCall = "messageInput.audioCall" +} + +public typealias MessageInputActionCustomizer = (CustomEditor) -> Void + +public struct MessageInputMenuAction: CustomItem { + public var ID: String + + public let title: String + + public let iconName: String + + public let icon: UIImage? + + public var dangerous: Bool + + public let onClick: () -> Void + + public init(ID: String, + title: String, + iconName: String, + icon: UIImage? = nil, + dangerous: Bool = false, + onClick: @escaping () -> Void) { + self.ID = ID + self.title = title + self.iconName = iconName + self.icon = icon + self.dangerous = dangerous + self.onClick = onClick + } +} + +final class MessageInputView: UIView { + private static let c2cConversationPrefix = "c2c_" + + static let inputInteractNotification = NSNotification.Name("MessageInputInteract") + + static let voiceRecordStartNotification = NSNotification.Name("MessageInputVoiceRecordStart") + + static let typingMessageBusinessID = "user_typing_status" + + var bottomSafeAreaInset: CGFloat { + get { impl.bottomSafeAreaInset } + set { impl.bottomSafeAreaInset = newValue } + } + + var onTypingContentChanged: ((Bool) -> Void)? { + get { impl.onTypingContentChanged } + set { impl.onTypingContentChanged = newValue } + } + + var onSendFailure: ((String) -> Void)? { + get { impl.onSendFailure } + set { impl.onSendFailure = newValue } + } + + var typingPublisher: AnyPublisher? { + typingController?.typingPublisher + } + + private let impl: MessageInputViewImpl + + private let typingController: TypingIndicatorController? + + override var intrinsicContentSize: CGSize { + impl.intrinsicContentSize + } + + init(conversationID: String, config: MessageInputConfigProtocol = ChatMessageInputConfig()) { + impl = MessageInputViewImpl(conversationID: conversationID, config: config) + if config.enableTyping, conversationID.hasPrefix(Self.c2cConversationPrefix) { + let currentUserID = LoginStore.shared.state.value.loginUserInfo?.userID + typingController = TypingIndicatorController(conversationID: conversationID, currentUserID: currentUserID) + } else { + typingController = nil + } + super.init(frame: .zero) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .vertical) + addSubview(impl) + impl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + impl.onIntrinsicContentSizeInvalidated = { [weak self] in + self?.invalidateIntrinsicContentSize() + } + impl.onAnimateSuperviewLayout = { [weak self] in + self?.superview?.layoutIfNeeded() + } + } + + func sendTypingStatus(hasContent: Bool) { + typingController?.sendTypingStatus(hasContent: hasContent) + } + + func invalidateTypingIndicator() { + typingController?.invalidate() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +struct MessagePlaceholderInfo { + enum MediaKind { + case image + case video + } + + let placeholderID: String + + let mediaKind: MediaKind + + let thumbnailPath: String? + + let thumbnailSize: CGSize + + let mediaPath: String? + + let videoDuration: Int + + let progress: Int + + init(placeholderID: String, + mediaKind: MediaKind, + thumbnailPath: String?, + thumbnailSize: CGSize, + mediaPath: String? = nil, + videoDuration: Int = 0, + progress: Int = 0) { + self.placeholderID = placeholderID + self.mediaKind = mediaKind + self.thumbnailPath = thumbnailPath + self.thumbnailSize = thumbnailSize + self.mediaPath = mediaPath + self.videoDuration = videoDuration + self.progress = progress + } +} + +final class AlbumPickerPlaceholderStore { + static let shared = AlbumPickerPlaceholderStore() + + var placeholdersByConversation: AnyPublisher<[String: [MessageInfo]], Never> { + impl.$placeholdersByConversation.eraseToAnyPublisher() + } + + private let impl = AlbumPickerPlaceholderStoreImpl.shared + + func upsert(conversationID: String, info: MessagePlaceholderInfo) { + impl.upsert(conversationID: conversationID, info: info) + } + + func remove(conversationID: String, placeholderID: String) { + impl.remove(conversationID: conversationID, placeholderID: placeholderID) + } + + static func localMediaPath(of message: MessageInfo) -> String? { + return AlbumPickerPlaceholderStoreImpl.localMediaPath(of: message) + } + + private init() {} +} diff --git a/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator+Media.swift b/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator+Media.swift new file mode 100644 index 000000000..79a05fa4a --- /dev/null +++ b/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator+Media.swift @@ -0,0 +1,165 @@ +import Foundation +import AVFoundation +import Photos +import UIKit +import AlbumPicker + +extension AlbumPickerMediaSendCoordinator { + private static let thumbnailDirectoryName = "album_picker_processing_thumbnail" + + private static let thumbnailMaxPixel: CGFloat = 720 + + private static let thumbnailCompressionQuality: CGFloat = 0.8 + + private static let snapshotTimescale: CMTimeScale = 60 + + static func thumbnail(for media: AlbumMedia, + onUpdate: @escaping (String?, CGSize, Bool) -> Void) { + if let path = localThumbnailPath(for: media), let image = readImage(at: path) { + onUpdate(path, image.size, true) + return + } + if media.mediaType == .video, let videoPath = media.mediaPath, isReadableFile(videoPath) { + DispatchQueue.global().async { + let path = writeThumbnail(makeVideoSnapshot(videoPath: videoPath), mediaID: media.id) + let image = path.flatMap { readImage(at: $0) } + runOnMain { onUpdate(path, image?.size ?? pixelSize(of: media.asset), path != nil) } + } + return + } + if media.mediaType == .image, let imagePath = media.mediaPath, isReadableFile(imagePath) { + if let image = readImage(at: imagePath) { + onUpdate(imagePath, image.size, true) + return + } + onUpdate(nil, .zero, true) + return + } + requestAssetThumbnail(for: media, onUpdate: onUpdate) + } + + static func placeholderSize(for media: AlbumMedia) -> CGSize { + if media.mediaType == .video { + if let asset = media.asset, asset.pixelWidth > 0, asset.pixelHeight > 0 { + return CGSize(width: asset.pixelWidth, height: asset.pixelHeight) + } + return .zero + } + if let path = media.mediaPath, isReadableFile(path), let image = readImage(at: path) { + return image.size + } + return .zero + } + + private static func localThumbnailPath(for media: AlbumMedia) -> String? { + if media.mediaType == .video { + if let path = media.videoThumbnailPath, isReadableFile(path) { + return path + } + return nil + } + if let path = media.mediaPath, isReadableFile(path) { + return path + } + return nil + } + + private static func requestAssetThumbnail(for media: AlbumMedia, + onUpdate: @escaping (String?, CGSize, Bool) -> Void) { + let fallbackSize = pixelSize(of: media.asset) + guard let asset = media.asset else { + onUpdate(nil, fallbackSize, true) + return + } + let options = PHImageRequestOptions() + options.isNetworkAccessAllowed = true + options.deliveryMode = .opportunistic + options.resizeMode = .fast + let targetSize = CGSize(width: thumbnailMaxPixel, height: thumbnailMaxPixel) + PHImageManager.default().requestImage( + for: asset, + targetSize: targetSize, + contentMode: .aspectFit, + options: options + ) { image, info in + let isDegraded = (info?[PHImageResultIsDegradedKey] as? NSNumber)?.boolValue ?? false + guard let image = image else { + if !isDegraded { onUpdate(nil, fallbackSize, true) } + return + } + DispatchQueue.global().async { + let path = writeThumbnail(image, mediaID: media.id, isPreview: isDegraded) + let size = (path.flatMap { readImage(at: $0) }?.size) ?? fallbackSize + runOnMain { onUpdate(path, size, path != nil && !isDegraded) } + } + } + } + + private static func makeVideoSnapshot(videoPath: String) -> UIImage? { + let asset = AVAsset(url: URL(fileURLWithPath: videoPath)) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + guard let cgImage = try? generator.copyCGImage( + at: CMTime(seconds: 0, preferredTimescale: snapshotTimescale), + actualTime: nil + ) else { + return nil + } + return UIImage(cgImage: cgImage) + } + + private static func writeThumbnail(_ image: UIImage?, mediaID: UInt64, isPreview: Bool = false) -> String? { + let fileName = isPreview ? "\(mediaID)_preview.jpg" : "\(mediaID).jpg" + guard let image = image, + let data = image.jpegData(compressionQuality: thumbnailCompressionQuality), + let destination = makeTemporaryFileURL( + directory: thumbnailDirectoryName, + fileName: fileName + ) else { + return nil + } + guard (try? data.write(to: destination, options: .atomic)) != nil else { return nil } + return destination.path + } + + private static func pixelSize(of asset: PHAsset?) -> CGSize { + guard let asset = asset, asset.pixelWidth > 0, asset.pixelHeight > 0 else { return .zero } + return CGSize(width: asset.pixelWidth, height: asset.pixelHeight) + } + + private static func readImage(at path: String) -> UIImage? { + guard isReadableFile(path), let image = UIImage(contentsOfFile: path), + image.size.width > 0, image.size.height > 0 else { + return nil + } + return image + } + + private static func isReadableFile(_ path: String?) -> Bool { + guard let path = path, !path.isEmpty else { return false } + return FileManager.default.fileExists(atPath: path) + } + + private static func makeTemporaryFileURL(directory: String, fileName: String) -> URL? { + let directoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(directory) + guard (try? FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + )) != nil else { + return nil + } + let fileURL = directoryURL.appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: fileURL.path) { + try? FileManager.default.removeItem(at: fileURL) + } + return fileURL + } + + private static func runOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } +} diff --git a/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator.swift b/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator.swift new file mode 100644 index 000000000..867f57c79 --- /dev/null +++ b/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerMediaSendCoordinator.swift @@ -0,0 +1,365 @@ +import Foundation +import UIKit +import AlbumPicker +import AlbumPickerCore +import AtomicXCore + +final class AlbumPickerMediaSendCoordinator { + static let shared = AlbumPickerMediaSendCoordinator() + + private static let maxSelectionCount = 9 + + private static let maxVideoOutputSizeInMB = 100 + + private static let maxVideoDurationInSeconds = 600 + + private var activeSessions: [String: AlbumPickerSendSession] = [:] + + private var isPickerVisible = false + + func showAlbumPicker(conversationID: String) { + if conversationID.isEmpty || isPickerVisible { + return + } + guard let presenter = Self.topMostViewController() else { return } + AlbumPickerCoreTheme.shared.currentPrimaryColor = ChatUIKitTheme.colors.buttonColorPrimaryDefault + + let pickerController = UIViewController() + pickerController.modalPresentationStyle = .fullScreen + + let session = AlbumPickerSendSession( + conversationID: conversationID, + pickerController: pickerController, + onPickerDismissed: { [weak self] in + self?.isPickerVisible = false + }, + onCompleted: { [weak self] finishedSession in + self?.activeSessions.removeValue(forKey: finishedSession.sessionID) + } + ) + activeSessions[session.sessionID] = session + + let albumView = AlbumPickerView() + albumView.delegate = session + albumView.initialize(config: Self.makeConfig(), theme: Self.makeTheme()) + pickerController.view = albumView + + isPickerVisible = true + presenter.present(pickerController, animated: true) + } + + private init() {} + + private static func makeConfig() -> AlbumPickerConfig { + var config = AlbumPickerConfig( + maxSelectionCount: maxSelectionCount, + style: .likeWeChat, + mediaFilter: .imageAndVideo, + compressQuality: .standard + ) + config.maxOutputFileSizeInMB = maxVideoOutputSizeInMB + config.maxVideoDurationInSeconds = maxVideoDurationInSeconds + return config + } + + private static func makeTheme() -> AlbumPickerTheme { + let colors = ChatUIKitTheme.colors + return AlbumPickerTheme( + currentPrimaryColor: colors.buttonColorPrimaryDefault + ) + } + + private static func topMostViewController() -> UIViewController? { + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first + var controller = window?.rootViewController + while let presented = controller?.presentedViewController { + controller = presented + } + return controller + } +} + +private final class AlbumPickerSendSession: NSObject, AlbumPickerDelegate { + let sessionID = UUID().uuidString + + private struct PlaceholderState { + let placeholderID: String + var thumbnailPath: String? + var isThumbnailFinal: Bool + var thumbnailSize: CGSize + var mediaPath: String? + var progress: Int + } + + private let conversationID: String + + private let inputStore: MessageInputStore + + private weak var pickerController: UIViewController? + + private let onPickerDismissed: () -> Void + + private let onCompleted: (AlbumPickerSendSession) -> Void + + private let placeholderStore = AlbumPickerPlaceholderStore.shared + + private var placeholderStates: [String: PlaceholderState] = [:] + + private var sentMediaIDs: Set = [] + + private var pendingText: String? + + private var didSendPendingText = false + + private var isFinished = false + + init(conversationID: String, + pickerController: UIViewController, + onPickerDismissed: @escaping () -> Void, + onCompleted: @escaping (AlbumPickerSendSession) -> Void) { + self.conversationID = conversationID + self.inputStore = MessageInputStore.create(conversationID: conversationID) + self.pickerController = pickerController + self.onPickerDismissed = onPickerDismissed + self.onCompleted = onCompleted + super.init() + } + + func onPickConfirm(pickedAlbumMedias: [AlbumMedia], textMessage: String?) { + pendingText = textMessage + for media in pickedAlbumMedias { + insertPlaceholder(for: media) + } + dismissPicker() + } + + func onMediaProcessing(albumMedia: AlbumMedia, progress: Float, error: Bool) { + if error { + markSendFailed(albumMedia) + return + } + if progress < Self.completedProgress { + updatePlaceholderProgress(for: albumMedia, progress: progress) + return + } + if let path = albumMedia.mediaPath, !path.isEmpty { + sendOnce(albumMedia) { [weak self] in + self?.sendMedia(albumMedia, path: path) + } + return + } + markSendFailed(albumMedia) + } + + func onMediaProcessed() { + sendPendingTextIfNeeded() + finish() + } + + func onCancel() { + finish() + } + + private func insertPlaceholder(for media: AlbumMedia) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + let initialSize = AlbumPickerMediaSendCoordinator.placeholderSize(for: media) + placeholderStates[placeholderID] = PlaceholderState( + placeholderID: placeholderID, + thumbnailPath: nil, + isThumbnailFinal: false, + thumbnailSize: initialSize, + mediaPath: nil, + progress: 0 + ) + applyPlaceholder(for: media) + requestThumbnail(for: media) + } + + private func requestThumbnail(for media: AlbumMedia) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + AlbumPickerMediaSendCoordinator.thumbnail(for: media) { [weak self] path, size, isFinal in + guard let self = self else { return } + guard var state = self.placeholderStates[placeholderID] else { return } + + if state.isThumbnailFinal { return } + if let path = path { state.thumbnailPath = path } + if size.width > 0, size.height > 0 { state.thumbnailSize = size } + + state.isThumbnailFinal = isFinal && path != nil + self.placeholderStates[placeholderID] = state + self.applyPlaceholder(for: media) + } + } + + private func updatePlaceholderProgress(for media: AlbumMedia, progress: Float) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + guard var state = placeholderStates[placeholderID] else { return } + let percent = Self.toPercent(progress) + if percent - state.progress < Self.progressUpdateStep { + return + } + state.progress = percent + placeholderStates[placeholderID] = state + applyPlaceholder(for: media) + } + + private func applyPlaceholder(for media: AlbumMedia) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + guard let state = placeholderStates[placeholderID] else { return } + placeholderStore.upsert( + conversationID: conversationID, + info: MessagePlaceholderInfo( + placeholderID: state.placeholderID, + mediaKind: media.mediaType == .video ? .video : .image, + thumbnailPath: state.thumbnailPath, + thumbnailSize: state.thumbnailSize, + mediaPath: state.mediaPath, + videoDuration: Self.durationInSeconds(media.duration), + progress: state.progress + ) + ) + } + + private func markPlaceholderSending(for media: AlbumMedia, path: String) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + guard var state = placeholderStates[placeholderID] else { return } + state.mediaPath = path + placeholderStates[placeholderID] = state + applyPlaceholder(for: media) + } +private func sendOnce(_ media: AlbumMedia, _ send: @escaping () -> Void) { + if sentMediaIDs.insert(media.id).inserted { + send() + } + } + + private func sendMedia(_ media: AlbumMedia, path: String) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + let state = placeholderStates[placeholderID] + let reusableSnapshotPath = (state?.isThumbnailFinal == true ? state?.thumbnailPath : nil) + ?? media.videoThumbnailPath + markPlaceholderSending(for: media, path: path) + switch media.mediaType { + case .video: + sendVideoMessage(path, snapshotPath: reusableSnapshotPath ?? "", media: media) + case .image: + inputStore.sendMessage(payload: .image(makeImagePayload(imagePath: path)), option: makeSendOption(), completion: nil) + } + } + + private func makeSendOption() -> SendMessageOption { + var option = SendMessageOption() + option.needReadReceipt = ChatMessageInputConfig().enableReadReceipt + return option + } + + private func makeImagePayload(imagePath: String) -> ImageSendMessagePayload { + var payload = ImageSendMessagePayload(imagePath: imagePath) + if let image = UIImage(contentsOfFile: imagePath) { + payload.imageWidth = Int(image.size.width) + payload.imageHeight = Int(image.size.height) + } + return payload + } + + private func sendVideoMessage(_ videoPath: String, snapshotPath: String, media: AlbumMedia) { + var snapshotWidth = 0 + var snapshotHeight = 0 + if let image = UIImage(contentsOfFile: snapshotPath) { + snapshotWidth = Int(image.size.width) + snapshotHeight = Int(image.size.height) + } + let payload = VideoSendMessagePayload( + videoFilePath: videoPath, + videoType: "mp4", + duration: Self.durationInSeconds(media.duration), + snapshotPath: snapshotPath, + snapshotWidth: snapshotWidth, + snapshotHeight: snapshotHeight + ) + inputStore.sendMessage(payload: .video(payload), option: makeSendOption(), completion: nil) + } + + private func markSendFailed(_ media: AlbumMedia) { + if sentMediaIDs.insert(media.id).inserted { + removePlaceholder(for: media) + WindowToastManager.shared.error(LocalizedChatString("TUIGroupNoteSendFail")) + } + } + + private func removePlaceholder(for media: AlbumMedia) { + let placeholderID = Self.makePlaceholderID(sessionID: sessionID, mediaID: media.id) + guard let state = placeholderStates.removeValue(forKey: placeholderID) else { return } + placeholderStore.remove(conversationID: conversationID, placeholderID: state.placeholderID) + } + + private func sendPendingTextIfNeeded() { + if let text = pendingText, !text.isEmpty, !didSendPendingText { + didSendPendingText = true + inputStore.sendMessage(payload: .text(TextSendMessagePayload(text: text)), option: makeSendOption(), completion: nil) + } + } + + private func dismissPicker() { + if let controller = pickerController { + pickerController = nil + controller.dismiss(animated: true) + onPickerDismissed() + } + } + + private func finish() { + if isFinished { + return + } + isFinished = true + dismissPicker() + cleanupPlaceholders() + onCompleted(self) + } + + private func cleanupPlaceholders() { + let states = placeholderStates.values + placeholderStates.removeAll() + + for state in states where state.mediaPath == nil { + placeholderStore.remove(conversationID: conversationID, placeholderID: state.placeholderID) + } + let sendingIDs = states.filter { $0.mediaPath != nil }.map { $0.placeholderID } + if sendingIDs.isEmpty { + return + } + let store = placeholderStore + let targetConversationID = conversationID + DispatchQueue.main.asyncAfter(deadline: .now() + Self.placeholderCleanupDelay) { + for placeholderID in sendingIDs { + store.remove(conversationID: targetConversationID, placeholderID: placeholderID) + } + } + } + + private static func makePlaceholderID(sessionID: String, mediaID: UInt64) -> String { + return "\(placeholderIDPrefix)\(sessionID)_\(mediaID)" + } + + private static func toPercent(_ progress: Float) -> Int { + let clamped = min(max(progress, 0), completedProgress) + return Int((clamped * progressPercentScale).rounded()) + } + + private static func durationInSeconds(_ seconds: Int64) -> Int { + if seconds <= 0 { + return 0 + } + return Int(seconds) + } + + private static let completedProgress: Float = 1.0 + private static let progressPercentScale: Float = 100 + private static let progressUpdateStep = 5 + private static let placeholderCleanupDelay: TimeInterval = 3 + private static let placeholderIDPrefix = "album_picker_processing_" +} diff --git a/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerPlaceholderImpl.swift b/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerPlaceholderImpl.swift new file mode 100644 index 000000000..d929130b2 --- /dev/null +++ b/chat/uikit/components/messageinput/albumpickercoordinator/AlbumPickerPlaceholderImpl.swift @@ -0,0 +1,154 @@ +import AtomicXCore +import Combine +import Foundation +import UIKit + +final class AlbumPickerPlaceholderStoreImpl { + static let shared = AlbumPickerPlaceholderStoreImpl() + + @Published private(set) var placeholdersByConversation: [String: [MessageInfo]] = [:] + + private static let c2cConversationPrefix = "c2c_" + + private static let groupConversationPrefix = "group_" + + private static let defaultVideoType = "mp4" + + private static let maxProgress = 100 + + func upsert(conversationID: String, info: MessagePlaceholderInfo) { + runOnMainThread { [weak self] in + guard let self = self else { return } + var messages = self.placeholdersByConversation[conversationID] ?? [] + let existingIndex = messages.firstIndex(where: { $0.msgID == info.placeholderID }) + let timestamp = existingIndex + .map { messages[$0].timestamp } + .flatMap { $0 } ?? Int64(Date().timeIntervalSince1970) + let message = Self.makePlaceholderMessage( + conversationID: conversationID, + info: info, + timestamp: timestamp + ) + guard let index = existingIndex else { + messages.append(message) + self.placeholdersByConversation[conversationID] = messages + return + } + if Self.isEquivalent(messages[index], message) { + return + } + messages[index] = message + self.placeholdersByConversation[conversationID] = messages + } + } + + func remove(conversationID: String, placeholderID: String) { + runOnMainThread { [weak self] in + guard let self = self else { return } + var messages = self.placeholdersByConversation[conversationID] ?? [] + guard messages.contains(where: { $0.msgID == placeholderID }) else { return } + messages.removeAll { $0.msgID == placeholderID } + if messages.isEmpty { + self.placeholdersByConversation.removeValue(forKey: conversationID) + } else { + self.placeholdersByConversation[conversationID] = messages + } + } + } + + static func localMediaPath(of message: MessageInfo) -> String? { + switch message.messagePayload { + case .image(let payload): + return payload.originalImagePath?.isEmpty == false ? payload.originalImagePath : nil + case .video(let payload): + return payload.videoPath?.isEmpty == false ? payload.videoPath : nil + default: + return nil + } + } + + private init() {} + + private static func makePlaceholderMessage(conversationID: String, + info: MessagePlaceholderInfo, + timestamp: Int64) -> MessageInfo { + let loginUserInfo = LoginStore.shared.state.value.loginUserInfo + var sender = MessageSenderInfo() + sender.userID = loginUserInfo?.userID ?? "" + sender.avatarURL = loginUserInfo?.avatarURL + sender.nickname = loginUserInfo?.nickname + + let isGroup = conversationID.hasPrefix(groupConversationPrefix) + let targetID: String + if isGroup { + targetID = String(conversationID.dropFirst(groupConversationPrefix.count)) + } else if conversationID.hasPrefix(c2cConversationPrefix) { + targetID = String(conversationID.dropFirst(c2cConversationPrefix.count)) + } else { + targetID = conversationID + } + + var message = MessageInfo() + message.msgID = info.placeholderID + message.status = .sending + message.timestamp = timestamp + message.from = sender + message.to = targetID + message.isSentBySelf = true + message.conversationType = isGroup ? .group : .c2c + message.messageType = info.mediaKind == .video ? .video : .image + message.messagePayload = makePlaceholderPayload(info: info) + message.uploadMediaProgress = min(max(info.progress, 0), maxProgress) + return message + } + + private static func makePlaceholderPayload(info: MessagePlaceholderInfo) -> MessagePayload { + let width = Int(info.thumbnailSize.width.rounded()) + let height = Int(info.thumbnailSize.height.rounded()) + if info.mediaKind == .video { + var payload = VideoMessagePayload() + payload.videoSnapshotPath = info.thumbnailPath + payload.videoSnapshotWidth = width + payload.videoSnapshotHeight = height + payload.videoType = defaultVideoType + payload.videoDuration = info.videoDuration + payload.videoPath = info.mediaPath + return .video(payload) + } + var payload = ImageMessagePayload() + payload.largeImagePath = info.thumbnailPath + payload.originalImagePath = info.mediaPath + payload.originalImageWidth = width + payload.originalImageHeight = height + return .image(payload) + } + + private static func isEquivalent(_ lhs: MessageInfo, _ rhs: MessageInfo) -> Bool { + if lhs.uploadMediaProgress != rhs.uploadMediaProgress { + return false + } + if localMediaPath(of: lhs) != localMediaPath(of: rhs) { + return false + } + return thumbnailDescriptor(of: lhs) == thumbnailDescriptor(of: rhs) + } + + private static func thumbnailDescriptor(of message: MessageInfo) -> String? { + switch message.messagePayload { + case .video(let payload): + return "\(payload.videoSnapshotPath ?? "")_\(payload.videoSnapshotWidth)x\(payload.videoSnapshotHeight)" + case .image(let payload): + return "\(payload.largeImagePath ?? "")_\(payload.originalImageWidth)x\(payload.originalImageHeight)" + default: + return nil + } + } + + private func runOnMainThread(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } +} diff --git a/chat/uikit/components/messageinput/typing/TypingIndicatorController.swift b/chat/uikit/components/messageinput/typing/TypingIndicatorController.swift new file mode 100644 index 000000000..64dd08e84 --- /dev/null +++ b/chat/uikit/components/messageinput/typing/TypingIndicatorController.swift @@ -0,0 +1,164 @@ +import UIKit +import Combine +import AtomicXCore + +final class TypingIndicatorController { + private static let typingSendInterval: TimeInterval = 4 + + private static let peerActiveWindow: TimeInterval = 30 + + private static let displayTimeout: TimeInterval = 5 + + private static let recentMessageScanCount = 20 + + private static let businessID = MessageInputView.typingMessageBusinessID + + private static let actionParamTyping = "EIMAMSG_InputStatus_Ing" + + private static let actionParamEnd = "EIMAMSG_InputStatus_End" + + private static let typingUserActionCode = 14 + + private let conversationID: String + + private let peerUserID: String? + + private let currentUserID: String? + + private let messageListStore: MessageListStore + + private let messageInputStore: MessageInputStore + + private var cancellables = Set() + + private var lastPeerMessageAt: TimeInterval? + + private var lastTypingStartSentAt: TimeInterval = 0 + + private var hasSentTypingStart = false + + private var displayTimeoutWorkItem: DispatchWorkItem? + + private let typingSubject = CurrentValueSubject(false) + + var typingPublisher: AnyPublisher { + return typingSubject.eraseToAnyPublisher() + } + + init(conversationID: String, currentUserID: String?) { + self.conversationID = conversationID + self.currentUserID = currentUserID + self.peerUserID = ChatUtil.getUserID(conversationID) + self.messageListStore = MessageListStore.create(conversationID: conversationID) + self.messageInputStore = MessageInputStore.create(conversationID: conversationID) + bindIncomingMessages() + initializePeerActivity() + } + + func sendTypingStatus(hasContent: Bool) { + guard peerUserID != nil else { return } + guard isPeerActive() else { return } + let now = Date().timeIntervalSince1970 + if hasContent { + guard now - lastTypingStartSentAt >= Self.typingSendInterval else { return } + lastTypingStartSentAt = now + hasSentTypingStart = true + sendTypingMessage(isTyping: true) + } else { + guard hasSentTypingStart else { return } + hasSentTypingStart = false + sendTypingMessage(isTyping: false) + } + } + + func invalidate() { + displayTimeoutWorkItem?.cancel() + cancellables.removeAll() + if hasSentTypingStart { + hasSentTypingStart = false + sendTypingMessage(isTyping: false) + } + typingSubject.send(false) + } + + private func bindIncomingMessages() { + messageListStore.messageEventPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self = self else { return } + guard case .onReceiveNewMessage(let message) = event, + message.from.userID == self.peerUserID + else { return } + if let isTyping = self.typingStatus(from: message) { + self.handlePeerTypingStatus(isTyping) + return + } + self.lastPeerMessageAt = Date().timeIntervalSince1970 + } + .store(in: &cancellables) + } + + private func typingStatus(from message: MessageInfo) -> Bool? { + guard case .custom(let payload) = message.messagePayload, + let data = payload.customData.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let businessID = json["businessID"] as? String, + businessID == Self.businessID + else { return nil } + if let typingStatus = json["typingStatus"] as? Int { + return typingStatus == 1 + } + if let actionParam = json["actionParam"] as? String { + if actionParam == Self.actionParamTyping { return true } + if actionParam == Self.actionParamEnd { return false } + } + return nil + } + + private func initializePeerActivity() { + let messages = messageListStore.state.value.messageList + for message in messages.suffix(Self.recentMessageScanCount).reversed() { + guard let peerUserID = peerUserID, message.from.userID == peerUserID else { continue } + if let timestamp = message.timestamp, timestamp > 0 { + lastPeerMessageAt = TimeInterval(timestamp) + } + break + } + } + + private func isPeerActive() -> Bool { + guard let lastPeerMessageAt = lastPeerMessageAt else { return false } + return Date().timeIntervalSince1970 - lastPeerMessageAt <= Self.peerActiveWindow + } + + private func handlePeerTypingStatus(_ isTyping: Bool) { + displayTimeoutWorkItem?.cancel() + displayTimeoutWorkItem = nil + if isTyping { + typingSubject.send(true) + let workItem = DispatchWorkItem { [weak self] in + self?.typingSubject.send(false) + } + displayTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.displayTimeout, execute: workItem) + } else { + typingSubject.send(false) + } + } + + private func sendTypingMessage(isTyping: Bool) { + let json: [String: Any] = [ + "businessID": Self.businessID, + "typingStatus": isTyping ? 1 : 0, + "version": 0, + "userAction": isTyping ? Self.typingUserActionCode : 0, + "actionParam": isTyping ? Self.actionParamTyping : Self.actionParamEnd + ] + guard let data = try? JSONSerialization.data(withJSONObject: json), + let jsonString = String(data: data, encoding: .utf8) else { return } + let payload = CustomSendMessagePayload(customData: jsonString) + var option = SendMessageOption() + option.onlineUserOnly = true + messageInputStore.sendMessage(payload: .custom(payload), option: option, completion: nil) + } +} diff --git a/chat/uikit/components/messageinput/view/EmojiPickerView.swift b/chat/uikit/components/messageinput/view/EmojiPickerView.swift new file mode 100644 index 000000000..15a10954d --- /dev/null +++ b/chat/uikit/components/messageinput/view/EmojiPickerView.swift @@ -0,0 +1,520 @@ +import UIKit +import Combine +import SnapKit +import Kingfisher + +final class EmojiPickerView: UIView { + var onEmojiSelected: ((EmojiData) -> Void)? + + var onCustomEmojiSelected: ((EmojiGroup, EmojiData) -> Void)? + + var onDelete: (() -> Void)? + + var onSend: (() -> Void)? + + var bottomSafeAreaInset: CGFloat = 0 { + didSet { + guard bottomSafeAreaInset != oldValue else { return } + applyBottomSafeAreaInset() + } + } + + private static let littleColumnCount: CGFloat = 8 + + private static let bigColumnCount: CGFloat = 5 + + private static let tabStripHeight: CGFloat = 40 + + private static let tabIconSize: CGFloat = 28 + + private static let tabIconSpacing: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let horizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let headerHeight: CGFloat = 30 + + private static let deleteButtonSize = CGSize(width: 40, height: 30) + + private static let sendButtonSize = CGSize(width: 50, height: 30) + + private static let actionButtonSpacing: CGFloat = 10 + + private static let actionButtonPadding: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private static let actionButtonBottomContentInset: CGFloat = 10 + + private static let tabDividerHeight: CGFloat = 0.5 + + private static let tabStackContentInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + + private static let deleteIconPointSize: CGFloat = 14 + + private static let selectedTabAlpha: CGFloat = 1.0 + + private static let unselectedTabAlpha: CGFloat = 0.4 + + private static let itemWidthChangeThreshold: CGFloat = 0.5 + + private let collectionLayout = UICollectionViewFlowLayout() + + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionLayout) + + private let deleteButton = UIButton(type: .system) + + private let sendButton = UIButton(type: .system) + + private let tabStrip = UIView() + + private let tabDivider = UIView() + + private let tabScrollView = UIScrollView() + + private let tabStackView = UIStackView() + + private var tabButtons: [UIButton] = [] + + private var sendButtonBottomConstraint: Constraint? + + private var tabStripHeightConstraint: Constraint? + + private var currentGroupIndex = 0 + + private var groupsChangeCancellable: AnyCancellable? + + private var recentEmojis: [EmojiData] = [] + + private var allEmojis: [EmojiData] = [] + + private var sections: [EmojiSection] = [] + + private var itemWidth: CGFloat = 0 + + private enum EmojiSection { + case recent + case all + } + + private var lastLayoutHeight: CGFloat = 0 + + private var groups: [EmojiGroup] { + return EmojiConfig.shared.emojiGroups + } + + private var currentGroup: EmojiGroup? { + guard !groups.isEmpty else { return nil } + let safeIndex = max(0, min(currentGroupIndex, groups.count - 1)) + return groups[safeIndex] + } + + private var currentColumnCount: CGFloat { + return (currentGroup?.isLittleEmoji ?? true) ? Self.littleColumnCount : Self.bigColumnCount + } + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + rebuildTabs() + reloadEmojis() + groupsChangeCancellable = EmojiConfig.shared.groupsDidChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.rebuildTabs() + self?.reloadEmojis() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public API + + func reloadRecentEmojis() { + guard let group = currentGroup, group.isLittleEmoji else { + recentEmojis = [] + rebuildSections() + collectionView.reloadData() + return + } + recentEmojis = EmojiManager.shared.getRecentEmojiDataList(groupID: group.id) + rebuildSections() + collectionView.reloadData() + } + + // MARK: - Private Helpers + + override func layoutSubviews() { + super.layoutSubviews() + updateLayoutIfNeeded() + } + + private func constructViewHierarchy() { + addSubview(collectionView) + addSubview(tabStrip) + tabStrip.addSubview(tabDivider) + tabStrip.addSubview(tabScrollView) + tabScrollView.addSubview(tabStackView) + addSubview(deleteButton) + addSubview(sendButton) + } + + private func activateConstraints() { + tabStrip.snp.makeConstraints { make in + make.leading.trailing.top.equalToSuperview() + tabStripHeightConstraint = make.height.equalTo(Self.tabStripHeight).constraint + } + tabDivider.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(Self.tabDividerHeight) + } + tabScrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + tabStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.tabStackContentInsets) + make.height.equalToSuperview() + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(tabStrip.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + + sendButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.actionButtonPadding) + sendButtonBottomConstraint = make.bottom.equalToSuperview().offset(-Self.actionButtonPadding).constraint + make.width.equalTo(Self.sendButtonSize.width) + make.height.equalTo(Self.sendButtonSize.height) + } + deleteButton.snp.makeConstraints { make in + make.trailing.equalTo(sendButton.snp.leading).offset(-Self.actionButtonSpacing) + make.centerY.equalTo(sendButton) + make.width.equalTo(Self.deleteButtonSize.width) + make.height.equalTo(Self.deleteButtonSize.height) + } + } + + private func bindInteraction() { + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register(EmojiGridCell.self, forCellWithReuseIdentifier: EmojiGridCell.reuseID) + collectionView.register( + EmojiSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: EmojiSectionHeaderView.reuseID + ) + deleteButton.addTarget(self, action: #selector(handleDeleteTapped), for: .touchUpInside) + sendButton.addTarget(self, action: #selector(handleSendTapped), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + clipsToBounds = true + + collectionLayout.minimumInteritemSpacing = 0 + collectionLayout.minimumLineSpacing = 0 + collectionLayout.sectionInset = UIEdgeInsets( + top: 0, left: Self.horizontalInset, bottom: 0, right: Self.horizontalInset + ) + collectionView.backgroundColor = .clear + collectionView.alwaysBounceVertical = true + collectionView.contentInset = UIEdgeInsets( + top: 0, left: 0, bottom: Self.actionButtonBottomContentInset, right: 0 + ) + + let deleteImage = UIImage(systemName: "delete.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.deleteIconPointSize, weight: .medium)) + deleteButton.setImage(deleteImage, for: .normal) + deleteButton.tintColor = colors.textColorPrimary + deleteButton.backgroundColor = colors.buttonColorSecondaryDefault + deleteButton.layer.cornerRadius = Self.deleteButtonSize.height / 2 + deleteButton.layer.masksToBounds = true + + sendButton.setTitle(LocalizedChatString("Send"), for: .normal) + sendButton.setTitleColor(colors.textColorButton, for: .normal) + sendButton.titleLabel?.font = FontScheme.caption2Medium + sendButton.backgroundColor = colors.buttonColorPrimaryDefault + sendButton.layer.cornerRadius = Self.sendButtonSize.height / 2 + sendButton.layer.masksToBounds = true + + tabStrip.backgroundColor = colors.bgColorOperate + tabDivider.backgroundColor = colors.strokeColorPrimary + tabScrollView.showsHorizontalScrollIndicator = false + tabStackView.axis = .horizontal + tabStackView.spacing = Self.tabIconSpacing + tabStackView.alignment = .center + } + + private func rebuildTabs() { + tabButtons.forEach { $0.removeFromSuperview() } + tabButtons.removeAll() + for (index, group) in groups.enumerated() { + let button = UIButton(type: .custom) + button.frame = CGRect(x: 0, y: 0, width: Self.tabIconSize, height: Self.tabIconSize) + button.imageView?.contentMode = .scaleAspectFit + button.tag = index + button.addTarget(self, action: #selector(handleTabTapped(_:)), for: .touchUpInside) + if let iconPath = group.menuPath, !iconPath.isEmpty { + if iconPath.hasPrefix("http") { + button.kf.setImage(with: URL(string: iconPath), for: .normal) + } else { + button.setImage(EmojiCache.shared.getImageFromCache(iconPath), for: .normal) + } + } + tabStackView.addArrangedSubview(button) + button.snp.makeConstraints { make in + make.width.height.equalTo(Self.tabIconSize) + } + tabButtons.append(button) + } + updateTabStripVisibility() + updateTabSelection() + } + + private func updateTabStripVisibility() { + let showTabs = groups.count > 1 + tabStrip.isHidden = !showTabs + tabStripHeightConstraint?.update(offset: showTabs ? Self.tabStripHeight : 0) + } + + private func updateTabSelection() { + for (index, button) in tabButtons.enumerated() { + button.alpha = index == currentGroupIndex ? Self.selectedTabAlpha : Self.unselectedTabAlpha + } + } + + @objc private func handleTabTapped(_ sender: UIButton) { + guard sender.tag != currentGroupIndex else { return } + currentGroupIndex = sender.tag + updateTabSelection() + reloadEmojis() + } + + private func applyBottomSafeAreaInset() { + collectionView.contentInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: Self.actionButtonBottomContentInset + bottomSafeAreaInset, + right: 0 + ) + sendButtonBottomConstraint?.update(offset: -(Self.actionButtonPadding + bottomSafeAreaInset)) + } + + private func reloadEmojis() { + let totalWidth = bounds.width - Self.horizontalInset * 2 + if totalWidth > 0 { + itemWidth = floor(totalWidth / currentColumnCount) + } + guard let group = currentGroup else { + allEmojis = [] + recentEmojis = [] + sections = [] + collectionView.reloadData() + return + } + allEmojis = EmojiManager.shared.pickerVisibleEmojis(in: group) + if group.isLittleEmoji { + recentEmojis = EmojiManager.shared.getRecentEmojiDataList(groupID: group.id) + } else { + recentEmojis = [] + } + deleteButton.isHidden = !group.isLittleEmoji + sendButton.isHidden = !group.isLittleEmoji + rebuildSections() + collectionView.reloadData() + } + + private func rebuildSections() { + var result: [EmojiSection] = [] + if !recentEmojis.isEmpty { + result.append(.recent) + } + result.append(.all) + sections = result + } + + private func emojis(in section: EmojiSection) -> [EmojiData] { + switch section { + case .recent: return recentEmojis + case .all: return allEmojis + } + } + + private func headerTitle(for section: EmojiSection) -> String { + switch section { + case .recent: return LocalizedChatString("TUIChatFaceGroupRecentEmojiName") + case .all: return LocalizedChatString("TUIChatFaceGroupAllEmojiName") + } + } + + private func updateLayoutIfNeeded() { + let totalWidth = bounds.width - Self.horizontalInset * 2 + let currentHeight = bounds.height + let previousHeight = lastLayoutHeight + lastLayoutHeight = currentHeight + guard totalWidth > 0, currentHeight > 0 else { return } + let heightBecameVisible = previousHeight == 0 && currentHeight > 0 + let newWidth = floor(totalWidth / currentColumnCount) + let widthChanged = abs(newWidth - itemWidth) > Self.itemWidthChangeThreshold + if widthChanged { + itemWidth = newWidth + } + if widthChanged || heightBecameVisible { + collectionView.reloadData() + } + } + + @objc private func handleDeleteTapped() { + onDelete?() + } + + @objc private func handleSendTapped() { + onSend?() + } +} + +// MARK: - UICollectionViewDataSource + +extension EmojiPickerView: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return emojis(in: sections[section]).count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: EmojiGridCell.reuseID, for: indexPath + ) as? EmojiGridCell ?? EmojiGridCell() + let emoji = emojis(in: sections[indexPath.section])[indexPath.item] + cell.configure(with: emoji) + return cell + } + + func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: EmojiSectionHeaderView.reuseID, + for: indexPath + ) as? EmojiSectionHeaderView ?? EmojiSectionHeaderView() + header.configure(title: headerTitle(for: sections[indexPath.section])) + return header + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension EmojiPickerView: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width = itemWidth > 0 ? itemWidth : (bounds.width - Self.horizontalInset * 2) / currentColumnCount + return CGSize(width: width, height: width) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForHeaderInSection section: Int + ) -> CGSize { + if currentGroup?.isLittleEmoji == false { + return CGSize(width: collectionView.bounds.width, height: 0) + } + return CGSize(width: collectionView.bounds.width, height: Self.headerHeight) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let group = currentGroup else { return } + let emoji = emojis(in: sections[indexPath.section])[indexPath.item] + if group.isLittleEmoji { + EmojiManager.shared.addRecentEmoji(emoji, groupID: group.id) + onEmojiSelected?(emoji) + } else { + onCustomEmojiSelected?(group, emoji) + } + } +} + +// MARK: - Cell + +private final class EmojiGridCell: UICollectionViewCell { + static let reuseID = "EmojiGridCell" + + private static let imageScaleFactor: CGFloat = 0.7 + + private let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalToSuperview().multipliedBy(Self.imageScaleFactor) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with emoji: EmojiData) { + if let url = emoji.url, !url.isEmpty { + imageView.kf.setImage(with: URL(string: url)) + return + } + imageView.image = EmojiManager.cachedImage(for: emoji) + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + } +} + +// MARK: - Section Header + +private final class EmojiSectionHeaderView: UICollectionReusableView { + static let reuseID = "EmojiSectionHeaderView" + + private static let titleLeadingInset: CGFloat = 6 + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = FontScheme.caption2Regular + titleLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.titleLeadingInset) + make.centerY.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + titleLabel.text = title + } +} diff --git a/chat/uikit/components/messageinput/view/GroupCallMemberPickerViewController.swift b/chat/uikit/components/messageinput/view/GroupCallMemberPickerViewController.swift new file mode 100644 index 000000000..c2b34ad2a --- /dev/null +++ b/chat/uikit/components/messageinput/view/GroupCallMemberPickerViewController.swift @@ -0,0 +1,241 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class GroupCallMemberPickerViewController: UIViewController { + private static let headerBarHeight: CGFloat = 56 + + private static let navButtonHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let rowHeight: CGFloat = 56 + + private let groupID: String + + private let onConfirm: ([String]) -> Void + + private let memberStore: GroupMemberStore + + private var members: [GroupMember] = [] + + private var selectedMemberIDs: Set = [] + + private var isLoading = false + + private var isLoadingMore = false + + private var hasMoreData = true + + private let headerBar = UIView() + + private let cancelButton = UIButton(type: .system) + + private let titleLabel = UILabel() + + private let confirmButton = UIButton(type: .system) + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = Self.rowHeight + table.register(MentionMemberCell.self, forCellReuseIdentifier: MentionMemberCell.reuseIdentifier) + table.dataSource = self + table.delegate = self + return table + }() + + // MARK: - Init + + init(groupID: String, onConfirm: @escaping ([String]) -> Void) { + self.groupID = groupID + self.onConfirm = onConfirm + self.memberStore = GroupMemberStore.create(groupID: groupID) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + loadMembers() + } + + // MARK: - Actions + + private func constructViewHierarchy() { + view.addSubview(headerBar) + headerBar.addSubview(cancelButton) + headerBar.addSubview(titleLabel) + headerBar.addSubview(confirmButton) + view.addSubview(tableView) + view.addSubview(loadingIndicator) + } + + private func activateConstraints() { + headerBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.headerBarHeight) + } + cancelButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.navButtonHorizontalInset) + make.centerY.equalToSuperview() + } + confirmButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.navButtonHorizontalInset) + make.centerY.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.top.equalTo(headerBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + } + + private func bindInteraction() { + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(handleConfirm), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + headerBar.backgroundColor = colors.bgColorOperate + tableView.backgroundColor = colors.bgColorOperate + + titleLabel.text = LocalizedChatString("MentionSelectMember") + titleLabel.font = FontScheme.caption1Medium + titleLabel.textColor = colors.textColorPrimary + + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.setTitleColor(colors.textColorLink, for: .normal) + cancelButton.titleLabel?.font = FontScheme.caption1Regular + + confirmButton.setTitle(LocalizedChatString("Confirm"), for: .normal) + confirmButton.titleLabel?.font = FontScheme.caption1Regular + updateConfirmButtonState() + } + + private func loadMembers() { + guard !isLoading else { return } + isLoading = true + loadingIndicator.startAnimating() + memberStore.loadMembers(roleList: [.all]) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.isLoading = false + self.loadingIndicator.stopAnimating() + if case .success = result { + self.refreshMembersFromState() + } + } + } + } + + private func loadMoreMembers() { + guard !isLoadingMore, hasMoreData else { return } + isLoadingMore = true + memberStore.loadMoreMembers { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.isLoadingMore = false + switch result { + case .success: + self.refreshMembersFromState() + case .failure: + self.hasMoreData = false + } + } + } + } + + private func refreshMembersFromState() { + let newMembers = memberStore.state.value.memberList + if newMembers.count == members.count { + hasMoreData = false + } + members = newMembers + tableView.reloadData() + } + + @objc private func handleCancel() { + dismiss(animated: true) + } + + @objc private func handleConfirm() { + let userIDs = members + .filter { selectedMemberIDs.contains($0.userID) } + .map { $0.userID } + guard !userIDs.isEmpty else { return } + onConfirm(userIDs) + dismiss(animated: true) + } + + private func toggleSelection(_ userID: String) { + if selectedMemberIDs.contains(userID) { + selectedMemberIDs.remove(userID) + } else { + selectedMemberIDs.insert(userID) + } + updateConfirmButtonState() + } + + private func updateConfirmButtonState() { + let colors = ChatUIKitTheme.colors + let enabled = !selectedMemberIDs.isEmpty + confirmButton.isEnabled = enabled + confirmButton.setTitleColor(enabled ? colors.textColorLink : colors.textColorTertiary, for: .normal) + } +} + +// MARK: - UITableViewDataSource / Delegate + +extension GroupCallMemberPickerViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return members.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: MentionMemberCell.reuseIdentifier, + for: indexPath + ) as? MentionMemberCell else { + return UITableViewCell() + } + let member = members[indexPath.row] + let name = MentionMemberPickerViewController.displayName(for: member) + cell.configure( + name: name, + avatarURL: member.avatarURL, + isSelected: selectedMemberIDs.contains(member.userID) + ) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + let member = members[indexPath.row] + toggleSelection(member.userID) + tableView.reloadRows(at: [indexPath], with: .none) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.row >= members.count - 1 else { return } + loadMoreMembers() + } +} diff --git a/chat/uikit/components/messageinput/view/MentionMemberCell.swift b/chat/uikit/components/messageinput/view/MentionMemberCell.swift new file mode 100644 index 000000000..f6570b4e2 --- /dev/null +++ b/chat/uikit/components/messageinput/view/MentionMemberCell.swift @@ -0,0 +1,80 @@ +import UIKit +import SnapKit + +final class MentionMemberCell: UITableViewCell { + static let reuseIdentifier = "MentionMemberCell" + + private static let checkboxSize: CGFloat = 16 + + private static let checkboxAvatarSpacing: CGFloat = 10 + + private static let avatarTextSpacing: CGFloat = 13 + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let endReserved: CGFloat = 26 + + private let checkboxView = SelectionCheckBox() + + private let avatarView = ChatAvatarView(size: .m, isRound: false) + + private let nameLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(name: String, avatarURL: String?, isSelected: Bool) { + nameLabel.text = name + avatarView.configure(avatarURL: avatarURL, fallbackName: name) + checkboxView.isChecked = isSelected + } + + override func prepareForReuse() { + super.prepareForReuse() + nameLabel.text = nil + checkboxView.isChecked = false + } + + private func constructViewHierarchy() { + selectionStyle = .none + contentView.addSubview(checkboxView) + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + } + + private func activateConstraints() { + checkboxView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.checkboxSize) + } + avatarView.snp.makeConstraints { make in + make.leading.equalTo(checkboxView.snp.trailing).offset(Self.checkboxAvatarSpacing) + make.centerY.equalToSuperview() + make.width.height.equalTo(ChatAvatarSize.m.size) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarTextSpacing) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().offset(-Self.endReserved) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + contentView.backgroundColor = colors.bgColorOperate + nameLabel.font = FontScheme.caption2Regular + nameLabel.textColor = colors.textColorPrimary + nameLabel.numberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + } +} diff --git a/chat/uikit/components/messageinput/view/MentionMemberPickerViewController.swift b/chat/uikit/components/messageinput/view/MentionMemberPickerViewController.swift new file mode 100644 index 000000000..d86bd6bb8 --- /dev/null +++ b/chat/uikit/components/messageinput/view/MentionMemberPickerViewController.swift @@ -0,0 +1,280 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MentionMemberPickerViewController: UIViewController { + private static let headerBarHeight: CGFloat = 56 + + private static let navButtonHorizontalInset: CGFloat = 10 + + private static let separatorHeight: CGFloat = 0.5 + + private static let atAllRowHeight: CGFloat = 56 + + private static let atAllCheckboxSize: CGFloat = 16 + + private static let atAllCheckboxLeadingInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let atAllAvatarLeadingGap: CGFloat = 10 + + private static let atAllLabelLeadingGap: CGFloat = 13 + + private static let atAllLabelTrailingInset: CGFloat = 26 + + private let groupID: String + + private let atPosition: Int + + private let onMembersSelected: ([MentionInfo], Int) -> Void + + private let memberStore: GroupMemberStore + + private var members: [GroupMember] = [] + + private var selectedMemberIDs: Set = [] + + private var isAtAllSelected = false + + private var isLoading = false + + private var isLoadingMore = false + + private var hasMoreData = true + + private let headerBar = UIView() + + private let cancelButton = UIButton(type: .system) + + private let titleLabel = UILabel() + + private let confirmButton = UIButton(type: .system) + + private let atAllRow = UIControl() + + private let atAllCheckbox = SelectionCheckBox() + + private let atAllAvatar = ChatAvatarView(size: .m, isRound: false) + + private let atAllLabel = UILabel() + + private let separatorView = UIView() + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private let pickerView = UserPickerView() + + // MARK: - Init + + init(groupID: String, + atPosition: Int, + onMembersSelected: @escaping ([MentionInfo], Int) -> Void) { + self.groupID = groupID + self.atPosition = atPosition + self.onMembersSelected = onMembersSelected + self.memberStore = GroupMemberStore.create(groupID: groupID) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + loadMembers() + } + + // MARK: - Display Name (nameCard > nickname > userID) + + static func displayName(for member: GroupMember) -> String { + if let nameCard = member.nameCard, !nameCard.isEmpty { return nameCard } + if let nickname = member.nickname, !nickname.isEmpty { return nickname } + return member.userID + } + + private func constructViewHierarchy() { + view.addSubview(headerBar) + headerBar.addSubview(cancelButton) + headerBar.addSubview(titleLabel) + headerBar.addSubview(confirmButton) + view.addSubview(separatorView) + view.addSubview(atAllRow) + atAllRow.addSubview(atAllCheckbox) + atAllRow.addSubview(atAllAvatar) + atAllRow.addSubview(atAllLabel) + view.addSubview(pickerView) + view.addSubview(loadingIndicator) + } + + private func activateConstraints() { + headerBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.headerBarHeight) + } + cancelButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.navButtonHorizontalInset) + make.centerY.equalToSuperview() + } + confirmButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.navButtonHorizontalInset) + make.centerY.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + separatorView.snp.makeConstraints { make in + make.top.equalTo(headerBar.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.separatorHeight) + } + atAllRow.snp.makeConstraints { make in + make.top.equalTo(separatorView.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.atAllRowHeight) + } + atAllCheckbox.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.atAllCheckboxLeadingInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.atAllCheckboxSize) + } + atAllAvatar.snp.makeConstraints { make in + make.leading.equalTo(atAllCheckbox.snp.trailing).offset(Self.atAllAvatarLeadingGap) + make.centerY.equalToSuperview() + make.width.height.equalTo(ChatAvatarSize.m.size) + } + atAllLabel.snp.makeConstraints { make in + make.leading.equalTo(atAllAvatar.snp.trailing).offset(Self.atAllLabelLeadingGap) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().offset(-Self.atAllLabelTrailingInset) + } + pickerView.snp.makeConstraints { make in + make.top.equalTo(atAllRow.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(pickerView) + } + } + + private func bindInteraction() { + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(handleConfirm), for: .touchUpInside) + atAllRow.addTarget(self, action: #selector(handleAtAll), for: .touchUpInside) + pickerView.onSelectionChanged = { [weak self] selection in + self?.selectedMemberIDs = Set(selection.map { $0.userID }) + } + pickerView.onReachEnd = { [weak self] in + self?.loadMoreMembers() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorInput + headerBar.backgroundColor = colors.bgColorOperate + atAllRow.backgroundColor = colors.bgColorOperate + separatorView.backgroundColor = colors.strokeColorPrimary + + titleLabel.text = LocalizedChatString("MentionSelectMember") + titleLabel.font = FontScheme.caption1Bold + titleLabel.textColor = colors.textColorPrimary + + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.setTitleColor(colors.textColorLink, for: .normal) + cancelButton.titleLabel?.font = FontScheme.caption1Regular + + confirmButton.setTitle(LocalizedChatString("Confirm"), for: .normal) + confirmButton.setTitleColor(colors.textColorLink, for: .normal) + confirmButton.titleLabel?.font = FontScheme.caption1Regular + + atAllAvatar.configure(avatarURL: nil, fallbackName: LocalizedChatString("MentionAll")) + atAllLabel.text = "@\(LocalizedChatString("MentionAll"))" + atAllLabel.font = FontScheme.caption2Regular + atAllLabel.textColor = colors.textColorPrimary + } + + private func loadMembers() { + guard !isLoading else { return } + isLoading = true + loadingIndicator.startAnimating() + memberStore.loadMembers(roleList: [.all]) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.isLoading = false + self.loadingIndicator.stopAnimating() + if case .success = result { + self.refreshMembersFromState() + } + } + } + } + + private func loadMoreMembers() { + guard !isLoadingMore, hasMoreData else { return } + isLoadingMore = true + memberStore.loadMoreMembers { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.isLoadingMore = false + switch result { + case .success: + self.refreshMembersFromState() + case .failure: + self.hasMoreData = false + } + } + } + } + + private func refreshMembersFromState() { + let newMembers = memberStore.state.value.memberList + if newMembers.count == members.count { + hasMoreData = false + } + members = newMembers + pickerView.configure( + userList: members.map { + UserPickerItem(userID: $0.userID, avatarURL: $0.avatarURL, title: Self.displayName(for: $0)) + }, + maxCount: 0 + ) + } + + @objc private func handleCancel() { + dismiss(animated: true) + } + + @objc private func handleConfirm() { + var mentionInfos: [MentionInfo] = [] + if isAtAllSelected { + mentionInfos.append(MentionInfo.create( + userID: MentionInfo.atAllUserID, + displayName: LocalizedChatString("MentionAll"), + atPosition: atPosition + )) + } + mentionInfos.append(contentsOf: members + .filter { selectedMemberIDs.contains($0.userID) } + .map { member in + MentionInfo.create( + userID: member.userID, + displayName: Self.displayName(for: member), + atPosition: atPosition + ) + }) + onMembersSelected(mentionInfos, atPosition) + dismiss(animated: true) + } + + @objc private func handleAtAll() { + isAtAllSelected.toggle() + atAllCheckbox.isChecked = isAtAllSelected + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputAudioRecorderView.swift b/chat/uikit/components/messageinput/view/MessageInputAudioRecorderView.swift new file mode 100644 index 000000000..583929225 --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputAudioRecorderView.swift @@ -0,0 +1,587 @@ +import UIKit + +enum AudioRecorderReleaseAction { + case sendAudio + case cancel + case transcribe +} + +enum AudioRecorderRecordUiState { + case idle + case recording + case readyToCancel + case readyToTranscribe +} + +struct AudioRecorderGestureTarget { + let left: CGFloat + let top: CGFloat + let width: CGFloat + let height: CGFloat + + func contains(_ x: CGFloat, _ y: CGFloat) -> Bool { + return x >= left && x <= left + width && y >= top && y <= top + height + } +} + +final class MessageInputAudioRecorderView: UIView { + private let backgroundView = RecorderGradientBackgroundView() + + private let statusLabel = UILabel() + + private let cancelButton = RecorderCircleButton() + + private let transcribeButton = RecorderCircleButton() + + private let bubbleView = RecorderBubbleView() + + private var isDismissed = false + + private static let defaultMaxDurationMs: Int = 60000 + + private static let shownAlpha: CGFloat = 0.92 + + private static let showAnimationDuration: TimeInterval = 0.12 + + private static let dismissAnimationDuration: TimeInterval = 0.1 + + private static let autoStopCountdownThresholdMs: Int = 10000 + + private static let gradientLocations: [CGFloat] = [0, 0.42, 0.67, 1.0] + + private let maxDurationMs: Int + + private let actionSize: CGFloat = 80 + + private let cancelLeftMargin: CGFloat = CGFloat(SpacingScheme.maxSpacing) + + private let cancelBottomMargin: CGFloat = 107 + + private let transcribeRightMargin: CGFloat = CGFloat(SpacingScheme.maxSpacing) + + private let transcribeBottomMargin: CGFloat = 107 + + private let statusBottomMargin: CGFloat = 199 + + private let statusHorizontalMargin: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private let bubbleHorizontalMargin: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private let bubbleBottomMargin: CGFloat = 43 + + private let bubbleHeight: CGFloat = 40 + + init(frame: CGRect, maxDurationMs: Int = MessageInputAudioRecorderView.defaultMaxDurationMs) { + self.maxDurationMs = maxDurationMs + super.init(frame: frame) + constructHierarchy() + setupStyle() + setNeedsLayout() + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + bubbleView.stopAnimating() + } + + // MARK: - Public + + func stopAnimating() { + bubbleView.stopAnimating() + } + + func show() { + guard !isDismissed else { return } + isHidden = false + alpha = Self.shownAlpha + UIView.animate(withDuration: Self.showAnimationDuration) { + self.alpha = 1.0 + } + } + + func dismiss() { + guard !isDismissed else { return } + isDismissed = true + UIView.animate(withDuration: Self.dismissAnimationDuration, animations: { + self.alpha = 0 + }, completion: { _ in + self.removeFromSuperview() + }) + } + + func releaseAction(for finger: CGPoint) -> AudioRecorderReleaseAction { + let local = CGPoint(x: finger.x - frame.origin.x, y: finger.y - frame.origin.y) + let cancelTarget = AudioRecorderGestureTarget( + left: cancelButton.frame.origin.x, + top: cancelButton.frame.origin.y, + width: cancelButton.frame.width, + height: cancelButton.frame.height + ) + let transcribeTarget = AudioRecorderGestureTarget( + left: transcribeButton.frame.origin.x, + top: transcribeButton.frame.origin.y, + width: transcribeButton.frame.width, + height: transcribeButton.frame.height + ) + if cancelTarget.contains(local.x, local.y) { + return .cancel + } + if transcribeTarget.contains(local.x, local.y) { + return .transcribe + } + return .sendAudio + } + + func update(state: AudioRecorderRecordUiState, durationMs: Int?, powerLevel: Int, maxDurationMs: Int) { + let action = action(for: state) + statusLabel.text = statusText(for: action, durationMs: durationMs, maxDurationMs: maxDurationMs) + + let colors = ChatUIKitTheme.colors + let cancelActive = action == .cancel + let transcribeActive = action == .transcribe + + cancelButton.setActive( + cancelActive, + idleColor: colors.buttonColorSecondaryDefault, + activeColor: colors.buttonColorHangupDefault, + contentColor: cancelActive ? colors.textColorButton : colors.textColorPrimary + ) + transcribeButton.setActive( + transcribeActive, + idleColor: colors.buttonColorSecondaryDefault, + activeColor: colors.buttonColorPrimaryDefault, + contentColor: transcribeActive ? colors.textColorButton : colors.textColorPrimary + ) + + let bubbleColor: UIColor = cancelActive ? colors.buttonColorHangupDefault : colors.buttonColorPrimaryDefault + bubbleView.setBubbleColor(bubbleColor) + bubbleView.setContentColor(colors.textColorButton) + bubbleView.setPowerLevel(powerLevel) + bubbleView.setDuration(durationMs) + } + + // MARK: - Private + + override func layoutSubviews() { + super.layoutSubviews() + backgroundView.frame = bounds + + let width = bounds.width + let height = bounds.height + let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft + + let cancelX = isRTL ? width - cancelLeftMargin - actionSize : cancelLeftMargin + cancelButton.frame = CGRect( + x: cancelX, + y: height - cancelBottomMargin - actionSize, + width: actionSize, + height: actionSize + ) + let transcribeX = isRTL ? transcribeRightMargin : width - transcribeRightMargin - actionSize + transcribeButton.frame = CGRect( + x: transcribeX, + y: height - transcribeBottomMargin - actionSize, + width: actionSize, + height: actionSize + ) + + statusLabel.numberOfLines = 0 + statusLabel.frame = CGRect(x: statusHorizontalMargin, y: 0, width: width - 2 * statusHorizontalMargin, height: 0) + statusLabel.sizeToFit() + var statusFrame = statusLabel.frame + statusFrame.origin.x = (width - statusFrame.width) / 2 + statusFrame.origin.y = height - statusBottomMargin - statusFrame.height + statusLabel.frame = statusFrame + + bubbleView.frame = CGRect( + x: bubbleHorizontalMargin, + y: height - bubbleBottomMargin - bubbleHeight, + width: width - 2 * bubbleHorizontalMargin, + height: bubbleHeight + ) + } + + private func constructHierarchy() { + addSubview(backgroundView) + addSubview(statusLabel) + addSubview(cancelButton) + addSubview(transcribeButton) + addSubview(bubbleView) + } + + private func setupStyle() { + backgroundColor = .clear + backgroundView.setGradientSpec(gradientColors(base: ChatUIKitTheme.colors.bgColorOperate), Self.gradientLocations) + statusLabel.font = FontScheme.caption2Medium + statusLabel.textAlignment = .center + statusLabel.numberOfLines = 0 + statusLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + statusLabel.text = LocalizedChatString("message_input_release_send_hint") + + cancelButton.setTitle(LocalizedChatString("message_input_cancel")) + transcribeButton.setTitle(LocalizedChatString("message_input_transcribe_to_text")) + } + + private func action(for state: AudioRecorderRecordUiState) -> AudioRecorderReleaseAction { + switch state { + case .readyToCancel: + return .cancel + case .readyToTranscribe: + return .transcribe + default: + return .sendAudio + } + } + + private func statusText(for action: AudioRecorderReleaseAction, durationMs: Int?, maxDurationMs: Int) -> String { + if action == .cancel { + return LocalizedChatString("message_input_release_cancel_hint") + } + if action == .transcribe { + return LocalizedChatString("message_input_release_transcribe_hint") + } + if let durationMs = durationMs, let countdown = remainingSecondsBeforeAutoStop(durationMs, maxDurationMs) { + return String(format: LocalizedChatString("message_input_auto_stop_countdown"), countdown) + } + return LocalizedChatString("message_input_release_send_hint") + } + + private func remainingSecondsBeforeAutoStop(_ durationMs: Int, _ maxDurationMs: Int) -> Int? { + guard maxDurationMs > 0 else { return nil } + let remaining = max(maxDurationMs - durationMs, 0) + guard remaining <= Self.autoStopCountdownThresholdMs else { return nil } + return max(Int(ceil(Double(remaining) / 1000)), 1) + } + + private func gradientColors(base: UIColor) -> [UIColor] { + return [ + base.withAlphaComponent(0), + base.withAlphaComponent(0xB3 / 255.0), + base, + base + ] + } +} + +// MARK: - 渐变背景(顶到底,对齐 Android `AudioRecorderOverlayBackgroundPolicy`) + +final class RecorderGradientBackgroundView: UIView { + private var gradientColors: [CGColor] = [] + + private var gradientLocations: [CGFloat] = [] + + private var cachedHeight: CGFloat = -1 + + private var gradient: CGGradient? + + static func gradientColors(base: UIColor) -> [UIColor] { + return [ + base.withAlphaComponent(0), + base.withAlphaComponent(0xB3 / 255.0), + base, + base + ] + } + + override init(frame: CGRect) { + super.init(frame: frame) + isOpaque = false + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setGradientSpec(_ colors: [UIColor], _ locations: [CGFloat]) { + gradientColors = colors.map { $0.cgColor } + gradientLocations = locations + cachedHeight = -1 + setNeedsDisplay() + } + + override func draw(_ rect: CGRect) { + guard let ctx = UIGraphicsGetCurrentContext() else { return } + if cachedHeight != rect.height || gradient == nil { + gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: gradientColors as CFArray, + locations: gradientLocations + ) + cachedHeight = rect.height + } + guard let gradient = gradient else { return } + ctx.clear(rect) + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: 0, y: 0), + end: CGPoint(x: 0, y: rect.height), + options: [] + ) + } +} + +// MARK: - 气泡(圆角背景 + 波形 + 时长,对齐 Android `BubbleView`) + +final class RecorderBubbleView: UIView { + private static let totalBars = 24 + + private static let barWidth: CGFloat = 2.5 + + private static let barGap: CGFloat = 3 + + private static let cornerRadius: CGFloat = CGFloat(RadiusScheme.alertRadius) + + private static let amplitudeBase: CGFloat = 13 + + private static let minBarHeight: CGFloat = 3 + + private static let durationRightPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let minBarFrequency: CGFloat = 2.4 + + private static let barFrequencySpan: CGFloat = 3.2 + + private static let amplitudeBaseFactor: CGFloat = 0.45 + + private static let amplitudePowerFactor: CGFloat = 0.55 + + private static let pseudoUpdateInterval: CFTimeInterval = 0.08 + + private static let pseudoTargetMinValue: CGFloat = 0.1 + + private static let pseudoSmoothingFactor: CGFloat = 0.35 + + private var bubbleColor: UIColor = .clear + + private var contentColor: UIColor = .white + + private var powerLevel: Int = 0 + + private var isFlat = false + + private var pseudoSpeaking = false + + private var pseudoTargets: [CGFloat] = [] + + private var pseudoCurrent: [CGFloat] = [] + + private var pseudoTimer: CFTimeInterval = 0 + + private var durationText: String = "--:--" + + private let phases: [CGFloat] + + private let freqs: [CGFloat] + + private let startTime = CACurrentMediaTime() + + private let durationFont: UIFont + + private var displayLink: CADisplayLink? + + override init(frame: CGRect) { + var seed: UInt32 = 20260421 + func nextRandom() -> CGFloat { + seed = (seed &* 1103515245 &+ 12345) & 0x7fffffff + return CGFloat(seed) / CGFloat(0x7fffffff) + } + phases = (0.. RecorderBubbleView.pseudoUpdateInterval else { return } + pseudoTimer = now + for index in 0.. (startX: CGFloat, totalWidth: CGFloat) { + let totalWidth = CGFloat(RecorderBubbleView.totalBars) * RecorderBubbleView.barWidth + + CGFloat(RecorderBubbleView.totalBars - 1) * RecorderBubbleView.barGap + let startX = (containerWidth - totalWidth) / 2 + return (startX, totalWidth) + } + + private static func formatDuration(_ milliseconds: Int?) -> String { + guard let milliseconds = milliseconds else { return "--:--" } + return DateHelper.formatDurationMillis(milliseconds) + } +} + +// MARK: - 圆钮(取消 / 转文字,对齐 Android `cancelButtonView` / `transcribeButtonView`) + +final class RecorderCircleButton: UIView { + private static let titleMinimumScaleFactor: CGFloat = 0.6 + + private let circleView = UIView() + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + circleView.layer.masksToBounds = true + addSubview(circleView) + circleView.addSubview(titleLabel) + titleLabel.font = FontScheme.caption1Bold + titleLabel.textAlignment = .center + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = Self.titleMinimumScaleFactor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTitle(_ text: String) { + titleLabel.text = text + } + + override func layoutSubviews() { + super.layoutSubviews() + circleView.frame = bounds + circleView.layer.cornerRadius = bounds.width / 2 + titleLabel.frame = bounds + } + + func setActive(_ active: Bool, idleColor: UIColor, activeColor: UIColor, contentColor: UIColor) { + let background = active ? activeColor : idleColor + if circleView.backgroundColor != background { + circleView.backgroundColor = background + } + if titleLabel.textColor != contentColor { + titleLabel.textColor = contentColor + } + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputMediaCoordinator.swift b/chat/uikit/components/messageinput/view/MessageInputMediaCoordinator.swift new file mode 100644 index 000000000..21dcf2e43 --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputMediaCoordinator.swift @@ -0,0 +1,404 @@ +import UIKit +import AVFoundation +import AtomicXCore + +final class MessageInputMediaCoordinator: NSObject { + private weak var presentingView: UIView? + + private let viewModel: MessageInputViewModel + + private var voiceOverlay: MessageInputAudioRecorderView? + + private var voiceTranscriptionOverlay: VoiceTranscriptionOverlayView? + + private var speechPlayer: SpeechPlayer? + + private var currentRecordDuration: Int = 0 + + private var currentRecordPower: Int = 0 + + private var currentGestureState: AudioRecorderRecordUiState = .recording + + private let recordMaxDurationMs: Int = 60000 + + private var lastReleaseAction: AudioRecorderReleaseAction = .sendAudio + + private static let fileSizeLimit: Int64 = 1_000_000_000 + + private static let videoThumbnailCompressionQuality: CGFloat = 0.7 + + private static let imageSaveCompressionQuality: CGFloat = 0.8 + + private static let toastDuration: TimeInterval = 3 + + init(presentingView: UIView, viewModel: MessageInputViewModel) { + self.presentingView = presentingView + self.viewModel = viewModel + super.init() + } + + func showCamera() { + showCamera(recordMode: .videoPhotoMix) + } + + // MARK: - Camera(拍摄/录像,直接 present 命令式 VideoRecorderController) + + func showCamera(recordMode: RecordMode) { + guard let presenter = findViewController() else { return } + + fetchVideoRecorderSignature() + VideoRecorderConfigInternal.sharedInstance().setCustomConfig("{\"record_mode\": \(recordMode.rawValue)}") + + let recorder = VideoRecorderController() + recorder.recordFilePath = makeMediaPath(messageType: .video, withExtension: "mov") + recorder.modalPresentationStyle = .fullScreen + recorder.resultCallback = { [weak self, weak recorder] videoPath, image, _ in + recorder?.dismiss(animated: true) + guard let self = self else { return } + if let videoPath = videoPath { + self.createThumbnailAndSendVideo(videoPath, nil) + return + } + if let image = image, let imagePath = self.saveImage(image) { + self.viewModel.sendImageMessage(imagePath) + } + } + recorder.photoPreviewOverride = { controller, image in + VideoRecorderPhotoEditBridge.present(from: controller, image: image, onConfirm: { editedImage in + controller.resultCallback?(nil, editedImage, 0) + }, onCancel: {}) + } + presenter.present(recorder, animated: true) + } + + // MARK: - File(文件,直接用系统 UIDocumentPickerViewController) + + func showFilePicker() { + guard let presenter = findViewController() else { return } + let picker: UIDocumentPickerViewController + if #available(iOS 14.0, *) { + picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item]) + } else { + picker = UIDocumentPickerViewController(documentTypes: ["public.item"], in: .import) + } + picker.delegate = self + picker.allowsMultipleSelection = false + presenter.present(picker, animated: true) + } + + // MARK: - Voice(语音,直接驱动命令式 AudioRecorder) + + func beginVoiceRecording() { + guard voiceOverlay == nil, + let inputView = presentingView, + let window = inputView.window else { return } + + let overlay = MessageInputAudioRecorderView( + frame: window.bounds, + maxDurationMs: recordMaxDurationMs + ) + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(overlay) + voiceOverlay = overlay + + let recorder = AudioRecorder.sharedRecorder + recorder.onRecordTime = { [weak self] milliseconds in + DispatchQueue.main.async { + guard let self = self else { return } + self.currentRecordDuration = milliseconds + self.voiceOverlay?.update( + state: self.currentGestureState, + durationMs: self.currentRecordDuration, + powerLevel: self.currentRecordPower, + maxDurationMs: self.recordMaxDurationMs + ) + } + } + recorder.onPowerLevel = { [weak self] power in + DispatchQueue.main.async { + guard let self = self else { return } + self.currentRecordPower = power + self.voiceOverlay?.update( + state: self.currentGestureState, + durationMs: self.currentRecordDuration, + powerLevel: self.currentRecordPower, + maxDurationMs: self.recordMaxDurationMs + ) + } + } + recorder.onRecordingComplete = { [weak self] resultCode, filePath, duration in + guard let self = self else { return } + self.dismissVoiceOverlay() + self.handleVoiceRecordingComplete(resultCode: resultCode, filePath: filePath, duration: duration) + } + NotificationCenter.default.post(name: MessageInputView.voiceRecordStartNotification, object: nil) + recorder.startRecord() + overlay.show() + currentGestureState = .recording + overlay.update(state: .recording, durationMs: 0, powerLevel: 0, maxDurationMs: recordMaxDurationMs) + } + + func updateVoiceRecording(finger: CGPoint) { + guard let overlay = voiceOverlay else { return } + let action = overlay.releaseAction(for: finger) + let state: AudioRecorderRecordUiState = action == .cancel + ? .readyToCancel + : (action == .transcribe ? .readyToTranscribe : .recording) + guard state != currentGestureState else { return } + currentGestureState = state + overlay.update( + state: state, + durationMs: currentRecordDuration, + powerLevel: currentRecordPower, + maxDurationMs: recordMaxDurationMs + ) + } + + func endVoiceRecording(finger: CGPoint) { + guard let overlay = voiceOverlay else { return } + let action = overlay.releaseAction(for: finger) + overlay.dismiss() + voiceOverlay = nil + lastReleaseAction = action + + let recorder = AudioRecorder.sharedRecorder + switch action { + case .cancel: + recorder.cancelRecord() + case .sendAudio, .transcribe: + recorder.stopRecord() + } + } + + // MARK: - Helpers + + private func handleFileSelection(_ fileURL: URL) { + let fileName = fileURL.lastPathComponent + let filePath = makeMediaPath(messageType: .file, withExtension: fileName) + guard let fullFilePath = (filePath as NSString).appendingPathExtension(fileURL.pathExtension) else { return } + do { + if FileManager.default.fileExists(atPath: fullFilePath) { + try FileManager.default.removeItem(atPath: fullFilePath) + } + try FileManager.default.copyItem(at: fileURL, to: URL(fileURLWithPath: fullFilePath)) + let attributes = try FileManager.default.attributesOfItem(atPath: fullFilePath) + let fileSize = attributes[.size] as? Int64 ?? 0 + if fileSize > Self.fileSizeLimit || fileSize == 0 { + WindowAlertManager.shared.showAlert( + message: LocalizedChatString("FileSizeCheckLimited"), + confirmText: LocalizedChatString("Confirm") + ) + return + } + viewModel.sendFileMessage(fullFilePath, fileName: fileName, fileSize: Int(fileSize)) + } catch {} + } + + private func handleVoiceRecordingComplete(resultCode: AudioRecordResultCode, filePath: String, duration: Int) { + if resultCode == .errorLessThanMinDuration { + WindowToastManager.shared.show( + LocalizedChatString("AudioRecorderLessThanMinTime"), type: .warning, duration: Self.toastDuration + ) + } + if resultCode == .exceedMaxDuration { + WindowToastManager.shared.show( + LocalizedChatString("AudioRecordTimeLimitReached"), type: .warning, duration: Self.toastDuration + ) + } + if lastReleaseAction == .transcribe { + if resultCode.rawValue >= 0, !filePath.isEmpty { + showVoiceTranscriptionOverlay(filePath: filePath, duration: duration) + } + return + } + if resultCode.rawValue >= 0, !filePath.isEmpty { + viewModel.sendVoiceMessage(filePath, duration: duration) + } + } + + private func showVoiceTranscriptionOverlay(filePath: String, duration: Int) { + guard let window = presentingView?.window else { return } + let callbacks = VoiceTranscriptionCallbacks( + onCancel: { [weak self] in + self?.voiceTranscriptionOverlay = nil + }, + onSendAudio: { [weak self] path, dur in + self?.voiceTranscriptionOverlay = nil + self?.viewModel.sendVoiceMessage(path, duration: dur) + }, + onSendText: { [weak self] text in + self?.voiceTranscriptionOverlay = nil + self?.viewModel.sendTextMessage(text) + }, + onTranslate: { text, language, onSuccess, onFailure in + AiMediaProcessManager.translateSingleText( + text: text, targetLanguage: language, + onSuccess: onSuccess, + onFailure: { _, _ in onFailure() } + ) + }, + onStartSpeak: { [weak self] text, onStart, onComplete, onError in + self?.startSpeak(text: text, onStart: onStart, onComplete: onComplete, onError: onError) + }, + onStopSpeak: { [weak self] in + self?.stopSpeak() + } + ) + let overlay = VoiceTranscriptionOverlayView( + frame: window.bounds, + audioPath: filePath, + audioDurationSecond: duration, + text: nil, + callbacks: callbacks + ) + voiceTranscriptionOverlay = overlay + overlay.show(in: window) + viewModel.convertLocalAudioToText(filePath: filePath) { [weak overlay] text in + overlay?.updateResult(text) + } + } + + private func startSpeak(text: String, + onStart: @escaping (TimeInterval) -> Void, + onComplete: @escaping () -> Void, + onError: @escaping () -> Void) { + AiMediaProcessManager.convertTextToVoice(text: text, + onSuccess: { [weak self] url in + guard let audioURL = URL(string: url) else { onError(); return } + URLSession.shared.downloadTask(with: audioURL) { localURL, _, error in + guard let localURL = localURL, error == nil else { onError(); return } + self?.speechPlayer = SpeechPlayer() + self?.speechPlayer?.play( + url: localURL, + onStart: onStart, + onComplete: onComplete, + onError: onError + ) + }.resume() + }, + onFailure: { _, _ in onError() } + ) + } + + private func stopSpeak() { + speechPlayer?.stop() + speechPlayer = nil + } + + private func dismissVoiceOverlay() { + voiceOverlay?.stopAnimating() + voiceOverlay?.removeFromSuperview() + voiceOverlay = nil + } + + private func createThumbnailAndSendVideo(_ videoPath: String, _ videoThumbnailPath: String?) { + if let thumbnailPath = videoThumbnailPath, !thumbnailPath.isEmpty { + viewModel.sendVideoMessage(videoPath, snapshotPath: thumbnailPath) + return + } + let generatedPath = makeMediaPath(messageType: .image, withExtension: "jpg") + if let thumbnail = makeVideoThumbnail(from: URL(fileURLWithPath: videoPath)), + let imageData = thumbnail.jpegData(compressionQuality: Self.videoThumbnailCompressionQuality) { + try? imageData.write(to: URL(fileURLWithPath: generatedPath)) + } else { + try? Data().write(to: URL(fileURLWithPath: generatedPath)) + } + viewModel.sendVideoMessage(videoPath, snapshotPath: generatedPath) + } + + private func makeVideoThumbnail(from videoURL: URL) -> UIImage? { + let asset = AVAsset(url: videoURL) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + guard let cgImage = try? generator.copyCGImage( + at: CMTime(seconds: 0, preferredTimescale: 60), actualTime: nil + ) else { + return nil + } + return UIImage(cgImage: cgImage) + } + + private func saveImage(_ image: UIImage) -> String? { + guard let imageData = image.jpegData(compressionQuality: Self.imageSaveCompressionQuality) else { return nil } + let path = makeMediaPath(messageType: .image, withExtension: "png") + try? imageData.write(to: URL(fileURLWithPath: path)) + return path + } + + private func makeMediaPath(messageType: MessageType, withExtension: String?) -> String { + let path = ChatUtil.generateMediaPath(messageType: messageType, withExtension: withExtension) + let directory = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + return path + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = presentingView + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } +} + +// MARK: - 朗读播放器(TTS 结果音频播放,对齐 Android `TtsPlaybackHelper`) + +private final class SpeechPlayer: NSObject, AVAudioPlayerDelegate { + private var player: AVAudioPlayer? + + private var onComplete: (() -> Void)? + + private var onError: (() -> Void)? + + func play(url: URL, + onStart: @escaping (TimeInterval) -> Void, + onComplete: @escaping () -> Void, + onError: @escaping () -> Void) { + do { + let player = try AVAudioPlayer(contentsOf: url) + self.player = player + self.onComplete = onComplete + self.onError = onError + player.delegate = self + if player.play() { + onStart(player.duration) + } else { + onError() + } + } catch { + onError() + } + } + + func stop() { + player?.stop() + player = nil + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if flag { + onComplete?() + } else { + onError?() + } + } +} + +// MARK: - UIDocumentPickerDelegate + +extension MessageInputMediaCoordinator: UIDocumentPickerDelegate { + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + let securityScoped = url.startAccessingSecurityScopedResource() + defer { + if securityScoped { + url.stopAccessingSecurityScopedResource() + } + } + handleFileSelection(url) + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputMorePanel.swift b/chat/uikit/components/messageinput/view/MessageInputMorePanel.swift new file mode 100644 index 000000000..68941c5fb --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputMorePanel.swift @@ -0,0 +1,240 @@ +import UIKit +import SnapKit + +protocol MessageInputMorePanelDelegate: AnyObject { + func morePanelDidSelectAlbum() + func morePanelDidSelectCamera() + func morePanelDidSelectVideo() + func morePanelDidSelectFile() + func morePanelDidSelectVideoCall() + func morePanelDidSelectAudioCall() +} + +final class MessageInputMorePanel: UIView { + weak var delegate: MessageInputMorePanelDelegate? + + var bottomSafeAreaInset: CGFloat = 0 { + didSet { + guard bottomSafeAreaInset != oldValue else { return } + collectionBottomConstraint?.update(inset: Self.panelPadding + bottomSafeAreaInset) + } + } + + private let config: MessageInputConfigProtocol + + fileprivate static let columns = 4 + + fileprivate static let iconSize: CGFloat = 56 + + fileprivate static let iconTileCornerRadius: CGFloat = CGFloat(RadiusScheme.alertRadius) + + fileprivate static let iconGlyphSize: CGFloat = 28 + + fileprivate static let titleTopSpacing: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + fileprivate static let titleAreaHeight: CGFloat = 29 + + fileprivate static let itemVerticalSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + fileprivate static let panelPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private let collectionView: UICollectionView + + private var collectionBottomConstraint: Constraint? + + private var actions: [MessageInputMenuAction] = [] + + init(config: MessageInputConfigProtocol) { + self.config = config + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = Self.itemVerticalSpacing * 2 + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + super.init(frame: .zero) + setupActions() + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupActions() { + var defaults: [MessageInputMenuAction] = [ + MessageInputMenuAction( + ID: MessageInputActionIDs.album, + title: LocalizedChatString("MorePhoto"), + iconName: "more_picture", + onClick: { [weak self] in self?.delegate?.morePanelDidSelectAlbum() } + ) + ] + if config.isShowPhotoTaker { + defaults.append(MessageInputMenuAction( + ID: MessageInputActionIDs.takePhoto, + title: LocalizedChatString("MoreCamera"), + iconName: "more_camera", + onClick: { [weak self] in self?.delegate?.morePanelDidSelectCamera() } + )) + defaults.append(MessageInputMenuAction( + ID: MessageInputActionIDs.recordVideo, + title: LocalizedChatString("MoreVideo"), + iconName: "more_video", + onClick: { [weak self] in self?.delegate?.morePanelDidSelectVideo() } + )) + } + defaults.append(MessageInputMenuAction( + ID: MessageInputActionIDs.file, + title: LocalizedChatString("MoreFile"), + iconName: "more_file", + onClick: { [weak self] in self?.delegate?.morePanelDidSelectFile() } + )) + if config.isShowVideoCall { + defaults.append(MessageInputMenuAction( + ID: MessageInputActionIDs.videoCall, + title: LocalizedChatString("MoreVideoCall"), + iconName: "more_video_call", + onClick: { [weak self] in self?.delegate?.morePanelDidSelectVideoCall() } + )) + } + if config.isShowAudioCall { + defaults.append(MessageInputMenuAction( + ID: MessageInputActionIDs.audioCall, + title: LocalizedChatString("MoreVoiceCall"), + iconName: "more_voice_call", + onClick: { [weak self] in self?.delegate?.morePanelDidSelectAudioCall() } + )) + } + if let customizer = config.actionCustomizer { + let editor = CustomEditor(items: defaults) + customizer(editor) + actions = editor.build() + } else { + actions = defaults + } + } + + private func setupView() { + backgroundColor = ChatUIKitTheme.colors.bgColorOperate + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register(MorePanelCell.self, forCellWithReuseIdentifier: MorePanelCell.reuseID) + addSubview(collectionView) + collectionView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(Self.panelPadding) + collectionBottomConstraint = make.bottom.equalToSuperview() + .inset(Self.panelPadding).constraint + } + } +} + +// MARK: - UICollectionViewDataSource + +extension MessageInputMorePanel: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return actions.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: MorePanelCell.reuseID, for: indexPath + ) as! MorePanelCell + let item = actions[indexPath.item] + cell.configure(icon: item.icon ?? AtomicXChatResources.image(named: item.iconName), title: item.title) + return cell + } +} + +// MARK: - UICollectionViewDelegate + +extension MessageInputMorePanel: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + actions[indexPath.item].onClick() + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension MessageInputMorePanel: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let availableWidth = collectionView.bounds.width + let itemWidth = floor(availableWidth / CGFloat(Self.columns)) + let itemHeight = Self.iconSize + Self.titleTopSpacing + Self.titleAreaHeight + Self.itemVerticalSpacing + return CGSize(width: itemWidth, height: itemHeight) + } +} + +// MARK: - MorePanelCell + +private final class MorePanelCell: UICollectionViewCell { + static let reuseID = "MorePanelCell" + + private let iconTileView = UIView() + + private let iconImageView = UIImageView() + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(icon: UIImage?, title: String) { + iconImageView.image = icon + titleLabel.text = title + } + + private func setupCell() { + let colors = ChatUIKitTheme.colors + + iconTileView.backgroundColor = colors.bgColorInput + iconTileView.layer.cornerRadius = MessageInputMorePanel.iconTileCornerRadius + iconTileView.layer.masksToBounds = true + contentView.addSubview(iconTileView) + + iconImageView.contentMode = .scaleAspectFit + iconImageView.tintColor = colors.textColorPrimary + iconTileView.addSubview(iconImageView) + + titleLabel.font = FontScheme.caption3Regular + titleLabel.textColor = colors.textColorSecondary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 2 + titleLabel.lineBreakMode = .byWordWrapping + contentView.addSubview(titleLabel) + + iconTileView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.centerX.equalToSuperview() + make.width.height.equalTo(MessageInputMorePanel.iconSize) + } + + iconImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(MessageInputMorePanel.iconGlyphSize) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconTileView.snp.bottom).offset(MessageInputMorePanel.titleTopSpacing) + make.leading.trailing.equalToSuperview() + make.height.equalTo(MessageInputMorePanel.titleAreaHeight) + } + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputQuotePreviewBar.swift b/chat/uikit/components/messageinput/view/MessageInputQuotePreviewBar.swift new file mode 100644 index 000000000..2ced5e4ba --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputQuotePreviewBar.swift @@ -0,0 +1,105 @@ +import UIKit +import SnapKit + +final class MessageInputQuotePreviewBar: UIView { + private static let senderNameFontSize: CGFloat = 13 + + private static let summaryFontSize: CGFloat = 13 + + private static let separatorHeight: CGFloat = 0.5 + + private static let contentLeadingInset: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let contentTrailingGap: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let closeButtonSize: CGFloat = 32 + + private static let closeButtonTitleFontSize: CGFloat = 20 + + private static let closeButtonTrailingInset: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + var onClose: (() -> Void)? + + private let contentLabel = UILabel() + + private let closeButton = UIButton(type: .system) + + private let separatorLine = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(senderName: String, summary: String) { + let colors = ChatUIKitTheme.colors + let attributedText = NSMutableAttributedString() + let nameAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: Self.senderNameFontSize, weight: .semibold), + .foregroundColor: colors.textColorSecondary + ] + let summaryAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: Self.summaryFontSize, weight: .regular), + .foregroundColor: colors.textColorTertiary + ] + attributedText.append(NSAttributedString(string: senderName, attributes: nameAttributes)) + if !summary.isEmpty { + attributedText.append(NSAttributedString(string: ":", attributes: nameAttributes)) + attributedText.append(NSAttributedString(string: summary, attributes: summaryAttributes)) + } + contentLabel.attributedText = attributedText + } + + private func constructViewHierarchy() { + addSubview(separatorLine) + addSubview(contentLabel) + addSubview(closeButton) + } + + private func activateConstraints() { + separatorLine.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Self.separatorHeight) + } + + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.closeButtonTrailingInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.closeButtonSize) + } + + contentLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.contentLeadingInset) + make.trailing.equalTo(closeButton.snp.leading).offset(-Self.contentTrailingGap) + make.centerY.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorDefault + clipsToBounds = true + isHidden = true + + contentLabel.numberOfLines = 1 + contentLabel.lineBreakMode = .byTruncatingTail + contentLabel.textAlignment = .natural + + separatorLine.backgroundColor = colors.strokeColorPrimary + + closeButton.setTitle("×", for: .normal) + closeButton.titleLabel?.font = UIFont.systemFont(ofSize: Self.closeButtonTitleFontSize) + closeButton.setTitleColor(colors.textColorSecondary, for: .normal) + closeButton.addTarget(self, action: #selector(handleCloseTapped), for: .touchUpInside) + } + + @objc private func handleCloseTapped() { + onClose?() + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputQuoteSummaryFormatter.swift b/chat/uikit/components/messageinput/view/MessageInputQuoteSummaryFormatter.swift new file mode 100644 index 000000000..027a17baa --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputQuoteSummaryFormatter.swift @@ -0,0 +1,33 @@ +import Foundation +import AtomicXCore + +enum MessageInputQuoteSummaryFormatter { + + static func format(message: MessageInfo) -> String { + guard let payload = message.messagePayload else { + return "" + } + switch payload { + case .text(let textPayload): + return EmojiManager.shared.createLocalizedStringFromEmojiCodes(textPayload.text) + case .image: + return LocalizedChatString("MessageTypeImage") + case .video: + return LocalizedChatString("MessageTypeVideo") + case .audio: + return LocalizedChatString("MessageTypeVoice") + case .file(let filePayload): + return filePayload.fileName ?? LocalizedChatString("MessageTypeFile") + case .merged: + return LocalizedChatString("MessageTypeMerged") + case .face(let facePayload): + return facePayload.faceData ?? LocalizedChatString("MessageTypeCustom") + case .custom: + return LocalizedChatString("MessageTypeCustom") + case .tips: + return "" + case .stream: + return LocalizedChatString("MessageTypeCustom") + } + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputTextView.swift b/chat/uikit/components/messageinput/view/MessageInputTextView.swift new file mode 100644 index 000000000..b8959ae46 --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputTextView.swift @@ -0,0 +1,371 @@ +import UIKit +import SnapKit + +final class MessageInputTextView: UIView { + var onContentChanged: ((NSAttributedString?) -> Void)? + + var onHeightChanged: ((CGFloat) -> Void)? + + var onSend: (() -> Void)? + + var onAtTriggered: ((Int) -> Void)? + + var mentionToDeleteAtPosition: ((Int) -> (startIndex: Int, length: Int)?)? + + var onMentionDeleted: ((Int) -> Void)? + + var onUserTextChanged: ((Bool) -> Void)? + + var shouldBlockFirstResponder: (() -> Bool)? + + var isGroupChat: Bool = false + + var placeholderText: String? { + didSet { placeholderLabel.text = placeholderText } + } + + var suppressPlaceholderForPanel: Bool = false { + didSet { updatePlaceholder() } + } + + var enableMention: Bool = true + + var maxLines: Int = 5 + + var attributedContent: NSAttributedString? { + return textView.attributedText.length > 0 ? textView.attributedText : nil + } + + override var isFirstResponder: Bool { + return textView.isFirstResponder + } + + private static let normalFont = FontScheme.caption1Regular + + private static let initialHeight: CGFloat = 36 + + private static let fallbackLineHeight: CGFloat = 24 + + private static let heightChangeThreshold: CGFloat = 0.5 + + private let textContainerInsets = UIEdgeInsets( + top: CGFloat(SpacingScheme.smallSpacing), + left: CGFloat(SpacingScheme.iconTextSpacing), + bottom: CGFloat(SpacingScheme.smallSpacing), + right: CGFloat(SpacingScheme.iconTextSpacing) + ) + + private let textView = UITextView() + + private let placeholderLabel = UILabel() + + private var isUserEditing = false + + private var currentHeight: CGFloat = MessageInputTextView.initialHeight + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public API + + @discardableResult + + override func becomeFirstResponder() -> Bool { + return textView.becomeFirstResponder() + } + + @discardableResult + + override func resignFirstResponder() -> Bool { + return textView.resignFirstResponder() + } + + func setContent(_ attributedText: NSAttributedString?) { + if let attributedText = attributedText { + textView.attributedText = attributedText + textView.selectedRange = NSRange(location: attributedText.length, length: 0) + } else { + textView.attributedText = NSAttributedString(string: "") + } + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + } + + func clear() { + setContent(nil) + } + + func insertEmoji(_ emojiString: NSAttributedString, emojiName: String) { + if let attachment = emojiString.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment, + let image = attachment.image { + image.accessibilityIdentifier = emojiName + } + let selectedRange = textView.selectedRange + textView.textStorage.insert(emojiString, at: selectedRange.location) + textView.selectedRange = NSRange(location: selectedRange.location + 1, length: 0) + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + } + + func deleteLastCharacter() { + guard textView.textStorage.length > 0 else { return } + let selectedRange = textView.selectedRange + if selectedRange.length > 0 { + textView.textStorage.deleteCharacters(in: selectedRange) + textView.selectedRange = NSRange(location: selectedRange.location, length: 0) + } else if selectedRange.location > 0 { + let deleteRange = NSRange(location: selectedRange.location - 1, length: 1) + textView.textStorage.deleteCharacters(in: deleteRange) + textView.selectedRange = NSRange(location: selectedRange.location - 1, length: 0) + } + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + } + + func appendText(_ attributedText: NSAttributedString) { + let endPosition = textView.textStorage.length + textView.textStorage.insert(attributedText, at: endPosition) + textView.selectedRange = NSRange(location: textView.textStorage.length, length: 0) + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + } + + func extractSendText() -> String { + guard let attributedString = attributedContent else { return "" } + var resultText = "" + var currentPosition = 0 + attributedString.enumerateAttributes( + in: NSRange(location: 0, length: attributedString.length), options: [] + ) { attributes, range, _ in + if currentPosition < range.location { + let textRange = NSRange(location: currentPosition, length: range.location - currentPosition) + resultText += attributedString.attributedSubstring(from: textRange).string + } + if let attachment = attributes[.attachment] as? EmojiTextAttachment { + resultText += attachment.emojiTag ?? "[emoji]" + } else { + resultText += attributedString.attributedSubstring(from: range).string + } + currentPosition = range.location + range.length + } + if currentPosition < attributedString.length { + let textRange = NSRange(location: currentPosition, length: attributedString.length - currentPosition) + resultText += attributedString.attributedSubstring(from: textRange).string + } + return resultText + } + + func convertDisplayPositionToTextPosition(_ displayPosition: Int) -> Int { + guard let attributedString = attributedContent else { return displayPosition } + var textPosition = 0 + var displayPos = 0 + attributedString.enumerateAttributes( + in: NSRange(location: 0, length: attributedString.length), options: [] + ) { attributes, range, stop in + if displayPos >= displayPosition { + stop.pointee = true + return + } + if let attachment = attributes[.attachment] as? EmojiTextAttachment { + textPosition += attachment.emojiTag?.count ?? "[emoji]".count + displayPos += range.length + } else { + let charsToAdd = min(range.length, displayPosition - displayPos) + textPosition += charsToAdd + displayPos += charsToAdd + } + } + if displayPos < displayPosition { + textPosition += displayPosition - displayPos + } + return textPosition + } + + // MARK: - Private Helpers + + override func layoutSubviews() { + super.layoutSubviews() + recalculateHeight() + } + + private func constructViewHierarchy() { + addSubview(textView) + addSubview(placeholderLabel) + } + + private func activateConstraints() { + textView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + placeholderLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview() + } + } + + private func bindInteraction() { + textView.delegate = self + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + textView.font = Self.normalFont + textView.backgroundColor = .clear + textView.isScrollEnabled = false + textView.textContainer.lineFragmentPadding = 0 + textView.textContainerInset = textContainerInsets + textView.returnKeyType = .send + textView.textContainer.lineBreakMode = .byWordWrapping + textView.textColor = colors.textColorPrimary + textView.tintColor = colors.textColorLink + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + placeholderLabel.font = FontScheme.caption1Regular + placeholderLabel.textColor = colors.textColorTertiary + placeholderLabel.textAlignment = .center + placeholderLabel.text = LocalizedChatString("SendMessage") + placeholderLabel.isUserInteractionEnabled = false + } + + private func updatePlaceholder() { + let hasContent = attributedContent?.string.isEmpty == false + placeholderLabel.isHidden = hasContent || textView.isFirstResponder || suppressPlaceholderForPanel + } + + private func recalculateHeight() { + let width = textView.frame.width > 0 ? textView.frame.width : bounds.width + let fitting = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + let singleLineHeight = textView.font?.lineHeight ?? Self.fallbackLineHeight + let padding = textContainerInsets.top + textContainerInsets.bottom + let maxHeight = singleLineHeight * CGFloat(maxLines) + padding + let newHeight = min(max(singleLineHeight + padding, fitting.height), maxHeight) + textView.isScrollEnabled = fitting.height > maxHeight + guard abs(newHeight - currentHeight) > Self.heightChangeThreshold else { return } + currentHeight = newHeight + onHeightChanged?(newHeight) + } + + private func resetTextStyle() { + let colors = ChatUIKitTheme.colors + let wholeRange = NSRange(location: 0, length: textView.textStorage.length) + textView.textStorage.removeAttribute(.font, range: wholeRange) + textView.textStorage.removeAttribute(.foregroundColor, range: wholeRange) + textView.textStorage.addAttribute(.foregroundColor, value: colors.textColorPrimary, range: wholeRange) + textView.textStorage.addAttribute(.font, value: Self.normalFont, range: wholeRange) + textView.textAlignment = .natural + textView.textColor = colors.textColorPrimary + } +} + +// MARK: - UITextViewDelegate + +extension MessageInputTextView: UITextViewDelegate { + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + return shouldBlockFirstResponder?() != true + } + + func textViewDidBeginEditing(_ textView: UITextView) { + isUserEditing = true + updatePlaceholder() + } + + func textViewDidEndEditing(_ textView: UITextView) { + isUserEditing = false + updatePlaceholder() + } + + func textViewDidChange(_ textView: UITextView) { + isUserEditing = true + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + onUserTextChanged?(attributedContent != nil) + } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + if (text == "@" || text == "@") && isGroupChat && enableMention { + DispatchQueue.main.async { [weak self] in + self?.onAtTriggered?(range.location) + } + return true + } + + if text.isEmpty && range.length > 0 { + if handleMentionDeletion(in: textView, deletePosition: range.location) { + return false + } + } + + if !text.contains("[") && !text.contains("]") { + if text == "\n" { + onSend?() + return false + } + return true + } + + let selectedRange = textView.selectedRange + if selectedRange.length > 0 { + textView.textStorage.deleteCharacters(in: selectedRange) + } + let textChange = EmojiManager.shared.createAttributedStringWithTextAndStyle( + text: text, withFont: Self.normalFont, textColor: ChatUIKitTheme.colors.textColorPrimary + ) + textView.textStorage.insert(textChange, at: selectedRange.location) + let cursorLocation = selectedRange.location + textChange.length + textView.selectedRange = NSRange(location: cursorLocation, length: 0) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let end = min(cursorLocation, self.textView.textStorage.length) + self.textView.selectedRange = NSRange(location: end, length: 0) + } + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + return false + } + + private func handleMentionDeletion(in textView: UITextView, deletePosition: Int) -> Bool { + guard let mention = mentionToDeleteAtPosition?(deletePosition) else { return false } + let textLength = textView.textStorage.length + let mentionStart = mention.startIndex + guard mentionStart >= 0, mentionStart < textLength else { return false } + let safeLength = min(mention.length, textLength - mentionStart) + guard safeLength > 0 else { return false } + let mentionRange = NSRange(location: mentionStart, length: safeLength) + textView.textStorage.deleteCharacters(in: mentionRange) + textView.selectedRange = NSRange(location: mentionStart, length: 0) + resetTextStyle() + updatePlaceholder() + recalculateHeight() + onContentChanged?(attributedContent) + onMentionDeleted?(mentionStart) + return true + } +} diff --git a/chat/uikit/components/messageinput/view/MessageInputViewImpl.swift b/chat/uikit/components/messageinput/view/MessageInputViewImpl.swift new file mode 100644 index 000000000..247304f26 --- /dev/null +++ b/chat/uikit/components/messageinput/view/MessageInputViewImpl.swift @@ -0,0 +1,1072 @@ +import UIKit +import SnapKit +import AtomicXCore +import TUICallKit_Swift + +final class MessageInputViewImpl: UIView { + var onIntrinsicContentSizeInvalidated: (() -> Void)? + + var onAnimateSuperviewLayout: (() -> Void)? + + var bottomSafeAreaInset: CGFloat = 0 { + didSet { + guard bottomSafeAreaInset != oldValue else { return } + + if didAttachEmojiPicker { + emojiPickerView.bottomSafeAreaInset = bottomSafeAreaInset + } + if didAttachMorePanel { + morePanel.bottomSafeAreaInset = bottomSafeAreaInset + } + syncBottomAreaHeight() + } + } + + override var intrinsicContentSize: CGSize { + let inputRowHeight = Self.inputRowVerticalPadding + currentInputContainerHeight + Self.inputRowVerticalPadding + return CGSize( + width: UIView.noIntrinsicMetric, + height: Self.topDividerHeight + currentQuoteBarHeight + inputRowHeight + currentBottomAreaHeight + ) + } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onIntrinsicContentSizeInvalidated?() + } + + private let viewModel: MessageInputViewModel + + private let config: MessageInputConfigProtocol + + var onTypingContentChanged: ((Bool) -> Void)? + + var onSendFailure: ((String) -> Void)? + + private let topDivider = UIView() + + private let inputRow = UIView() + + private let audioButton = UIButton(type: .system) + + private let inputContainer = UIView() + + private let messageTextView = MessageInputTextView() + + private let voiceLabel = UILabel() + + private let emojiButton = UIButton(type: .system) + + private let moreButton = UIButton(type: .system) + + private let sendButton = UIButton(type: .system) + + private let bottomContainer = UIView() + + private let emojiPanelContainer = UIView() + + private let morePanelContainer = UIView() + + private lazy var emojiPickerView: EmojiPickerView = { + let picker = EmojiPickerView() + picker.onEmojiSelected = { [weak self] emoji in self?.handleEmojiSelected(emoji) } + picker.onCustomEmojiSelected = { [weak self] group, emoji in self?.handleCustomEmojiSelected(group: group, emoji: emoji) } + picker.onDelete = { [weak self] in self?.deleteLastCharacter() } + picker.onSend = { [weak self] in self?.requestSendText() } + return picker + }() + + private var didAttachEmojiPicker = false + + private lazy var morePanel: MessageInputMorePanel = { + let panel = MessageInputMorePanel(config: viewModel.config) + panel.delegate = self + return panel + }() + + private var didAttachMorePanel = false + + private lazy var mediaCoordinator = MessageInputMediaCoordinator(presentingView: self, viewModel: viewModel) + + private let quotePreviewBar = MessageInputQuotePreviewBar() + + private var currentQuotedMessage: MessageInfo? + + private var inputContainerHeightConstraint: Constraint? + + private var bottomAreaHeightConstraint: Constraint? + + private var quotePreviewBarHeightConstraint: Constraint? + + private var currentInputContainerHeight: CGFloat = MessageInputViewImpl.defaultInputContainerHeight + + private var currentEmojiPanelHeight: CGFloat = 0 + + private var currentMorePanelHeight: CGFloat = 0 + + private var currentKeyboardHeight: CGFloat = 0 + + private var currentQuoteBarHeight: CGFloat = 0 + + private var mentionList: [MentionInfo] = [] + + private var draftSaveWorkItem: DispatchWorkItem? + + private var isLoadingDraft = false + + private var didLoadDraft = false + + private var isEmojiPanelShown = false + + private var isMorePanelShown = false + + private var isLongPressingAudio = false + + private var isTextInputLongPressArmed = false + + private lazy var textInputLongPress = UILongPressGestureRecognizer(target: self, action: #selector(handleTextInputLongPress(_:))) + + private lazy var textInputTap: UITapGestureRecognizer = { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTextInputTap(_:))) + tap.cancelsTouchesInView = false + return tap + }() + + private var isVoiceInputMode = false + + private static let emojiPanelHeight: CGFloat = 280 + + private static let morePanelHeight: CGFloat = 242 + + private static let quotePreviewBarHeight: CGFloat = 48 + + private static let inputRowVerticalPadding: CGFloat = 11 + + private static let topDividerHeight: CGFloat = 0.5 + + private static let defaultInputContainerHeight: CGFloat = 34 + + private static let inputRowHorizontalInset: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let inputRowButtonSize: CGFloat = 28 + + private static let textViewHorizontalInset: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let textViewMaxLines: Int = 6 + + private static let voiceLongPressMinimumDuration: TimeInterval = 0.2 + + private static let inputContainerCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let sendButtonCornerRadius: CGFloat = 14 + + private static let sendButtonIconPointSize: CGFloat = 14 + + private static let draftSaveDebounceInterval: TimeInterval = 0.8 + + private static let draftKeyboardFocusDelay: TimeInterval = 0.15 + + private static let draftLoadingResetDelay: TimeInterval = 0.2 + + private static let layoutAnimationDuration: TimeInterval = 0.2 + + private var currentBottomAreaHeight: CGFloat { + if currentKeyboardHeight > 0 { + return currentKeyboardHeight + } + return currentEmojiPanelHeight + currentMorePanelHeight + bottomSafeAreaInset + } + + // MARK: - Init + + init(conversationID: String, config: MessageInputConfigProtocol = ChatMessageInputConfig()) { + let viewModel = MessageInputViewModel(conversationID: conversationID, config: config) + self.viewModel = viewModel + self.config = viewModel.config + super.init(frame: .zero) + viewModel.onSendFailure = { [weak self] reason in + self?.onSendFailure?(reason) + } + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Four-Step Lifecycle + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Window Lifecycle (对齐声明式 onAppear/onDisappear 草稿逻辑) + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + if !didLoadDraft { + didLoadDraft = true + loadDraft() + } + } else { + saveDraftImmediately() + } + } + + // MARK: - Quote (引用) + + private func constructViewHierarchy() { + addSubview(topDivider) + addSubview(quotePreviewBar) + addSubview(inputRow) + if config.isShowAudioRecorder { + inputRow.addSubview(audioButton) + } + inputRow.addSubview(inputContainer) + inputContainer.addSubview(messageTextView) + inputContainer.addSubview(voiceLabel) + if config.isShowEmoji { + inputRow.addSubview(emojiButton) + } + if config.isShowMore { + inputRow.addSubview(moreButton) + } + inputRow.addSubview(sendButton) + addSubview(bottomContainer) + bottomContainer.addSubview(emojiPanelContainer) + bottomContainer.addSubview(morePanelContainer) + } + + private func activateConstraints() { + topDivider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Self.topDividerHeight) + } + + quotePreviewBar.snp.makeConstraints { make in + make.top.equalTo(inputRow.snp.bottom) + make.leading.trailing.equalToSuperview() + quotePreviewBarHeightConstraint = make.height.equalTo(0).constraint + } + + inputRow.snp.makeConstraints { make in + make.top.equalTo(topDivider.snp.bottom) + make.leading.trailing.equalToSuperview() + } + + var leftAnchor = inputRow.snp.leading + var leftInset: CGFloat = Self.inputRowHorizontalInset + if config.isShowAudioRecorder { + audioButton.snp.makeConstraints { make in + make.leading.equalTo(leftAnchor).offset(leftInset) + make.centerY.equalTo(inputContainer) + make.width.height.equalTo(Self.inputRowButtonSize) + } + leftAnchor = audioButton.snp.trailing + leftInset = Self.inputRowHorizontalInset + } + + var rightAnchor = inputRow.snp.trailing + var rightInset: CGFloat = -Self.inputRowHorizontalInset + + if config.isShowMore { + moreButton.snp.makeConstraints { make in + make.trailing.equalTo(rightAnchor).offset(rightInset) + make.centerY.equalTo(inputContainer) + make.width.height.equalTo(Self.inputRowButtonSize) + } + } + sendButton.snp.makeConstraints { make in + make.trailing.equalTo(rightAnchor).offset(rightInset) + make.centerY.equalTo(inputContainer) + make.width.height.equalTo(Self.inputRowButtonSize) + } + rightAnchor = config.isShowMore ? moreButton.snp.leading : sendButton.snp.leading + rightInset = -Self.inputRowHorizontalInset + + if config.isShowEmoji { + emojiButton.snp.makeConstraints { make in + make.trailing.equalTo(rightAnchor).offset(rightInset) + make.centerY.equalTo(inputContainer) + make.width.height.equalTo(Self.inputRowButtonSize) + } + rightAnchor = emojiButton.snp.leading + rightInset = -Self.inputRowHorizontalInset + } + + inputContainer.snp.makeConstraints { make in + make.leading.equalTo(leftAnchor).offset(leftInset) + make.trailing.equalTo(rightAnchor).offset(rightInset) + make.top.equalToSuperview().offset(Self.inputRowVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.inputRowVerticalPadding) + inputContainerHeightConstraint = make.height.equalTo(Self.defaultInputContainerHeight).constraint + } + + messageTextView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.textViewHorizontalInset) + make.trailing.equalToSuperview().offset(-Self.textViewHorizontalInset) + make.top.bottom.equalToSuperview() + } + + voiceLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + bottomContainer.snp.makeConstraints { make in + make.top.equalTo(quotePreviewBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + bottomAreaHeightConstraint = make.height.equalTo(0).constraint + } + + emojiPanelContainer.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + morePanelContainer.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func bindInteraction() { + messageTextView.isGroupChat = viewModel.isGroupChat + messageTextView.enableMention = config.enableMention + messageTextView.maxLines = Self.textViewMaxLines + messageTextView.placeholderText = LocalizedChatString(config.enableLongPressToTalk ? "SendMessage" : "SendMessageTextOnly") + + messageTextView.onSend = { [weak self] in self?.sendText() } + messageTextView.onHeightChanged = { [weak self] height in + guard let self = self else { return } + self.inputContainerHeightConstraint?.update(offset: height) + self.currentInputContainerHeight = height + self.invalidateIntrinsicContentSize() + } + messageTextView.onContentChanged = { [weak self] _ in + self?.handleContentChanged() + self?.updateSendButtonVisibility() + } + messageTextView.onUserTextChanged = { [weak self] hasContent in + self?.onTypingContentChanged?(hasContent) + } + messageTextView.onAtTriggered = { [weak self] position in + guard let self = self, self.viewModel.isGroupChat, self.config.enableMention else { return } + self.presentMentionPicker(atPosition: position) + } + messageTextView.mentionToDeleteAtPosition = { [weak self] position in + self?.findMentionToDelete(at: position) + } + messageTextView.onMentionDeleted = { [weak self] startIndex in + self?.mentionList.removeAll { $0.startIndex == startIndex } + } + + moreButton.addTarget(self, action: #selector(handleMoreTapped), for: .touchUpInside) + emojiButton.addTarget(self, action: #selector(handleEmojiTapped), for: .touchUpInside) + sendButton.addTarget(self, action: #selector(handleSendTapped), for: .touchUpInside) + + audioButton.addTarget(self, action: #selector(handleAudioTapped), for: .touchUpInside) + + let voiceLongPress = UILongPressGestureRecognizer(target: self, action: #selector(handleAudioLongPress(_:))) + voiceLongPress.minimumPressDuration = Self.voiceLongPressMinimumDuration + voiceLongPress.isEnabled = config.enableLongPressToTalk + voiceLabel.addGestureRecognizer(voiceLongPress) + voiceLabel.isUserInteractionEnabled = true + let voiceTap = UITapGestureRecognizer(target: self, action: #selector(handleAudioTapped)) + voiceLabel.addGestureRecognizer(voiceTap) + + textInputLongPress.isEnabled = config.enableLongPressToTalk + textInputLongPress.delegate = self + messageTextView.addGestureRecognizer(textInputLongPress) + textInputTap.require(toFail: textInputLongPress) + textInputTap.delegate = self + messageTextView.addGestureRecognizer(textInputTap) + messageTextView.shouldBlockFirstResponder = { [weak self] in + return self?.isTextInputLongPressArmed == true + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTextViewDidChange), + name: UITextView.textDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMentionUserNotification(_:)), + name: NSNotification.Name("mentionUserNotification"), + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleQuoteMessageNotification(_:)), + name: NSNotification.Name("quoteMessageNotification"), + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMessageListBlankAreaClick), + name: MessageListView.blankAreaClickNotification, + object: nil + ) + + quotePreviewBar.onClose = { [weak self] in + self?.clearQuote() + } + } + + private func setupViewStyle() { + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .vertical) + + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + topDivider.backgroundColor = colors.strokeColorPrimary + inputRow.backgroundColor = colors.bgColorOperate + + inputContainer.backgroundColor = colors.bgColorInput + inputContainer.layer.cornerRadius = Self.inputContainerCornerRadius + inputContainer.layer.masksToBounds = true + + bottomContainer.backgroundColor = colors.bgColorOperate + bottomContainer.clipsToBounds = true + emojiPanelContainer.backgroundColor = colors.bgColorOperate + emojiPanelContainer.clipsToBounds = true + emojiPanelContainer.isHidden = true + morePanelContainer.backgroundColor = colors.bgColorOperate + morePanelContainer.clipsToBounds = true + morePanelContainer.isHidden = true + + configureIconButton(audioButton, imageName: "input_audio") + configureIconButton(emojiButton, imageName: "input_emoji") + configureIconButton(moreButton, imageName: "input_more") + configureSendButton() + + configureVoiceLabel() + sendButton.isHidden = true + } + + private func configureIconButton(_ button: UIButton, imageName: String) { + let image = AtomicXChatResources.image(named: imageName)?.withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) + button.tintColor = ChatUIKitTheme.colors.textColorSecondary + button.imageView?.contentMode = .scaleAspectFit + } + + private func configureSendButton() { + let colors = ChatUIKitTheme.colors + sendButton.backgroundColor = colors.buttonColorPrimaryDefault + sendButton.layer.cornerRadius = Self.sendButtonCornerRadius + sendButton.layer.masksToBounds = true + let arrowImage = UIImage(systemName: "arrow.up")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.sendButtonIconPointSize, weight: .bold)) + .withRenderingMode(.alwaysTemplate) + sendButton.setImage(arrowImage, for: .normal) + sendButton.tintColor = colors.textColorButton + } + + private func configureVoiceLabel() { + let colors = ChatUIKitTheme.colors + voiceLabel.text = LocalizedChatString("InputHoldToTalk") + voiceLabel.font = FontScheme.caption1Regular + voiceLabel.textAlignment = .center + voiceLabel.textColor = colors.textColorPrimary + voiceLabel.backgroundColor = .clear + voiceLabel.isHidden = true + } + + private func setEmojiPanelContent(_ contentView: UIView?) { + emojiPanelContainer.subviews.forEach { $0.removeFromSuperview() } + guard let contentView = contentView else { return } + emojiPanelContainer.addSubview(contentView) + contentView.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + private func showKeyboard() { + messageTextView.becomeFirstResponder() + } + + private func insertEmoji(_ emojiString: NSAttributedString, emojiName: String) { + messageTextView.insertEmoji(emojiString, emojiName: emojiName) + } + + private func deleteLastCharacter() { + messageTextView.deleteLastCharacter() + } + + private func requestSendText() { + sendText() + } + + private func appendMention(userID: String, displayName: String) { + let displayTextLength = messageTextView.attributedContent?.length ?? 0 + let mentionText = "@\(displayName) " + let mention = MentionInfo( + userID: userID, + displayName: displayName, + startIndex: displayTextLength, + length: mentionText.count + ) + let attributed = EmojiManager.shared.createAttributedStringWithTextAndStyle( + text: mentionText, + withFont: FontScheme.caption1Regular, + textColor: ChatUIKitTheme.colors.textColorPrimary + ) + mentionList.append(mention) + messageTextView.appendText(attributed) + messageTextView.becomeFirstResponder() + } + + private func insertMentions(_ mentions: [MentionInfo], atPosition: Int) { + guard !mentions.isEmpty else { return } + let adjustedPosition = messageTextView.convertDisplayPositionToTextPosition(atPosition) + var currentText = messageTextView.extractSendText() + if currentText.isEmpty { currentText = "@" } + + let atIndex = currentText.index(currentText.startIndex, offsetBy: min(adjustedPosition, currentText.count)) + let afterAtIndex = currentText.index(after: atIndex) + + var combinedMentionText = "" + var newMentions: [MentionInfo] = [] + var currentOffset = adjustedPosition + for mention in mentions { + var newMention = mention + newMention.startIndex = currentOffset + combinedMentionText += mention.mentionText + newMentions.append(newMention) + currentOffset += mention.mentionText.count + } + + currentText.replaceSubrange(atIndex.. adjustedPosition { + mentionList[i].startIndex += insertedLength + } + mentionList.append(contentsOf: newMentions) + + let newAttributedString = EmojiManager.shared.createAttributedStringWithTextAndStyle( + text: currentText, + withFont: FontScheme.caption1Regular, + textColor: ChatUIKitTheme.colors.textColorPrimary + ) + messageTextView.setContent(newAttributedString) + } + + private func sendText() { + let text = messageTextView.extractSendText() + guard !text.isEmpty else { return } + + var convertedMentions: [MentionInfo] = [] + for mention in mentionList { + let convertedStartIndex = messageTextView.convertDisplayPositionToTextPosition(mention.startIndex) + convertedMentions.append(MentionInfo( + userID: mention.userID, + displayName: mention.displayName, + startIndex: convertedStartIndex, + length: mention.length + )) + } + + viewModel.sendTextMessage(text, mentionList: convertedMentions, quotedMessage: currentQuotedMessage) + viewModel.clearDraft() + mentionList.removeAll() + clearQuote() + messageTextView.clear() + updateSendButtonVisibility() + } + + private func findMentionToDelete(at position: Int) -> (startIndex: Int, length: Int)? { + for mention in mentionList where position > mention.startIndex && position < mention.endIndex { + return (mention.startIndex, mention.length) + } + return nil + } + + private func presentMentionPicker(atPosition: Int) { + let groupID = viewModel.groupID + guard viewModel.isGroupChat, !groupID.isEmpty else { return } + let picker = MentionMemberPickerViewController( + groupID: groupID, + atPosition: atPosition + ) { [weak self] mentions, position in + self?.insertMentions(mentions, atPosition: position) + self?.messageTextView.becomeFirstResponder() + } + findViewController()?.present(picker, animated: true) + } + + @objc private func handleMentionUserNotification(_ notification: Notification) { + guard viewModel.isGroupChat, config.enableMention else { return } + guard let userInfo = notification.userInfo, + let userID = userInfo["userID"] as? String, + let nickname = userInfo["nickname"] as? String else { return } + appendMention(userID: userID, displayName: nickname) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } + + private func handleContentChanged() { + guard !isLoadingDraft else { return } + saveDraftWithDebounce() + } + + private func saveDraftWithDebounce() { + draftSaveWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in self?.saveDraftImmediately() } + draftSaveWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.draftSaveDebounceInterval, execute: workItem) + } + + private func saveDraftImmediately() { + viewModel.saveDraft(messageTextView.extractSendText()) + } + + private func loadDraft() { + viewModel.loadDraft { [weak self] draft in + guard let self = self, let draft = draft, !draft.isEmpty else { return } + self.isLoadingDraft = true + let attributed = EmojiManager.shared.createAttributedStringFromEmojiCodes(from: draft) + self.messageTextView.setContent(attributed) + self.updateSendButtonVisibility() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.draftKeyboardFocusDelay) { + self.messageTextView.becomeFirstResponder() + } + DispatchQueue.main.asyncAfter(deadline: .now() + Self.draftLoadingResetDelay) { + self.isLoadingDraft = false + } + } + } + + private func showEmojiPanel() { + + if isVoiceInputMode { + switchToTextMode() + } + attachBuiltInEmojiPickerIfNeeded() + if didAttachEmojiPicker { + emojiPickerView.reloadRecentEmojis() + } + + hideMorePanel(silent: true) + + currentKeyboardHeight = 0 + isEmojiPanelShown = true + currentEmojiPanelHeight = Self.emojiPanelHeight + emojiPanelContainer.isHidden = false + syncBottomAreaHeight() + endEditing(true) + emojiButton.setImage( + AtomicXChatResources.image(named: "input_keyboard")?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + syncPlaceholderSuppression() + postInputInteract() + animateLayout() + } + + private func hideEmojiPanel(silent: Bool = false) { + guard isEmojiPanelShown else { return } + isEmojiPanelShown = false + currentEmojiPanelHeight = 0 + emojiPanelContainer.isHidden = true + emojiButton.setImage( + AtomicXChatResources.image(named: "input_emoji")?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + syncPlaceholderSuppression() + if !silent { + syncBottomAreaHeight() + animateLayout() + } + } + + private func attachBuiltInEmojiPickerIfNeeded() { + guard !didAttachEmojiPicker, emojiPanelContainer.subviews.isEmpty else { return } + didAttachEmojiPicker = true + + emojiPickerView.bottomSafeAreaInset = bottomSafeAreaInset + setEmojiPanelContent(emojiPickerView) + } + + private func showMorePanel() { + + if isVoiceInputMode { + switchToTextMode() + } + attachMorePanelIfNeeded() + + hideEmojiPanel(silent: true) + + currentKeyboardHeight = 0 + isMorePanelShown = true + currentMorePanelHeight = Self.morePanelHeight + morePanelContainer.isHidden = false + syncBottomAreaHeight() + endEditing(true) + syncPlaceholderSuppression() + postInputInteract() + animateLayout() + } + + private func hideMorePanel(silent: Bool = false) { + guard isMorePanelShown else { return } + isMorePanelShown = false + currentMorePanelHeight = 0 + morePanelContainer.isHidden = true + syncPlaceholderSuppression() + if !silent { + syncBottomAreaHeight() + animateLayout() + } + } + + private func syncPlaceholderSuppression() { + messageTextView.suppressPlaceholderForPanel = isEmojiPanelShown || isMorePanelShown + } + + private func syncBottomAreaHeight() { + bottomAreaHeightConstraint?.update(offset: currentBottomAreaHeight) + invalidateIntrinsicContentSize() + } + + private func attachMorePanelIfNeeded() { + guard !didAttachMorePanel else { return } + didAttachMorePanel = true + + morePanel.bottomSafeAreaInset = bottomSafeAreaInset + morePanelContainer.addSubview(morePanel) + morePanel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func handleEmojiSelected(_ emoji: EmojiData) { + let attributed = EmojiManager.shared.createAttributedStringFromEmojiData(emoji) + messageTextView.insertEmoji(attributed, emojiName: emoji.name ?? "") + } + + private func handleCustomEmojiSelected(group: EmojiGroup, emoji: EmojiData) { + guard let groupIndex = EmojiConfig.shared.emojiGroups.firstIndex(where: { $0.id == group.id }), + let key = emoji.name, !key.isEmpty else { return } + viewModel.sendFaceMessage(groupIndex: groupIndex, faceData: key, quotedMessage: currentQuotedMessage) + if currentQuotedMessage != nil { + clearQuote() + } + } + + private func animateLayout() { + UIView.animate(withDuration: Self.layoutAnimationDuration) { + self.onAnimateSuperviewLayout?() + self.layoutIfNeeded() + } + } + + private func updateSendButtonVisibility() { + let hasText = !(messageTextView.extractSendText().isEmpty) + let showSend = hasText && !isVoiceInputMode + if showSend { + moreButton.isHidden = true + sendButton.isHidden = false + } else { + sendButton.isHidden = true + moreButton.isHidden = !config.isShowMore + } + } + + private func postInputInteract() { + NotificationCenter.default.post(name: MessageInputView.inputInteractNotification, object: nil) + } + + @objc private func handleMessageListBlankAreaClick() { + hideEmojiPanel() + hideMorePanel() + } + + @objc private func handleKeyboardWillShow(_ notification: Notification) { + + hideEmojiPanel(silent: true) + hideMorePanel(silent: true) + if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + let screenHeight = UIScreen.main.bounds.height + + currentKeyboardHeight = max(screenHeight - frame.origin.y, 0) + } + syncBottomAreaHeight() + animateLayout() + postInputInteract() + } + + @objc private func handleKeyboardWillHide(_ notification: Notification) { + guard !isEmojiPanelShown, !isMorePanelShown else { return } + currentKeyboardHeight = 0 + syncBottomAreaHeight() + } + + @objc private func handleTextViewDidChange() { + guard !isLoadingDraft else { return } + postInputInteract() + } + + @objc private func handleMoreTapped() { + if isMorePanelShown { + hideMorePanel() + } else { + showMorePanel() + } + } + + @objc private func handleEmojiTapped() { + if isEmojiPanelShown { + showKeyboard() + } else { + showEmojiPanel() + } + } + + @objc private func handleSendTapped() { + sendText() + } + + @objc private func handleAudioTapped() { + if isVoiceInputMode { + switchToTextMode() + } else { + switchToVoiceMode() + } + } + + private func switchToVoiceMode() { + guard config.isShowAudioRecorder else { return } + isVoiceInputMode = true + messageTextView.isHidden = true + voiceLabel.isHidden = false + audioButton.setImage( + AtomicXChatResources.image(named: "input_keyboard")?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + endEditing(true) + hideEmojiPanel() + hideMorePanel() + updateSendButtonVisibility() + postInputInteract() + } + + private func switchToTextMode() { + isVoiceInputMode = false + messageTextView.isHidden = false + voiceLabel.isHidden = true + audioButton.setImage( + AtomicXChatResources.image(named: "input_audio")?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + updateSendButtonVisibility() + postInputInteract() + } + + @objc private func handleAudioLongPress(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + hideEmojiPanel() + hideMorePanel() + messageTextView.resignFirstResponder() + isLongPressingAudio = true + postInputInteract() + mediaCoordinator.beginVoiceRecording() + case .changed: + guard isLongPressingAudio else { return } + mediaCoordinator.updateVoiceRecording(finger: gesture.location(in: gesture.view?.window)) + case .ended, .cancelled, .failed: + guard isLongPressingAudio else { return } + isLongPressingAudio = false + mediaCoordinator.endVoiceRecording(finger: gesture.location(in: gesture.view?.window)) + default: + break + } + } + + @objc private func handleTextInputLongPress(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + guard canArmVoiceRecordingFromTextInput() else { return } + hideEmojiPanel() + hideMorePanel() + messageTextView.resignFirstResponder() + isLongPressingAudio = true + postInputInteract() + mediaCoordinator.beginVoiceRecording() + case .changed: + guard isLongPressingAudio else { return } + mediaCoordinator.updateVoiceRecording(finger: gesture.location(in: gesture.view?.window)) + case .ended, .cancelled: + isTextInputLongPressArmed = false + guard isLongPressingAudio else { return } + isLongPressingAudio = false + mediaCoordinator.endVoiceRecording(finger: gesture.location(in: gesture.view?.window)) + default: + break + } + } + + @objc private func handleTextInputTap(_ gesture: UITapGestureRecognizer) { + guard gesture.state == .ended else { return } + isTextInputLongPressArmed = false + guard !isLongPressingAudio, !isVoiceInputMode else { return } + messageTextView.becomeFirstResponder() + } + + private func canArmVoiceRecordingFromTextInput() -> Bool { + return config.isShowAudioRecorder + && !isVoiceInputMode + && !isEmojiPanelShown + && !isMorePanelShown + && messageTextView.attributedContent == nil + } + + @objc private func handleQuoteMessageNotification(_ notification: Notification) { + guard let message = notification.userInfo?["message"] as? MessageInfo else { return } + setQuoteMessage(message) + } + + private func setQuoteMessage(_ message: MessageInfo) { + currentQuotedMessage = message + let senderName = message.from.nickname ?? message.from.userID + let summary = MessageInputQuoteSummaryFormatter.format(message: message) + quotePreviewBar.configure(senderName: senderName, summary: summary) + showQuoteBar() + messageTextView.becomeFirstResponder() + postInputInteract() + } + + private func clearQuote() { + currentQuotedMessage = nil + hideQuoteBar() + } + + private func showQuoteBar() { + guard currentQuoteBarHeight == 0 else { return } + quotePreviewBarHeightConstraint?.update(offset: Self.quotePreviewBarHeight) + currentQuoteBarHeight = Self.quotePreviewBarHeight + quotePreviewBar.isHidden = false + invalidateIntrinsicContentSize() + animateLayout() + } + + private func hideQuoteBar() { + guard currentQuoteBarHeight != 0 else { return } + quotePreviewBarHeightConstraint?.update(offset: 0) + currentQuoteBarHeight = 0 + quotePreviewBar.isHidden = true + invalidateIntrinsicContentSize() + animateLayout() + } +} + +// MARK: - MessageInputMorePanelDelegate + +extension MessageInputViewImpl: MessageInputMorePanelDelegate { + private static let c2cConversationPrefix = "c2c_" + + func morePanelDidSelectAlbum() { + hideMorePanel() + AlbumPickerMediaSendCoordinator.shared.showAlbumPicker(conversationID: viewModel.conversationID) + } + + func morePanelDidSelectCamera() { + hideMorePanel() + mediaCoordinator.showCamera(recordMode: .photoOnly) + } + + func morePanelDidSelectVideo() { + hideMorePanel() + mediaCoordinator.showCamera(recordMode: .videoPhotoMix) + } + + func morePanelDidSelectFile() { + hideMorePanel() + mediaCoordinator.showFilePicker() + } + + func morePanelDidSelectVideoCall() { + hideMorePanel() + startCall(mediaType: .video) + } + + func morePanelDidSelectAudioCall() { + hideMorePanel() + startCall(mediaType: .audio) + } + + private func startCall(mediaType: CallMediaType) { + if !viewModel.isGroupChat, let targetUserID = c2cTargetUserID() { + DataReport.reportInteractionMetrics(.chatInvokeCall) + TUICallKit.createInstance().calls( + userIdList: [targetUserID], + mediaType: mediaType, + params: nil, + completion: nil + ) + return + } + presentGroupCallMemberPicker(mediaType: mediaType) + } + + private func c2cTargetUserID() -> String? { + let conversationID = viewModel.conversationID + guard conversationID.hasPrefix(Self.c2cConversationPrefix) else { return nil } + let target = String(conversationID.dropFirst(Self.c2cConversationPrefix.count)) + return target.isEmpty ? nil : target + } + + private func presentGroupCallMemberPicker(mediaType: CallMediaType) { + let groupID = viewModel.groupID + guard !groupID.isEmpty, let presenter = findViewController() else { return } + let picker = GroupCallMemberPickerViewController(groupID: groupID) { userIDs in + DataReport.reportInteractionMetrics(.chatInvokeCall) + TUICallKit.createInstance().calls( + userIdList: userIDs, + mediaType: mediaType, + params: nil, + completion: nil + ) + } + presenter.present(UINavigationController(rootViewController: picker), animated: true) + } +} + +extension MessageInputViewImpl: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer == textInputLongPress || gestureRecognizer == textInputTap + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if gestureRecognizer == textInputLongPress, canArmVoiceRecordingFromTextInput() { + isTextInputLongPressArmed = true + } + return true + } +} diff --git a/chat/uikit/components/messageinput/view/VoiceTranscriptionOverlayView.swift b/chat/uikit/components/messageinput/view/VoiceTranscriptionOverlayView.swift new file mode 100644 index 000000000..0a3c06bba --- /dev/null +++ b/chat/uikit/components/messageinput/view/VoiceTranscriptionOverlayView.swift @@ -0,0 +1,1125 @@ +import UIKit +import AVFoundation + +struct VoiceTranscriptionCallbacks { + let onCancel: () -> Void + let onSendAudio: (String, Int) -> Void + let onSendText: (String) -> Void + let onTranslate: (String, String, @escaping (String) -> Void, @escaping () -> Void) -> Void + let onStartSpeak: (String, @escaping (TimeInterval) -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + let onStopSpeak: () -> Void +} + +final class VoiceTranscriptionOverlayView: UIView { + private enum TranscriptionState { + case loading + case empty + case text + } + + private struct OverlayLayout { + let bubbleTop: CGFloat + let chipRowTop: CGFloat + let cancelButtonTop: CGFloat + let sendAudioButtonTop: CGFloat + let sendTextButtonTop: CGFloat + let labelTop: CGFloat + let waveformTop: CGFloat + } + + private static let showAnimationDuration: TimeInterval = 0.12 + + private static let dismissAnimationDuration: TimeInterval = 0.1 + + private static let gradientLocations: [CGFloat] = [0, 0.442, 0.632, 1.0] + + private static let editTextContentInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + + private static let waveformPreviewPowerLevel: Int = 60 + + private static let smallBubbleHeight: CGFloat = 68 + + private static let largeBubbleHeight: CGFloat = 104 + + private static let bubbleHeightLineTolerance: CGFloat = 1 + + private static let singleLineTextHeight: CGFloat = FontScheme.caption1Regular.lineHeight + + private static let chipContentInsets = UIEdgeInsets(top: 3, left: 12, bottom: 3, right: 12) + + private static let loadingDotsLeadingInset: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let loadingDotsWidth: CGFloat = 48 + + private static let loadingDotsHeight: CGFloat = 20 + + private static let actionLabelWidth: CGFloat = 96 + + private static let vectorIconSize: CGFloat = 20 + + private let designWidthDP: CGFloat = 375 + + private let bubbleTriangleHeight: CGFloat = 14 + + private let chipRowHeight: CGFloat = 26 + + private let bubbleWidth: CGFloat = 330 + + private let bubbleLeftMargin: CGFloat = 22 + + private let waveformHorizontalMargin: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private let iconButtonSize: CGFloat = 48 + + private let sendTextButtonSize: CGFloat = 80 + + private let labelHeight: CGFloat = 20 + + private let waveformHeight: CGFloat = 40 + + private let waveformBottom: CGFloat = 43 + + private let labelToWaveformGap: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private let iconToLabelGap: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private let sendTextToWaveformGap: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private let chipRowToActionGap: CGFloat = 30 + + private let bubbleToKeyboardGap: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private let bubbleToChipGap: CGFloat = CGFloat(SpacingScheme.iconTextSpacing) + + private let chipCornerRadius: CGFloat = CGFloat(RadiusScheme.roundRadius) + + private let cancelButtonLeftDP: CGFloat = 48 + + private let sendAudioButtonLeftDP: CGFloat = 142 + + private let sendTextButtonLeftDP: CGFloat = 247 + + private let backgroundView = RecorderGradientBackgroundView() + + private let bubbleView = TranscriptionBubbleView() + + private let editTextView = VerticalCenterTextView() + + private let loadingDotsView = LoadingDotsView() + + private let waveformView = RecorderBubbleView() + + private let cancelButton = UIButton(type: .system) + + private let sendAudioButton = UIButton(type: .system) + + private let sendTextButton = UIButton(type: .system) + + private let cancelLabel = UILabel() + + private let sendAudioLabel = UILabel() + + private let chipRow = UIStackView() + + private let audioPath: String + + private let audioDurationSecond: Int + + private let originalAudioDurationMs: Int + + private var callbacks: VoiceTranscriptionCallbacks + + private var state: TranscriptionState + + private var originalText: String = "" + + private var translatedText: String? + + private var isTranslating = false + + private var isSpeaking = false + + private var isDismissed = false + + private var keyboardHeight: CGFloat = 0 + + private var bubbleHeight: CGFloat = VoiceTranscriptionOverlayView.smallBubbleHeight + + private var audioPlayer: AVAudioPlayer? + + // MARK: - Init + + init(frame: CGRect, + audioPath: String, + audioDurationSecond: Int, + text: String?, + callbacks: VoiceTranscriptionCallbacks) { + self.audioPath = audioPath + self.audioDurationSecond = audioDurationSecond + self.originalAudioDurationMs = audioDurationSecond * 1000 + self.callbacks = callbacks + if let text = text, !text.isEmpty { + self.state = .text + self.originalText = text + } else if text != nil { + self.state = .empty + } else { + self.state = .loading + } + super.init(frame: frame) + constructHierarchy() + setupStyle() + setupActions() + bindInitialText() + renderChipRow() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopSpeaking() + } + + // MARK: - Public + + func show(in window: UIWindow) { + alpha = 0 + window.addSubview(self) + self.frame = window.bounds + self.autoresizingMask = [.flexibleWidth, .flexibleHeight] + installKeyboardTracking() + UIView.animate(withDuration: Self.showAnimationDuration) { + self.alpha = 1 + } + } + + func dismiss() { + guard !isDismissed else { return } + isDismissed = true + stopSpeaking() + removeKeyboardTracking() + UIView.animate(withDuration: Self.dismissAnimationDuration, animations: { + self.alpha = 0 + }, completion: { _ in + self.removeFromSuperview() + }) + } + + func updateResult(_ resultText: String?) { + if let resultText = resultText, !resultText.isEmpty { + state = .text + if originalText.isEmpty { + originalText = resultText + } + translatedText = nil + isTranslating = false + if isSpeaking { + isSpeaking = false + callbacks.onStopSpeak() + } + } else { + state = .empty + } + updateBubbleHeightForState(resultText) + bindBubbleText(resultText) + renderChipRow() + applyTheme() + setNeedsLayout() + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + backgroundView.frame = bounds + let layout = calculateOverlayLayout( + rootHeight: bounds.height, + bubbleHeight: bubbleHeight, + keyboardHeight: keyboardHeight, + showChipRow: state == .text + ) + bubbleView.frame = CGRect(x: scaleX(bubbleLeftMargin), y: layout.bubbleTop, width: scaleX(bubbleWidth), height: bubbleHeight) + positionInBubble() + waveformView.frame = CGRect( + x: scaleX(waveformHorizontalMargin), + y: layout.waveformTop, + width: bounds.width - scaleX(waveformHorizontalMargin) * 2, + height: waveformHeight + ) + chipRow.frame = CGRect(x: scaleX(bubbleLeftMargin), y: layout.chipRowTop, width: bounds.width - scaleX(bubbleLeftMargin) * 2, height: chipRowHeight) + cancelButton.frame = circleFrame(leftDP: cancelButtonLeftDP, top: layout.cancelButtonTop, size: iconButtonSize) + sendAudioButton.frame = circleFrame(leftDP: sendAudioButtonLeftDP, top: layout.sendAudioButtonTop, size: iconButtonSize) + sendTextButton.frame = circleFrame(leftDP: sendTextButtonLeftDP, top: layout.sendTextButtonTop, size: sendTextButtonSize) + cancelLabel.frame = labelFrame(centerX: cancelButton.center.x, top: layout.labelTop) + sendAudioLabel.frame = labelFrame(centerX: sendAudioButton.center.x, top: layout.labelTop) + makeCircular(cancelButton) + makeCircular(sendAudioButton) + makeCircular(sendTextButton) + applyTheme() + } + + private func constructHierarchy() { + addSubview(backgroundView) + addSubview(bubbleView) + addSubview(waveformView) + addSubview(chipRow) + addSubview(cancelButton) + addSubview(sendAudioButton) + addSubview(sendTextButton) + addSubview(cancelLabel) + addSubview(sendAudioLabel) + + bubbleView.addSubview(editTextView) + bubbleView.addSubview(loadingDotsView) + + chipRow.axis = .horizontal + chipRow.spacing = CGFloat(SpacingScheme.iconIconSpacing) + chipRow.alignment = .center + } + + private func setupStyle() { + backgroundColor = .clear + let colors = ChatUIKitTheme.colors + backgroundView.setGradientSpec( + Self.overlayGradientColors(base: colors.bgColorOperate), + Self.gradientLocations + ) + + editTextView.backgroundColor = .clear + editTextView.font = FontScheme.caption1Regular + editTextView.textColor = colors.textColorButton + editTextView.textAlignment = .left + editTextView.semanticContentAttribute = .forceLeftToRight + editTextView.textContainerInset = Self.editTextContentInsets + editTextView.textContainer.lineFragmentPadding = 0 + editTextView.isScrollEnabled = true + editTextView.returnKeyType = .done + + let closeIcon = Self.vectorIcon(.close) + let transcribeIcon = Self.vectorIcon(.transcribe) + cancelButton.setImage(closeIcon, for: .normal) + sendAudioButton.setImage(transcribeIcon, for: .normal) + cancelButton.setTitle(nil, for: .normal) + sendAudioButton.setTitle(nil, for: .normal) + cancelButton.tintColor = colors.textColorPrimary + sendAudioButton.tintColor = colors.textColorPrimary + sendTextButton.titleLabel?.font = FontScheme.caption1Medium + sendTextButton.setTitleColor(colors.textColorButton, for: .normal) + sendTextButton.setTitle(LocalizedChatString("message_input_send"), for: .normal) + + cancelLabel.font = FontScheme.caption3Regular + sendAudioLabel.font = FontScheme.caption3Regular + cancelLabel.textAlignment = .center + sendAudioLabel.textAlignment = .center + cancelLabel.text = LocalizedChatString("message_input_cancel") + sendAudioLabel.text = LocalizedChatString("message_input_send_original_voice") + + waveformView.setBubbleColor(ChatUIKitTheme.colors.bgColorInput) + waveformView.setContentColor(ChatUIKitTheme.colors.textColorPrimary) + waveformView.setDuration(originalAudioDurationMs) + waveformView.setPowerLevel(Self.waveformPreviewPowerLevel) + waveformView.setFlat(true) + waveformView.stopAnimating() + waveformView.isUserInteractionEnabled = true + waveformView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(toggleWaveformPlayback))) + } + + private func setupActions() { + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + sendAudioButton.addTarget(self, action: #selector(handleSendAudio), for: .touchUpInside) + sendTextButton.addTarget(self, action: #selector(handleSendText), for: .touchUpInside) + let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap(_:))) + backgroundTap.cancelsTouchesInView = false + addGestureRecognizer(backgroundTap) + } + + @objc private func handleBackgroundTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: self) + guard !bubbleView.frame.contains(location) else { return } + editTextView.resignFirstResponder() + } + + private func bindInitialText() { + bindBubbleText(originalText.isEmpty ? nil : originalText) + updateBubbleHeightForState(originalText.isEmpty ? nil : originalText) + applyTheme() + } + + @objc private func handleCancel() { + dismiss() + callbacks.onCancel() + } + + @objc private func handleSendAudio() { + dismiss() + callbacks.onSendAudio(audioPath, max(audioDurationSecond, 1)) + } + + @objc private func handleSendText() { + let currentText = editTextView.text ?? "" + guard !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + dismiss() + callbacks.onSendText(currentText) + } + + private func applyTheme() { + let colors = ChatUIKitTheme.colors + backgroundView.setGradientSpec( + Self.overlayGradientColors(base: colors.bgColorOperate), + Self.gradientLocations + ) + bubbleView.bubbleColor = state == .empty ? colors.buttonColorHangupDefault : colors.buttonColorPrimaryDefault + let arrowCenterX = sendTextButton.frame.midX - bubbleView.frame.minX + bubbleView.arrowCenterX = arrowCenterX + loadingDotsView.dotColor = colors.textColorButton + editTextView.textColor = colors.textColorButton + cancelButton.backgroundColor = colors.buttonColorSecondaryDefault + sendAudioButton.backgroundColor = colors.buttonColorSecondaryDefault + sendTextButton.backgroundColor = state == .text ? colors.buttonColorPrimaryDefault : colors.buttonColorPrimaryDisabled + cancelButton.setTitleColor(colors.textColorPrimary, for: .normal) + sendAudioButton.setTitleColor(colors.textColorPrimary, for: .normal) + sendTextButton.setTitleColor(colors.textColorButton, for: .normal) + cancelLabel.textColor = colors.textColorTertiary + sendAudioLabel.textColor = colors.textColorTertiary + waveformView.setBubbleColor(colors.bgColorInput) + waveformView.setContentColor(colors.textColorPrimary) + sendTextButton.isEnabled = state == .text + } + + private func bindBubbleText(_ resultText: String?) { + if state == .loading { + editTextView.isHidden = true + loadingDotsView.isHidden = false + editTextView.text = "" + } else { + let display: String + switch state { + case .empty: + display = LocalizedChatString("message_input_voice_transcription_empty") + case .text: + display = resultText ?? originalText + default: + display = "" + } + loadingDotsView.isHidden = true + editTextView.isHidden = false + editTextView.text = display + editTextView.isEditable = state == .text + } + sendTextButton.isEnabled = state == .text + } + + private func updateBubbleHeightForState(_ resultText: String?) { + let exceedsSingleLine = state == .text + && renderedTextHeight(resultText ?? "") > Self.singleLineTextHeight + Self.bubbleHeightLineTolerance + let next: CGFloat = exceedsSingleLine ? Self.largeBubbleHeight : Self.smallBubbleHeight + guard next != bubbleHeight else { return } + bubbleHeight = next + setNeedsLayout() + } + + private func renderedTextHeight(_ text: String) -> CGFloat { + let width = scaleX(bubbleWidth) - Self.editTextContentInsets.left - Self.editTextContentInsets.right + guard width > 0, !text.isEmpty else { return 0 } + return (text as NSString).boundingRect( + with: CGSize(width: width, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: FontScheme.caption1Regular], + context: nil + ).height + } + + private func renderChipRow() { + chipRow.arrangedSubviews.forEach { $0.removeFromSuperview() } + guard state == .text else { + chipRow.isHidden = true + return + } + chipRow.isHidden = false + if translatedText == nil { + chipRow.addArrangedSubview(makeChip( + title: LocalizedChatString("voice_message_translate"), + enabled: !isTranslating, + action: { [weak self] in self?.handleTranslateChip() } + )) + } else { + chipRow.addArrangedSubview(makeChip( + title: LocalizedChatString("voice_message_undo_translate"), + enabled: true, + action: { [weak self] in self?.handleUndoTranslate() } + )) + let speakTitle = isSpeaking + ? LocalizedChatString("voice_message_stop") + : LocalizedChatString("voice_message_read_aloud") + let dualPill = TranscriptionDualPillView() + dualPill.configure( + leftTitle: LocalizedChatString("voice_message_switch_language"), + leftEnabled: !isTranslating, + rightTitle: speakTitle, + onLeft: { [weak self] in self?.handleSwitchLanguage() }, + onRight: { [weak self] in self?.handleReadAloud() } + ) + chipRow.addArrangedSubview(dualPill) + } + chipRow.addArrangedSubview(makeFlexibleSpacer()) + } + + private func makeFlexibleSpacer() -> UIView { + let spacer = UIView() + spacer.backgroundColor = .clear + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return spacer + } + + private static func overlayGradientColors(base: UIColor) -> [UIColor] { + return [ + base.withAlphaComponent(0), + base.withAlphaComponent(0), + base, + base + ] + } + + private func makeChip(title: String, enabled: Bool, action: @escaping () -> Void) -> UIButton { + let chip = UIButton(type: .system) + chip.setTitle(title, for: .normal) + chip.titleLabel?.font = FontScheme.caption3Regular + chip.setTitleColor(enabled ? ChatUIKitTheme.colors.textColorSecondary : ChatUIKitTheme.colors.textColorDisable, for: .normal) + chip.contentEdgeInsets = Self.chipContentInsets + chip.backgroundColor = ChatUIKitTheme.colors.buttonColorSecondaryDefault + chip.layer.cornerRadius = chipCornerRadius + chip.contentHorizontalAlignment = .center + chip.setContentHuggingPriority(.required, for: .horizontal) + chip.heightAnchor.constraint(equalToConstant: chipRowHeight).isActive = true + chip.isEnabled = enabled + if enabled { + chip.addAction(UIAction(handler: { _ in action() }), for: .touchUpInside) + } + return chip + } + + private func handleTranslateChip() { + if isTranslating { return } + let lang = VoiceTranscriptionLanguageStore.selected + if lang.isEmpty { + presentLanguageSelector { [weak self] picked in + VoiceTranscriptionLanguageStore.selected = picked + self?.translateWith(picked) + } + } else { + translateWith(lang) + } + } + + private func handleSwitchLanguage() { + if isTranslating { return } + presentLanguageSelector { [weak self] picked in + VoiceTranscriptionLanguageStore.selected = picked + self?.translateWith(picked) + } + } + + private func handleUndoTranslate() { + if isSpeaking { + stopReadAloud() + } + translatedText = nil + setBubbleText(originalText) + renderChipRow() + } + + private func handleReadAloud() { + if isSpeaking { + stopReadAloud() + return + } + let current = editTextView.text ?? "" + guard !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + isSpeaking = true + waveformView.setPseudoSpeaking(true) + renderChipRow() + callbacks.onStartSpeak(current, { [weak self] ttsDuration in + guard let self = self, !self.isDismissed else { return } + guard self.isSpeaking else { return } + let ttsMs = Int(ttsDuration * 1000) + waveformView.setDuration(ttsMs > 0 ? ttsMs : self.originalAudioDurationMs) + }, { [weak self] in + guard let self = self, !self.isDismissed else { return } + if self.isSpeaking { + self.stopReadAloud() + } + }, { [weak self] in + guard let self = self, !self.isDismissed else { return } + self.stopReadAloud() + }) + } + + private func stopReadAloud() { + isSpeaking = false + waveformView.setPseudoSpeaking(false) + waveformView.setDuration(originalAudioDurationMs) + callbacks.onStopSpeak() + renderChipRow() + } + + private func translateWith(_ languageCode: String) { + guard !originalText.isEmpty, !isTranslating else { return } + isTranslating = true + renderChipRow() + callbacks.onTranslate(originalText, languageCode, { [weak self] translated in + guard let self = self, !self.isDismissed else { return } + self.isTranslating = false + if translated.isEmpty { + self.renderChipRow() + return + } + self.translatedText = translated + self.setBubbleText(translated) + self.renderChipRow() + }, { [weak self] in + guard let self = self, !self.isDismissed else { return } + self.isTranslating = false + self.renderChipRow() + }) + } + + private func setBubbleText(_ value: String) { + updateBubbleHeightForState(value) + editTextView.text = value + sendTextButton.isEnabled = state == .text && !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func presentLanguageSelector(onPicked: @escaping (String) -> Void) { + let sheet = UIAlertController( + title: LocalizedChatString("voice_message_select_language"), + message: nil, + preferredStyle: .actionSheet + ) + let current = VoiceTranscriptionLanguageStore.selected + for option in VoiceTranscriptionLanguageStore.options { + let action = UIAlertAction(title: option.nativeName, style: .default) { _ in + onPicked(option.code) + } + if option.code == current { + action.setValue(true, forKey: "checked") + } + sheet.addAction(action) + } + sheet.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + findViewController()?.present(sheet, animated: true) + } + + @objc private func toggleWaveformPlayback() { + if audioPlayer?.isPlaying == true { + audioPlayer?.pause() + waveformView.setFlat(true) + waveformView.stopAnimating() + return + } + if audioPlayer == nil { + let url = URL(fileURLWithPath: audioPath) + audioPlayer = try? AVAudioPlayer(contentsOf: url) + audioPlayer?.delegate = self + } + audioPlayer?.play() + if audioPlayer?.isPlaying == true { + waveformView.setFlat(false) + waveformView.setPowerLevel(Self.waveformPreviewPowerLevel) + waveformView.startAnimating() + } + } + + private func stopSpeaking() { + if audioPlayer?.isPlaying == true { + audioPlayer?.stop() + } + waveformView.setPseudoSpeaking(false) + waveformView.setDuration(originalAudioDurationMs) + } + + private func installKeyboardTracking() { + NotificationCenter.default.addObserver( + self, selector: #selector(keyboardWillChange(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(keyboardWillChange(_:)), + name: UIResponder.keyboardWillHideNotification, object: nil + ) + } + + private func removeKeyboardTracking() { + NotificationCenter.default.removeObserver(self) + } + + @objc private func keyboardWillChange(_ notification: Notification) { + guard let info = notification.userInfo, + let frame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let height = frame.minY < bounds.height ? bounds.height - frame.minY : 0 + let next = max(height, 0) + guard next != keyboardHeight else { return } + keyboardHeight = next + setNeedsLayout() + } + + private func positionInBubble() { + let bodyHeight = bubbleHeight - bubbleTriangleHeight + editTextView.contentInset = .zero + editTextView.scrollIndicatorInsets = .zero + editTextView.frame = CGRect(x: 0, y: 0, width: bubbleView.bounds.width, height: bodyHeight) + editTextView.setNeedsLayout() + editTextView.layoutIfNeeded() + let textViewHeight = min(editTextView.contentSize.height, bodyHeight) + let verticalOffset = max(0, (bodyHeight - textViewHeight) / 2) + editTextView.frame = CGRect( + x: 0, + y: verticalOffset, + width: bubbleView.bounds.width, + height: bodyHeight - verticalOffset + ) + loadingDotsView.frame = CGRect(x: Self.loadingDotsLeadingInset, y: (bodyHeight - Self.loadingDotsHeight) / 2, width: Self.loadingDotsWidth, height: Self.loadingDotsHeight) + } + + private func circleFrame(leftDP: CGFloat, top: CGFloat, size: CGFloat) -> CGRect { + return CGRect(x: scaleX(leftDP), y: top, width: size, height: size) + } + + private func labelFrame(centerX: CGFloat, top: CGFloat) -> CGRect { + return CGRect(x: centerX - Self.actionLabelWidth / 2, y: top, width: Self.actionLabelWidth, height: labelHeight) + } + + private func makeCircular(_ button: UIButton) { + button.clipsToBounds = true + button.layer.cornerRadius = min(button.bounds.width, button.bounds.height) / 2 + } + + private func scaleX(_ dp: CGFloat) -> CGFloat { + return dp * bounds.width / designWidthDP + } + + private func calculateOverlayLayout(rootHeight: CGFloat, + bubbleHeight: CGFloat, + keyboardHeight: CGFloat, + showChipRow: Bool) -> OverlayLayout { + let safeRootHeight = max(rootHeight, 1) + let iconButtonSize = self.iconButtonSize + let sendTextButtonSize = self.sendTextButtonSize + let labelHeight = self.labelHeight + let waveformHeight = self.waveformHeight + let waveformBottom = self.waveformBottom + let labelToWaveformGap = self.labelToWaveformGap + let iconToLabelGap = self.iconToLabelGap + let sendTextToWaveformGap = self.sendTextToWaveformGap + let chipRowToActionGap = self.chipRowToActionGap + let bubbleToKeyboardGap = self.bubbleToKeyboardGap + let bubbleToChipGap = self.bubbleToChipGap + let chipBlock = showChipRow ? (chipRowHeight + bubbleToChipGap) : 0 + + let waveformTop = max(safeRootHeight - waveformBottom - waveformHeight, 0) + let labelTop = max(waveformTop - labelToWaveformGap - labelHeight, 0) + let iconButtonTop = max(labelTop - iconToLabelGap - iconButtonSize, 0) + let sendTextButtonTop = max(waveformTop - sendTextToWaveformGap - sendTextButtonSize, 0) + let firstActionTop = min(iconButtonTop, sendTextButtonTop) + + let bubbleTopAboveActions = max(firstActionTop - chipRowToActionGap - chipBlock - bubbleHeight, 0) + let maxBubbleTopBeforeKeyboard: CGFloat = keyboardHeight > 0 + ? max(safeRootHeight - keyboardHeight - bubbleToKeyboardGap - chipBlock - bubbleHeight, 0) + : .greatestFiniteMagnitude + + let bubbleTop = min(bubbleTopAboveActions, maxBubbleTopBeforeKeyboard) + return OverlayLayout( + bubbleTop: bubbleTop, + chipRowTop: bubbleTop + bubbleHeight + bubbleToChipGap, + cancelButtonTop: iconButtonTop, + sendAudioButtonTop: iconButtonTop, + sendTextButtonTop: sendTextButtonTop, + labelTop: labelTop, + waveformTop: waveformTop + ) + } + + private func findViewController() -> UIViewController? { + if let root = self.window?.rootViewController { + var controller: UIViewController? = root + while let presented = controller?.presentedViewController { + controller = presented + } + return controller + } + return nil + } +} + +// MARK: - AVAudioPlayerDelegate + +extension VoiceTranscriptionOverlayView: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + audioPlayer = nil + waveformView.setPseudoSpeaking(false) + waveformView.setDuration(originalAudioDurationMs) + } +} + +// MARK: - 气泡(圆角 + 底部箭头,对齐 Android `TranscriptionBubbleView`) + +final class TranscriptionBubbleView: UIView { + var bubbleColor: UIColor = .clear { + didSet { setNeedsDisplay() } + } + + var arrowCenterX: CGFloat? { + didSet { setNeedsDisplay() } + } + + private static let defaultArrowCenterTrailingInset: CGFloat = 64 + + private let paintLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + layer.addSublayer(paintLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + redraw() + } + + private func redraw() { + let triangleHeight: CGFloat = 14 + let bodyBottom = bounds.height - triangleHeight + let radius: CGFloat = 8 + let halfArrow: CGFloat = 12 + let path = UIBezierPath() + let bodyRect = CGRect(x: 0, y: 0, width: bounds.width, height: bodyBottom) + path.append(UIBezierPath(roundedRect: bodyRect, cornerRadius: radius)) + let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft + let fallbackArrowCenterX = isRTL ? Self.defaultArrowCenterTrailingInset : bounds.width - Self.defaultArrowCenterTrailingInset + let centerX = (arrowCenterX ?? fallbackArrowCenterX).clamped(to: radius + halfArrow ... bounds.width - radius - halfArrow) + path.move(to: CGPoint(x: centerX - halfArrow, y: bodyBottom)) + path.addLine(to: CGPoint(x: centerX, y: bounds.height)) + path.addLine(to: CGPoint(x: centerX + halfArrow, y: bodyBottom)) + path.close() + paintLayer.path = path.cgPath + paintLayer.fillColor = bubbleColor.cgColor + } +} + +// MARK: - 垂直居中文本视图(对齐 Android `Gravity.CENTER_VERTICAL`) + +final class VerticalCenterTextView: UITextView { + + override func layoutSubviews() { + super.layoutSubviews() + } +} + +// MARK: - Loading 三点(对齐 Android `LoadingDotsView`) + +final class LoadingDotsView: UIView { + var dotColor: UIColor = .white { + didSet { setNeedsDisplay() } + } + + private static let dotPhaseStep: CGFloat = 0.18 + + private static let dotAnimationCycleDuration: CGFloat = 0.9 + + private static let dotMinAlpha: CGFloat = 90 + + private static let dotAlphaAmplitude: CGFloat = 165 + + private var displayLink: CADisplayLink? + + private var progress: CGFloat = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + startAnimating() + } else { + stopAnimating() + } + } + + override func draw(_ rect: CGRect) { + guard let ctx = UIGraphicsGetCurrentContext() else { return } + let radius: CGFloat = 3 + let gap: CGFloat = 10 + let startX = radius + let centerY = bounds.height / 2 + for index in 0 ..< 3 { + let phase = (progress + CGFloat(index) * Self.dotPhaseStep).truncatingRemainder(dividingBy: 1) + let alpha = (Self.dotMinAlpha + Self.dotAlphaAmplitude * abs(sin(phase * .pi))).clamped(to: Self.dotMinAlpha ... 255) + ctx.setFillColor(dotColor.withAlphaComponent(alpha / 255).cgColor) + ctx.addArc(center: CGPoint(x: startX + CGFloat(index) * gap, y: centerY), radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true) + ctx.fillPath() + } + } + + private func startAnimating() { + displayLink?.invalidate() + displayLink = CADisplayLink(target: self, selector: #selector(tick(_:))) + displayLink?.add(to: .main, forMode: .common) + } + + private func stopAnimating() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func tick(_ link: CADisplayLink) { + progress = (progress + CGFloat(link.duration) / Self.dotAnimationCycleDuration).truncatingRemainder(dividingBy: 1) + setNeedsDisplay() + } +} + +final class TranscriptionDualPillView: UIView { + private static let pillHeight: CGFloat = 26 + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let segmentSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let dividerWidth: CGFloat = 0.5 + + private static let dividerHeight: CGFloat = 12 + + private static let dividerColor = UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark ? Colors.GrayDark5 : Colors.GrayLight4 + } + + private let leftButton = UIButton(type: .system) + + private let rightButton = UIButton(type: .system) + + private let dividerView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + leftButton.titleLabel?.font = FontScheme.caption3Regular + rightButton.titleLabel?.font = FontScheme.caption3Regular + dividerView.backgroundColor = Self.dividerColor + addSubview(leftButton) + addSubview(dividerView) + addSubview(rightButton) + setContentHuggingPriority(.required, for: .horizontal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + let leftWidth = leftButton.intrinsicContentSize.width + let rightWidth = rightButton.intrinsicContentSize.width + let width = Self.horizontalPadding * 2 + leftWidth + Self.segmentSpacing + Self.dividerWidth + Self.segmentSpacing + rightWidth + return CGSize(width: width, height: Self.pillHeight) + } + + func configure(leftTitle: String, + leftEnabled: Bool, + rightTitle: String, + onLeft: @escaping () -> Void, + onRight: @escaping () -> Void) { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.buttonColorSecondaryDefault + leftButton.setTitle(leftTitle, for: .normal) + rightButton.setTitle(rightTitle, for: .normal) + leftButton.setTitleColor(leftEnabled ? colors.textColorSecondary : colors.textColorDisable, for: .normal) + rightButton.setTitleColor(colors.textColorSecondary, for: .normal) + leftButton.isEnabled = leftEnabled + if leftEnabled { + leftButton.addAction(UIAction(handler: { _ in onLeft() }), for: .touchUpInside) + } + rightButton.addAction(UIAction(handler: { _ in onRight() }), for: .touchUpInside) + invalidateIntrinsicContentSize() + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + let leftWidth = leftButton.intrinsicContentSize.width + let rightWidth = rightButton.intrinsicContentSize.width + leftButton.frame = CGRect(x: Self.horizontalPadding, y: 0, width: leftWidth, height: bounds.height) + dividerView.frame = CGRect( + x: leftButton.frame.maxX + Self.segmentSpacing, + y: (bounds.height - Self.dividerHeight) / 2, + width: Self.dividerWidth, + height: Self.dividerHeight + ) + rightButton.frame = CGRect(x: dividerView.frame.maxX + Self.segmentSpacing, y: 0, width: rightWidth, height: bounds.height) + } +} + +// MARK: - 翻译目标语言存储(对齐 Android `VoiceMessageConfig`) + +enum VoiceTranscriptionLanguageStore { + struct Option { + let code: String + let nativeName: String + } + + static let options: [Option] = [ + Option(code: "zh", nativeName: "简体中文"), + Option(code: "zh-TW", nativeName: "繁體中文"), + Option(code: "en", nativeName: "English"), + Option(code: "ja", nativeName: "日本語"), + Option(code: "ko", nativeName: "한국어"), + Option(code: "fr", nativeName: "Français"), + Option(code: "es", nativeName: "Español"), + Option(code: "it", nativeName: "Italiano"), + Option(code: "de", nativeName: "Deutsch"), + Option(code: "tr", nativeName: "Türkçe"), + Option(code: "ru", nativeName: "Русский"), + Option(code: "pt", nativeName: "Português"), + Option(code: "vi", nativeName: "Tiếng Việt"), + Option(code: "id", nativeName: "Bahasa Indonesia"), + Option(code: "th", nativeName: "ภาษาไทย"), + Option(code: "ms", nativeName: "Bahasa Melayu"), + Option(code: "hi", nativeName: "हिन्दी") + ] + + private static let key = "AtomicXRecordTranslateTargetLanguage" + + static var selected: String { + get { UserDefaults.standard.string(forKey: key) ?? "" } + set { UserDefaults.standard.set(newValue, forKey: key) } + } +} + +// MARK: - Android 矢量图标(对齐 message_input_voice_close_icon / message_input_voice_transcribe_icon) + +private enum ChatVectorIcon { + case close + case transcribe + + var pathData: String { + switch self { + case .close: + return "M16.6286,4.2548 L4.2542,16.6291 L3.3704,15.7452 L15.7447,3.3709 L16.6286,4.2548 Z M15.7446,16.6292 L3.3703,4.2549 L4.2542,3.371 L16.6285,15.7453 L15.7446,16.6292 Z" + case .transcribe: + return "M11.8427,18.3046 C13.8068,16.0966 15,13.1876 15,10 C13.8068,3.9034 13.8068,3.9034 11.8427,1.6953 L12.777,0.8649 C14.9375,3.2937 16.25,6.4936 16.25,10 C14.9375,16.7062 14.9375,16.7062 12.777,19.1351 L11.8427,18.3046 Z M9.9742,3.3563 C11.5455,5.1227 12.5,7.4499 12.5,10 C11.5455,14.8773 11.5455,14.8773 9.9742,16.6437 L9.0399,15.8132 C10.4148,14.2676 11.25,12.2313 11.25,10 C10.4148,5.7323 10.4148,5.7323 9.0399,4.1867 L9.9742,3.3563 Z M7.1714,14.1523 C8.1534,13.0483 8.75,11.5938 8.75,10 C8.1534,6.9517 8.1534,6.9517 7.1714,5.8477 L6.2371,6.6781 C7.0227,7.5613 7.5,8.7249 7.5,10 C7.0227,12.4386 7.0227,12.4386 6.2371,13.3218 L7.1714,14.1523 Z M4.3685,11.6609 C4.7614,11.2193 5,10.6375 5,10 C5,9.3624 4.7614,8.7807 4.3685,8.339 L2.5,10 L4.3685,11.6609 Z" + } + } +} + +private extension VoiceTranscriptionOverlayView { + static func vectorIcon(_ icon: ChatVectorIcon) -> UIImage { + let size = CGSize(width: Self.vectorIconSize, height: Self.vectorIconSize) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + UIColor.white.setFill() + for path in parseVectorPaths(icon.pathData) { + path.fill() + } + }.withRenderingMode(.alwaysTemplate) + } +} + +private enum VectorToken { + case command(Character) + case number(CGFloat) +} + +private func parseVectorPaths(_ data: String) -> [UIBezierPath] { + var tokens: [VectorToken] = [] + var cursor = data.startIndex + while cursor < data.endIndex { + let character = data[cursor] + if character.isLetter { + tokens.append(.command(character)) + cursor = data.index(after: cursor) + } else if character == " " || character == "," || character == "\n" || character == "\t" { + cursor = data.index(after: cursor) + } else { + var end = cursor + while end < data.endIndex { + let unit = data[end] + if unit.isNumber || unit == "." || unit == "-" || unit == "+" || unit == "e" || unit == "E" { + end = data.index(after: end) + } else { + break + } + } + if let value = Double(String(data[cursor..= 2 { + path = UIBezierPath() + path?.move(to: CGPoint(x: numbers[0], y: numbers[1])) + paths.append(path!) + } + case "L": + if numbers.count >= 2 { + path?.addLine(to: CGPoint(x: numbers[0], y: numbers[1])) + } + case "C": + var index = 0 + while index + 5 < numbers.count { + path?.addCurve( + to: CGPoint(x: numbers[index + 4], y: numbers[index + 5]), + controlPoint1: CGPoint(x: numbers[index], y: numbers[index + 1]), + controlPoint2: CGPoint(x: numbers[index + 2], y: numbers[index + 3]) + ) + index += 6 + } + case "Z": + path?.close() + default: + break + } +} + +private extension Comparable { + func clamped(to range: ClosedRange) -> Self { + return min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/chat/uikit/components/messageinput/viewmodel/MentionInfo.swift b/chat/uikit/components/messageinput/viewmodel/MentionInfo.swift new file mode 100644 index 000000000..8d428bc69 --- /dev/null +++ b/chat/uikit/components/messageinput/viewmodel/MentionInfo.swift @@ -0,0 +1,42 @@ +import Foundation + +struct MentionInfo { + static let atAllUserID = "__kImSDK_MesssageAtALL__" + + public let userID: String + + public let displayName: String + + var startIndex: Int + + let length: Int + + var isAtAll: Bool { + return userID == Self.atAllUserID + } + + var endIndex: Int { + return startIndex + length + } + + var mentionText: String { + return "@\(displayName) " + } + + init(userID: String, displayName: String, startIndex: Int, length: Int) { + self.userID = userID + self.displayName = displayName + self.startIndex = startIndex + self.length = length + } + + public static func create(userID: String, displayName: String, atPosition: Int) -> MentionInfo { + let mentionText = "@\(displayName) " + return MentionInfo( + userID: userID, + displayName: displayName, + startIndex: atPosition, + length: mentionText.count + ) + } +} diff --git a/chat/uikit/components/messageinput/viewmodel/MessageInputViewModel.swift b/chat/uikit/components/messageinput/viewmodel/MessageInputViewModel.swift new file mode 100644 index 000000000..41c285dd3 --- /dev/null +++ b/chat/uikit/components/messageinput/viewmodel/MessageInputViewModel.swift @@ -0,0 +1,300 @@ +import Foundation +import AVFoundation +import UIKit +import AtomicXCore + +final class MessageInputViewModel { + private static let groupConversationPrefix = "group_" + + private static let pushDescriptionMaxLength: Int = 50 + + private static let groupChatTypeValue: Int = 2 + + private static let c2cChatTypeValue: Int = 1 + + let conversationID: String + + let config: MessageInputConfigProtocol + + var onSendFailure: ((String) -> Void)? + + var isGroupChat: Bool { + return conversationID.hasPrefix(Self.groupConversationPrefix) + } + + var groupID: String { + return isGroupChat ? String(conversationID.dropFirst(Self.groupConversationPrefix.count)) : conversationID + } + + private let store: MessageInputStore + + private let conversationStore = ConversationListStore.create() + + private var conversationInfo: ConversationInfo? + + // MARK: - Init + + init(conversationID: String, config: MessageInputConfigProtocol = ChatMessageInputConfig()) { + self.conversationID = conversationID + self.config = config + self.store = MessageInputStore.create(conversationID: conversationID) + fetchConversationInfo() + } + + // MARK: - Send + + func sendTextMessage(_ text: String, mentionList: [MentionInfo] = [], quotedMessage: MessageInfo? = nil) { + var option = createSendMessageOption( + pushDescription: EmojiManager.shared.createLocalizedStringFromEmojiCodes(text) + ) + if !mentionList.isEmpty { + option.atUserList = mentionList.map { $0.userID } + } + if let quoted = quotedMessage { + option.quotedMessage = quoted + } + store.sendMessage(payload: .text(TextSendMessagePayload(text: text)), option: option) { [weak self] result in + self?.handleSendResult(result) + } + } + + func sendImageMessage(_ imagePath: String) { + var payload = ImageSendMessagePayload(imagePath: imagePath) + if let image = UIImage(contentsOfFile: imagePath) { + payload.imageWidth = Int(image.size.width) + payload.imageHeight = Int(image.size.height) + } + store.sendMessage( + payload: .image(payload), + option: createSendMessageOption(pushDescription: LocalizedChatString("MessageTypeImage")) + ) { [weak self] result in + self?.handleSendResult(result) + } + } + + func sendVideoMessage(_ videoPath: String, snapshotPath: String) { + var snapshotWidth = 0 + var snapshotHeight = 0 + if let image = UIImage(contentsOfFile: snapshotPath) { + snapshotWidth = Int(image.size.width) + snapshotHeight = Int(image.size.height) + } + let durationInSeconds = CMTimeGetSeconds(AVAsset(url: URL(fileURLWithPath: videoPath)).duration) + let payload = VideoSendMessagePayload( + videoFilePath: videoPath, + videoType: "mp4", + duration: Int(durationInSeconds), + snapshotPath: snapshotPath, + snapshotWidth: snapshotWidth, + snapshotHeight: snapshotHeight + ) + store.sendMessage( + payload: .video(payload), + option: createSendMessageOption(pushDescription: LocalizedChatString("MessageTypeVideo")) + ) { [weak self] result in + self?.handleSendResult(result) + } + } + + func sendFileMessage(_ filePath: String, fileName: String, fileSize: Int) { + let payload = FileSendMessagePayload(filePath: filePath, fileName: fileName, fileSize: fileSize) + store.sendMessage( + payload: .file(payload), + option: createSendMessageOption(pushDescription: LocalizedChatString("MessageTypeFile")) + ) { [weak self] result in + self?.handleSendResult(result) + } + } + + func sendFaceMessage(groupIndex: Int, faceData: String, quotedMessage: MessageInfo? = nil) { + let payload = FaceSendMessagePayload(index: groupIndex, data: faceData) + var option = createSendMessageOption(pushDescription: LocalizedChatString("MessageTypeAnimateEmoji")) + if let quoted = quotedMessage { + option.quotedMessage = quoted + } + store.sendMessage(payload: .face(payload), option: option) { [weak self] result in + self?.handleSendResult(result) + } + } + + func sendVoiceMessage(_ voicePath: String, duration: Int) { + let payload = AudioSendMessagePayload(audioFilePath: voicePath, duration: duration) + store.sendMessage( + payload: .audio(payload), + option: createSendMessageOption(pushDescription: LocalizedChatString("MessageTypeVoice")) + ) { [weak self] result in + self?.handleSendResult(result) + } + } + + func convertLocalAudioToText(filePath: String, onCompleted: @escaping (String?) -> Void) { + AiMediaProcessManager.convertLocalAudioToText( + filePath: filePath, + onSuccess: { text in + DispatchQueue.main.async { onCompleted(text) } + }, + onFailure: { [weak self] _, _ in + DispatchQueue.main.async { + self?.notifySendFailure(LocalizedChatString("ConvertToTextFailed")) + onCompleted(nil) + } + } + ) + } + + // MARK: - Draft + + func loadDraft(completion: @escaping (String?) -> Void) { + conversationStore.getConversationInfo( + conversationID: conversationID, + completion: DraftConversationInfoHandler( + onSuccess: { conversation in + DispatchQueue.main.async { completion(conversation.draft) } + }, + onFailure: { _, _ in + DispatchQueue.main.async { completion(nil) } + } + ) + ) + } + + func saveDraft(_ text: String?) { + let normalized = (text?.isEmpty == false) ? text : nil + conversationStore.setConversationDraft(conversationID: conversationID, draft: normalized, completion: nil) + } + + func clearDraft() { + conversationStore.setConversationDraft(conversationID: conversationID, draft: nil, completion: nil) + } + + // MARK: - Private + + private func fetchConversationInfo() { + conversationStore.getConversationInfo( + conversationID: conversationID, + completion: DraftConversationInfoHandler( + onSuccess: { [weak self] info in self?.conversationInfo = info }, + onFailure: { _, _ in } + ) + ) + } + + private func handleSendResult(_ result: Result) { + switch result { + case .success: + break + case .failure(let error): + notifySendFailure(Self.failureReason(from: error)) + } + } + + private func notifySendFailure(_ reason: String) { + if Thread.isMainThread { + onSendFailure?(reason) + } else { + DispatchQueue.main.async { [weak self] in + self?.onSendFailure?(reason) + } + } + } + + private static func failureReason(from error: ErrorInfo) -> String { + return LocalizedChatString("TUIGroupNoteSendFail") + } + + private func createSendMessageOption(pushDescription: String) -> SendMessageOption { + var option = SendMessageOption() + option.needReadReceipt = config.enableReadReceipt + option.offlinePushInfo = createOfflinePushInfo(pushDescription: pushDescription) + return option + } + + private func createOfflinePushInfo(pushDescription: String) -> OfflinePushInfo { + let loginUserInfo = LoginStore.shared.state.value.loginUserInfo + let selfUserId = loginUserInfo?.userID ?? "" + let selfName = loginUserInfo?.nickname ?? selfUserId + let chatName = conversationInfo?.title?.isEmpty == false ? conversationInfo?.title : nil + let senderNickName = isGroupChat ? (chatName ?? groupID) : selfName + + let description = trimPushDescription(pushDescription) + let ext = createOfflinePushExtJson( + senderId: isGroupChat ? groupID : selfUserId, + senderNickName: senderNickName, + faceUrl: loginUserInfo?.avatarURL, + content: description + ) + + var pushInfo = OfflinePushInfo() + pushInfo.title = senderNickName + pushInfo.description = description + pushInfo.extensionInfo = [ + "ext": ext, + "AndroidOPPOChannelID": "tuikit", + "AndroidHuaWeiCategory": "IM", + "AndroidVIVOCategory": "IM", + "AndroidHonorImportance": "NORMAL", + "AndroidMeizuNotifyType": 1, + "iOSInterruptionLevel": "time-sensitive", + "enableIOSBackgroundNotification": false + ] + return pushInfo + } + + private func trimPushDescription(_ text: String, maxLength: Int = MessageInputViewModel.pushDescriptionMaxLength) -> String { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + return normalized.count <= maxLength ? normalized : String(normalized.prefix(maxLength)) + } + + private func createOfflinePushExtJson( + senderId: String, + senderNickName: String, + faceUrl: String?, + content: String? + ) -> String { + var entity: [String: Any] = [ + "sender": senderId, + "nickname": senderNickName, + "chatType": isGroupChat ? Self.groupChatTypeValue : Self.c2cChatTypeValue, + "version": 1, + "action": 1 + ] + if let content = content, !content.isEmpty { + entity["content"] = content + } + if let faceUrl = faceUrl { + entity["faceUrl"] = faceUrl + } + let extDict: [String: Any] = [ + "entity": entity, + "timPushFeatures": ["fcmPushType": 0, "fcmNotificationType": 0] + ] + if let jsonData = try? JSONSerialization.data(withJSONObject: extDict, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + return "{}" + } +} + +// MARK: - GetConversationInfoCompletionHandler + +private final class DraftConversationInfoHandler: GetConversationInfoCompletionHandler { + private let onSuccessBlock: (ConversationInfo) -> Void + + private let onFailureBlock: (Int, String) -> Void + + init(onSuccess: @escaping (ConversationInfo) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessBlock = onSuccess + self.onFailureBlock = onFailure + } + + func onSuccess(conversationInfo: ConversationInfo) { + onSuccessBlock(conversationInfo) + } + + func onFailure(code: Int, desc: String) { + onFailureBlock(code, desc) + } +} diff --git a/chat/uikit/components/messagelist/MessageListView.swift b/chat/uikit/components/messagelist/MessageListView.swift new file mode 100644 index 000000000..96275e5ef --- /dev/null +++ b/chat/uikit/components/messagelist/MessageListView.swift @@ -0,0 +1,273 @@ +import UIKit +import SnapKit +import AtomicXCore + +public typealias MessageMatcher = (MessageInfo) -> Bool + +public protocol MessageListConfigProtocol { + var alignment: Int { get } + var isShowTimeMessage: Bool { get } + var isShowLeftAvatar: Bool { get } + var isShowLeftNickname: Bool { get } + var isShowRightAvatar: Bool { get } + var isShowRightNickname: Bool { get } + var isShowTimeInBubble: Bool { get } + var cellSpacing: CGFloat { get } + var isShowSystemMessage: Bool { get } + var isShowUnsupportMessage: Bool { get } + var horizontalPadding: CGFloat { get } + var avatarSpacing: CGFloat { get } + var isSupportReaction: Bool { get } + var enableTyping: Bool { get } + var messageExclusionMatchers: [MessageMatcher] { get } +} + +extension MessageListConfigProtocol { + public var enableTyping: Bool { true } + public var messageExclusionMatchers: [MessageMatcher] { [] } +} + +extension MessageListConfigProtocol { + var isSupportQuote: Bool { true } +} + +public protocol MessageActionConfigProtocol { + var isSupportCopy: Bool { get } + var isSupportDelete: Bool { get } + var isSupportRecall: Bool { get } + var isSupportForward: Bool { get } + var isSupportMultiSelect: Bool { get } + var isSupportConvertToText: Bool { get } + var isSupportTranslate: Bool { get } + var isSupportListenFromHere: Bool { get } + var actionCustomizer: MessageActionCustomizer? { get } +} + +extension MessageActionConfigProtocol { + public var actionCustomizer: MessageActionCustomizer? { nil } +} + +extension ChatMessageListConfig: MessageListConfigProtocol, MessageActionConfigProtocol { + public var alignment: Int { + if let userAlignment = userAlignment { + return userAlignment + } else { + let config = AppBuilderConfig.shared + switch config.messageAlignment { + case .left: + return 1 + case .right: + return 2 + case .twoSided: + return 0 + } + } + } + + public var isShowTimeMessage: Bool { + return userIsShowTimeMessage ?? true + } + + public var isShowLeftAvatar: Bool { + return userIsShowLeftAvatar ?? true + } + + public var isShowLeftNickname: Bool { + return userIsShowLeftNickname ?? true + } + + public var isShowRightAvatar: Bool { + return userIsShowRightAvatar ?? false + } + + public var isShowRightNickname: Bool { + return userIsShowRightNickname ?? true + } + + public var isShowTimeInBubble: Bool { + return userIsShowTimeInBubble ?? true + } + + public var cellSpacing: CGFloat { + return userCellSpacing ?? Self.defaultCellSpacing + } + + public var isShowSystemMessage: Bool { + return userIsShowSystemMessage ?? true + } + + public var isShowUnsupportMessage: Bool { + return userIsShowUnsupportMessage ?? true + } + + public var isSupportCopy: Bool { + if let userIsSupportCopy = userIsSupportCopy { + return userIsSupportCopy + } else { + let config = AppBuilderConfig.shared + return config.messageActionList.contains(.copy) + } + } + + public var isSupportDelete: Bool { + if let userIsSupportDelete = userIsSupportDelete { + return userIsSupportDelete + } else { + let config = AppBuilderConfig.shared + return config.messageActionList.contains(.delete) + } + } + + public var isSupportRecall: Bool { + if let userIsSupportRecall = userIsSupportRecall { + return userIsSupportRecall + } else { + let config = AppBuilderConfig.shared + return config.messageActionList.contains(.recall) + } + } + + public var isSupportForward: Bool { + return userIsSupportForward ?? true + } + + public var isSupportMultiSelect: Bool { + return userIsSupportMultiSelect ?? true + } + + public var isSupportConvertToText: Bool { + return userIsSupportConvertToText ?? true + } + + public var isSupportTranslate: Bool { + return userIsSupportTranslate ?? true + } + + public var isSupportListenFromHere: Bool { + return userIsSupportListenFromHere ?? true + } + + public var actionCustomizer: MessageActionCustomizer? { + return userActionCustomizer + } + + public var horizontalPadding: CGFloat { + return userHorizontalPadding ?? Self.defaultHorizontalPadding + } + + public var avatarSpacing: CGFloat { + return userAvatarSpacing ?? Self.defaultAvatarSpacing + } + + public var isSupportReaction: Bool { + return userIsSupportReaction ?? true + } + + public var enableTyping: Bool { + return userEnableTyping ?? true + } + + public var messageExclusionMatchers: [MessageMatcher] { + return exclusionMatchers + } + + public mutating func addMessageExclusion(_ matcher: @escaping MessageMatcher) { + exclusionMatchers.append(matcher) + } + + public mutating func excludeMessagesByType(_ types: MessageType...) { + exclusionMatchers.append { message in + return types.contains(message.messageType) + } + } + + public mutating func excludeCustomMessagesByBusinessID(_ businessID: String) { + exclusionMatchers.append { message in + guard case .custom(let payload) = message.messagePayload, + let data = payload.customData.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return false + } + return json["businessID"] as? String == businessID + } + } + + public mutating func clearMessageExclusions() { + exclusionMatchers.removeAll() + } +} + +public final class MessageListView: UIView { + static let blankAreaClickNotification = NSNotification.Name("MessageListBlankAreaClick") + + public static func registerCustomMessageCell(businessID: String, + summaryProvider: @escaping (CustomMessagePayload) -> String?, + makeContentView: @escaping () -> MessageContentView) { + MessageCellRegistry.shared.registerCustomMessageCell( + businessID: businessID, + summaryProvider: summaryProvider, + makeContentView: makeContentView + ) + } + + var onMultiSelectModeChange: ((Bool) -> Void)? { + get { impl.onMultiSelectModeChange } + set { impl.onMultiSelectModeChange = newValue } + } + + private let impl: MessageListViewImpl + + init(conversationID: String, + config: (MessageListConfigProtocol & MessageActionConfigProtocol) = ChatMessageListConfig(), + locateMessage: MessageInfo? = nil, + onUserClick: ((String) -> Void)? = nil) { + impl = MessageListViewImpl( + conversationID: conversationID, + config: config, + locateMessage: locateMessage, + onUserClick: onUserClick + ) + super.init(frame: .zero) + addSubview(impl) + impl.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func hostVisibilityDidChange(_ isVisible: Bool) { + impl.hostVisibilityDidChange(isVisible) + } + + func applyChatBackgroundImage(uri: String?) { + impl.applyChatBackgroundImage(uri: uri) + } + + func exitMultiSelectMode() { + impl.exitMultiSelectMode() + } +} + +public struct MessageContentContext { + let config: MessageListConfigProtocol + public let isLeft: Bool + public let isSelf: Bool + var showsReadReceipt: Bool = true + public var isGroupChat: Bool = false + public var isMultiSelectMode: Bool = false + var detailTimeText: String? = nil + weak var messageListStore: MessageListStore? + + var onMediaTap: ((MessageInfo) -> Void)? = nil + + var onMergedMessageTap: ((MessageInfo) -> Void)? = nil +} + +public protocol MessageContentView: UIView { + + func bind(message: MessageInfo, context: MessageContentContext) +} diff --git a/chat/uikit/components/messagelist/cell/AuxiliaryTextDisplayState.swift b/chat/uikit/components/messagelist/cell/AuxiliaryTextDisplayState.swift new file mode 100644 index 000000000..01513f24f --- /dev/null +++ b/chat/uikit/components/messagelist/cell/AuxiliaryTextDisplayState.swift @@ -0,0 +1,7 @@ +import Foundation + +enum AuxiliaryTextDisplayState { + case hidden + case loading + case text(content: String, footer: String?) +} diff --git a/chat/uikit/components/messagelist/cell/ChatAudioPlaybackCoordinator.swift b/chat/uikit/components/messagelist/cell/ChatAudioPlaybackCoordinator.swift new file mode 100644 index 000000000..0a3891bb1 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/ChatAudioPlaybackCoordinator.swift @@ -0,0 +1,42 @@ +import Foundation +import Combine + +final class ChatAudioPlaybackCoordinator { + static let shared = ChatAudioPlaybackCoordinator() + + var playingPublisher: AnyPublisher { + playingSubject.eraseToAnyPublisher() + } + + private let player = AudioPlayer.create() + + private let playingSubject = CurrentValueSubject(nil) + + private var isPlayingCancellable: AnyCancellable? + + func isPlaying(msgID: String) -> Bool { + return playingSubject.value == msgID && player.isPlaying + } + + func toggle(msgID: String, url: URL) { + if isPlaying(msgID: msgID) { + player.pause() + playingSubject.send(nil) + } else { + playingSubject.send(msgID) + player.play(url) + } + } + + func stop() { + player.stop() + playingSubject.send(nil) + } + + private init() { + isPlayingCancellable = player.$isPlaying.sink { [weak self] isPlaying in + guard !isPlaying else { return } + self?.playingSubject.send(nil) + } + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageAudioContentView.swift b/chat/uikit/components/messagelist/cell/MessageAudioContentView.swift new file mode 100644 index 000000000..0af2cce4f --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageAudioContentView.swift @@ -0,0 +1,366 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class MessageAudioContentView: UIView, MessageContentView { + private static let horizontalInset = CGFloat(SpacingScheme.iconIconSpacing) + + private static let verticalInset = CGFloat(SpacingScheme.smallSpacing) + + private static let iconSize: CGFloat = 16 + + private static let contentSpacing = CGFloat(SpacingScheme.iconTextSpacing) + + private static let maxDurationSeconds: Int = 60 + + private static let absoluteMinWidth: CGFloat = 80 + + private static let absoluteMaxWidth: CGFloat = 260 + + private static let minWidthScreenRatio: CGFloat = 0.22 + + private static let maxWidthScreenRatio: CGFloat = 0.55 + + private static let dimmedContainerAlpha: CGFloat = 0.85 + + private static let durationMaxLines = 1 + + private var message: MessageInfo? + + private var isSelf = false + + private weak var messageListStore: MessageListStore? + + private var playingCancellable: AnyCancellable? + + private var currentPlayingMsgID: String? + + private var contentWidthConstraint: Constraint? + + private var contentStackLeading: Constraint? + + private var contentStackTrailing: Constraint? + + private let bubbleView = UIView() + + private let contentStack = UIStackView() + + private let voiceIconView = VoiceIconView() + + private let durationLabel = UILabel() + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + subscribePlaybackState() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + self.message = message + self.isSelf = context.isSelf + self.messageListStore = context.messageListStore + + let duration = Self.audioDuration(from: message) + durationLabel.text = Self.formatDuration(duration) + applyColors(isSelf: context.isSelf) + applyAlignment(isSelf: context.isSelf) + contentWidthConstraint?.update(offset: Self.computeWidth(duration: duration)) + updatePlaybackVisualState() + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(bubbleView) + bubbleView.addSubview(contentStack) + contentStack.addArrangedSubview(voiceIconView) + contentStack.addArrangedSubview(durationLabel) + } + + private func activateConstraints() { + bubbleView.snp.makeConstraints { make in + make.edges.equalToSuperview() + contentWidthConstraint = make.width.equalTo(Self.absoluteMinWidth).constraint + } + contentStack.snp.makeConstraints { make in + make.top.equalTo(bubbleView).offset(Self.verticalInset) + make.bottom.equalTo(bubbleView).offset(-Self.verticalInset) + contentStackLeading = make.leading.equalTo(bubbleView).offset(Self.horizontalInset).constraint + } + voiceIconView.snp.makeConstraints { make in + make.width.height.equalTo(Self.iconSize) + } + durationLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + durationLabel.setContentHuggingPriority(.required, for: .horizontal) + } + + private func setupViewStyle() { + contentStack.axis = .horizontal + contentStack.alignment = .center + contentStack.spacing = Self.contentSpacing + durationLabel.font = FontScheme.caption1Regular + durationLabel.numberOfLines = Self.durationMaxLines + } + + private func bindInteraction() { + bubbleView.isUserInteractionEnabled = true + bubbleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) + } + + private func subscribePlaybackState() { + playingCancellable = ChatAudioPlaybackCoordinator.shared.playingPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] msgID in + self?.currentPlayingMsgID = msgID + self?.updatePlaybackVisualState() + } + } + + private func applyColors(isSelf: Bool) { + let colors = ChatUIKitTheme.colors + let contentColor = isSelf ? colors.textColorAntiPrimary : colors.textColorPrimary + durationLabel.textColor = contentColor + voiceIconView.setColor(contentColor) + } + + private func applyAlignment(isSelf: Bool) { + contentStackLeading?.deactivate() + contentStackTrailing?.deactivate() + if isSelf { + contentStackTrailing = contentStack.snp.prepareConstraints { make in + make.trailing.equalTo(bubbleView).offset(-Self.horizontalInset) + }.first + contentStackTrailing?.activate() + } else { + contentStackLeading = contentStack.snp.prepareConstraints { make in + make.leading.equalTo(bubbleView).offset(Self.horizontalInset) + }.first + contentStackLeading?.activate() + } + } + + private func updatePlaybackVisualState() { + guard let message = message else { return } + let isCurrent = currentPlayingMsgID == message.msgID + let isPlaying = isCurrent && ChatAudioPlaybackCoordinator.shared.isPlaying(msgID: message.msgID) + if isPlaying { + voiceIconView.startAnimating() + } else { + voiceIconView.stopAnimating() + } + durationLabel.alpha = isCurrent ? 1.0 : Self.dimmedContainerAlpha + let someonePlaying = currentPlayingMsgID != nil + self.alpha = (isCurrent || !someonePlaying) ? 1.0 : Self.dimmedContainerAlpha + } + + @objc private func handleTap() { + guard let message = message else { return } + guard case .audio(let payload) = message.messagePayload else { return } + + if let path = payload.audioPath, + !path.isEmpty, + FileManager.default.fileExists(atPath: path) { + ChatAudioPlaybackCoordinator.shared.toggle(msgID: message.msgID, url: URL(fileURLWithPath: path)) + return + } + if let urlString = payload.audioURL, + !urlString.isEmpty, + let url = URL(string: urlString) { + ChatAudioPlaybackCoordinator.shared.toggle(msgID: message.msgID, url: url) + return + } + downloadThenPlay(message: message) + } + + private func downloadThenPlay(message: MessageInfo) { + MessageActionStore.create(message: message).downloadMedia(quality: nil) { [weak self] result in + guard case .success = result else { return } + DispatchQueue.main.async { + self?.playAfterDownload(msgID: message.msgID) + } + } + } + + private func playAfterDownload(msgID: String) { + let latest = messageListStore?.state.value.messageList.first { $0.msgID == msgID } + guard case .audio(let payload)? = latest?.messagePayload else { return } + if let path = payload.audioPath, + !path.isEmpty, + FileManager.default.fileExists(atPath: path) { + ChatAudioPlaybackCoordinator.shared.toggle(msgID: msgID, url: URL(fileURLWithPath: path)) + } else if let urlString = payload.audioURL, + !urlString.isEmpty, + let url = URL(string: urlString) { + ChatAudioPlaybackCoordinator.shared.toggle(msgID: msgID, url: url) + } + } + + private static func audioDuration(from message: MessageInfo) -> Int { + if case .audio(let payload) = message.messagePayload { + return payload.audioDuration + } + return 0 + } + + private static func formatDuration(_ seconds: Int) -> String { + return String(format: "%d\"", max(seconds, 0)) + } + + private static func computeWidth(duration: Int) -> CGFloat { + let screenWidth = UIScreen.main.bounds.width + let minWidth = min(max(screenWidth * Self.minWidthScreenRatio, Self.absoluteMinWidth), Self.absoluteMaxWidth) + let maxWidth = min(max(screenWidth * Self.maxWidthScreenRatio, minWidth), Self.absoluteMaxWidth) + let clamped = min(max(CGFloat(duration), 1), CGFloat(Self.maxDurationSeconds)) + return minWidth + (maxWidth - minWidth) * (clamped / CGFloat(Self.maxDurationSeconds)) + } +} + +private final class VoiceIconView: UIView { + private let dotLayer = CAShapeLayer() + + private let arcInnerLayer = CAShapeLayer() + + private let arcOuterLayer = CAShapeLayer() + + private var shapeLayers: [CAShapeLayer] { [dotLayer, arcInnerLayer, arcOuterLayer] } + + private let viewportSize: CGFloat = 33.7525 + + private let displaySize: CGFloat = 16 + + private var scale: CGFloat { displaySize / viewportSize } + + private let dimmedAlpha: CGFloat = 76.0 / 255.0 + + private let animationStepInterval: TimeInterval = 0.3 + + private var animationTimer: Timer? + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + let isRTL = effectiveUserInterfaceLayoutDirection == .rightToLeft + transform = isRTL ? CGAffineTransform(scaleX: -1, y: 1) : .identity + } + + func setColor(_ color: UIColor) { + for layer in shapeLayers { + layer.fillColor = color.cgColor + } + } + + func startAnimating() { + guard animationTimer == nil else { return } + var step = 0 + animationTimer = Timer.scheduledTimer(withTimeInterval: animationStepInterval, repeats: true) { [weak self] _ in + guard let self = self else { return } + for (index, layer) in self.shapeLayers.enumerated() { + layer.opacity = index == step ? 1.0 : Float(self.dimmedAlpha) + } + step = (step + 1) % self.shapeLayers.count + } + } + + func stopAnimating() { + animationTimer?.invalidate() + animationTimer = nil + for layer in shapeLayers { + layer.opacity = 1.0 + } + } + + deinit { + animationTimer?.invalidate() + } + + private func setupLayers() { + for layer in shapeLayers { + layer.fillColor = UIColor.black.cgColor + self.layer.addSublayer(layer) + } + dotLayer.path = Self.buildDotPath(scale: scale) + arcInnerLayer.path = Self.buildArcInnerPath(scale: scale) + arcOuterLayer.path = Self.buildArcOuterPath(scale: scale) + } + + private static func point(_ x: CGFloat, _ y: CGFloat, _ scale: CGFloat) -> CGPoint { + return CGPoint(x: x * scale, y: y * scale) + } + + private static func buildDotPath(scale: CGFloat) -> CGPath { + let path = UIBezierPath() + path.move(to: point(10.12625, 17.0747, scale)) + path.addQuadCurve(to: point(9.13775, 14.6976, scale), controlPoint: point(10.12625, 15.6822, scale)) + path.addQuadCurve(to: point(6.75125, 13.7129, scale), controlPoint: point(8.14925, 13.7129, scale)) + path.addQuadCurve(to: point(4.36475, 14.6976, scale), controlPoint: point(5.35325, 13.7129, scale)) + path.addQuadCurve(to: point(3.37625, 17.0747, scale), controlPoint: point(3.37625, 15.6822, scale)) + path.addQuadCurve(to: point(4.36475, 19.4519, scale), controlPoint: point(3.37625, 18.4672, scale)) + path.addQuadCurve(to: point(6.75125, 20.4365, scale), controlPoint: point(5.35325, 20.4365, scale)) + path.addQuadCurve(to: point(9.13775, 19.4519, scale), controlPoint: point(8.14925, 20.4365, scale)) + path.addQuadCurve(to: point(10.12625, 17.0747, scale), controlPoint: point(10.12625, 18.4672, scale)) + path.close() + return path.cgPath + } + + private static func buildArcInnerPath(scale: CGFloat) -> CGPath { + let path = UIBezierPath() + path.move(to: point(20.25125, 17.0745, scale)) + path.addCurve(to: point(16.05425, 7.3301, scale), + controlPoint1: point(20.25125, 13.2397, scale), + controlPoint2: point(18.63975, 9.7797, scale)) + path.addLine(to: point(13.73595, 9.7733, scale)) + path.addCurve(to: point(16.87625, 17.0745, scale), + controlPoint1: point(15.67065, 11.6102, scale), + controlPoint2: point(16.87625, 14.2021, scale)) + path.addCurve(to: point(13.89755, 24.2189, scale), + controlPoint1: point(16.87625, 19.8661, scale), + controlPoint2: point(15.73755, 22.3928, scale)) + path.addLine(to: point(16.27795, 26.6021, scale)) + path.addCurve(to: point(20.25125, 17.0745, scale), + controlPoint1: point(18.73225, 24.1672, scale), + controlPoint2: point(20.25125, 20.7975, scale)) + path.close() + return path.cgPath + } + + private static func buildArcOuterPath(scale: CGFloat) -> CGPath { + let path = UIBezierPath() + path.move(to: point(27.00125, 17.0746, scale)) + path.addCurve(to: point(20.69045, 2.4432, scale), + controlPoint1: point(27.00125, 11.3146, scale), + controlPoint2: point(24.57745, 6.1186, scale)) + path.addLine(to: point(22.00875, 0, scale)) + path.addCurve(to: point(30.37625, 17.0746, scale), + controlPoint1: point(27.54645, 4.2882, scale), + controlPoint2: point(30.37625, 10.3522, scale)) + path.addCurve(to: point(23.41855, 33.7525, scale), + controlPoint1: point(30.37625, 23.5922, scale), + controlPoint2: point(27.71625, 29.491, scale)) + path.addLine(to: point(21.03815, 31.3693, scale)) + path.addCurve(to: point(27.00125, 17.0746, scale), + controlPoint1: point(24.72155, 27.7166, scale), + controlPoint2: point(27.00125, 22.6608, scale)) + path.close() + return path.cgPath + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageAuxiliaryTextBubbleView.swift b/chat/uikit/components/messagelist/cell/MessageAuxiliaryTextBubbleView.swift new file mode 100644 index 000000000..fd100b2e3 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageAuxiliaryTextBubbleView.swift @@ -0,0 +1,217 @@ +import UIKit +import SnapKit +import Combine + +final class MessageAuxiliaryTextBubbleView: UIView { + var onLongPress: (() -> Void)? + + override var intrinsicContentSize: CGSize { + let maxContentWidth = Self.maxBubbleWidth - 2 * Self.horizontalInset + if !contentLabel.isHidden { + let contentSize = contentLabel.sizeThatFits(CGSize(width: maxContentWidth, height: .greatestFiniteMagnitude)) + var contentWidth = contentSize.width + var totalHeight = Self.verticalInset + contentSize.height + Self.verticalInset + if !footerLabel.isHidden, let footerText = footerLabel.text, !footerText.isEmpty { + let footerSize = footerLabel.sizeThatFits(CGSize(width: maxContentWidth, height: .greatestFiniteMagnitude)) + contentWidth = max(contentWidth, footerSize.width) + totalHeight += Self.footerTopSpacing + footerSize.height + } + return CGSize( + width: min(ceil(contentWidth) + 2 * Self.horizontalInset, Self.maxBubbleWidth), + height: ceil(totalHeight) + ) + } + let indicatorSize = loadingIndicator.intrinsicContentSize + return CGSize( + width: indicatorSize.width * Self.loadingIndicatorScale + 2 * Self.horizontalInset, + height: indicatorSize.height * Self.loadingIndicatorScale + 2 * Self.verticalInset + ) + } + + private static let cornerRadius: CGFloat = 9 + + private static let verticalInset: CGFloat = 6 + + private static let horizontalInset: CGFloat = 9 + + private static let footerTopSpacing = CGFloat(SpacingScheme.iconTextSpacing) + + private static let contentLineHeightMultiple: CGFloat = 1.3 + + private static let maxBubbleWidth: CGFloat = UIScreen.main.bounds.width * 0.72 + + private static let loadingIndicatorScale: CGFloat = 0.8 + + private static let footerSelfAlpha: CGFloat = 0.6 + + private let contentLabel = UILabel() + + private let footerLabel = UILabel() + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private var lastIsSelf = false + + private var lastContentText: String? + + private var themeCancellable: AnyCancellable? + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + observeThemeChanges() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func bind(isSelf: Bool, isLoading: Bool, contentText: String?, footerText: String?) { + lastIsSelf = isSelf + applyColors() + + if isLoading { + loadingIndicator.startAnimating() + contentLabel.isHidden = true + footerLabel.isHidden = true + loadingIndicator.snp.remakeConstraints { make in + make.center.equalToSuperview() + make.top.equalToSuperview().offset(Self.verticalInset) + make.bottom.equalToSuperview().offset(-Self.verticalInset) + } + contentLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(Self.verticalInset) + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + } + footerLabel.snp.remakeConstraints { make in + make.top.equalTo(contentLabel.snp.bottom).offset(Self.footerTopSpacing) + make.leading.equalTo(contentLabel) + make.trailing.lessThanOrEqualTo(contentLabel) + make.bottom.equalToSuperview().offset(-Self.verticalInset) + } + } else { + contentLabel.isHidden = false + applyContentText(contentText) + if let footer = footerText, !footer.isEmpty { + footerLabel.isHidden = false + footerLabel.text = footer + } else { + footerLabel.isHidden = true + footerLabel.text = nil + } + contentLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(Self.verticalInset) + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + if footerLabel.isHidden { + make.bottom.equalToSuperview().offset(-Self.verticalInset) + } + } + if !footerLabel.isHidden { + footerLabel.snp.remakeConstraints { make in + make.top.equalTo(contentLabel.snp.bottom).offset(Self.footerTopSpacing) + make.leading.equalTo(contentLabel) + make.trailing.lessThanOrEqualTo(contentLabel) + make.bottom.equalToSuperview().offset(-Self.verticalInset) + } + } + loadingIndicator.stopAnimating() + } + invalidateIntrinsicContentSize() + } + + func reset() { + loadingIndicator.stopAnimating() + contentLabel.attributedText = nil + contentLabel.isHidden = false + lastContentText = nil + footerLabel.text = nil + footerLabel.isHidden = true + onLongPress = nil + invalidateIntrinsicContentSize() + } + + private func constructViewHierarchy() { + addSubview(loadingIndicator) + addSubview(contentLabel) + addSubview(footerLabel) + } + + private func activateConstraints() { + snp.makeConstraints { make in + make.width.lessThanOrEqualTo(Self.maxBubbleWidth) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(Self.verticalInset) + make.bottom.lessThanOrEqualToSuperview().offset(-Self.verticalInset) + } + contentLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.verticalInset) + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + } + footerLabel.snp.makeConstraints { make in + make.top.equalTo(contentLabel.snp.bottom).offset(Self.footerTopSpacing) + make.leading.equalTo(contentLabel) + make.trailing.lessThanOrEqualTo(contentLabel) + make.bottom.equalToSuperview().offset(-Self.verticalInset) + } + } + + private func setupViewStyle() { + layer.cornerRadius = Self.cornerRadius + clipsToBounds = true + contentLabel.numberOfLines = 0 + footerLabel.font = FontScheme.caption3Regular + loadingIndicator.hidesWhenStopped = true + loadingIndicator.transform = CGAffineTransform(scaleX: Self.loadingIndicatorScale, y: Self.loadingIndicatorScale) + } + + private func bindInteraction() { + isUserInteractionEnabled = true + addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))) + } + + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + onLongPress?() + } + + private func applyColors() { + let colors = ChatUIKitTheme.colors + backgroundColor = lastIsSelf ? colors.bgColorBubbleOwn : colors.bgColorBubbleReciprocal + footerLabel.textColor = lastIsSelf + ? colors.textColorAntiPrimary.withAlphaComponent(Self.footerSelfAlpha) + : colors.textColorSecondary + applyContentText(lastContentText) + } + + private func applyContentText(_ text: String?) { + lastContentText = text + guard let text = text, !text.isEmpty else { + contentLabel.attributedText = nil + return + } + let paragraph = NSMutableParagraphStyle() + paragraph.lineHeightMultiple = Self.contentLineHeightMultiple + let contentColor = lastIsSelf ? ChatUIKitTheme.colors.textColorAntiPrimary : ChatUIKitTheme.colors.textColorPrimary + contentLabel.attributedText = NSAttributedString(string: text, attributes: [ + .font: FontScheme.caption2Regular, + .foregroundColor: contentColor, + .paragraphStyle: paragraph + ]) + } + + private func observeThemeChanges() { + themeCancellable = ThemeState.shared.$currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.applyColors() + } + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageBaseCell.swift b/chat/uikit/components/messagelist/cell/MessageBaseCell.swift new file mode 100644 index 000000000..cecc02baa --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageBaseCell.swift @@ -0,0 +1,908 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MessageBaseCell: UITableViewCell { + var onAvatarTap: ((MessageInfo) -> Void)? + + var onAvatarLongPress: ((MessageInfo) -> Void)? + + var onResendRequested: ((MessageInfo) -> Void)? + + var onQuotePreviewTap: ((MessageInfo) -> Void)? + + var onBubbleLongPress: ((MessageInfo, CGRect) -> Void)? + + var onReactionBarTap: ((MessageInfo) -> Void)? + + var onAuxiliaryTextLongPress: ((MessageInfo, String, CGRect) -> Void)? + + var onReadReceiptTap: ((MessageInfo) -> Void)? + + internal let reactionBar = MessageReactionBarView() + + private static let avatarSize: CGFloat = 40 + + private static let checkboxSize: CGFloat = 22 + + private static let checkboxMarginEnd = CGFloat(SpacingScheme.iconIconSpacing) + + private static let statusIconSize: CGFloat = 14 + + private static let sendingIndicatorScale: CGFloat = 0.6 + + private static let statusSlotGap = CGFloat(SpacingScheme.smallSpacing) + + private static let timeVerticalMargin = CGFloat(SpacingScheme.contentSpacing) + + private static let nicknameBottomMargin = CGFloat(SpacingScheme.iconTextSpacing) + + private static let bubbleMinHeight: CGFloat = 40 + + private static let reactionBarMaxWidth: CGFloat = UIScreen.main.bounds.width * 0.72 + + private static let reactionBarTopInset: CGFloat = 2 + + private static let reactionBarBottomInset: CGFloat = 6 + + private static let reactionBarHorizontalInset = CGFloat(SpacingScheme.smallSpacing) + + private static let reactionBarCardTopInset = CGFloat(SpacingScheme.iconTextSpacing) + + private static let reactionBarCardBottomInset: CGFloat = 10 + + private static let reactionBarCardHorizontalInset = CGFloat(SpacingScheme.iconIconSpacing) + + private static let reactionBarMediaTopInset: CGFloat = 6 + + private static let mediaBubblePadding = CGFloat(SpacingScheme.iconTextSpacing) + + private static let mergedCardBorderWidth: CGFloat = 1 + + private static let auxiliaryTextTopInset: CGFloat = 6 + + private static let auxiliaryAudioTopInset: CGFloat = 10 + + private static let violationTopInset: CGFloat = 2 + + private static let quoteTopSpacing = CGFloat(SpacingScheme.iconTextSpacing) + + private static let quoteMaxWidth: CGFloat = UIScreen.main.bounds.width * 0.72 + + private static let twoSidedAlignment = 0 + + private static let nicknameDetailTimeSpacing = CGFloat(SpacingScheme.iconTextSpacing) + + private var message: MessageInfo? + + private var contentKind: MessageContentKind? + + private var contentView0: MessageContentView? + + private var lastContext: MessageContentContext? + + private var normalBubbleColor: UIColor? + + private var highlightGeneration = 0 + + private let timeLabel = UILabel() + + private let bodyContainer = UIView() + + private let checkboxView = UIImageView() + + private let avatarView = ChatAvatarView() + + private let nicknameLabel = UILabel() + + private let detailTimeLabel = UILabel() + + private let statusImageView = UIImageView() + + private let sendingIndicator = UIActivityIndicatorView(style: .medium) + + private let bubbleColumn = UIView() + + private let quoteView = MessageQuoteBubbleView() + + private let contentContainer = MessageBubbleContainerView() + + private let highlightOverlayView = UIView() + + private let readReceiptLabel = UILabel() + + private lazy var readReceiptTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleReadReceiptTap)) + + private let auxiliaryTextBubbleView = MessageAuxiliaryTextBubbleView() + + private let violationLabel = UILabel() + + private var isMediaPayload: Bool { + guard let payload = message?.messagePayload else { return false } + switch payload { + case .image, .video: + return true + default: + return false + } + } + + private var isMergedPayload: Bool { + guard let payload = message?.messagePayload else { return false } + if case .merged = payload { return true } + return false + } + + private var shouldWrapMediaInBubble: Bool { + return isMediaPayload && !reactionBar.isHidden + } + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + LanguageHelper.applyLayoutDirection(to: self) + constructViewHierarchy() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + func configure(message: MessageInfo, + contentKind: MessageContentKind, + timeString: String?, + context: MessageContentContext, + isMultiSelectMode: Bool, + isSelected: Bool, + auxiliaryTextState: AuxiliaryTextDisplayState = .hidden) { + self.message = message + self.lastContext = context + installContentViewIfNeeded(kind: contentKind) + contentView0?.bind(message: message, context: context) + applyReadReceipt(message: message, context: context) + applyQuote(message: message, isSupportQuote: context.config.isSupportQuote, isLeft: context.isLeft) + + applyTime(timeString, show: context.config.isShowTimeMessage) + applyDetailTime(context.detailTimeText) + applyAvatar(message: message, isLeft: context.isLeft, show: shouldShowAvatar(context: context)) + applyNickname(message: message, context: context) + applyStatus(message: message) + applyCheckbox(isMultiSelectMode: isMultiSelectMode, isSelected: isSelected) + applyAuxiliaryText(message: message, state: auxiliaryTextState, isSelf: context.isSelf) + applyReaction(message: message, context: context) + applyViolation(message: message) + + rebuildLayout(context: context, + showAvatar: shouldShowAvatar(context: context), + showNickname: !nicknameLabel.isHidden, + showStatus: !statusImageView.isHidden || sendingIndicator.isAnimating || !readReceiptLabel.isHidden, + multiSelect: isMultiSelectMode) + } + + func updateMultiSelectState(isMultiSelectMode: Bool, isSelected: Bool) { + guard let context = lastContext else { return } + applyCheckbox(isMultiSelectMode: isMultiSelectMode, isSelected: isSelected) + rebuildLayout(context: context, + showAvatar: shouldShowAvatar(context: context), + showNickname: !nicknameLabel.isHidden, + showStatus: !statusImageView.isHidden || sendingIndicator.isAnimating || !readReceiptLabel.isHidden, + multiSelect: isMultiSelectMode) + } + + func updateAuxiliaryTextState(state: AuxiliaryTextDisplayState) { + guard let message = message, let context = lastContext else { return } + applyAuxiliaryText(message: message, state: state, isSelf: context.isSelf) + rebuildLayout(context: context, + showAvatar: shouldShowAvatar(context: context), + showNickname: !nicknameLabel.isHidden, + showStatus: !statusImageView.isHidden || sendingIndicator.isAnimating || !readReceiptLabel.isHidden, + multiSelect: false) + } + + func updateReaction(message: MessageInfo) { + guard let context = lastContext else { return } + self.message = message + applyReaction(message: message, context: context) + rebuildLayout(context: context, + showAvatar: shouldShowAvatar(context: context), + showNickname: !nicknameLabel.isHidden, + showStatus: !statusImageView.isHidden || sendingIndicator.isAnimating || !readReceiptLabel.isHidden, + multiSelect: !checkboxView.isHidden) + } + + // MARK: - Element Binding + + func updateMediaProgressIfNeeded(_ message: MessageInfo) { + (contentView0 as? MessageFileContentView)?.updateProgress(message: message) + } + + internal func playHighlightAnimation() { + highlightGeneration += 1 + let generation = highlightGeneration + let bubbleColor = normalBubbleColor ?? contentContainer.backgroundColor ?? .clear + let spec = MessageBubbleStyler.highlightSpec(bubbleColor: bubbleColor, traitCollection: traitCollection) + highlightOverlayView.layer.removeAllAnimations() + highlightOverlayView.backgroundColor = spec.color + highlightOverlayView.alpha = 0 + let segmentCount = MessageBubbleStyler.highlightFlashCount * 2 + let total = MessageBubbleStyler.highlightFlashDuration * TimeInterval(segmentCount) + UIView.animateKeyframes(withDuration: total, delay: 0, options: []) { + for index in 0.. (contentPadding: CGFloat, top: CGFloat, bottom: CGFloat, horizontal: CGFloat) { + if isMergedPayload { + return (0, Self.reactionBarCardTopInset, Self.reactionBarCardBottomInset, Self.reactionBarCardHorizontalInset) + } + if isMediaPayload { + if shouldWrapMediaInBubble { + return (Self.mediaBubblePadding, + Self.reactionBarTopInset, + Self.mediaBubblePadding + Self.reactionBarBottomInset, + Self.mediaBubblePadding + Self.reactionBarHorizontalInset) + } + return (0, Self.reactionBarMediaTopInset, 0, 0) + } + return (0, Self.reactionBarTopInset, Self.reactionBarBottomInset, Self.reactionBarHorizontalInset) + } + + private func applyViolation(message: MessageInfo) { + if message.status == .violation { + violationLabel.text = LocalizedChatString("MessageTypeSecurityStrikeInfo") + violationLabel.isHidden = false + } else { + violationLabel.isHidden = true + } + } + + private func applyAuxiliaryText(message: MessageInfo, state: AuxiliaryTextDisplayState, isSelf: Bool) { + switch state { + case .hidden: + auxiliaryTextBubbleView.isHidden = true + auxiliaryTextBubbleView.reset() + case .loading: + auxiliaryTextBubbleView.isHidden = false + auxiliaryTextBubbleView.bind(isSelf: isSelf, isLoading: true, contentText: nil, footerText: nil) + auxiliaryTextBubbleView.onLongPress = nil + case .text(let content, let footer): + auxiliaryTextBubbleView.isHidden = false + auxiliaryTextBubbleView.bind(isSelf: isSelf, isLoading: false, contentText: content, footerText: footer) + auxiliaryTextBubbleView.onLongPress = { [weak self] in + guard let self = self, let msg = self.message else { return } + let anchorFrame = self.auxiliaryTextBubbleView.convert(self.auxiliaryTextBubbleView.bounds, to: nil) + self.onAuxiliaryTextLongPress?(msg, content, anchorFrame) + } + } + } + + private func applyReadReceipt(message: MessageInfo, context: MessageContentContext) { + guard context.showsReadReceipt, MessageReadReceiptHelper.shouldShowReadReceipt(message) else { + readReceiptLabel.isHidden = true + readReceiptLabel.text = nil + return + } + readReceiptLabel.text = MessageReadReceiptHelper.receiptText(message) + readReceiptLabel.isHidden = false + readReceiptTapGesture.isEnabled = !context.isMultiSelectMode + } + + @objc private func handleReadReceiptTap() { + guard let message = message, message.conversationType == .group else { return } + onReadReceiptTap?(message) + } + + private func installContentViewIfNeeded(kind: MessageContentKind) { + guard contentKind != kind || contentView0 == nil else { return } + contentView0?.removeFromSuperview() + let view = MessageContentViewFactory.makeContentView(for: kind) + contentContainer.insertSubview(view, belowSubview: highlightOverlayView) + view.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.greaterThanOrEqualTo(Self.bubbleMinHeight) + } + contentView0 = view + contentKind = kind + } + + private func shouldShowAvatar(context: MessageContentContext) -> Bool { + return context.isLeft ? context.config.isShowLeftAvatar : context.config.isShowRightAvatar + } + + private func applyTime(_ timeString: String?, show: Bool) { + if let timeString = timeString, show, !timeString.isEmpty { + timeLabel.text = timeString + timeLabel.isHidden = false + } else { + timeLabel.text = nil + timeLabel.isHidden = true + } + } + + private func applyAvatar(message: MessageInfo, isLeft: Bool, show: Bool) { + avatarView.isHidden = !show + guard show else { return } + let displayName = Self.senderDisplayName(of: message.from) + avatarView.configure(avatarURL: message.from.avatarURL, fallbackName: displayName) + } + + private static func senderDisplayName(of sender: MessageSenderInfo) -> String { + if let nameCard = sender.nameCard, !nameCard.isEmpty { return nameCard } + if let friendRemark = sender.friendRemark, !friendRemark.isEmpty { return friendRemark } + if let nickname = sender.nickname, !nickname.isEmpty { return nickname } + return sender.userID + } + + private func applyNickname(message: MessageInfo, context: MessageContentContext) { + let show = context.isSelf + ? context.config.alignment != Self.twoSidedAlignment + : context.isGroupChat + if show { + nicknameLabel.text = Self.senderDisplayName(of: message.from) + nicknameLabel.isHidden = false + } else { + nicknameLabel.text = nil + nicknameLabel.isHidden = true + } + } + + private func applyDetailTime(_ text: String?) { + detailTimeLabel.text = text + detailTimeLabel.isHidden = text == nil + } + + private func applyStatus(message: MessageInfo) { + sendingIndicator.stopAnimating() + statusImageView.isHidden = true + switch message.status { + case .sendFail, .violation: + statusImageView.image = UIImage(systemName: "exclamationmark.circle.fill") + statusImageView.isHidden = false + case .sending: + sendingIndicator.startAnimating() + default: + break + } + } + + private func applyQuote(message: MessageInfo, isSupportQuote: Bool, isLeft: Bool) { + if isSupportQuote, let quoteInfo = message.quoteInfo, !quoteInfo.msgID.isEmpty { + quoteView.isHidden = false + quoteView.bind(quoteInfo: quoteInfo) + } else { + quoteView.isHidden = true + } + layoutBubbleColumnContent(showQuote: !quoteView.isHidden, isLeft: isLeft) + } + + private func layoutBubbleColumnContent(showQuote: Bool, isLeft: Bool) { + if showQuote { + contentContainer.snp.remakeConstraints { make in + make.top.equalToSuperview() + make.width.equalTo(0).priority(.low) + if isLeft { + make.leading.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } else { + make.trailing.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview() + } + } + quoteView.snp.remakeConstraints { make in + make.top.equalTo(contentContainer.snp.bottom).offset(Self.quoteTopSpacing) + make.bottom.equalToSuperview() + make.width.lessThanOrEqualTo(Self.quoteMaxWidth) + make.width.equalTo(0).priority(.low) + if isLeft { + make.leading.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } else { + make.trailing.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview() + } + } + } else { + quoteView.snp.removeConstraints() + contentContainer.snp.remakeConstraints { make in + make.edges.equalToSuperview() + } + } + } + + private func applyCheckbox(isMultiSelectMode: Bool, isSelected: Bool) { + checkboxView.isHidden = !isMultiSelectMode + readReceiptTapGesture.isEnabled = !isMultiSelectMode + guard isMultiSelectMode else { return } + let colors = ChatUIKitTheme.colors + if isSelected { + checkboxView.image = UIImage(systemName: "checkmark.circle.fill") + checkboxView.tintColor = colors.textColorLink + } else { + checkboxView.image = UIImage(systemName: "circle") + checkboxView.tintColor = colors.textColorTertiary + } + } + + private func applyBubbleBackground(context: MessageContentContext) { + let colors = ChatUIKitTheme.colors + + if isMergedPayload { + let showReactions = !reactionBar.isHidden + contentContainer.layer.mask = nil + contentContainer.clipsToBounds = true + contentContainer.isCustomMaskEnabled = false + if showReactions { + contentContainer.backgroundColor = colors.bgColorDialog + contentContainer.layer.cornerRadius = CGFloat(RadiusScheme.alertRadius) + contentContainer.layer.borderWidth = Self.mergedCardBorderWidth + contentContainer.layer.borderColor = colors.strokeColorPrimary.cgColor + } else { + contentContainer.backgroundColor = .clear + contentContainer.layer.cornerRadius = 0 + contentContainer.layer.borderWidth = 0 + contentContainer.layer.borderColor = nil + } + highlightOverlayView.layer.cornerRadius = CGFloat(RadiusScheme.alertRadius) + (contentView0 as? MessageMergedContentView)?.setCardChromeHidden(showReactions) + return + } + normalBubbleColor = context.isSelf ? colors.bgColorBubbleOwn : colors.bgColorBubbleReciprocal + + if isMediaPayload, !shouldWrapMediaInBubble { + contentContainer.backgroundColor = .clear + contentContainer.layer.mask = nil + contentContainer.layer.cornerRadius = 0 + contentContainer.layer.borderWidth = 0 + contentContainer.layer.borderColor = nil + contentContainer.clipsToBounds = true + contentContainer.isCustomMaskEnabled = false + highlightOverlayView.layer.cornerRadius = CGFloat(RadiusScheme.alertRadius) + return + } + + contentContainer.layer.cornerRadius = 0 + contentContainer.layer.borderWidth = 0 + contentContainer.layer.borderColor = nil + + contentContainer.isLeftBubble = context.isLeft + contentContainer.isCustomMaskEnabled = true + highlightOverlayView.layer.cornerRadius = 0 + MessageBubbleStyler.apply(to: contentContainer, + isSelf: context.isSelf, + isLeft: context.isLeft) + } + + @objc private func handleAvatarTap() { + guard let message = message, !message.isSentBySelf else { return } + onAvatarTap?(message) + } + + @objc private func handleAvatarLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began, let message = message, !message.isSentBySelf else { return } + onAvatarLongPress?(message) + } + + @objc private func handleStatusTap() { + guard let message = message, message.status == .sendFail else { return } + onResendRequested?(message) + } + + private func rebuildLayout(context: MessageContentContext, + showAvatar: Bool, + showNickname: Bool, + showStatus: Bool, + multiSelect: Bool) { + applyBubbleBackground(context: context) + let hp = context.config.horizontalPadding + let avatarSpacing = context.config.avatarSpacing + let cellSpacing = context.config.cellSpacing + let timeShown = !timeLabel.isHidden + let statusReserve: CGFloat = showStatus ? (Self.statusIconSize + Self.statusSlotGap) : 0 + + layoutHeader(timeShown: timeShown, cellSpacing: cellSpacing) + layoutCheckbox(show: multiSelect, leadingInset: hp) + + if context.isLeft { + layoutLeft(showAvatar: showAvatar, showNickname: showNickname, showStatus: showStatus, + multiSelect: multiSelect, hp: hp, avatarSpacing: avatarSpacing, + statusReserve: statusReserve) + } else { + layoutRight(showAvatar: showAvatar, showNickname: showNickname, showStatus: showStatus, + multiSelect: multiSelect, hp: hp, avatarSpacing: avatarSpacing, + statusReserve: statusReserve) + } + } + + private func layoutHeader(timeShown: Bool, cellSpacing: CGFloat) { + let timeToBody = max(Self.timeVerticalMargin - cellSpacing, 0) + timeLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(timeShown ? Self.timeVerticalMargin : 0) + make.centerX.equalToSuperview() + if !timeShown { make.height.equalTo(0) } + } + bodyContainer.snp.remakeConstraints { make in + make.top.equalTo(timeLabel.snp.bottom).offset(timeShown ? timeToBody : cellSpacing) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().offset(-cellSpacing) + } + } + + private func layoutCheckbox(show: Bool, leadingInset: CGFloat) { + guard show else { + checkboxView.snp.removeConstraints() + return + } + checkboxView.snp.remakeConstraints { make in + make.leading.equalToSuperview().offset(leadingInset) + make.centerY.equalTo(bubbleColumn) + make.width.height.equalTo(Self.checkboxSize) + } + } + + private func layoutLeft(showAvatar: Bool, showNickname: Bool, showStatus: Bool, + multiSelect: Bool, hp: CGFloat, avatarSpacing: CGFloat, + statusReserve: CGFloat) { + avatarView.snp.removeConstraints() + nicknameLabel.snp.removeConstraints() + detailTimeLabel.snp.removeConstraints() + if showAvatar { + avatarView.snp.makeConstraints { make in + if multiSelect { + make.leading.equalTo(checkboxView.snp.trailing).offset(Self.checkboxMarginEnd) + } else { + make.leading.equalToSuperview().offset(hp) + } + make.top.equalToSuperview() + make.width.height.equalTo(Self.avatarSize) + make.bottom.lessThanOrEqualToSuperview() + } + } + + bubbleColumn.snp.remakeConstraints { make in + if showAvatar { + make.leading.equalTo(avatarView.snp.trailing).offset(avatarSpacing) + } else if multiSelect { + make.leading.equalTo(checkboxView.snp.trailing).offset(Self.checkboxMarginEnd) + } else { + make.leading.equalToSuperview().offset(hp) + } + if showNickname { + make.top.equalTo(nicknameLabel.snp.bottom).offset(Self.nicknameBottomMargin) + } else { + make.top.equalToSuperview() + } + make.bottom.lessThanOrEqualToSuperview() + if detailTimeLabel.isHidden { + make.trailing.lessThanOrEqualToSuperview().offset(-(hp + statusReserve)) + } else { + make.trailing.lessThanOrEqualTo(detailTimeLabel.snp.leading).offset(-Self.nicknameDetailTimeSpacing) + } + make.width.equalTo(0).priority(.low) + } + + if showNickname { + nicknameLabel.snp.makeConstraints { make in + make.leading.equalTo(bubbleColumn.snp.leading) + make.top.equalToSuperview() + if detailTimeLabel.isHidden { + make.trailing.lessThanOrEqualToSuperview().offset(-(hp + statusReserve)) + } else { + make.trailing.lessThanOrEqualTo(detailTimeLabel.snp.leading).offset(-Self.nicknameDetailTimeSpacing) + } + } + } + if !detailTimeLabel.isHidden { + detailTimeLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-hp) + if showNickname { + make.centerY.equalTo(nicknameLabel) + } else { + make.top.equalToSuperview() + } + } + } + + layoutStatusSlot(show: showStatus, isLeft: true, hp: hp) + layoutReactionBar(isLeft: true) + layoutAuxiliaryTextBubble(isLeft: true, hp: hp) + layoutViolationLabel(isLeft: true, hp: hp) + } + + private func layoutRight(showAvatar: Bool, showNickname: Bool, showStatus: Bool, + multiSelect: Bool, hp: CGFloat, avatarSpacing: CGFloat, + statusReserve: CGFloat) { + avatarView.snp.removeConstraints() + nicknameLabel.snp.removeConstraints() + detailTimeLabel.snp.removeConstraints() + if showAvatar { + avatarView.snp.makeConstraints { make in + if detailTimeLabel.isHidden { + make.trailing.equalToSuperview().offset(-hp) + } else { + make.trailing.equalTo(detailTimeLabel.snp.leading).offset(-Self.nicknameDetailTimeSpacing) + } + make.top.equalToSuperview() + make.width.height.equalTo(Self.avatarSize) + make.bottom.lessThanOrEqualToSuperview() + } + } + + let leadingBound = hp + statusReserve + (multiSelect ? (Self.checkboxSize + Self.checkboxMarginEnd) : 0) + bubbleColumn.snp.remakeConstraints { make in + if showAvatar { + make.trailing.equalTo(avatarView.snp.leading).offset(-avatarSpacing) + } else if !detailTimeLabel.isHidden { + make.trailing.equalTo(detailTimeLabel.snp.leading).offset(-Self.nicknameDetailTimeSpacing) + } else { + make.trailing.equalToSuperview().offset(-hp) + } + if showNickname { + make.top.equalTo(nicknameLabel.snp.bottom).offset(Self.nicknameBottomMargin) + } else { + make.top.equalToSuperview() + } + make.bottom.lessThanOrEqualToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(leadingBound) + make.width.equalTo(0).priority(.low) + } + + if showNickname { + nicknameLabel.snp.makeConstraints { make in + make.trailing.equalTo(bubbleColumn.snp.trailing) + make.top.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(leadingBound) + } + } + if !detailTimeLabel.isHidden { + detailTimeLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-hp) + if showNickname { + make.centerY.equalTo(nicknameLabel) + } else { + make.top.equalToSuperview() + } + } + } + + layoutStatusSlot(show: showStatus, isLeft: false, hp: hp) + layoutReactionBar(isLeft: false) + layoutAuxiliaryTextBubble(isLeft: false, hp: hp) + layoutViolationLabel(isLeft: false, hp: hp) + } + + private func layoutReactionBar(isLeft: Bool) { + guard let contentView = contentView0 else { return } + let showReaction = !reactionBar.isHidden + let insets = reactionBarLayoutInsets() + if showReaction { + contentView.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(insets.contentPadding) + make.leading.equalToSuperview().offset(insets.contentPadding) + make.trailing.equalToSuperview().offset(-insets.contentPadding) + make.height.greaterThanOrEqualTo(Self.bubbleMinHeight) + make.bottom.equalTo(reactionBar.snp.top).offset(-insets.top) + } + reactionBar.snp.remakeConstraints { make in + make.top.equalTo(contentView.snp.bottom).offset(insets.top) + make.bottom.equalToSuperview().offset(-insets.bottom) + if isLeft { + make.leading.equalToSuperview().offset(insets.horizontal) + make.trailing.equalToSuperview().offset(-insets.horizontal) + } else { + make.trailing.equalToSuperview().offset(-insets.horizontal) + make.leading.equalToSuperview().offset(insets.horizontal) + } + } + } else { + contentView.snp.remakeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.greaterThanOrEqualTo(Self.bubbleMinHeight) + make.bottom.equalToSuperview() + } + reactionBar.snp.removeConstraints() + } + } + + private func layoutViolationLabel(isLeft: Bool, hp: CGFloat) { + if violationLabel.isHidden { + violationLabel.snp.removeConstraints() + return + } + violationLabel.snp.remakeConstraints { make in + make.top.equalTo(bubbleColumn.snp.bottom).offset(Self.violationTopInset) + make.bottom.equalToSuperview() + if isLeft { + make.leading.equalTo(bubbleColumn) + make.trailing.lessThanOrEqualToSuperview().offset(-hp) + } else { + make.trailing.equalTo(bubbleColumn) + make.leading.greaterThanOrEqualToSuperview().offset(hp) + } + } + } + + private func layoutAuxiliaryTextBubble(isLeft: Bool, hp: CGFloat) { + guard !auxiliaryTextBubbleView.isHidden else { + auxiliaryTextBubbleView.snp.removeConstraints() + return + } + let topInset = contentKind == .audio ? Self.auxiliaryAudioTopInset : Self.auxiliaryTextTopInset + auxiliaryTextBubbleView.snp.remakeConstraints { make in + make.top.equalTo(bubbleColumn.snp.bottom).offset(topInset) + make.bottom.equalToSuperview() + if isLeft { + make.leading.equalTo(bubbleColumn) + make.trailing.lessThanOrEqualToSuperview().offset(-hp) + } else { + make.trailing.equalTo(bubbleColumn) + make.leading.greaterThanOrEqualToSuperview().offset(hp) + } + } + } + + private func layoutStatusSlot(show: Bool, isLeft: Bool, hp: CGFloat) { + guard show else { + statusImageView.snp.removeConstraints() + sendingIndicator.snp.removeConstraints() + readReceiptLabel.snp.removeConstraints() + return + } + statusImageView.snp.remakeConstraints { make in + if isLeft { + make.leading.equalTo(bubbleColumn.snp.trailing).offset(Self.statusSlotGap) + } else { + make.trailing.equalTo(bubbleColumn.snp.leading).offset(-Self.statusSlotGap) + } + make.bottom.equalTo(bubbleColumn) + make.width.height.equalTo(Self.statusIconSize) + } + sendingIndicator.snp.remakeConstraints { make in + make.center.equalTo(statusImageView) + } + if !readReceiptLabel.isHidden { + readReceiptLabel.snp.remakeConstraints { make in + if isLeft { + make.leading.equalTo(bubbleColumn.snp.trailing).offset(Self.statusSlotGap) + } else { + make.trailing.equalTo(bubbleColumn.snp.leading).offset(-Self.statusSlotGap) + } + make.bottom.equalTo(bubbleColumn) + } + } else { + readReceiptLabel.snp.removeConstraints() + } + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageBubbleStyler.swift b/chat/uikit/components/messagelist/cell/MessageBubbleStyler.swift new file mode 100644 index 000000000..8c0523182 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageBubbleStyler.swift @@ -0,0 +1,122 @@ +import UIKit + +final class MessageBubbleContainerView: UIView { + var isLeftBubble = true + + var isCustomMaskEnabled = false + + override func layoutSubviews() { + super.layoutSubviews() + guard isCustomMaskEnabled else { return } + MessageBubbleStyler.updateBubbleMask(for: self, isLeft: isLeftBubble) + } +} + +struct BubbleHighlightSpec { + let color: UIColor + let maxAlpha: CGFloat +} + +enum MessageBubbleStyler { + + static let cornerRadius: CGFloat = 10 + + static let avatarSideTopCornerRadius: CGFloat = 2 + + static let highlightMaxAlpha: CGFloat = 72 / 255 + + static let highlightDarkBubbleMaxAlpha: CGFloat = 128 / 255 + + static let highlightDarkBubbleLuminanceThreshold: CGFloat = 0.5 + + static let highlightDarkBubbleLightenRatio: CGFloat = 0.4 + + static let highlightFlashDuration: TimeInterval = 0.26 + + static let highlightFlashCount = 3 + + static func highlightSpec(bubbleColor: UIColor, traitCollection: UITraitCollection) -> BubbleHighlightSpec { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + bubbleColor.resolvedColor(with: traitCollection).getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let luminance = 0.299 * red + 0.587 * green + 0.114 * blue + let isDarkBubble = luminance < highlightDarkBubbleLuminanceThreshold + var warningRed: CGFloat = 0 + var warningGreen: CGFloat = 0 + var warningBlue: CGFloat = 0 + var warningAlpha: CGFloat = 0 + ChatUIKitTheme.colors.textColorWarning.resolvedColor(with: traitCollection).getRed(&warningRed, green: &warningGreen, blue: &warningBlue, alpha: &warningAlpha) + let lightenRatio = isDarkBubble ? highlightDarkBubbleLightenRatio : 0 + let flashColor = UIColor( + red: warningRed + (1 - warningRed) * lightenRatio, + green: warningGreen + (1 - warningGreen) * lightenRatio, + blue: warningBlue + (1 - warningBlue) * lightenRatio, + alpha: warningAlpha + ) + return BubbleHighlightSpec( + color: flashColor, + maxAlpha: isDarkBubble ? highlightDarkBubbleMaxAlpha : highlightMaxAlpha + ) + } + + static func apply(to bubble: UIView, isSelf: Bool, isLeft: Bool) { + bubble.clipsToBounds = true + let colors = ChatUIKitTheme.colors + bubble.backgroundColor = isSelf ? colors.bgColorBubbleOwn : colors.bgColorBubbleReciprocal + updateBubbleMask(for: bubble, isLeft: isLeft) + } + + static func updateBubbleMask(for bubble: UIView, isLeft: Bool) { + guard bubble.bounds.width > 0, bubble.bounds.height > 0 else { return } + let maskLayer = CAShapeLayer() + let isRTL = bubble.effectiveUserInterfaceLayoutDirection == .rightToLeft + maskLayer.path = bubblePath(in: bubble.bounds, isLeft: isLeft != isRTL).cgPath + bubble.layer.mask = maskLayer + } + + static func squaredCornerMask(isLeft: Bool) -> CACornerMask { + if isLeft { + return [.layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] + } + return [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] + } + + private static func bubblePath(in rect: CGRect, isLeft: Bool) -> UIBezierPath { + let big = cornerRadius + let small = avatarSideTopCornerRadius + let path = UIBezierPath() + if isLeft { + path.move(to: CGPoint(x: rect.minX + small, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - big, y: rect.minY)) + path.addArc(withCenter: CGPoint(x: rect.maxX - big, y: rect.minY + big), radius: big, + startAngle: -.pi / 2, endAngle: 0, clockwise: true) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - big)) + path.addArc(withCenter: CGPoint(x: rect.maxX - big, y: rect.maxY - big), radius: big, + startAngle: 0, endAngle: .pi / 2, clockwise: true) + path.addLine(to: CGPoint(x: rect.minX + big, y: rect.maxY)) + path.addArc(withCenter: CGPoint(x: rect.minX + big, y: rect.maxY - big), radius: big, + startAngle: .pi / 2, endAngle: .pi, clockwise: true) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + small)) + path.addArc(withCenter: CGPoint(x: rect.minX + small, y: rect.minY + small), radius: small, + startAngle: .pi, endAngle: -.pi / 2, clockwise: true) + } else { + path.move(to: CGPoint(x: rect.minX + big, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - small, y: rect.minY)) + path.addArc(withCenter: CGPoint(x: rect.maxX - small, y: rect.minY + small), radius: small, + startAngle: -.pi / 2, endAngle: 0, clockwise: true) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - big)) + path.addArc(withCenter: CGPoint(x: rect.maxX - big, y: rect.maxY - big), radius: big, + startAngle: 0, endAngle: .pi / 2, clockwise: true) + path.addLine(to: CGPoint(x: rect.minX + big, y: rect.maxY)) + path.addArc(withCenter: CGPoint(x: rect.minX + big, y: rect.maxY - big), radius: big, + startAngle: .pi / 2, endAngle: .pi, clockwise: true) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + big)) + path.addArc(withCenter: CGPoint(x: rect.minX + big, y: rect.minY + big), radius: big, + startAngle: .pi, endAngle: -.pi / 2, clockwise: true) + } + path.close() + return path + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageCallContentView.swift b/chat/uikit/components/messagelist/cell/MessageCallContentView.swift new file mode 100644 index 000000000..c2348b396 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageCallContentView.swift @@ -0,0 +1,175 @@ +import UIKit +import SnapKit +import AtomicXCore +import TUICallKit_Swift + +final class MessageCallContentView: UIView, MessageContentView { + private static let horizontalInset = CGFloat(SpacingScheme.iconIconSpacing) + + private static let verticalInset = CGFloat(SpacingScheme.smallSpacing) + + private static let iconTextSpacing = CGFloat(SpacingScheme.smallSpacing) + + private static let audioIconSize = CGSize(width: 22, height: 8) + + private static let videoIconSize = CGSize(width: 22, height: 15) + + private static let textMaxLines = 2 + + private let textLabel = UILabel() + + private let iconImageView = UIImageView() + + private var currentMessage: MessageInfo? + + private var currentCallModel: CallMessageModel? + + private var currentIconSize: CGSize = .zero + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + guard let callModel = CallMessageParser.parse(message) else { + currentMessage = nil + currentCallModel = nil + textLabel.text = "" + iconImageView.isHidden = true + return + } + currentMessage = message + currentCallModel = callModel + + let contentColor = context.isSelf + ? ChatUIKitTheme.colors.textColorAntiPrimary + : ChatUIKitTheme.colors.textColorPrimary + + textLabel.text = callModel.displayString( + senderShowName: MessageListHelper.senderShowName(of: message) + ) + textLabel.textColor = contentColor + + applyIcon(callModel: callModel, isSelf: context.isSelf, tintColor: contentColor) + applyContentOrder(isSelf: context.isSelf) + } + + // MARK: - Re-call(对齐 Android reInitiateCall) + + private func constructViewHierarchy() { + addSubview(textLabel) + addSubview(iconImageView) + } + + private func activateConstraints() { + textLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(Self.verticalInset) + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalInset) + } + iconImageView.snp.makeConstraints { make in + make.centerY.equalTo(textLabel) + } + } + + private func setupViewStyle() { + textLabel.font = FontScheme.caption1Regular + textLabel.numberOfLines = Self.textMaxLines + iconImageView.contentMode = .scaleAspectFit + } + + private func bindInteraction() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + isUserInteractionEnabled = true + } + + private func applyIcon(callModel: CallMessageModel, isSelf: Bool, tintColor: UIColor) { + let iconName: String + let iconSize: CGSize + switch callModel.streamMediaType { + case .voice: + iconName = "message_call_audio" + iconSize = Self.audioIconSize + case .video: + iconName = "message_call_video" + iconSize = Self.videoIconSize + default: + iconImageView.isHidden = true + iconImageView.transform = .identity + return + } + iconImageView.isHidden = false + iconImageView.image = AtomicXChatResources.image(named: iconName) + iconImageView.tintColor = tintColor + + let shouldMirror = !isSelf && (callModel.streamMediaType == .video || callModel.streamMediaType == .voice) + iconImageView.transform = shouldMirror ? CGAffineTransform(scaleX: -1, y: 1) : .identity + currentIconSize = iconSize + } + + private func applyContentOrder(isSelf: Bool) { + let iconVisible = !iconImageView.isHidden + textLabel.snp.remakeConstraints { make in + make.top.bottom.equalToSuperview().inset(Self.verticalInset) + if isSelf { + make.leading.equalToSuperview().offset(Self.horizontalInset) + if iconVisible { + make.trailing.equalTo(iconImageView.snp.leading).offset(-Self.iconTextSpacing) + } else { + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + } + } else { + if iconVisible { + make.leading.equalTo(iconImageView.snp.trailing).offset(Self.iconTextSpacing) + } else { + make.leading.equalToSuperview().offset(Self.horizontalInset) + } + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + } + } + iconImageView.snp.remakeConstraints { make in + make.centerY.equalTo(textLabel) + if iconVisible { + make.width.equalTo(currentIconSize.width) + make.height.equalTo(currentIconSize.height) + if isSelf { + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + } else { + make.leading.equalToSuperview().offset(Self.horizontalInset) + } + } + } + } + + @objc private func handleTap() { + guard let message = currentMessage, let callModel = currentCallModel else { return } + let targetUserID: String + if callModel.isCaller { + targetUserID = callModel.inviteeList.first ?? message.to.trimmingCharacters(in: .whitespaces) + } else { + targetUserID = callModel.caller.trimmingCharacters(in: .whitespaces) + } + guard !targetUserID.isEmpty else { return } + let mediaType: CallMediaType = callModel.streamMediaType == .video ? .video : .audio + DataReport.reportInteractionMetrics(.chatInvokeCall) + TUICallKit.createInstance().calls( + userIdList: [targetUserID], + mediaType: mediaType, + params: nil, + completion: nil + ) + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageCellRegistry.swift b/chat/uikit/components/messagelist/cell/MessageCellRegistry.swift new file mode 100644 index 000000000..014b3df9a --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageCellRegistry.swift @@ -0,0 +1,193 @@ +import Foundation +import AtomicXCore + +enum MessageRenderKind: Equatable { + + case systemTip + + case revoked + + case bubble(MessageContentKind) + + case custom(businessID: String) +} + +enum MessageContentKind: Equatable { + case text + case image + case video + case audio + case file + case face + case merged + + case call + + case custom(String) + + case unsupported + + var reuseIdentifier: String { + switch self { + case .text: return "MessageCell.text" + case .image: return "MessageCell.image" + case .video: return "MessageCell.video" + case .audio: return "MessageCell.audio" + case .file: return "MessageCell.file" + case .face: return "MessageCell.face" + case .merged: return "MessageCell.merged" + case .call: return "MessageCell.call" + case .custom(let businessID): return "MessageCell.custom.\(businessID)" + case .unsupported: return "MessageCell.unsupported" + } + } + + static var allReuseIdentifiers: [String] { + let kinds: [MessageContentKind] = [.text, .image, .video, .audio, .file, .face, .merged, .call, .unsupported] + return kinds.map { $0.reuseIdentifier } + } +} + +final class MessageCellRegistry { + static let shared = MessageCellRegistry() + + var builtInReuseIdentifiers: [String] { + let contentKinds: [MessageContentKind] = [.text, .image, .video, .audio, .file, .face, .merged, .call, .unsupported] + return contentKinds.map { $0.reuseIdentifier } + [Self.systemTipReuseIdentifier, Self.revokedReuseIdentifier] + } + + static let systemTipReuseIdentifier = "MessageCell.systemTip" + + static let revokedReuseIdentifier = "MessageCell.revoked" + + private var customSystemBusinessIDs: Set = ["group_create"] + + private var customContentViewFactories: [String: () -> MessageContentView] = [:] + + var customReuseIdentifiers: [String] { + return customContentViewFactories.keys.map { MessageContentKind.custom($0).reuseIdentifier } + } + + // MARK: - Resolution + + func renderKind(for message: MessageInfo) -> MessageRenderKind { + if message.status == .revoked { + return .revoked + } + switch message.messagePayload { + case .tips: + return .systemTip + case .custom(let payload): + return resolveCustomKind(payload, message: message) + case .text: + return .bubble(.text) + case .image: + return .bubble(.image) + case .video: + return .bubble(.video) + case .audio: + return .bubble(.audio) + case .file: + return .bubble(.file) + case .face: + return .bubble(.face) + case .merged: + return .bubble(.merged) + case .stream, .none: + return .bubble(.unsupported) + } + } + + func reuseIdentifier(for kind: MessageRenderKind) -> String { + switch kind { + case .systemTip: return Self.systemTipReuseIdentifier + case .revoked: return Self.revokedReuseIdentifier + case .bubble(let contentKind): return contentKind.reuseIdentifier + case .custom(let businessID): return MessageContentKind.custom(businessID).reuseIdentifier + } + } + + // MARK: - Custom Message Registration + + func registerCustomMessageCell(businessID: String, + summaryProvider: @escaping (CustomMessagePayload) -> String?, + makeContentView: @escaping () -> MessageContentView) { + CustomMessageSummaryRegistry.shared.register(businessID: businessID, summaryProvider: summaryProvider) + customContentViewFactories[businessID] = makeContentView + } + + func makeCustomContentView(for businessID: String) -> MessageContentView? { + return customContentViewFactories[businessID]?() + } + + // MARK: - Centered Text (系统提示 / 撤回 居中灰字文案) + + func centeredText(for message: MessageInfo, kind: MessageRenderKind, config: MessageListConfigProtocol) -> String { + switch kind { + case .revoked: + return MessageListHelper.getMessageAbstract(message) + case .systemTip: + guard config.isShowSystemMessage else { return "" } + return systemTipText(for: message) + default: + return "" + } + } + + // MARK: - Private + + private init() {} + + private func systemTipText(for message: MessageInfo) -> String { + switch message.messagePayload { + case .tips(let payload): + return MessageListHelper.getGroupTipsDisplayString(payload.groupTips) + case .custom(let payload): + if let callModel = CallMessageParser.parse(message) { + return callModel.displayString(senderShowName: MessageListHelper.senderShowName(of: message)) + } + return customSystemText(from: payload) + default: + return "" + } + } + + private func customSystemText(from payload: CustomMessagePayload) -> String { + guard let data = payload.customData.data(using: .utf8), + let customInfo = ChatUtil.jsonData2Dictionary(jsonData: data) else { + return "" + } + let opUser = customInfo["opUser"] as? String ?? "" + let content = customInfo["content"] as? String ?? "" + if !opUser.isEmpty, !content.isEmpty { + return "\(opUser) \(content)" + } + return opUser.isEmpty ? content : opUser + } + + private func resolveCustomKind(_ payload: CustomMessagePayload, message: MessageInfo) -> MessageRenderKind { + + if let callModel = CallMessageParser.parse(message) { + return callModel.isGroup ? .systemTip : .bubble(.call) + } + guard let businessID = businessID(from: payload) else { + return .bubble(.unsupported) + } + if customSystemBusinessIDs.contains(businessID) { + return .systemTip + } + if customContentViewFactories[businessID] != nil { + return .custom(businessID: businessID) + } + return .bubble(.unsupported) + } + + private func businessID(from payload: CustomMessagePayload) -> String? { + guard let data = payload.customData.data(using: .utf8), + let customInfo = ChatUtil.jsonData2Dictionary(jsonData: data), + let businessID = customInfo["businessID"] as? String else { + return nil + } + return businessID + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageCenteredTextCell.swift b/chat/uikit/components/messagelist/cell/MessageCenteredTextCell.swift new file mode 100644 index 000000000..ff6f58ce2 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageCenteredTextCell.swift @@ -0,0 +1,88 @@ +import UIKit +import SnapKit + +final class MessageCenteredTextCell: UITableViewCell { + private static let timeTopPadding = CGFloat(SpacingScheme.contentSpacing) + + private static let timeBottomPadding = CGFloat(SpacingScheme.contentSpacing) + + private static let tipBottomPadding = CGFloat(SpacingScheme.contentSpacing) + + private static let tipHorizontalMargin = CGFloat(SpacingScheme.normalSpacing) + + private let timeLabel = UILabel() + + private let tipLabel = UILabel() + + private var timeHeightConstraint: Constraint? + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + func configure(text: String, timeString: String?, showTime: Bool) { + if let timeString = timeString, showTime, !timeString.isEmpty { + timeLabel.text = timeString + timeLabel.isHidden = false + timeLabel.snp.updateConstraints { $0.top.equalToSuperview().offset(Self.timeTopPadding) } + timeHeightConstraint?.deactivate() + } else { + timeLabel.text = nil + timeLabel.isHidden = true + timeLabel.snp.updateConstraints { $0.top.equalToSuperview().offset(0) } + timeHeightConstraint?.activate() + } + tipLabel.text = text + tipLabel.isHidden = text.isEmpty + } + + override func prepareForReuse() { + super.prepareForReuse() + timeLabel.text = nil + tipLabel.text = nil + } + + private func constructViewHierarchy() { + selectionStyle = .none + contentView.addSubview(timeLabel) + contentView.addSubview(tipLabel) + } + + private func activateConstraints() { + timeLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.timeTopPadding) + make.centerX.equalToSuperview() + timeHeightConstraint = make.height.equalTo(0).constraint + } + tipLabel.snp.makeConstraints { make in + make.top.equalTo(timeLabel.snp.bottom).offset(Self.timeBottomPadding) + make.centerX.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(Self.tipHorizontalMargin) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.tipHorizontalMargin) + make.bottom.equalToSuperview().offset(-Self.tipBottomPadding) + } + } + + private func setupViewStyle() { + backgroundColor = .clear + contentView.backgroundColor = .clear + timeLabel.textAlignment = .center + timeLabel.font = FontScheme.caption2Regular + timeLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + tipLabel.textAlignment = .center + tipLabel.numberOfLines = 0 + tipLabel.font = FontScheme.caption2Regular + tipLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageContentView.swift b/chat/uikit/components/messagelist/cell/MessageContentView.swift new file mode 100644 index 000000000..ab8586dfd --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageContentView.swift @@ -0,0 +1,28 @@ +import UIKit +import AtomicXCore + +enum MessageContentViewFactory { + + static func makeContentView(for kind: MessageContentKind) -> MessageContentView { + switch kind { + case .text: + return MessageTextContentView() + case .image: + return MessageImageContentView() + case .video: + return MessageVideoContentView() + case .audio: + return MessageAudioContentView() + case .file: + return MessageFileContentView() + case .merged: + return MessageMergedContentView() + case .call: + return MessageCallContentView() + case .custom(let businessID): + return MessageCellRegistry.shared.makeCustomContentView(for: businessID) ?? MessageFallbackContentView(kind: .unsupported) + case .face, .unsupported: + return MessageFallbackContentView(kind: kind) + } + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageFallbackContentView.swift b/chat/uikit/components/messagelist/cell/MessageFallbackContentView.swift new file mode 100644 index 000000000..816dbb086 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageFallbackContentView.swift @@ -0,0 +1,85 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MessageFallbackContentView: UIView, MessageContentView { + private static let cornerRadius: CGFloat = 10 + + private static let horizontalInset = CGFloat(SpacingScheme.iconIconSpacing) + + private static let verticalInset = CGFloat(SpacingScheme.smallSpacing) + + private static let maxBubbleWidth: CGFloat = UIScreen.main.bounds.width * 0.72 + + private let kind: MessageContentKind + + private let bubbleView = UIView() + + private let textLabel = UILabel() + + // MARK: - Init + + init(kind: MessageContentKind) { + self.kind = kind + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + textLabel.text = displayText(for: message, context: context) + applyBubbleStyle(isSelf: message.isSentBySelf) + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(bubbleView) + bubbleView.addSubview(textLabel) + } + + private func activateConstraints() { + bubbleView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.lessThanOrEqualTo(Self.maxBubbleWidth) + } + textLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(Self.verticalInset) + make.leading.trailing.equalToSuperview().inset(Self.horizontalInset) + } + } + + private func setupViewStyle() { + bubbleView.layer.cornerRadius = Self.cornerRadius + bubbleView.clipsToBounds = true + textLabel.numberOfLines = 0 + textLabel.font = FontScheme.caption2Regular + } + + private func displayText(for message: MessageInfo, context: MessageContentContext) -> String { + if message.messagePayload == nil { + return LocalizedChatString("NoMessageContent") + } + + if kind == .unsupported { + return context.config.isShowUnsupportMessage ? LocalizedChatString("MessageUnsupportedType") : "" + } + let abstract = MessageListHelper.getMessageAbstract(message) + if !abstract.isEmpty { + return abstract + } + return context.config.isShowUnsupportMessage ? LocalizedChatString("MessageUnsupportedType") : "" + } + + private func applyBubbleStyle(isSelf: Bool) { + let colors = ChatUIKitTheme.colors + textLabel.textColor = isSelf ? colors.textColorAntiPrimary : colors.textColorPrimary + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageFileContentView.swift b/chat/uikit/components/messagelist/cell/MessageFileContentView.swift new file mode 100644 index 000000000..883fb5c58 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageFileContentView.swift @@ -0,0 +1,229 @@ +import UIKit +import QuickLook +import SnapKit +import AtomicXCore + +final class MessageFileContentView: UIView, MessageContentView { + private static let bubbleWidth: CGFloat = 237 + + private static let bubblePadding = CGFloat(SpacingScheme.iconIconSpacing) + + private static let iconTextGap = CGFloat(SpacingScheme.smallSpacing) + + private static let iconSize: CGFloat = 40 + + private static let footerTopMargin: CGFloat = 6 + + private static let footerItemGap: CGFloat = 6 + + private static let downloadButtonSize: CGFloat = 20 + + private static let nameMaxLines = 1 + + private static let progressPercentMax = 100 + + private var message: MessageInfo? + + private let bubbleView = UIView() + + private let iconView = UIImageView() + + private let nameLabel = UILabel() + + private let footerRow = UIStackView() + + private let contentCenterGuide = UILayoutGuide() + + private let sizeLabel = UILabel() + + private let statusLabel = UILabel() + + private let downloadButton = UIButton(type: .custom) + + private var previewDataSource: FilePreviewDataSource? + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + self.message = message + guard case .file(let payload) = message.messagePayload else { return } + + let colors = ChatUIKitTheme.colors + let isSelf = context.isSelf + iconView.image = AtomicXChatResources.image(named: Self.fileTypeIcon(for: payload.fileName ?? "")) + nameLabel.text = payload.fileName ?? LocalizedChatString("UnknownFile") + nameLabel.textColor = isSelf ? colors.textColorAntiPrimary : colors.textColorPrimary + let footerColor = isSelf ? colors.textColorAntiSecondary : colors.textColorSecondary + sizeLabel.textColor = footerColor + statusLabel.textColor = footerColor + sizeLabel.text = Self.formatFileSize(Int64(payload.fileSize)) + + refreshDownloadState(message: message) + } + + func updateProgress(message: MessageInfo) { + self.message = message + refreshDownloadState(message: message) + } + + // MARK: - Local Helpers (FilePreviewManager 在 AtomicX 内为 internal,此处本地实现) + + private func constructViewHierarchy() { + addSubview(bubbleView) + bubbleView.addSubview(iconView) + bubbleView.addSubview(nameLabel) + footerRow.addArrangedSubview(sizeLabel) + footerRow.addArrangedSubview(statusLabel) + footerRow.addArrangedSubview(downloadButton) + bubbleView.addSubview(footerRow) + bubbleView.addLayoutGuide(contentCenterGuide) + } + + private func activateConstraints() { + bubbleView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(Self.bubbleWidth) + } + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.bubblePadding) + make.top.equalToSuperview().offset(Self.bubblePadding) + make.width.height.equalTo(Self.iconSize) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(Self.iconTextGap) + make.trailing.equalToSuperview().offset(-Self.bubblePadding) + make.top.equalTo(iconView.snp.top).priority(.high) + } + footerRow.axis = .horizontal + footerRow.spacing = Self.footerItemGap + footerRow.alignment = .center + footerRow.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.trailing.equalToSuperview().offset(-Self.bubblePadding) + make.top.equalTo(nameLabel.snp.bottom).offset(Self.footerTopMargin) + make.bottom.equalToSuperview().offset(-Self.bubblePadding).priority(.high) + } + downloadButton.snp.makeConstraints { make in + make.width.height.equalTo(Self.downloadButtonSize) + } + contentCenterGuide.snp.makeConstraints { make in + make.top.equalTo(nameLabel.snp.top) + make.bottom.equalTo(sizeLabel.snp.bottom) + make.centerY.equalToSuperview() + } + } + + private func setupViewStyle() { + iconView.contentMode = .scaleAspectFit + nameLabel.font = FontScheme.caption2Regular + nameLabel.numberOfLines = Self.nameMaxLines + nameLabel.lineBreakMode = .byTruncatingMiddle + sizeLabel.font = FontScheme.caption3Regular + statusLabel.font = FontScheme.caption3Regular + sizeLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + statusLabel.setContentHuggingPriority(.required, for: .horizontal) + downloadButton.setImage(AtomicXChatResources.image(named: "message_file_download"), for: .normal) + } + + private func bindInteraction() { + bubbleView.isUserInteractionEnabled = true + bubbleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleBubbleTap))) + downloadButton.addTarget(self, action: #selector(handleDownloadTap), for: .touchUpInside) + } + + private func refreshDownloadState(message: MessageInfo) { + guard case .file(let payload) = message.messagePayload else { return } + let isDownloaded = !(payload.filePath?.isEmpty ?? true) + let progress = message.downloadMediaProgress > 0 ? message.downloadMediaProgress : message.uploadMediaProgress + let isDownloading = !isDownloaded && progress > 0 && progress < Self.progressPercentMax + statusLabel.text = isDownloading ? "\(progress)%" : "" + downloadButton.isHidden = isDownloaded || isDownloading + } + + @objc private func handleBubbleTap() { + guard case .file(let payload)? = message?.messagePayload else { return } + + guard let filePath = payload.filePath, !filePath.isEmpty, + FileManager.default.fileExists(atPath: filePath) else { + handleDownloadTap() + return + } + presentPreview(filePath: filePath) + } + + @objc private func handleDownloadTap() { + guard let message = message else { return } + MessageActionStore.create(message: message).downloadMedia(quality: nil) { _ in } + } + + private func presentPreview(filePath: String) { + let dataSource = FilePreviewDataSource(fileURL: URL(fileURLWithPath: filePath)) + previewDataSource = dataSource + let controller = QLPreviewController() + controller.dataSource = dataSource + findViewController()?.present(controller, animated: true) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } + + private static func fileTypeIcon(for fileName: String) -> String { + let ext = (fileName as NSString).pathExtension.lowercased() + switch ext { + case "pdf": return "message_file_type_pdf" + case "ppt", "pptx", "key", "keynote": return "message_file_type_ppt" + case "doc", "docx": return "message_file_type_word" + case "xls", "xlsx", "csv", "numbers": return "message_file_type_excel" + case "txt", "log", "md", "rtf": return "message_file_type_txt" + case "zip", "rar", "7z", "tar", "gz", "bz2": return "message_file_type_zip" + case "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "heic", "heif": return "message_file_type_img" + default: return "message_file_type_unknown" + } + } + + private static func formatFileSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + +private final class FilePreviewDataSource: NSObject, QLPreviewControllerDataSource { + private let fileURL: URL + + init(fileURL: URL) { + self.fileURL = fileURL + super.init() + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + return 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + return fileURL as QLPreviewItem + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageImageContentView.swift b/chat/uikit/components/messagelist/cell/MessageImageContentView.swift new file mode 100644 index 000000000..b3fef7726 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageImageContentView.swift @@ -0,0 +1,158 @@ +import UIKit +import SnapKit +import Kingfisher +import AtomicXCore + +final class MessageImageContentView: UIView, MessageContentView { + private static let maxSize: CGFloat = 200 + + private static let minSize: CGFloat = 80 + + private static let cornerRadius = CGFloat(RadiusScheme.alertRadius) + + private static let placeholderIconSize: CGFloat = 40 + + private var boundMsgID: String? + + private var hasTriggeredDownload = false + + private var boundMessage: MessageInfo? + + private var onMediaTap: ((MessageInfo) -> Void)? + + private let imageView = UIImageView() + + private let placeholderIcon = UIImageView() + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + if boundMsgID != message.msgID { + hasTriggeredDownload = false + } + boundMsgID = message.msgID + boundMessage = message + onMediaTap = context.onMediaTap + + guard case .image(let payload) = message.messagePayload else { + showPlaceholder() + return + } + updateDisplaySize(for: payload) + loadThumbnail(payload: payload, message: message) + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(imageView) + imageView.addSubview(placeholderIcon) + } + + private func activateConstraints() { + remakeImageConstraints(size: CGSize(width: Self.maxSize, height: Self.maxSize)) + placeholderIcon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(Self.placeholderIconSize) + } + } + + private func remakeImageConstraints(size: CGSize) { + imageView.snp.remakeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(size.width) + make.height.equalTo(size.height) + } + } + + private func setupViewStyle() { + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = Self.cornerRadius + placeholderIcon.contentMode = .scaleAspectFit + placeholderIcon.image = UIImage(systemName: "photo") + placeholderIcon.tintColor = ChatUIKitTheme.colors.textColorSecondary + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleMediaTap))) + } + + @objc private func handleMediaTap() { + guard let message = boundMessage else { return } + + guard message.status != .violation else { return } + onMediaTap?(message) + } + + private func updateDisplaySize(for payload: ImageMessagePayload) { + let size = Self.displaySize(width: payload.originalImageWidth, height: payload.originalImageHeight) + remakeImageConstraints(size: size) + } + + private static func displaySize(width: Int, height: Int) -> CGSize { + guard width > 0, height > 0 else { + return CGSize(width: maxSize, height: maxSize) + } + let ratio = CGFloat(width) / CGFloat(height) + let unscaledWidth: CGFloat + let unscaledHeight: CGFloat + if ratio > 1 { + unscaledWidth = maxSize + unscaledHeight = maxSize / ratio + } else { + unscaledHeight = maxSize + unscaledWidth = maxSize * ratio + } + let finalWidth = min(max(unscaledWidth, minSize), maxSize) + let finalHeight = min(max(unscaledHeight, minSize), maxSize) + return CGSize(width: finalWidth, height: finalHeight) + } + + private func loadThumbnail(payload: ImageMessagePayload, message: MessageInfo) { + if let path = payload.largeImagePath ?? payload.originalImagePath, + !path.isEmpty, + FileManager.default.fileExists(atPath: path), + let image = UIImage(contentsOfFile: path) { + imageView.kf.cancelDownloadTask() + imageView.image = image + placeholderIcon.isHidden = true + imageView.backgroundColor = .clear + return + } + if let urlString = payload.largeImageURL ?? payload.originalImageURL ?? payload.thumbImageURL, + !urlString.isEmpty, + let url = URL(string: urlString) { + placeholderIcon.isHidden = true + imageView.backgroundColor = ChatUIKitTheme.colors.bgColorBubbleReciprocal + imageView.kf.setImage(with: url) + return + } + showPlaceholder() + triggerDownloadIfNeeded(message: message) + } + + private func showPlaceholder() { + imageView.kf.cancelDownloadTask() + imageView.image = nil + imageView.backgroundColor = ChatUIKitTheme.colors.bgColorBubbleReciprocal + placeholderIcon.isHidden = false + } + + private func triggerDownloadIfNeeded(message: MessageInfo) { + guard !hasTriggeredDownload else { return } + hasTriggeredDownload = true + MessageActionStore.create(message: message).downloadMedia(quality: .standard) { _ in } + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageMergedContentView.swift b/chat/uikit/components/messagelist/cell/MessageMergedContentView.swift new file mode 100644 index 000000000..e138b477d --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageMergedContentView.swift @@ -0,0 +1,189 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MessageMergedContentView: UIView, MessageContentView { + private static let cardWidth: CGFloat = 214 + + private static let cornerRadius = CGFloat(RadiusScheme.alertRadius) + + private static let horizontalPadding = CGFloat(SpacingScheme.iconIconSpacing) + + private static let verticalPadding: CGFloat = 10 + + private static let titleToAbstractSpacing: CGFloat = 6 + + private static let abstractLineSpacing: CGFloat = 2 + + private static let dividerTopSpacing: CGFloat = 9 + + private static let dividerBottomSpacing: CGFloat = 6 + + private static let dividerHeight: CGFloat = 0.5 + + private static let maxAbstractLines = 4 + + private static let cardBorderWidth: CGFloat = 1 + + private static let titleMaxLines = 1 + + private static let abstractMaxLines = 1 + + private let cardView = UIView() + + private let titleLabel = UILabel() + + private let abstractStack = UIStackView() + + private let dividerView = UIView() + + private let hintLabel = UILabel() + + private var onTap: ((MessageInfo) -> Void)? + + private var tappedMessage: MessageInfo? + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + tappedMessage = message + onTap = context.onMergedMessageTap + titleLabel.text = Self.mergedTitle(from: message) + rebuildAbstracts(Self.abstractList(from: message)) + } + + func setCardChromeHidden(_ hidden: Bool) { + let colors = ChatUIKitTheme.colors + cardView.backgroundColor = hidden ? .clear : colors.bgColorDialog + cardView.layer.borderWidth = hidden ? 0 : Self.cardBorderWidth + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(cardView) + cardView.addSubview(titleLabel) + cardView.addSubview(abstractStack) + cardView.addSubview(dividerView) + cardView.addSubview(hintLabel) + } + + private func bindInteraction() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + } + + @objc private func handleTap() { + guard let message = tappedMessage else { return } + onTap?(message) + } + + private func activateConstraints() { + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(Self.cardWidth) + } + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.verticalPadding) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + } + abstractStack.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Self.titleToAbstractSpacing) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + } + dividerView.snp.makeConstraints { make in + make.top.equalTo(abstractStack.snp.bottom).offset(Self.dividerTopSpacing) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.height.equalTo(Self.dividerHeight) + } + hintLabel.snp.makeConstraints { make in + make.top.equalTo(dividerView.snp.bottom).offset(Self.dividerBottomSpacing) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + + cardView.backgroundColor = colors.bgColorDialog + cardView.layer.cornerRadius = Self.cornerRadius + cardView.layer.borderWidth = Self.cardBorderWidth + cardView.layer.borderColor = colors.strokeColorPrimary.cgColor + cardView.clipsToBounds = true + + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorPrimary + titleLabel.numberOfLines = Self.titleMaxLines + titleLabel.textAlignment = .natural + + abstractStack.axis = .vertical + abstractStack.alignment = .fill + abstractStack.spacing = Self.abstractLineSpacing + + dividerView.backgroundColor = colors.strokeColorPrimary + + hintLabel.font = FontScheme.caption4Regular + hintLabel.textColor = colors.textColorSecondary + hintLabel.text = LocalizedChatString("RelayChatHistory") + hintLabel.textAlignment = .natural + } + + private func rebuildAbstracts(_ abstracts: [String]) { + abstractStack.arrangedSubviews.forEach { + abstractStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + abstractStack.isHidden = abstracts.isEmpty + for abstract in abstracts.prefix(Self.maxAbstractLines) { + abstractStack.addArrangedSubview(makeAbstractLabel(abstract)) + } + } + + private func makeAbstractLabel(_ abstract: String) -> UILabel { + let label = UILabel() + label.font = FontScheme.caption3Regular + label.textColor = ChatUIKitTheme.colors.textColorSecondary + label.numberOfLines = Self.abstractMaxLines + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .natural + if abstract.contains("[TUIEmoji_") { + label.attributedText = EmojiManager.shared.createStyledAttributedString( + fromEmojiCodes: abstract, + font: FontScheme.caption3Regular, + textColor: ChatUIKitTheme.colors.textColorSecondary + ) + } else { + label.text = abstract + } + return label + } + + private static func mergedTitle(from message: MessageInfo) -> String { + if case .merged(let payload) = message.messagePayload, !payload.title.isEmpty { + return payload.title + } + return LocalizedChatString("RelayChatHistory") + } + + private static func abstractList(from message: MessageInfo) -> [String] { + if case .merged(let payload) = message.messagePayload { + return payload.abstractList ?? [] + } + return [] + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageQuoteBubbleView.swift b/chat/uikit/components/messagelist/cell/MessageQuoteBubbleView.swift new file mode 100644 index 000000000..c1a738bf5 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageQuoteBubbleView.swift @@ -0,0 +1,345 @@ +import UIKit +import SnapKit +import Kingfisher +import AtomicXCore + +final class MessageQuoteBubbleView: UIView { + var onTap: (() -> Void)? + + private static let cornerRadius = CGFloat(RadiusScheme.smallRadius) + + private static let contentPadding = CGFloat(SpacingScheme.smallSpacing) + + private static let contentTextTopSpacing: CGFloat = 2 + + private static let thumbnailSize: CGFloat = 36 + + private static let thumbnailTopSpacing: CGFloat = 6 + + private static let thumbnailCornerRadius = CGFloat(RadiusScheme.tipsRadius) + + private static let senderMaxLines = 1 + + private static let contentMaxLines = 2 + + private static let emojiToFontSizeRatio: CGFloat = 1.5 + + private static let emojiVerticalOffsetRatio: CGFloat = -0.2 + + private let senderLabel = UILabel() + + private let contentLabel = UILabel() + + private let thumbnailContainer = UIView() + + private let thumbnailView = UIImageView() + + private let videoOverlayLabel = UILabel() + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Bind + + func bind(quoteInfo: MessageQuoteInfo) { + let display = MessageQuoteDisplayResolver.resolve(quoteInfo: quoteInfo) + + let senderShown = !display.senderName.isEmpty + senderLabel.isHidden = !senderShown + senderLabel.text = senderShown + ? String(format: LocalizedChatString("QuoteSenderFormat"), display.senderName) + : nil + + let thumbnailShown = display.thumbnail != nil + if let thumbnail = display.thumbnail { + contentLabel.isHidden = true + contentLabel.text = nil + bindThumbnail(thumbnail) + } else { + thumbnailContainer.isHidden = true + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + contentLabel.isHidden = false + bindContentText(display) + } + relayoutContent(senderShown: senderShown, thumbnailShown: thumbnailShown) + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(senderLabel) + addSubview(contentLabel) + addSubview(thumbnailContainer) + thumbnailContainer.addSubview(thumbnailView) + thumbnailContainer.addSubview(videoOverlayLabel) + } + + private func activateConstraints() { + senderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.contentPadding) + make.leading.equalToSuperview().offset(Self.contentPadding) + make.trailing.equalToSuperview().offset(-Self.contentPadding) + } + thumbnailView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + videoOverlayLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorBubbleReciprocal + layer.cornerRadius = Self.cornerRadius + clipsToBounds = true + + senderLabel.font = FontScheme.caption3Bold + senderLabel.textColor = colors.textColorSecondary + senderLabel.numberOfLines = Self.senderMaxLines + senderLabel.lineBreakMode = .byTruncatingTail + + contentLabel.font = FontScheme.caption3Regular + contentLabel.textColor = colors.textColorSecondary + contentLabel.numberOfLines = Self.contentMaxLines + contentLabel.lineBreakMode = .byTruncatingTail + + thumbnailContainer.backgroundColor = colors.bgColorBubbleReciprocal + thumbnailContainer.layer.cornerRadius = Self.thumbnailCornerRadius + thumbnailContainer.clipsToBounds = true + + thumbnailView.contentMode = .scaleAspectFill + thumbnailView.clipsToBounds = true + + videoOverlayLabel.text = "▶" + videoOverlayLabel.textAlignment = .center + videoOverlayLabel.font = FontScheme.caption1Regular + videoOverlayLabel.textColor = colors.textColorAntiPrimary + } + + private func bindInteraction() { + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) + } + + @objc private func handleTap() { + onTap?() + } + + private func relayoutContent(senderShown: Bool, thumbnailShown: Bool) { + contentLabel.snp.remakeConstraints { make in + if senderShown { + make.top.equalTo(senderLabel.snp.bottom).offset(Self.contentTextTopSpacing) + } else { + make.top.equalToSuperview().offset(Self.contentPadding) + } + make.leading.equalToSuperview().offset(Self.contentPadding) + make.trailing.equalToSuperview().offset(-Self.contentPadding) + if !thumbnailShown { + make.bottom.equalToSuperview().offset(-Self.contentPadding) + } + } + if thumbnailShown { + thumbnailContainer.snp.remakeConstraints { make in + if senderShown { + make.top.equalTo(senderLabel.snp.bottom).offset(Self.thumbnailTopSpacing) + } else { + make.top.equalToSuperview().offset(Self.contentPadding) + } + make.leading.equalToSuperview().offset(Self.contentPadding) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.contentPadding) + make.width.height.equalTo(Self.thumbnailSize) + make.bottom.equalToSuperview().offset(-Self.contentPadding) + } + } else { + thumbnailContainer.snp.removeConstraints() + } + } + + private func bindContentText(_ display: MessageQuoteDisplayData) { + if display.shouldRenderEmoji { + contentLabel.attributedText = emojiAttributedString(from: display.contentText) + } else { + contentLabel.text = display.contentText + } + } + + private func emojiAttributedString(from text: String) -> NSAttributedString { + let source = EmojiManager.shared.createAttributedStringFromEmojiCodes(from: text) + let mutable = NSMutableAttributedString(attributedString: source) + let fullRange = NSRange(location: 0, length: mutable.length) + let emojiSize = FontScheme.caption3Regular.pointSize * Self.emojiToFontSizeRatio + let colors = ChatUIKitTheme.colors + mutable.enumerateAttribute(.attachment, in: fullRange, options: []) { value, range, _ in + if let attachment = value as? NSTextAttachment { + attachment.bounds = CGRect( + x: 0, + y: emojiSize * Self.emojiVerticalOffsetRatio, + width: emojiSize, + height: emojiSize + ) + } else { + mutable.addAttribute(.font, value: FontScheme.caption3Regular, range: range) + mutable.addAttribute(.foregroundColor, value: colors.textColorSecondary, range: range) + } + } + return mutable + } + + private func bindThumbnail(_ thumbnail: MessageQuoteThumbnail) { + thumbnailContainer.isHidden = false + videoOverlayLabel.isHidden = !thumbnail.isVideo + + if !thumbnail.path.hasPrefix("http"), + FileManager.default.fileExists(atPath: thumbnail.path), + let image = UIImage(contentsOfFile: thumbnail.path) { + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = image + return + } + if let url = URL(string: thumbnail.path) { + thumbnailView.kf.setImage(with: url) + } + } +} + +// MARK: - Display Policy (对齐 Android MessageQuoteDisplayPolicy) + +struct MessageQuoteDisplayData { + let senderName: String + let contentText: String + var thumbnail: MessageQuoteThumbnail? = nil + var isStatusText: Bool = false + var isPartial: Bool = false + var shouldRenderEmoji: Bool = false +} + +struct MessageQuoteThumbnail { + let path: String + let isVideo: Bool +} + +enum MessageQuoteDisplayResolver { + + private static let secondsPerMinute = 60 + + static func resolve(quoteInfo: MessageQuoteInfo) -> MessageQuoteDisplayData { + let senderName = resolveSenderName(quoteInfo) + let payload = quoteInfo.messagePayload + + if quoteInfo.status == .revoked { + return MessageQuoteDisplayData( + senderName: senderName, + contentText: LocalizedChatString("ReferenceOriginMessageRevoke"), + isStatusText: true + ) + } + + if payload == nil { + return MessageQuoteDisplayData( + senderName: senderName, + contentText: "...", + isPartial: true + ) + } + + if quoteInfo.status == .deleted { + return MessageQuoteDisplayData( + senderName: senderName, + contentText: LocalizedChatString("ReferenceOriginMessageDeleted"), + isStatusText: true + ) + } + + return MessageQuoteDisplayData( + senderName: senderName, + contentText: resolveContentText(payload!), + thumbnail: resolveThumbnail(payload!), + shouldRenderEmoji: isTextPayload(payload!) + ) + } + + private static func resolveSenderName(_ quoteInfo: MessageQuoteInfo) -> String { + let sender = quoteInfo.sender + if let remark = sender.friendRemark, !remark.isEmpty { return remark } + if let nameCard = sender.nameCard, !nameCard.isEmpty { return nameCard } + if let nickname = sender.nickname, !nickname.isEmpty { return nickname } + return sender.userID + } + + private static func resolveContentText(_ payload: MessagePayload) -> String { + switch payload { + case .text(let text): + return text.text + case .image: + return LocalizedChatString("MessageTypeImage") + case .video: + return LocalizedChatString("MessageTypeVideo") + case .audio(let audio): + return resolveAudioText(audio) + case .file(let file): + if let name = file.fileName, !name.isEmpty { return name } + return LocalizedChatString("MessageTypeFile") + case .face: + return LocalizedChatString("MessageTypeAnimateEmoji") + case .custom(let custom): + if let desc = custom.description, !desc.isEmpty { return desc } + return LocalizedChatString("MessageTipsUnsupportCustomMessage") + case .merged(let merged): + if !merged.title.isEmpty { return merged.title } + return LocalizedChatString("MessageTypeMergedHistory") + default: + return LocalizedChatString("MessageTipsUnsupportCustomMessage") + } + } + + private static func resolveAudioText(_ payload: AudioMessagePayload) -> String { + let voiceLabel = LocalizedChatString("MessageTypeVoice") + let duration = payload.audioDuration + guard duration > 0 else { return voiceLabel } + let minutes = duration / secondsPerMinute + let seconds = duration % secondsPerMinute + let durationText = minutes > 0 + ? String(format: "%d:%02d\"", minutes, seconds) + : "\(seconds)\"" + return "\(voiceLabel) \(durationText)" + } + + private static func resolveThumbnail(_ payload: MessagePayload) -> MessageQuoteThumbnail? { + switch payload { + case .image(let image): + let path = firstNonEmpty(image.thumbImagePath, image.thumbImageURL, + image.originalImagePath, image.originalImageURL) + return path.map { MessageQuoteThumbnail(path: $0, isVideo: false) } + case .video(let video): + let path = firstNonEmpty(video.videoSnapshotPath, video.videoSnapshotURL) + return path.map { MessageQuoteThumbnail(path: $0, isVideo: true) } + default: + return nil + } + } + + private static func isTextPayload(_ payload: MessagePayload) -> Bool { + if case .text = payload { return true } + return false + } + + private static func firstNonEmpty(_ candidates: String?...) -> String? { + for candidate in candidates { + if let value = candidate, !value.isEmpty { return value } + } + return nil + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageTextContentView.swift b/chat/uikit/components/messagelist/cell/MessageTextContentView.swift new file mode 100644 index 000000000..fd151286e --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageTextContentView.swift @@ -0,0 +1,117 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MessageTextContentView: UIView, MessageContentView { + private static let horizontalInset = CGFloat(SpacingScheme.iconIconSpacing) + + private static let verticalInset = CGFloat(SpacingScheme.smallSpacing) + + private static let lineSpacingMultiplier: CGFloat = 0.3 + + private static let maxBubbleWidth: CGFloat = UIScreen.main.bounds.width * 0.72 + + private let textLabel = UILabel() + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + let text = Self.currentText(from: message) + applyText(text, isSelf: context.isSelf) + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(textLabel) + } + + private func activateConstraints() { + textLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(Self.verticalInset) + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalInset) + make.width.lessThanOrEqualTo(Self.maxBubbleWidth - Self.horizontalInset * 2) + } + } + + private func setupViewStyle() { + textLabel.numberOfLines = 0 + textLabel.font = FontScheme.caption1Regular + textLabel.textAlignment = .natural + textLabel.preferredMaxLayoutWidth = Self.maxBubbleWidth - Self.horizontalInset * 2 + } + + private static func currentText(from message: MessageInfo) -> String { + if case .text(let payload) = message.messagePayload { + return payload.text + } + return "" + } + + private func applyText(_ text: String, isSelf: Bool) { + let textColor = isSelf ? ChatUIKitTheme.colors.textColorAntiPrimary : ChatUIKitTheme.colors.textColorPrimary + textLabel.textAlignment = .natural + if text.contains("[TUIEmoji_") { + textLabel.attributedText = emojiAttributedString(from: text, textColor: textColor) + } else { + textLabel.attributedText = plainAttributedString(from: text, textColor: textColor) + } + applyLabelPosition(isSelf: isSelf) + } + + private func plainAttributedString(from text: String, textColor: UIColor) -> NSAttributedString { + let font = FontScheme.caption1Regular + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .natural + paragraph.lineSpacing = font.lineHeight * Self.lineSpacingMultiplier + return NSAttributedString(string: text, attributes: [ + .font: font, + .foregroundColor: textColor, + .paragraphStyle: paragraph + ]) + } + + private func applyLabelPosition(isSelf: Bool) { + textLabel.snp.remakeConstraints { make in + make.top.bottom.equalToSuperview().inset(Self.verticalInset) + make.width.lessThanOrEqualTo(Self.maxBubbleWidth - Self.horizontalInset * 2) + if isSelf { + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + make.leading.greaterThanOrEqualToSuperview().offset(Self.horizontalInset) + } else { + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalInset) + } + } + } + + private func emojiAttributedString(from text: String, textColor: UIColor) -> NSAttributedString { + let source = EmojiManager.shared.createAttributedStringFromEmojiCodes(from: text) + let mutable = NSMutableAttributedString(attributedString: source) + let fullRange = NSRange(location: 0, length: mutable.length) + mutable.enumerateAttribute(.attachment, in: fullRange, options: []) { value, range, _ in + guard value == nil else { return } + mutable.addAttribute(.font, value: FontScheme.caption1Regular, range: range) + mutable.addAttribute(.foregroundColor, value: textColor, range: range) + } + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .natural + paragraph.lineSpacing = FontScheme.caption1Regular.lineHeight * Self.lineSpacingMultiplier + mutable.addAttribute(.paragraphStyle, value: paragraph, range: fullRange) + return mutable + } +} diff --git a/chat/uikit/components/messagelist/cell/MessageVideoContentView.swift b/chat/uikit/components/messagelist/cell/MessageVideoContentView.swift new file mode 100644 index 000000000..b985dd9a0 --- /dev/null +++ b/chat/uikit/components/messagelist/cell/MessageVideoContentView.swift @@ -0,0 +1,223 @@ +import UIKit +import SnapKit +import Kingfisher +import AtomicXCore + +final class MessageVideoContentView: UIView, MessageContentView { + private static let maxSize: CGFloat = 200 + + private static let minSize: CGFloat = 80 + + private static let cornerRadius = CGFloat(RadiusScheme.alertRadius) + + private static let playButtonSize: CGFloat = 44 + + private static let durationChipRadius = CGFloat(RadiusScheme.tipsRadius) + + private static let durationChipMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let durationChipInsets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) + + private static let durationChipBackgroundAlpha: CGFloat = 0.4 + + private var boundMsgID: String? + + private var hasTriggeredDownload = false + + private var boundMessage: MessageInfo? + + private var onMediaTap: ((MessageInfo) -> Void)? + + private let snapshotView = UIImageView() + + private let playIconView = UIImageView() + + private let durationLabel = PaddedLabel(insets: MessageVideoContentView.durationChipInsets) + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - MessageContentView + + func bind(message: MessageInfo, context: MessageContentContext) { + if boundMsgID != message.msgID { + hasTriggeredDownload = false + } + boundMsgID = message.msgID + boundMessage = message + onMediaTap = context.onMediaTap + + guard case .video(let payload) = message.messagePayload else { + showPlaceholder() + durationLabel.isHidden = true + return + } + applyDuration(payload.videoDuration) + updateDisplaySize(for: payload) + loadSnapshot(payload: payload, message: message) + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(snapshotView) + snapshotView.addSubview(playIconView) + snapshotView.addSubview(durationLabel) + } + + private func activateConstraints() { + remakeSnapshotConstraints(size: CGSize(width: Self.maxSize, height: Self.maxSize)) + playIconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(Self.playButtonSize) + } + durationLabel.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(Self.durationChipMargin) + } + } + + private func remakeSnapshotConstraints(size: CGSize) { + snapshotView.snp.remakeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(size.width) + make.height.equalTo(size.height) + } + } + + private func setupViewStyle() { + snapshotView.contentMode = .scaleAspectFill + snapshotView.clipsToBounds = true + snapshotView.layer.cornerRadius = Self.cornerRadius + snapshotView.isUserInteractionEnabled = true + + playIconView.contentMode = .scaleAspectFit + if let playImage = AtomicXChatResources.image(named: "message_video_play_icon") { + playIconView.image = playImage + } else { + playIconView.image = UIImage(systemName: "play.fill") + playIconView.tintColor = .white + } + + durationLabel.font = FontScheme.caption3Regular + durationLabel.textColor = .white + durationLabel.backgroundColor = UIColor(white: 0, alpha: Self.durationChipBackgroundAlpha) + durationLabel.textAlignment = .center + durationLabel.layer.cornerRadius = Self.durationChipRadius + durationLabel.clipsToBounds = true + durationLabel.layer.masksToBounds = true + + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleMediaTap))) + } + + @objc private func handleMediaTap() { + guard let message = boundMessage else { return } + + guard message.status != .violation else { return } + onMediaTap?(message) + } + + private func updateDisplaySize(for payload: VideoMessagePayload) { + let size = Self.displaySize(width: payload.videoSnapshotWidth, height: payload.videoSnapshotHeight) + remakeSnapshotConstraints(size: size) + } + + private static func displaySize(width: Int, height: Int) -> CGSize { + guard width > 0, height > 0 else { + return CGSize(width: maxSize, height: maxSize) + } + let ratio = CGFloat(width) / CGFloat(height) + let unscaledWidth: CGFloat + let unscaledHeight: CGFloat + if ratio > 1 { + unscaledWidth = maxSize + unscaledHeight = maxSize / ratio + } else { + unscaledHeight = maxSize + unscaledWidth = maxSize * ratio + } + let finalWidth = min(max(unscaledWidth, minSize), maxSize) + let finalHeight = min(max(unscaledHeight, minSize), maxSize) + return CGSize(width: finalWidth, height: finalHeight) + } + + private func applyDuration(_ seconds: Int) { + durationLabel.isHidden = false + durationLabel.text = Self.formatDuration(seconds) + } + + private func loadSnapshot(payload: VideoMessagePayload, message: MessageInfo) { + if let path = payload.videoSnapshotPath, + !path.isEmpty, + FileManager.default.fileExists(atPath: path), + let image = UIImage(contentsOfFile: path) { + snapshotView.kf.cancelDownloadTask() + snapshotView.image = image + snapshotView.backgroundColor = .clear + playIconView.isHidden = false + return + } + if let urlString = payload.videoSnapshotURL, + !urlString.isEmpty, + let url = URL(string: urlString) { + snapshotView.backgroundColor = ChatUIKitTheme.colors.bgColorBubbleReciprocal + snapshotView.kf.setImage(with: url) + playIconView.isHidden = false + return + } + showPlaceholder() + triggerDownloadIfNeeded(message: message) + } + + private func showPlaceholder() { + snapshotView.kf.cancelDownloadTask() + snapshotView.image = nil + snapshotView.backgroundColor = ChatUIKitTheme.colors.bgColorBubbleReciprocal + playIconView.isHidden = false + } + + private func triggerDownloadIfNeeded(message: MessageInfo) { + guard !hasTriggeredDownload else { return } + guard message.status != .sending else { return } + hasTriggeredDownload = true + MessageActionStore.create(message: message).downloadMedia(quality: .thumbnail) { _ in } + } + + private static func formatDuration(_ seconds: Int) -> String { + return DateHelper.formatDurationSeconds(seconds) + } +} + +private final class PaddedLabel: UILabel { + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize( + width: size.width + insets.left + insets.right, + height: size.height + insets.top + insets.bottom + ) + } + + private let insets: UIEdgeInsets + + init(insets: UIEdgeInsets) { + self.insets = insets + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } +} diff --git a/chat/uikit/components/messagelist/config/MessageListConfig.swift b/chat/uikit/components/messagelist/config/MessageListConfig.swift new file mode 100644 index 000000000..b145b12fb --- /dev/null +++ b/chat/uikit/components/messagelist/config/MessageListConfig.swift @@ -0,0 +1,116 @@ +import UIKit +import AtomicXCore + +public struct ChatMessageListConfig { + static let defaultCellSpacing: CGFloat = 10.0 + + static let defaultHorizontalPadding = CGFloat(SpacingScheme.bubbleSpacing) + + static let defaultAvatarSpacing = CGFloat(SpacingScheme.smallSpacing) + + var userAlignment: Int? = nil + + var userIsShowTimeMessage: Bool? = nil + + var userIsShowLeftAvatar: Bool? = nil + + var userIsShowLeftNickname: Bool? = nil + + var userIsShowRightAvatar: Bool? = nil + + var userIsShowRightNickname: Bool? = nil + + var userIsShowTimeInBubble: Bool? = nil + + var userCellSpacing: CGFloat? = nil + + var userIsShowSystemMessage: Bool? = nil + + var userIsShowUnsupportMessage: Bool? = nil + + var userIsSupportCopy: Bool? = nil + + var userIsSupportDelete: Bool? = nil + + var userIsSupportRecall: Bool? = nil + + var userIsSupportForward: Bool? = nil + + var userIsSupportMultiSelect: Bool? = nil + + var userIsSupportConvertToText: Bool? = nil + + var userIsSupportTranslate: Bool? = nil + + var userIsSupportListenFromHere: Bool? = nil + + var userActionCustomizer: MessageActionCustomizer? = nil + + var userHorizontalPadding: CGFloat? = nil + + var userAvatarSpacing: CGFloat? = nil + + var userIsSupportReaction: Bool? = nil + + var userEnableTyping: Bool? = nil + + var exclusionMatchers: [MessageMatcher] = [] +} + +extension ChatMessageListConfig { + public init( + textFont: UIFont? = nil, + textBubbleCornerRadius: CGFloat? = nil, + alignment: Int? = nil, + isShowTimeMessage: Bool? = nil, + isShowLeftAvatar: Bool? = nil, + isShowLeftNickname: Bool? = nil, + isShowRightAvatar: Bool? = nil, + isShowRightNickname: Bool? = nil, + nicknameFont: UIFont? = nil, + isShowTimeInBubble: Bool? = nil, + cellSpacing: CGFloat? = nil, + displayName: String? = nil, + bottomViewCornerRadius: CGFloat? = nil, + bottomViewBorderWidth: CGFloat? = nil, + isShowSystemMessage: Bool? = nil, + isShowUnsupportMessage: Bool? = nil, + isSupportCopy: Bool? = nil, + isSupportDelete: Bool? = nil, + isSupportRecall: Bool? = nil, + isSupportForward: Bool? = nil, + isSupportMultiSelect: Bool? = nil, + isSupportConvertToText: Bool? = nil, + isSupportTranslate: Bool? = nil, + isSupportListenFromHere: Bool? = nil, + actionCustomizer: MessageActionCustomizer? = nil, + horizontalPadding: CGFloat? = nil, + avatarSpacing: CGFloat? = nil, + isSupportReaction: Bool? = nil, + enableTyping: Bool? = nil + ) { + self.userAlignment = alignment + self.userIsShowTimeMessage = isShowTimeMessage + self.userIsShowLeftAvatar = isShowLeftAvatar + self.userIsShowLeftNickname = isShowLeftNickname + self.userIsShowRightAvatar = isShowRightAvatar + self.userIsShowRightNickname = isShowRightNickname + self.userIsShowTimeInBubble = isShowTimeInBubble + self.userCellSpacing = cellSpacing + self.userIsShowSystemMessage = isShowSystemMessage + self.userIsShowUnsupportMessage = isShowUnsupportMessage + self.userIsSupportCopy = isSupportCopy + self.userIsSupportDelete = isSupportDelete + self.userIsSupportRecall = isSupportRecall + self.userIsSupportForward = isSupportForward + self.userIsSupportMultiSelect = isSupportMultiSelect + self.userIsSupportConvertToText = isSupportConvertToText + self.userIsSupportTranslate = isSupportTranslate + self.userIsSupportListenFromHere = isSupportListenFromHere + self.userActionCustomizer = actionCustomizer + self.userHorizontalPadding = horizontalPadding + self.userAvatarSpacing = avatarSpacing + self.userIsSupportReaction = isSupportReaction + self.userEnableTyping = enableTyping + } +} diff --git a/chat/uikit/components/messagelist/forward/ForwardBottomBar.swift b/chat/uikit/components/messagelist/forward/ForwardBottomBar.swift new file mode 100644 index 000000000..c9c4191e6 --- /dev/null +++ b/chat/uikit/components/messagelist/forward/ForwardBottomBar.swift @@ -0,0 +1,157 @@ +import UIKit +import SnapKit +import Kingfisher + +struct ForwardBottomBarItem { + let key: String + let avatarURL: String + let fallbackName: String +} + +final class ForwardBottomBar: UIView { + private static let avatarCornerRadius = CGFloat(RadiusScheme.largeRadius) + + private static let avatarSize: CGFloat = 32 + + private static let avatarSpacing: CGFloat = 6 + + private static let horizontalInset = CGFloat(SpacingScheme.iconIconSpacing) + + private static let scrollViewVerticalInset: CGFloat = 10 + + private static let confirmButtonCornerRadius: CGFloat = 6 + + private static let confirmButtonContentInsets = UIEdgeInsets( + top: CGFloat(SpacingScheme.smallSpacing), + left: CGFloat(SpacingScheme.bubbleSpacing), + bottom: CGFloat(SpacingScheme.smallSpacing), + right: CGFloat(SpacingScheme.bubbleSpacing) + ) + + private static let placeholderFontSize: CGFloat = 13 + + var onConfirm: (() -> Void)? + + private let scrollView = UIScrollView() + + private let avatarStack = UIStackView() + + private let confirmButton = UIButton(type: .custom) + + private var currentConfirmTitle: String? + + private var currentConfirmEnabled: Bool? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(items: [ForwardBottomBarItem]) { + avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for item in items { + let avatarView = UIImageView() + avatarView.layer.cornerRadius = Self.avatarCornerRadius + avatarView.clipsToBounds = true + avatarView.contentMode = .scaleAspectFill + avatarView.snp.makeConstraints { make in + make.width.height.equalTo(Self.avatarSize) + } + if !item.avatarURL.isEmpty, let url = URL(string: item.avatarURL) { + avatarView.kf.setImage(with: url, placeholder: generatePlaceholder(name: item.fallbackName)) + } else { + avatarView.image = generatePlaceholder(name: item.fallbackName) + } + avatarStack.addArrangedSubview(avatarView) + } + + updateConfirmButton(count: items.count) + } + + private func setupUI() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + + addSubview(scrollView) + addSubview(confirmButton) + + scrollView.showsHorizontalScrollIndicator = false + avatarStack.axis = .horizontal + avatarStack.spacing = Self.avatarSpacing + avatarStack.alignment = .center + scrollView.addSubview(avatarStack) + + scrollView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalInset) + make.top.equalToSuperview().offset(Self.scrollViewVerticalInset) + make.bottom.equalToSuperview().offset(-Self.scrollViewVerticalInset) + make.trailing.equalTo(confirmButton.snp.leading).offset(-Self.horizontalInset) + make.height.equalTo(Self.avatarSize) + } + + avatarStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + + confirmButton.titleLabel?.font = FontScheme.caption2Bold + confirmButton.layer.cornerRadius = Self.confirmButtonCornerRadius + confirmButton.clipsToBounds = true + confirmButton.contentEdgeInsets = Self.confirmButtonContentInsets + confirmButton.addTarget(self, action: #selector(handleConfirmTapped), for: .touchUpInside) + confirmButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalInset) + make.centerY.equalToSuperview() + } + + updateConfirmButton(count: 0) + } + + private func updateConfirmButton(count: Int) { + let colors = ChatUIKitTheme.colors + let enabled = count > 0 + let title = LocalizedChatString("Forward") + (count > 0 ? "(\(count))" : "") + if title != currentConfirmTitle { + currentConfirmTitle = title + confirmButton.setTitle(title, for: .normal) + } + guard enabled != currentConfirmEnabled else { return } + currentConfirmEnabled = enabled + confirmButton.isEnabled = enabled + confirmButton.setTitleColor( + enabled ? colors.textColorButton : colors.textColorTertiary, + for: .normal + ) + confirmButton.backgroundColor = enabled ? colors.buttonColorPrimaryDefault : colors.buttonColorPrimaryDisabled + } + + @objc private func handleConfirmTapped() { + onConfirm?() + } + + private func generatePlaceholder(name: String) -> UIImage? { + let size = CGSize(width: Self.avatarSize, height: Self.avatarSize) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { context in + let colors = ChatUIKitTheme.colors + colors.buttonColorPrimaryDefault.setFill() + context.fill(CGRect(origin: .zero, size: size)) + let initial = String(name.prefix(1)).uppercased() + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: Self.placeholderFontSize, weight: .medium), + .foregroundColor: UIColor.white + ] + let textSize = initial.size(withAttributes: attrs) + let point = CGPoint( + x: (size.width - textSize.width) / 2, + y: (size.height - textSize.height) / 2 + ) + initial.draw(at: point, withAttributes: attrs) + } + } +} diff --git a/chat/uikit/components/messagelist/forward/ForwardContactPickerViewController.swift b/chat/uikit/components/messagelist/forward/ForwardContactPickerViewController.swift new file mode 100644 index 000000000..42116ad60 --- /dev/null +++ b/chat/uikit/components/messagelist/forward/ForwardContactPickerViewController.swift @@ -0,0 +1,120 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class ForwardContactPickerViewController: UIViewController { + private static let topBarHeight: CGFloat = 56 + + private static let actionButtonFontSize: CGFloat = 16 + + private static let topBarHorizontalInset = CGFloat(SpacingScheme.bubbleSpacing) + + private let contacts: [ContactInfo] + + private let initialSelectedUserIDs: Set + + private let onConfirm: ([ContactInfo]) -> Void + + private let pickerView = UserPickerView() + + init(contacts: [ContactInfo], + preSelectedUserIDs: Set, + onConfirm: @escaping ([ContactInfo]) -> Void) { + self.contacts = contacts + self.initialSelectedUserIDs = preSelectedUserIDs + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + pickerView.configure(userList: Self.userPickerItems(from: contacts), maxCount: 0) + pickerView.setInitialSelectedIDs(initialSelectedUserIDs) + } + + private static func userPickerItems(from contacts: [ContactInfo]) -> [UserPickerItem] { + return contacts.map { contact in + UserPickerItem( + userID: contact.userID, + avatarURL: contact.avatarURL, + title: displayName(for: contact) + ) + } + } + + private static func displayName(for contact: ContactInfo) -> String { + if let remark = contact.friendRemark, !remark.isEmpty { return remark } + if let nickname = contact.nickname, !nickname.isEmpty { return nickname } + return contact.userID + } + + private func setupUI() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + + let topBar = UIView() + topBar.backgroundColor = colors.bgColorOperate + view.addSubview(topBar) + topBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.topBarHeight) + } + + let cancelButton = UIButton(type: .system) + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.setTitleColor(colors.textColorLink, for: .normal) + cancelButton.titleLabel?.font = FontScheme.caption1Regular + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + topBar.addSubview(cancelButton) + cancelButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.topBarHorizontalInset) + make.centerY.equalToSuperview() + } + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("RelayTargetSelectFromContacts") + titleLabel.font = FontScheme.caption1Bold + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + topBar.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + let confirmButton = UIButton(type: .system) + confirmButton.setTitle(LocalizedChatString("Confirm"), for: .normal) + confirmButton.setTitleColor(colors.textColorLink, for: .normal) + confirmButton.titleLabel?.font = .systemFont(ofSize: Self.actionButtonFontSize, weight: .semibold) + confirmButton.addTarget(self, action: #selector(handleConfirm), for: .touchUpInside) + topBar.addSubview(confirmButton) + confirmButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.topBarHorizontalInset) + make.centerY.equalToSuperview() + } + + view.addSubview(pickerView) + pickerView.snp.makeConstraints { make in + make.top.equalTo(topBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + @objc private func handleCancel() { + closePage() + } + + @objc private func handleConfirm() { + let selectedIDs = Set(pickerView.selectedItems.map(\.userID)) + let result = contacts.filter { selectedIDs.contains($0.userID) } + closePage { [weak self] in + self?.onConfirm(result) + } + } +} diff --git a/chat/uikit/components/messagelist/forward/ForwardConversationCell.swift b/chat/uikit/components/messagelist/forward/ForwardConversationCell.swift new file mode 100644 index 000000000..3922122c5 --- /dev/null +++ b/chat/uikit/components/messagelist/forward/ForwardConversationCell.swift @@ -0,0 +1,125 @@ +import UIKit +import SnapKit +import Kingfisher +import AtomicXCore + +final class ForwardConversationCell: UITableViewCell { + static let reuseIdentifier = "ForwardConversationCell" + + private static let contentHorizontalInset = CGFloat(SpacingScheme.bubbleSpacing) + + private static let checkboxSize: CGFloat = 22 + + private static let avatarCornerRadius = CGFloat(RadiusScheme.superLargeRadius) + + private static let horizontalSpacing = CGFloat(SpacingScheme.iconIconSpacing) + + private static let avatarSize: CGFloat = 40 + + private static let checkboxIconPointSize: CGFloat = 20 + + private let checkboxView = UIImageView() + + private let avatarView = UIImageView() + + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(conversation: ConversationInfo, isSelected: Bool) { + let colors = ChatUIKitTheme.colors + contentView.backgroundColor = colors.bgColorOperate + titleLabel.textColor = colors.textColorPrimary + + let title = conversation.title?.isEmpty == false ? conversation.title! : conversation.conversationID + titleLabel.text = title + + configureAvatar(url: conversation.avatarURL, fallbackName: title) + updateCheckbox(isSelected: isSelected) + } + + private func setupUI() { + let colors = ChatUIKitTheme.colors + contentView.backgroundColor = colors.bgColorOperate + + contentView.addSubview(checkboxView) + contentView.addSubview(avatarView) + contentView.addSubview(titleLabel) + + checkboxView.contentMode = .scaleAspectFit + checkboxView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.contentHorizontalInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.checkboxSize) + } + + avatarView.layer.cornerRadius = Self.avatarCornerRadius + avatarView.clipsToBounds = true + avatarView.contentMode = .scaleAspectFill + avatarView.snp.makeConstraints { make in + make.leading.equalTo(checkboxView.snp.trailing).offset(Self.horizontalSpacing) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.avatarSize) + } + + titleLabel.font = FontScheme.caption1Regular + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.horizontalSpacing) + make.trailing.equalToSuperview().offset(-Self.contentHorizontalInset) + make.centerY.equalToSuperview() + } + } + + private func configureAvatar(url: String?, fallbackName: String) { + if let urlString = url, !urlString.isEmpty, let imageURL = URL(string: urlString) { + avatarView.kf.setImage(with: imageURL, placeholder: generateAvatarPlaceholder(name: fallbackName)) + } else { + avatarView.image = generateAvatarPlaceholder(name: fallbackName) + } + } + + private func updateCheckbox(isSelected: Bool) { + let colors = ChatUIKitTheme.colors + if isSelected { + let config = UIImage.SymbolConfiguration(pointSize: Self.checkboxIconPointSize, weight: .regular) + let image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config) + checkboxView.image = image + checkboxView.tintColor = colors.textColorLink + } else { + let config = UIImage.SymbolConfiguration(pointSize: Self.checkboxIconPointSize, weight: .regular) + let image = UIImage(systemName: "circle", withConfiguration: config) + checkboxView.image = image + checkboxView.tintColor = colors.textColorTertiary + } + } + + private func generateAvatarPlaceholder(name: String) -> UIImage? { + let size = CGSize(width: Self.avatarSize, height: Self.avatarSize) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { context in + let colors = ChatUIKitTheme.colors + colors.buttonColorPrimaryDefault.setFill() + context.fill(CGRect(origin: .zero, size: size)) + let initial = String(name.prefix(1)).uppercased() + let attributes: [NSAttributedString.Key: Any] = [ + .font: FontScheme.caption1Medium, + .foregroundColor: UIColor.white + ] + let textSize = initial.size(withAttributes: attributes) + let textPoint = CGPoint( + x: (size.width - textSize.width) / 2, + y: (size.height - textSize.height) / 2 + ) + initial.draw(at: textPoint, withAttributes: attributes) + } + } +} diff --git a/chat/uikit/components/messagelist/forward/ForwardTargetSelectorViewController.swift b/chat/uikit/components/messagelist/forward/ForwardTargetSelectorViewController.swift new file mode 100644 index 000000000..ea19b9130 --- /dev/null +++ b/chat/uikit/components/messagelist/forward/ForwardTargetSelectorViewController.swift @@ -0,0 +1,377 @@ +import UIKit +import SnapKit +import Combine +import AtomicXCore + +final class ForwardTargetSelectorViewController: UIViewController { + private static let conversationRowHeight: CGFloat = 60 + + private static let topBarHeight: CGFloat = 56 + + private static let contactEntryHeight: CGFloat = 56 + + private static let contentHorizontalInset = CGFloat(SpacingScheme.bubbleSpacing) + + private static let arrowIconSize: CGFloat = 16 + + private static let sectionLabelVerticalInset = CGFloat(SpacingScheme.smallSpacing) + + private static let loadMoreThreshold: CGFloat = 60 + + private static let c2cIDPrefixLength = 4 + + private let onConfirm: ([String]) -> Void + + private let conversationStore = ConversationListStore.create() + + private let contactStore = ContactStore.shared + + private var cancellables = Set() + + private var conversations: [ConversationInfo] = [] + + private var friendList: [ContactInfo] = [] + + private var selectedConversationIDs = LinkedHashSet() + + private lazy var topBar = buildTopBar() + + private lazy var contactEntry = buildContactEntry() + + private lazy var sectionLabel = buildSectionLabel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = Self.conversationRowHeight + table.dataSource = self + table.delegate = self + table.register(ForwardConversationCell.self, forCellReuseIdentifier: ForwardConversationCell.reuseIdentifier) + return table + }() + + private lazy var bottomBar = ForwardBottomBar() + + init(onConfirm: @escaping ([String]) -> Void) { + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + bindState() + loadData() + } + + // MARK: - Actions + + private func setupUI() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + + view.addSubview(topBar) + view.addSubview(contactEntry) + view.addSubview(sectionLabel) + view.addSubview(tableView) + view.addSubview(bottomBar) + + topBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.topBarHeight) + } + + contactEntry.snp.makeConstraints { make in + make.top.equalTo(topBar.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.contactEntryHeight) + } + + sectionLabel.snp.makeConstraints { make in + make.top.equalTo(contactEntry.snp.bottom) + make.leading.trailing.equalToSuperview() + } + + bottomBar.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide) + } + + tableView.snp.makeConstraints { make in + make.top.equalTo(sectionLabel.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + tableView.backgroundColor = colors.bgColorOperate + + bottomBar.onConfirm = { [weak self] in + guard let self = self else { return } + let ids = Array(self.selectedConversationIDs) + guard !ids.isEmpty else { return } + self.closePage { + self.onConfirm(ids) + } + } + refreshBottomBar() + } + + private func bindState() { + conversationStore.state + .subscribe(StatePublisherSelector(keyPath: \ConversationListState.conversationList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.conversations = list + self?.tableView.reloadData() + } + .store(in: &cancellables) + + contactStore.state + .subscribe(StatePublisherSelector(keyPath: \ContactState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.friendList = list + } + .store(in: &cancellables) + } + + private func loadData() { + conversationStore.loadConversations(option: nil, completion: nil) + contactStore.loadFriends(completion: nil) + } + + private func toggleConversation(_ conversationID: String) { + if selectedConversationIDs.contains(conversationID) { + selectedConversationIDs.remove(conversationID) + } else { + selectedConversationIDs.insert(conversationID) + } + tableView.reloadData() + refreshBottomBar() + } + + private func refreshBottomBar() { + let allSelected = collectAllSelectedItems() + bottomBar.configure(items: allSelected) + } + + private func collectAllSelectedItems() -> [ForwardBottomBarItem] { + var items: [ForwardBottomBarItem] = [] + for id in selectedConversationIDs { + if let conv = conversations.first(where: { $0.conversationID == id }) { + items.append(ForwardBottomBarItem( + key: id, + avatarURL: conv.avatarURL ?? "", + fallbackName: conv.title ?? conv.conversationID + )) + } else { + let userID = id.hasPrefix("c2c_") ? String(id.dropFirst(Self.c2cIDPrefixLength)) : id + if let contact = friendList.first(where: { $0.userID == userID }) { + items.append(ForwardBottomBarItem( + key: id, + avatarURL: contact.avatarURL ?? "", + fallbackName: contact.nickname ?? contact.userID + )) + } else { + items.append(ForwardBottomBarItem(key: id, avatarURL: "", fallbackName: userID)) + } + } + } + return items + } + + private func buildTopBar() -> UIView { + let colors = ChatUIKitTheme.colors + let bar = UIView() + bar.backgroundColor = colors.bgColorOperate + + let cancelButton = UIButton(type: .system) + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.setTitleColor(colors.textColorLink, for: .normal) + cancelButton.titleLabel?.font = FontScheme.caption1Regular + cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside) + bar.addSubview(cancelButton) + cancelButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.contentHorizontalInset) + make.centerY.equalToSuperview() + } + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("Forward") + titleLabel.font = FontScheme.caption1Bold + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + bar.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + return bar + } + + private func buildContactEntry() -> UIView { + let colors = ChatUIKitTheme.colors + let container = UIView() + container.backgroundColor = colors.bgColorOperate + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleContactEntryTapped)) + container.addGestureRecognizer(tapGesture) + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("RelayTargetSelectFromContacts") + titleLabel.font = FontScheme.caption1Regular + titleLabel.textColor = colors.textColorPrimary + container.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.contentHorizontalInset) + make.centerY.equalToSuperview() + } + + let arrowImage = UIImageView() + arrowImage.image = UIImage(systemName: "chevron.right")?.withRenderingMode(.alwaysTemplate) + arrowImage.tintColor = colors.textColorSecondary + arrowImage.contentMode = .scaleAspectFit + container.addSubview(arrowImage) + arrowImage.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.contentHorizontalInset) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.arrowIconSize) + } + + return container + } + + private func buildSectionLabel() -> UIView { + let colors = ChatUIKitTheme.colors + let label = UILabel() + label.text = LocalizedChatString("RelayRecentMessages") + label.font = FontScheme.caption3Regular + label.textColor = colors.textColorSecondary + label.backgroundColor = colors.bgColorInput + let container = UIView() + container.backgroundColor = colors.bgColorInput + container.addSubview(label) + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.contentHorizontalInset) + make.trailing.equalToSuperview().offset(-Self.contentHorizontalInset) + make.top.equalToSuperview().offset(Self.sectionLabelVerticalInset) + make.bottom.equalToSuperview().offset(-Self.sectionLabelVerticalInset) + } + return container + } + + @objc private func handleCancelTapped() { + closePage() + } + + @objc private func handleContactEntryTapped() { + guard !friendList.isEmpty else { return } + let preSelected = collectPreSelectedUserIDs() + let picker = ForwardContactPickerViewController( + contacts: friendList, + preSelectedUserIDs: preSelected + ) { [weak self] returnedContacts in + self?.applyContactPickerResult(returnedContacts, preSelected: preSelected) + } + navigationController?.pushViewController(picker, animated: true) + } + + private func collectPreSelectedUserIDs() -> Set { + var ids = Set() + for id in selectedConversationIDs { + if id.hasPrefix("c2c_") { + ids.insert(String(id.dropFirst(Self.c2cIDPrefixLength))) + } + } + return ids + } + + private func applyContactPickerResult(_ returnedContacts: [ContactInfo], preSelected: Set) { + let returnedUserIDs = Set(returnedContacts.map { $0.userID }) + + for userID in preSelected where !returnedUserIDs.contains(userID) { + selectedConversationIDs.remove("c2c_\(userID)") + } + + for contact in returnedContacts { + let c2cID = "c2c_\(contact.userID)" + if !selectedConversationIDs.contains(c2cID) { + selectedConversationIDs.insert(c2cID) + } + } + + tableView.reloadData() + refreshBottomBar() + } +} + +// MARK: - UITableViewDataSource & Delegate + +extension ForwardTargetSelectorViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return conversations.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: ForwardConversationCell.reuseIdentifier, + for: indexPath + ) as? ForwardConversationCell else { + return UITableViewCell() + } + let conversation = conversations[indexPath.row] + let isSelected = selectedConversationIDs.contains(conversation.conversationID) + cell.configure(conversation: conversation, isSelected: isSelected) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + guard indexPath.row < conversations.count else { return } + toggleConversation(conversations[indexPath.row].conversationID) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let distanceFromBottom = scrollView.contentSize.height - (scrollView.contentOffset.y + scrollView.bounds.height) + if distanceFromBottom <= Self.loadMoreThreshold { + conversationStore.loadMoreConversations(completion: nil) + } + } +} + +// MARK: - LinkedHashSet + +private struct LinkedHashSet { + var count: Int { array.count } + + var isEmpty: Bool { array.isEmpty } + + private var array: [Element] = [] + + private var set: Set = [] + + func contains(_ element: Element) -> Bool { set.contains(element) } + + mutating func insert(_ element: Element) { + guard !set.contains(element) else { return } + array.append(element) + set.insert(element) + } + + mutating func remove(_ element: Element) { + guard set.contains(element) else { return } + set.remove(element) + array.removeAll { $0 == element } + } +} + +extension LinkedHashSet: Sequence { + func makeIterator() -> IndexingIterator<[Element]> { + return array.makeIterator() + } +} diff --git a/chat/uikit/components/messagelist/imageviewer/ImageElement.swift b/chat/uikit/components/messagelist/imageviewer/ImageElement.swift new file mode 100644 index 000000000..9e5bd433d --- /dev/null +++ b/chat/uikit/components/messagelist/imageviewer/ImageElement.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ImageElement { + public let type: Int + let imagePath: String + let videoPath: String? + + init(type: Int, imagePath: String, videoPath: String? = nil) { + self.type = type + self.imagePath = imagePath + self.videoPath = videoPath + } +} diff --git a/chat/uikit/components/messagelist/imageviewer/ImageViewerController.swift b/chat/uikit/components/messagelist/imageviewer/ImageViewerController.swift new file mode 100644 index 000000000..d1a85a969 --- /dev/null +++ b/chat/uikit/components/messagelist/imageviewer/ImageViewerController.swift @@ -0,0 +1,370 @@ +import UIKit +import SnapKit +import Kingfisher +import Photos + +final class ImageViewerController: UIViewController { + private static let closeButtonSize: CGFloat = 48 + + private static let closeIconPointSize: CGFloat = 17 + + private static let closeButtonTopMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let closeButtonLeadingMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let saveButtonSize: CGFloat = 48 + + private static let saveButtonTrailingMargin = CGFloat(SpacingScheme.bubbleSpacing) + + private static let saveButtonBottomMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let loadMoreLeadingThreshold = 1 + + private static let loadMoreTrailingThreshold = 2 + + private static let videoPlaceholderWidth = 1920 + + private static let videoPlaceholderHeight = 1080 + + private static let jpegCompressionQuality: CGFloat = 1 + + private static let toastShortDuration: TimeInterval = 2 + + private static let videoElementType = 1 + + private var elements: [ImageElement] + + private var currentIndex: Int + + private var hasPositionedInitialIndex = false + + private let provider: ImageViewerDataProvider + + private var downloadingIndices = Set() + + private lazy var collectionView: UICollectionView = { + let layout = FlippableFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + let collection = UICollectionView(frame: .zero, collectionViewLayout: layout) + collection.isPagingEnabled = true + collection.showsHorizontalScrollIndicator = false + collection.backgroundColor = .clear + collection.contentInsetAdjustmentBehavior = .never + collection.register(MediaZoomCell.self, forCellWithReuseIdentifier: MediaZoomCell.reuseID) + return collection + }() + + private lazy var closeButton: UIButton = { + let button = UIButton(type: .custom) + if let closeImage = AtomicXChatResources.image(named: "image_viewer_close_icon") { + button.setImage(closeImage, for: .normal) + } else { + let config = UIImage.SymbolConfiguration(pointSize: Self.closeIconPointSize, weight: .regular) + button.setImage(UIImage(systemName: "xmark", withConfiguration: config), for: .normal) + button.tintColor = .white + } + button.addTarget(self, action: #selector(handleClose), for: .touchUpInside) + return button + }() + + private lazy var saveButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(AtomicXChatResources.image(named: "image_viewer_save_button"), for: .normal) + button.addTarget(self, action: #selector(handleSave), for: .touchUpInside) + return button + }() + + private var isSavingMedia = false + + // MARK: - Init + + init(elements: [ImageElement], initialIndex: Int, provider: ImageViewerDataProvider) { + self.elements = elements + self.currentIndex = max(0, min(initialIndex, max(elements.count - 1, 0))) + self.provider = provider + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .crossDissolve + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + collectionView.dataSource = self + collectionView.delegate = self + view.addSubview(collectionView) + view.addSubview(closeButton) + view.addSubview(saveButton) + collectionView.snp.makeConstraints { $0.edges.equalToSuperview() } + closeButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(Self.closeButtonTopMargin) + make.leading.equalToSuperview().offset(Self.closeButtonLeadingMargin) + make.width.height.equalTo(Self.closeButtonSize) + } + saveButton.snp.makeConstraints { make in + make.trailing.equalTo(view.safeAreaLayoutGuide).inset(Self.saveButtonTrailingMargin) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(Self.saveButtonBottomMargin) + make.width.height.equalTo(Self.saveButtonSize) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + (collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.itemSize = collectionView.bounds.size + if !hasPositionedInitialIndex, currentIndex > 0, !elements.isEmpty, collectionView.bounds.width > 0 { + hasPositionedInitialIndex = true + scrollToIndex(currentIndex, animated: false) + } + } + + // MARK: - Actions + + private func scrollToIndex(_ index: Int, animated: Bool) { + guard index >= 0, index < elements.count else { return } + collectionView.scrollToItem(at: IndexPath(item: index, section: 0), at: .centeredHorizontally, animated: animated) + } + + private func currentPageIndex() -> Int { + let width = collectionView.bounds.width + guard width > 0 else { return currentIndex } + let rawPage = Int(round(collectionView.contentOffset.x / width)) + if collectionView.effectiveUserInterfaceLayoutDirection == .rightToLeft { + return max(0, elements.count - 1 - rawPage) + } + return rawPage + } + + private func checkLoadMore() { + if currentIndex <= Self.loadMoreLeadingThreshold { + loadMore(isOlder: true) + } + if currentIndex >= elements.count - Self.loadMoreTrailingThreshold { + loadMore(isOlder: false) + } + } + + private func loadMore(isOlder: Bool) { + provider.loadMore(isOlder: isOlder) { [weak self] newElements in + guard let self = self, !newElements.isEmpty else { return } + if isOlder { + self.elements = newElements + self.elements + self.currentIndex += newElements.count + self.collectionView.reloadData() + self.collectionView.layoutIfNeeded() + self.scrollToIndex(self.currentIndex, animated: false) + } else { + self.elements = self.elements + newElements + self.collectionView.reloadData() + } + } + } + + private func playVideo(at index: Int) { + guard index >= 0, index < elements.count, let videoPath = elements[index].videoPath, !videoPath.isEmpty else { return } + let isLocal = FileManager.default.fileExists(atPath: videoPath) + let videoData = VideoData( + uri: videoPath, + localPath: isLocal ? videoPath : nil, + width: Self.videoPlaceholderWidth, + height: Self.videoPlaceholderHeight + ) + dismiss(animated: false) { + VideoPlayer.shared.playWithUIKit(videoData: videoData, onDismiss: nil) + } + } + + private func downloadVideo(at index: Int) { + guard index >= 0, index < elements.count, !downloadingIndices.contains(index) else { return } + downloadingIndices.insert(index) + reloadItem(at: index) + provider.downloadVideo(at: index) { [weak self] videoPath in + guard let self = self else { return } + self.downloadingIndices.remove(index) + if let videoPath = videoPath, !videoPath.isEmpty, index < self.elements.count { + let old = self.elements[index] + self.elements[index] = ImageElement(type: old.type, imagePath: old.imagePath, videoPath: videoPath) + self.reloadItem(at: index) + self.playVideo(at: index) + } else { + self.reloadItem(at: index) + WindowToastManager.shared.show(LocalizedChatString("VideoDownloadFailed"), type: .error, duration: Self.toastShortDuration) + } + } + } + + private func reloadItem(at index: Int) { + guard index >= 0, index < elements.count else { return } + collectionView.reloadItems(at: [IndexPath(item: index, section: 0)]) + } + + @objc private func handleSave() { + guard !isSavingMedia, elements.indices.contains(currentIndex) else { return } + let element = elements[currentIndex] + isSavingMedia = true + saveButton.isEnabled = false + requestPhotoAddPermission { [weak self] granted in + guard let self = self else { return } + guard granted else { + self.finishSave(success: false) + return + } + if element.type == Self.videoElementType { + self.saveVideo(element: element, index: self.currentIndex) + } else { + self.saveImage(element: element) + } + } + } + + private func requestPhotoAddPermission(completion: @escaping (Bool) -> Void) { + let handler: (PHAuthorizationStatus) -> Void = { status in + DispatchQueue.main.async { + completion(status == .authorized || status == .limited) + } + } + if #available(iOS 14, *) { + PHPhotoLibrary.requestAuthorization(for: .addOnly, handler: handler) + } else { + PHPhotoLibrary.requestAuthorization(handler) + } + } + + private func saveImage(element: ImageElement) { + let path = element.imagePath + if FileManager.default.fileExists(atPath: path), let data = FileManager.default.contents(atPath: path) { + writePhotoData(data) + return + } + guard let url = URL(string: path), url.scheme?.hasPrefix("http") == true else { + finishSave(success: false) + return + } + ImageDownloader.default.downloadImage(with: url) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .success(let imageResult): + if let data = imageResult.image.pngData() ?? imageResult.image.jpegData(compressionQuality: Self.jpegCompressionQuality) { + self.writePhotoData(data) + } else { + self.finishSave(success: false) + } + case .failure: + self.finishSave(success: false) + } + } + } + } + + private func saveVideo(element: ImageElement, index: Int) { + if let path = element.videoPath, FileManager.default.fileExists(atPath: path) { + writeVideoFile(URL(fileURLWithPath: path)) + return + } + provider.downloadVideo(at: index) { [weak self] videoPath in + guard let self = self else { return } + guard let videoPath = videoPath, !videoPath.isEmpty else { + self.finishSave(success: false) + return + } + if FileManager.default.fileExists(atPath: videoPath) { + self.writeVideoFile(URL(fileURLWithPath: videoPath)) + } else if let url = URL(string: videoPath), url.scheme?.hasPrefix("http") == true { + self.downloadRemoteVideo(url: url) + } else { + self.finishSave(success: false) + } + } + } + + private func downloadRemoteVideo(url: URL) { + URLSession.shared.downloadTask(with: url) { [weak self] tempURL, _, _ in + DispatchQueue.main.async { + guard let self = self else { return } + guard let tempURL = tempURL else { + self.finishSave(success: false) + return + } + self.writeVideoFile(tempURL) + } + }.resume() + } + + private func writePhotoData(_ data: Data) { + PHPhotoLibrary.shared().performChanges({ + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, data: data, options: nil) + }) { [weak self] success, _ in + DispatchQueue.main.async { + self?.finishSave(success: success) + } + } + } + + private func writeVideoFile(_ fileURL: URL) { + PHPhotoLibrary.shared().performChanges({ + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .video, fileURL: fileURL, options: nil) + }) { [weak self] success, _ in + DispatchQueue.main.async { + self?.finishSave(success: success) + } + } + } + + private func finishSave(success: Bool) { + isSavingMedia = false + saveButton.isEnabled = true + let key = success ? "ImageViewerSaveSuccess" : "ImageViewerSaveFailed" + WindowToastManager.shared.show(LocalizedChatString(key), type: success ? .success : .error, duration: Self.toastShortDuration) + } + + @objc private func handleClose() { + dismiss(animated: true) + } +} + +// MARK: - UICollectionViewDataSource + +extension ImageViewerController: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return elements.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MediaZoomCell.reuseID, for: indexPath) as? MediaZoomCell else { + return UICollectionViewCell() + } + let index = indexPath.item + cell.configure(element: elements[index], isDownloading: downloadingIndices.contains(index)) + cell.onImageTap = { [weak self] in self?.dismiss(animated: true) } + cell.onPlayVideo = { [weak self] in self?.playVideo(at: index) } + cell.onDownloadVideo = { [weak self] in self?.downloadVideo(at: index) } + return cell + } +} + +// MARK: - UICollectionViewDelegate + +extension ImageViewerController: UICollectionViewDelegate { + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + currentIndex = currentPageIndex() + checkLoadMore() + } +} + +private final class FlippableFlowLayout: UICollectionViewFlowLayout { + override var flipsHorizontallyInOppositeLayoutDirection: Bool { + return true + } +} diff --git a/chat/uikit/components/messagelist/imageviewer/ImageViewerDataProvider.swift b/chat/uikit/components/messagelist/imageviewer/ImageViewerDataProvider.swift new file mode 100644 index 000000000..937e8749b --- /dev/null +++ b/chat/uikit/components/messagelist/imageviewer/ImageViewerDataProvider.swift @@ -0,0 +1,161 @@ +import Foundation +import AtomicXCore + +final class ImageViewerDataProvider { + private static let mediaPageCount = 5 + + private static let imageElementType = 0 + + private static let videoElementType = 1 + + private(set) var mediaMessages: [MessageInfo] = [] + + private let store: MessageListStore? + + private let currentMessage: MessageInfo + + private let fixedMediaMessages: [MessageInfo]? + + private var isLoadingOlder = false + + private var isLoadingNewer = false + + init(conversationID: String, currentMessage: MessageInfo) { + self.store = MessageListStore.create(conversationID: conversationID) + self.fixedMediaMessages = nil + self.currentMessage = currentMessage + } + + init(mediaMessages: [MessageInfo], currentMessage: MessageInfo) { + self.store = nil + self.fixedMediaMessages = mediaMessages + self.currentMessage = currentMessage + } + + // MARK: - Load + + func loadInitial(completion: @escaping ([ImageElement], Int) -> Void) { + if let fixed = fixedMediaMessages { + mediaMessages = fixed + let elements = fixed.map { element(from: $0) } + let index = fixed.firstIndex { $0.id == currentMessage.id } ?? 0 + DispatchQueue.main.async { completion(elements, index) } + return + } + guard let store = store else { completion([], 0); return } + var option = MessageLoadOption() + option.direction = .both + option.pageCount = Self.mediaPageCount + option.cursor = currentMessage + option.messageTypeList = [.image, .video] + store.loadMessages(option: option) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { completion([], 0); return } + if case .failure = result { completion([], 0); return } + self.mediaMessages = self.currentMediaMessages() + let elements = self.mediaMessages.map { self.element(from: $0) } + let index = self.mediaMessages.firstIndex { $0.id == self.currentMessage.id } ?? 0 + completion(elements, index) + } + } + } + + func loadMore(isOlder: Bool, completion: @escaping ([ImageElement]) -> Void) { + guard let store = store else { completion([]); return } + let hasMore = isOlder ? store.state.value.hasOlderMessages : store.state.value.hasNewerMessages + let isLoading = isOlder ? isLoadingOlder : isLoadingNewer + guard hasMore, !isLoading, !mediaMessages.isEmpty else { completion([]); return } + if isOlder { isLoadingOlder = true } else { isLoadingNewer = true } + + var option = MessageLoadOption() + option.direction = isOlder ? .older : .newer + option.pageCount = Self.mediaPageCount + option.cursor = isOlder ? mediaMessages.first : mediaMessages.last + option.messageTypeList = [.image, .video] + store.loadMessages(option: option) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { completion([]); return } + if isOlder { self.isLoadingOlder = false } else { self.isLoadingNewer = false } + if case .failure = result { completion([]); return } + let latest = self.currentMediaMessages() + let existingIDs = Set(self.mediaMessages.map { $0.id }) + let newMessages = latest.filter { !existingIDs.contains($0.id) } + guard !newMessages.isEmpty else { completion([]); return } + if isOlder { + self.mediaMessages = newMessages + self.mediaMessages + } else { + self.mediaMessages = self.mediaMessages + newMessages + } + completion(newMessages.map { self.element(from: $0) }) + } + } + } + + // MARK: - Video Download + + func downloadVideo(at index: Int, completion: @escaping (String?) -> Void) { + guard index >= 0, index < mediaMessages.count else { completion(nil); return } + let message = mediaMessages[index] + guard message.messageType == .video else { completion(nil); return } + MessageActionStore.create(message: message).downloadMedia(quality: .standard) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { completion(nil); return } + switch result { + case .success: + if let store = self.store { + let updated = store.state.value.messageList.first { $0.id == message.id } ?? message + completion(Self.videoSource(from: updated)) + } else { + + completion(Self.videoSource(from: message)) + } + case .failure: + completion(nil) + } + } + } + } + + // MARK: - Helpers + + func element(from message: MessageInfo) -> ImageElement { + if message.messageType == .image, case .image(let payload) = message.messagePayload { + let source = Self.localFile(payload.originalImagePath, payload.largeImagePath, payload.thumbImagePath) + ?? Self.nonEmpty(payload.originalImageURL, payload.largeImageURL, payload.thumbImageURL) + ?? "" + return ImageElement(type: Self.imageElementType, imagePath: source, videoPath: "") + } + if message.messageType == .video, case .video(let payload) = message.messagePayload { + let snapshot = Self.localFile(payload.videoSnapshotPath) + ?? Self.nonEmpty(payload.videoSnapshotURL) + ?? "" + let video = Self.videoSource(from: message) + return ImageElement(type: Self.videoElementType, imagePath: snapshot, videoPath: video ?? "") + } + let isVideo = message.messageType == .video + return ImageElement(type: isVideo ? Self.videoElementType : Self.imageElementType, imagePath: "", videoPath: "") + } + + private func currentMediaMessages() -> [MessageInfo] { + return store?.state.value.messageList.filter { $0.messageType == .image || $0.messageType == .video } ?? [] + } + + private static func videoSource(from message: MessageInfo) -> String? { + guard case .video(let payload) = message.messagePayload else { return nil } + return localFile(payload.videoPath) ?? nonEmpty(payload.videoURL) + } + + private static func localFile(_ paths: String?...) -> String? { + for case let path? in paths where !path.isEmpty && FileManager.default.fileExists(atPath: path) { + return path + } + return nil + } + + private static func nonEmpty(_ values: String?...) -> String? { + for case let value? in values where !value.isEmpty { + return value + } + return nil + } +} diff --git a/chat/uikit/components/messagelist/imageviewer/MediaZoomCell.swift b/chat/uikit/components/messagelist/imageviewer/MediaZoomCell.swift new file mode 100644 index 000000000..7a1c0ca14 --- /dev/null +++ b/chat/uikit/components/messagelist/imageviewer/MediaZoomCell.swift @@ -0,0 +1,172 @@ +import UIKit +import SnapKit +import Kingfisher + +final class MediaZoomCell: UICollectionViewCell { + static let reuseID = "MediaZoomCell" + + private static let minimumZoomScale: CGFloat = 1 + + private static let maximumZoomScale: CGFloat = 3 + + private static let playButtonSize: CGFloat = 60 + + private static let playIconPointSize: CGFloat = 60 + + private static let imageElementType = 0 + + private static let videoElementType = 1 + + var onImageTap: (() -> Void)? + + var onPlayVideo: (() -> Void)? + + var onDownloadVideo: (() -> Void)? + + private var element: ImageElement? + + private var isDownloading = false + + private let scrollView = UIScrollView() + + private let imageView = UIImageView() + + private let playButton = UIButton(type: .custom) + + private let spinner = UIActivityIndicatorView(style: .large) + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + buildUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + func configure(element: ImageElement, isDownloading: Bool) { + self.element = element + self.isDownloading = isDownloading + scrollView.setZoomScale(Self.minimumZoomScale, animated: false) + loadImage(path: element.imagePath) + updateVideoControls(element: element, isDownloading: isDownloading) + } + + // MARK: - Reuse + + override func prepareForReuse() { + super.prepareForReuse() + scrollView.setZoomScale(Self.minimumZoomScale, animated: false) + imageView.kf.cancelDownloadTask() + imageView.image = nil + playButton.isHidden = true + spinner.stopAnimating() + onImageTap = nil + onPlayVideo = nil + onDownloadVideo = nil + } + + private func buildUI() { + contentView.backgroundColor = .clear + + scrollView.delegate = self + scrollView.minimumZoomScale = Self.minimumZoomScale + scrollView.maximumZoomScale = Self.maximumZoomScale + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + + imageView.contentMode = .scaleAspectFit + imageView.isUserInteractionEnabled = true + + playButton.tintColor = .white + spinner.color = .white + spinner.hidesWhenStopped = true + + contentView.addSubview(scrollView) + scrollView.addSubview(imageView) + contentView.addSubview(playButton) + contentView.addSubview(spinner) + + scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } + imageView.snp.makeConstraints { $0.edges.equalToSuperview(); $0.size.equalTo(scrollView) } + playButton.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(Self.playButtonSize) + } + spinner.snp.makeConstraints { $0.center.equalToSuperview() } + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + contentView.addGestureRecognizer(tap) + playButton.addTarget(self, action: #selector(handlePlayButton), for: .touchUpInside) + } + + private func loadImage(path: String) { + imageView.kf.cancelDownloadTask() + guard !path.isEmpty else { + imageView.image = nil + imageView.backgroundColor = UIColor.black + return + } + if path.hasPrefix("http://") || path.hasPrefix("https://"), let url = URL(string: path) { + imageView.kf.setImage(with: url) + } else if FileManager.default.fileExists(atPath: path) { + imageView.image = UIImage(contentsOfFile: path) + } else { + imageView.image = nil + } + } + + private func updateVideoControls(element: ImageElement, isDownloading: Bool) { + guard element.type == Self.videoElementType else { + playButton.isHidden = true + spinner.stopAnimating() + return + } + if isDownloading { + playButton.isHidden = true + spinner.startAnimating() + return + } + spinner.stopAnimating() + playButton.isHidden = false + let hasVideo = !(element.videoPath ?? "").isEmpty + let iconName = hasVideo ? "play.circle.fill" : "arrow.down.circle.fill" + let config = UIImage.SymbolConfiguration(pointSize: Self.playIconPointSize) + playButton.setImage(UIImage(systemName: iconName, withConfiguration: config), for: .normal) + } + + @objc private func handleTap() { + guard let element = element else { return } + if element.type == Self.imageElementType { + onImageTap?() + } else { + triggerVideoAction(element: element) + } + } + + @objc private func handlePlayButton() { + guard let element = element, element.type == Self.videoElementType else { return } + triggerVideoAction(element: element) + } + + private func triggerVideoAction(element: ImageElement) { + if !(element.videoPath ?? "").isEmpty { + onPlayVideo?() + } else if !isDownloading { + onDownloadVideo?() + } + } +} + +// MARK: - UIScrollViewDelegate (缩放) + +extension MediaZoomCell: UIScrollViewDelegate { + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } +} diff --git a/chat/uikit/components/messagelist/listen/ListenFromHereController.swift b/chat/uikit/components/messagelist/listen/ListenFromHereController.swift new file mode 100644 index 000000000..c0269a53f --- /dev/null +++ b/chat/uikit/components/messagelist/listen/ListenFromHereController.swift @@ -0,0 +1,250 @@ +import AVFoundation +import Foundation +import AtomicXCore + +struct ListenPlaybackState { + var isActive = false + var isLoading = false + var currentText = "" +} + +struct ListenItem { + let speechText: String + let audioPath: String? +} + +enum ListenPlanBuilder { + + static func build(messages: [MessageInfo]) -> [ListenItem] { + var items: [ListenItem] = [] + var lastSpeakerKey: String? + for message in messages { + let speakerKey = message.isSentBySelf ? "self" : "user:\(message.from.userID)" + let speaker = message.isSentBySelf + ? LocalizedChatString("VoiceListenSelfSpeaker") + : MessageListHelper.senderShowName(of: message) + let sameAsPrevious = lastSpeakerKey == speakerKey + switch message.messageType { + case .text: + guard case .text(let payload) = message.messagePayload else { continue } + let content = TtsTextSanitizer.shared.sanitize(text: payload.text) + if content.isEmpty { continue } + let speechText = sameAsPrevious + ? content + : String(format: LocalizedChatString("VoiceListenSaysFormat"), speaker) + content + items.append(ListenItem(speechText: speechText, audioPath: nil)) + case .image: + items.append(ListenItem( + speechText: mediaSpeech(formatKey: "VoiceListenSentImageFormat", speaker: speaker, omitName: sameAsPrevious), + audioPath: nil + )) + case .video: + items.append(ListenItem( + speechText: mediaSpeech(formatKey: "VoiceListenSentVideoFormat", speaker: speaker, omitName: sameAsPrevious), + audioPath: nil + )) + case .file: + items.append(ListenItem( + speechText: mediaSpeech(formatKey: "VoiceListenSentFileFormat", speaker: speaker, omitName: sameAsPrevious), + audioPath: nil + )) + case .merged: + guard case .merged(let payload) = message.messagePayload else { continue } + let title = TtsTextSanitizer.shared.sanitize(text: payload.title) + let name = sameAsPrevious ? "" : speaker + items.append(ListenItem( + speechText: String(format: LocalizedChatString("VoiceListenSentMergedFormat"), name, title) + .trimmingCharacters(in: .whitespaces), + audioPath: nil + )) + case .audio: + var path: String? + if case .audio(let payload) = message.messagePayload { + if let audioPath = payload.audioPath, !audioPath.isEmpty { + path = audioPath + } else if let audioURL = payload.audioURL, !audioURL.isEmpty { + path = audioURL + } + } + let speechText = sameAsPrevious + ? "" + : String(format: LocalizedChatString("VoiceListenSaysFormat"), speaker) + items.append(ListenItem(speechText: speechText, audioPath: path)) + default: + continue + } + lastSpeakerKey = speakerKey + } + return items + } + + private static func mediaSpeech(formatKey: String, speaker: String, omitName: Bool) -> String { + let name = omitName ? "" : speaker + return String(format: LocalizedChatString(formatKey), name) + .trimmingCharacters(in: .whitespaces) + } +} + +final class ListenFromHereController { + private(set) var state = ListenPlaybackState() { + didSet { onStateChange?(state) } + } + + var onStateChange: ((ListenPlaybackState) -> Void)? + + private let tts = TtsPlaybackHelper() + + private var audioPlayer: AVPlayer? + + private var audioPlayerItem: AVPlayerItem? + + private var audioEndObserver: NSObjectProtocol? + + private var audioFailedObserver: NSObjectProtocol? + + private var queue: [ListenItem] = [] + + private var index = -1 + + private var active = false + + private var voiceId = "" + + private var session = 0 + + func start(plan: [ListenItem]) { + stop() + guard !plan.isEmpty else { return } + voiceId = VoiceMessageConfig.shared.getSelectedVoiceId() + queue = plan + index = 0 + active = true + session += 1 + let currentSession = session + state = ListenPlaybackState(isActive: true, isLoading: true, currentText: plan[0].speechText) + playCurrent(showLoading: true, currentSession: currentSession) + } + + func stop() { + let wasActive = active || state.isActive + active = false + session += 1 + index = -1 + queue = [] + teardownAudioPlayer() + tts.stop() + if wasActive { + state = ListenPlaybackState() + } + } + + deinit { + teardownAudioPlayer() + } + + private func playCurrent(showLoading: Bool, currentSession: Int) { + guard active, currentSession == session, index >= 0, index < queue.count else { return } + let item = queue[index] + state.currentText = item.speechText + state.isLoading = showLoading + + if item.speechText.isEmpty { + if let audioPath = item.audioPath, !audioPath.isEmpty { + playAudio(audioPath: audioPath, currentSession: currentSession) + } else { + advance(currentSession: currentSession) + } + return + } + + tts.speak( + text: item.speechText, + voiceId: voiceId, + onStart: { [weak self] in + self?.clearLoading(currentSession: currentSession) + }, + onComplete: { [weak self] in + guard let self, self.active, currentSession == self.session else { return } + if let audioPath = item.audioPath, !audioPath.isEmpty { + self.playAudio(audioPath: audioPath, currentSession: currentSession) + } else { + self.advance(currentSession: currentSession) + } + }, + onError: { [weak self] _ in + guard let self, self.active, currentSession == self.session else { return } + self.advance(currentSession: currentSession) + } + ) + } + + private func playAudio(audioPath: String, currentSession: Int) { + teardownAudioPlayer() + let url = audioPath.hasPrefix("http") ? URL(string: audioPath) : URL(fileURLWithPath: audioPath) + guard let url else { + advance(currentSession: currentSession) + return + } + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + advance(currentSession: currentSession) + return + } + let item = AVPlayerItem(url: url) + let avPlayer = AVPlayer(playerItem: item) + avPlayer.actionAtItemEnd = .pause + + audioEndObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + guard let self, self.active, currentSession == self.session else { return } + self.advance(currentSession: currentSession) + } + audioFailedObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + guard let self, self.active, currentSession == self.session else { return } + self.advance(currentSession: currentSession) + } + + audioPlayerItem = item + audioPlayer = avPlayer + clearLoading(currentSession: currentSession) + avPlayer.play() + } + + private func advance(currentSession: Int) { + guard active, currentSession == session else { return } + index += 1 + if index >= queue.count { + stop() + } else { + playCurrent(showLoading: false, currentSession: currentSession) + } + } + + private func clearLoading(currentSession: Int) { + guard active, currentSession == session, state.isLoading else { return } + state.isLoading = false + } + + private func teardownAudioPlayer() { + if let audioEndObserver { + NotificationCenter.default.removeObserver(audioEndObserver) + self.audioEndObserver = nil + } + if let audioFailedObserver { + NotificationCenter.default.removeObserver(audioFailedObserver) + self.audioFailedObserver = nil + } + audioPlayer?.pause() + audioPlayer = nil + audioPlayerItem = nil + } +} diff --git a/chat/uikit/components/messagelist/listen/ListenPlaybackBar.swift b/chat/uikit/components/messagelist/listen/ListenPlaybackBar.swift new file mode 100644 index 000000000..6f04d3f57 --- /dev/null +++ b/chat/uikit/components/messagelist/listen/ListenPlaybackBar.swift @@ -0,0 +1,175 @@ +import UIKit +import SnapKit + +final class ListenPlaybackBar: UIView { + var onClose: (() -> Void)? + + private static let rowHeight: CGFloat = 36 + + private static let maxExpandedWidth: CGFloat = 320 + + private static let horizontalPadding: CGFloat = 10 + + private static let iconSize: CGFloat = 20 + + private static let textIconSpacing = CGFloat(SpacingScheme.smallSpacing) + + private static let closeWidth: CGFloat = 36 + + private static let textFontSize: CGFloat = 13 + + private static let borderWidth: CGFloat = 0.5 + + private static let shadowOpacity: Float = 0.15 + + private static let shadowRadius: CGFloat = 4 + + private static let shadowOffset = CGSize(width: 0, height: 2) + + private static let expandAnimationDuration: TimeInterval = 0.2 + + private let iconImageView = UIImageView() + + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + private let textLabel = UILabel() + + private let closeButton = UIButton(type: .system) + + private var expanded = false + + private var isLoading = false + + private var currentText = "" + + private var textWidthConstraint: Constraint? + + private var closeWidthConstraint: Constraint? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + bindInteraction() + updateViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + + func render(loading: Bool, text: String) { + isLoading = loading + currentText = text + updateViews() + } + + func collapse() { + expanded = false + updateViews() + } + + // MARK: - Private + + private func constructViewHierarchy() { + addSubview(iconImageView) + addSubview(loadingIndicator) + addSubview(textLabel) + addSubview(closeButton) + } + + private func activateConstraints() { + snp.makeConstraints { make in + make.height.equalTo(Self.rowHeight) + } + iconImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.iconSize) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(iconImageView) + } + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.top.bottom.equalToSuperview() + closeWidthConstraint = make.width.equalTo(0).constraint + } + textLabel.snp.makeConstraints { make in + make.leading.equalTo(iconImageView.snp.trailing).offset(Self.textIconSpacing) + make.trailing.equalTo(closeButton.snp.leading) + make.centerY.equalToSuperview() + textWidthConstraint = make.width.equalTo(0).constraint + } + } + + private func setupViewStyle() { + backgroundColor = ChatUIKitTheme.colors.bgColorOperate + layer.cornerRadius = Self.rowHeight / 2 + layer.masksToBounds = false + layer.borderWidth = Self.borderWidth + layer.borderColor = ChatUIKitTheme.colors.strokeColorPrimary.cgColor + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Self.shadowOpacity + layer.shadowRadius = Self.shadowRadius + layer.shadowOffset = Self.shadowOffset + + iconImageView.image = AtomicXChatResources.image(named: "message_listen") + iconImageView.tintColor = ChatUIKitTheme.colors.textColorLink + iconImageView.contentMode = .scaleAspectFit + loadingIndicator.color = ChatUIKitTheme.colors.textColorLink + + textLabel.font = .systemFont(ofSize: Self.textFontSize) + textLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + textLabel.lineBreakMode = .byTruncatingTail + + closeButton.setImage(AtomicXChatResources.image(named: "message_listen_close"), for: .normal) + closeButton.tintColor = ChatUIKitTheme.colors.textColorPrimary + } + + private func bindInteraction() { + closeButton.addTarget(self, action: #selector(handleCloseTapped), for: .touchUpInside) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleBarTapped)) + addGestureRecognizer(tap) + } + + private func updateViews() { + loadingIndicator.isHidden = !isLoading + if isLoading { + loadingIndicator.startAnimating() + } else { + loadingIndicator.stopAnimating() + } + iconImageView.isHidden = isLoading + textLabel.isHidden = !expanded + closeButton.isHidden = !expanded + textLabel.text = expanded ? currentText : nil + textWidthConstraint?.update(offset: expanded ? Self.measuredTextWidth(currentText) : 0) + closeWidthConstraint?.update(offset: expanded ? Self.closeWidth : 0) + setNeedsLayout() + } + + private static func measuredTextWidth(_ text: String) -> CGFloat { + let font = UIFont.systemFont(ofSize: textFontSize) + let available = maxExpandedWidth - horizontalPadding - iconSize - textIconSpacing - closeWidth + let width = ceil((text as NSString).size(withAttributes: [.font: font]).width) + return min(width, available) + } + + @objc private func handleBarTapped() { + expanded.toggle() + UIView.animate(withDuration: Self.expandAnimationDuration) { + self.updateViews() + self.superview?.layoutIfNeeded() + } + } + + @objc private func handleCloseTapped() { + onClose?() + } +} diff --git a/chat/uikit/components/messagelist/menu/MessageActionMenuAction.swift b/chat/uikit/components/messagelist/menu/MessageActionMenuAction.swift new file mode 100644 index 000000000..abd14d6ff --- /dev/null +++ b/chat/uikit/components/messagelist/menu/MessageActionMenuAction.swift @@ -0,0 +1,278 @@ +import UIKit +import AtomicXCore + +enum MessageActionIDs { + static let multiSelect = "message.multiSelect" + static let forward = "message.forward" + static let quote = "message.quote" + static let copy = "message.copy" + static let recall = "message.recall" + static let delete = "message.delete" + static let convertToText = "message.convertToText" + static let translate = "message.translate" + static let readReceipt = "message.readReceipt" + static let listenFromHere = "message.listenFromHere" + static let reaction = "message.reaction" +} + +public typealias MessageActionCustomizer = (CustomEditor) -> Void + +public struct MessageActionMenuAction: CustomItem { + public var ID: String = "" + + public let iconName: String + + public let systemIconFallback: String + + public let label: String + + public var isDangerous: Bool = false + + public var isReactionEntry: Bool = false + + public let handler: (MessageInfo, MessageActionStore) -> Void + + public init(ID: String = "", + iconName: String, + systemIconFallback: String, + label: String, + isDangerous: Bool = false, + isReactionEntry: Bool = false, + handler: @escaping (MessageInfo, MessageActionStore) -> Void) { + self.ID = ID + self.iconName = iconName + self.systemIconFallback = systemIconFallback + self.label = label + self.isDangerous = isDangerous + self.isReactionEntry = isReactionEntry + self.handler = handler + } +} + +enum MessageActionMenuProvider { + + private static let revokeTimeLimit: TimeInterval = 2 * 60 + + static func visibleActions( + for message: MessageInfo, + config: MessageListConfigProtocol & MessageActionConfigProtocol, + auxiliaryHiddenIDs: Set = [] + ) -> [MessageActionMenuAction] { + var actions: [MessageActionMenuAction] = [] + + if config.isSupportCopy, canCopy(message) { + actions.append(copyAction()) + } + if config.isSupportForward, canForward(message) { + actions.append(forwardAction()) + } + if canQuote(message, config: config) { + actions.append(quoteAction()) + } + if config.isSupportMultiSelect { + actions.append(multiSelectAction()) + } + if config.isSupportDelete { + actions.append(deleteAction()) + } + if config.isSupportRecall, canRecall(message) { + actions.append(recallAction()) + } + if config.isSupportConvertToText, canConvertToText(message, auxiliaryHiddenIDs: auxiliaryHiddenIDs) { + actions.append(convertToTextAction()) + } + if config.isSupportTranslate, canTranslate(message, auxiliaryHiddenIDs: auxiliaryHiddenIDs) { + actions.append(translateAction()) + } + if config.isSupportListenFromHere { + actions.append(listenFromHereAction()) + } + if let customizer = config.actionCustomizer { + let editor = CustomEditor(items: actions) + customizer(editor) + return editor.build() + } + return actions + } + + // MARK: - shouldShow 判定(对齐声明式各 ButtonConfig.shouldShow) + + private static func canCopy(_ message: MessageInfo) -> Bool { + return message.messageType == .text && message.status != .violation + } + + private static func canQuote(_ message: MessageInfo, + config: MessageListConfigProtocol) -> Bool { + + return config.isSupportQuote && message.status == .sendSuccess + } + + private static func canForward(_ message: MessageInfo) -> Bool { + return message.status == .sendSuccess + } + + private static func canConvertToText(_ message: MessageInfo, auxiliaryHiddenIDs: Set) -> Bool { + guard message.messageType == .audio, message.status == .sendSuccess else { return false } + guard case .audio(let payload) = message.messagePayload else { return false } + let asrText = payload.asrText ?? "" + return asrText.isEmpty || auxiliaryHiddenIDs.contains(message.msgID) + } + + private static func canTranslate(_ message: MessageInfo, auxiliaryHiddenIDs: Set) -> Bool { + guard message.messageType == .text, message.status == .sendSuccess else { return false } + guard case .text(let payload) = message.messagePayload else { return false } + + if auxiliaryHiddenIDs.contains(message.msgID) { + return true + } + let targetLanguage = AppBuilderConfig.shared.translateTargetLanguage.isEmpty + ? "en" : AppBuilderConfig.shared.translateTargetLanguage + let translatedText = payload.translatedText ?? [:] + + let translatedToTarget = payload.translateLanguage == targetLanguage + || translatedText[targetLanguage] != nil + if !translatedText.isEmpty, translatedToTarget { + return false + } + return true + } + + private static func canRecall(_ message: MessageInfo) -> Bool { + guard message.isSentBySelf, message.status == .sendSuccess else { return false } + guard let timestamp = message.timestamp else { return false } + let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp)) + return Date().timeIntervalSince(messageDate) < revokeTimeLimit + } + + // MARK: - Action 工厂 + + private static func copyAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.copy, + iconName: "message_copy", + systemIconFallback: "doc.on.doc", + label: LocalizedChatString("Copy") + ) { message, _ in + if case .text(let payload) = message.messagePayload { + UIPasteboard.general.string = payload.text + } + } + } + + private static func quoteAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.quote, + iconName: "message_quote", + systemIconFallback: "quote.bubble", + label: LocalizedChatString("Quote") + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("quoteMessageNotification"), + object: nil, + userInfo: ["message": message] + ) + } + } + + private static func forwardAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.forward, + iconName: "message_forward", + systemIconFallback: "arrowshape.turn.up.right", + label: LocalizedChatString("Forward") + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("showForwardTargetSelector"), + object: nil, + userInfo: ["messages": [message]] + ) + } + } + + private static func convertToTextAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.convertToText, + iconName: "message_convert", + systemIconFallback: "text.bubble", + label: LocalizedChatString("ConvertToText") + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("convertVoiceToText"), + object: nil, + userInfo: ["message": message] + ) + } + } + + private static func translateAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.translate, + iconName: "message_translate", + systemIconFallback: "character.bubble", + label: LocalizedChatString("Translate") + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("translateTextMessage"), + object: nil, + userInfo: ["message": message] + ) + } + } + + private static func recallAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.recall, + iconName: "message_recall", + systemIconFallback: "arrow.uturn.backward", + label: LocalizedChatString("Revoke") + ) { _, actionStore in + actionStore.revoke { _ in } + } + } + + private static func multiSelectAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.multiSelect, + iconName: "message_multiselect", + systemIconFallback: "checkmark.circle", + label: LocalizedChatString("MultiSelect") + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("enterMultiSelectMode"), + object: nil, + userInfo: ["initialMessage": message] + ) + } + } + + private static func listenFromHereAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.listenFromHere, + iconName: "message_listen_from_here", + systemIconFallback: "speaker.wave.2", + label: LocalizedChatString("VoiceListenFromHere") + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("listenFromHereNotification"), + object: nil, + userInfo: ["message": message] + ) + } + } + + private static func deleteAction() -> MessageActionMenuAction { + MessageActionMenuAction( + ID: MessageActionIDs.delete, + iconName: "message_delete", + systemIconFallback: "trash", + label: LocalizedChatString("Delete"), + isDangerous: true + ) { message, _ in + NotificationCenter.default.post( + name: NSNotification.Name("deleteMessageNotification"), + object: nil, + userInfo: ["message": message] + ) + } + } +} diff --git a/chat/uikit/components/messagelist/menu/MessageActionMenuController.swift b/chat/uikit/components/messagelist/menu/MessageActionMenuController.swift new file mode 100644 index 000000000..850eac796 --- /dev/null +++ b/chat/uikit/components/messagelist/menu/MessageActionMenuController.swift @@ -0,0 +1,775 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MessageActionMenuController: NSObject { + static let shared = MessageActionMenuController() + + private enum Metric { + static let menuCornerRadius = CGFloat(RadiusScheme.smallRadius) + static let arrowWidth: CGFloat = 14 + static let arrowHeight: CGFloat = 8 + + static let columns = 5 + static let pageRows = 2 + + static let cellWidth: CGFloat = 52 + static let cellHeight: CGFloat = 68 + + static let auxiliaryCellWidth: CGFloat = 50 + static let auxiliaryCellHeight: CGFloat = 58 + + static let pagePadding = CGFloat(SpacingScheme.iconTextSpacing) + + static let rowDividerHeight: CGFloat = 1 + static let rowDividerSideMargin = CGFloat(SpacingScheme.smallSpacing) + static let screenPadding = CGFloat(SpacingScheme.smallSpacing) + static let bubbleSpacing = CGFloat(SpacingScheme.iconTextSpacing) + static let minEdgeSpacing = CGFloat(SpacingScheme.smallSpacing) + + static let indicatorDotSize: CGFloat = 6 + static let indicatorDotSpacing = CGFloat(SpacingScheme.iconTextSpacing) + static let indicatorVerticalPadding = CGFloat(SpacingScheme.smallSpacing) + static let indicatorInactiveAlpha: CGFloat = 64.0 / 255.0 + + static var indicatorAreaHeight: CGFloat { + return indicatorDotSize + indicatorVerticalPadding * 2 + } + + static let overlayAlpha: CGFloat = 0.001 + static let rowDividerAlpha: CGFloat = 140.0 / 255.0 + static let switchAnimationDuration: TimeInterval = 0.22 + static let menuAppearDuration: TimeInterval = 0.2 + static let menuAppearScale: CGFloat = 0.95 + } + + private enum MenuStyle { + case message + case auxiliary + + var cellWidth: CGFloat { + switch self { + case .message: return Metric.cellWidth + case .auxiliary: return Metric.auxiliaryCellWidth + } + } + + var cellHeight: CGFloat { + switch self { + case .message: return Metric.cellHeight + case .auxiliary: return Metric.auxiliaryCellHeight + } + } + + var buttonMetrics: MessageActionMenuButtonMetrics { + switch self { + case .message: return .standard + case .auxiliary: return .compact + } + } + } + + private struct MenuContentLayout { + let view: UIView + let cardWidth: CGFloat + let height: CGFloat + } + + private var overlayView: UIView? + + private weak var menuBubbleView: UIView? + + private weak var activeMenuContent: UIView? + + private weak var activeEmojiPanel: ReactionEmojiPanelView? + + private var isEmojiExpanded = false + + private var menuIsAbove = true + + private var collapsedContentSize: CGSize = .zero + + private var expandedContentSize: CGSize = .zero + + // MARK: - Public + + func show(for message: MessageInfo, + bubbleFrameInWindow: CGRect, + config: MessageListConfigProtocol & MessageActionConfigProtocol, + auxiliaryHiddenIDs: Set = []) { + var actions = MessageActionMenuProvider.visibleActions( + for: message, + config: config, + auxiliaryHiddenIDs: auxiliaryHiddenIDs + ) + guard !actions.isEmpty else { return } + let showReactionEntry = config.isSupportReaction + && message.status == .sendSuccess + && !EmojiManager.shared.getPickerEmojis().isEmpty + if showReactionEntry { + actions.append(makeReactionEntryAction()) + } + presentMenu( + actions: actions, + message: message, + anchorFrameInWindow: bubbleFrameInWindow, + hasReactionEntry: showReactionEntry, + style: .message + ) + } + + func showAuxiliaryMenu(actions: [MessageActionMenuAction], + message: MessageInfo, + anchorFrameInWindow: CGRect) { + presentMenu( + actions: actions, + message: message, + anchorFrameInWindow: anchorFrameInWindow, + hasReactionEntry: false, + style: .auxiliary + ) + } + + private func presentMenu(actions: [MessageActionMenuAction], + message: MessageInfo, + anchorFrameInWindow: CGRect, + hasReactionEntry: Bool, + style: MenuStyle) { + guard let window = Self.keyWindow(), !actions.isEmpty else { return } + dismiss() + + let overlay = UIView(frame: window.bounds) + overlay.backgroundColor = UIColor.black.withAlphaComponent(Metric.overlayAlpha) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleOverlayTap)) + tap.delegate = self + overlay.addGestureRecognizer(tap) + window.addSubview(overlay) + overlayView = overlay + + let menuView = buildMenuView( + actions: actions, + message: message, + hasReactionEntry: hasReactionEntry, + style: style + ) + overlay.addSubview(menuView) + menuBubbleView = menuView + layoutMenu(menuView, around: anchorFrameInWindow, in: overlay) + animateIn(menuView) + } + + func dismiss() { + overlayView?.removeFromSuperview() + overlayView = nil + menuBubbleView = nil + activeMenuContent = nil + activeEmojiPanel = nil + isEmojiExpanded = false + } + + // MARK: - Helpers + + private override init() { + super.init() + } + + private func makeReactionEntryAction() -> MessageActionMenuAction { + var action = MessageActionMenuAction( + ID: MessageActionIDs.reaction, + iconName: "message_reaction", + systemIconFallback: "face.smiling", + label: LocalizedChatString("Reaction") + ) { _, _ in } + action.isReactionEntry = true + return action + } + + private func buildMenuView(actions: [MessageActionMenuAction], + message: MessageInfo, + hasReactionEntry: Bool, + style: MenuStyle) -> MessageActionMenuBubbleView { + let bubble = MessageActionMenuBubbleView() + let menuContent = buildMenuContent( + actions: actions, + message: message, + forceFullColumns: hasReactionEntry, + style: style + ) + activeMenuContent = menuContent.view + + let switchArea = UIStackView() + switchArea.axis = .vertical + switchArea.spacing = 0 + switchArea.alignment = .center + switchArea.addArrangedSubview(menuContent.view) + + collapsedContentSize = CGSize(width: menuContent.cardWidth, height: menuContent.height) + expandedContentSize = collapsedContentSize + + if hasReactionEntry { + let panel = ReactionEmojiPanelView(message: message) + panel.isHidden = true + panel.onCollapse = { [weak self] in self?.toggleEmojiPanel() } + panel.onMore = { [weak self] in + self?.dismiss() + ReactionEmojiPickerViewController.present(message: message) + } + panel.onToggleEmoji = { [weak self] emoji in + guard let self = self, let name = emoji.name else { return } + self.dismiss() + let hasReacted = message.reactionList.contains { $0.reactionID == name && $0.reactedByMyself } + if !hasReacted { + EmojiManager.shared.addRecentEmoji(emoji, groupID: EmojiManager.shared.reactionGroupID()) + } + NotificationCenter.default.post( + name: NSNotification.Name("messageReactionToggle"), + object: nil, + userInfo: ["message": message, "reactionID": name] + ) + } + switchArea.addArrangedSubview(panel) + activeEmojiPanel = panel + expandedContentSize = CGSize(width: menuContent.cardWidth, height: ReactionEmojiPanelView.panelHeight) + } + + bubble.setContent(switchArea) + return bubble + } + + private func buildMenuContent(actions: [MessageActionMenuAction], + message: MessageInfo, + forceFullColumns: Bool, + style: MenuStyle) -> MenuContentLayout { + let pageSize = Metric.columns * Metric.pageRows + let pages = actions.chunked(into: pageSize) + if pages.count <= 1 { + let items = pages.first ?? [] + let columnCount = forceFullColumns ? Metric.columns : max(1, min(items.count, Metric.columns)) + let cardWidth = style.cellWidth * CGFloat(columnCount) + Metric.pagePadding * 2 + let pageView = buildMenuPage(items: items, columnCount: columnCount, message: message, style: style) + let height = menuPageHeight(rowCount: max(1, (items.count + columnCount - 1) / columnCount), style: style) + pageView.frame = CGRect(x: 0, y: 0, width: cardWidth, height: height) + return MenuContentLayout(view: pageView, cardWidth: cardWidth, height: height) + } + + let columnCount = Metric.columns + let cardWidth = style.cellWidth * CGFloat(columnCount) + Metric.pagePadding * 2 + let pagerHeight = menuPageHeight(rowCount: Metric.pageRows, style: style) + let pagerView = MessageActionMenuPager( + pages: pages, + columnCount: columnCount, + cardWidth: cardWidth, + pagerHeight: pagerHeight, + pageBuilder: { [weak self] items in + guard let self = self else { return UIView() } + return self.buildMenuPage(items: items, columnCount: columnCount, message: message, style: style) + } + ) + let totalHeight = pagerHeight + Metric.indicatorAreaHeight + pagerView.frame = CGRect(x: 0, y: 0, width: cardWidth, height: totalHeight) + return MenuContentLayout(view: pagerView, cardWidth: cardWidth, height: totalHeight) + } + + private func menuPageHeight(rowCount: Int, style: MenuStyle) -> CGFloat { + let rows = max(1, rowCount) + return Metric.pagePadding * 2 + + style.cellHeight * CGFloat(rows) + + Metric.rowDividerHeight * CGFloat(rows - 1) + } + + private func buildMenuPage(items: [MessageActionMenuAction], + columnCount: Int, + message: MessageInfo, + style: MenuStyle) -> UIView { + let rows = items.chunked(into: columnCount) + let page = UIStackView() + page.axis = .vertical + page.spacing = 0 + page.alignment = .leading + page.layoutMargins = UIEdgeInsets( + top: Metric.pagePadding, + left: Metric.pagePadding, + bottom: Metric.pagePadding, + right: Metric.pagePadding + ) + page.isLayoutMarginsRelativeArrangement = true + + let contentWidth = style.cellWidth * CGFloat(columnCount) + for (rowIndex, rowItems) in rows.enumerated() { + let rowStack = UIStackView() + rowStack.axis = .horizontal + rowStack.spacing = 0 + rowStack.alignment = .center + for action in rowItems { + rowStack.addArrangedSubview(makeButton(action: action, message: message, style: style)) + } + for _ in rowItems.count ..< columnCount { + let spacer = UIView() + spacer.snp.makeConstraints { make in + make.width.equalTo(style.cellWidth) + make.height.equalTo(style.cellHeight) + } + rowStack.addArrangedSubview(spacer) + } + rowStack.snp.makeConstraints { make in + make.width.equalTo(contentWidth) + } + page.addArrangedSubview(rowStack) + if rowIndex < rows.count - 1 { + page.addArrangedSubview(makeRowDivider(width: contentWidth)) + } + } + return page + } + + private func makeRowDivider(width: CGFloat) -> UIView { + let line = UIView() + line.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary.withAlphaComponent(Metric.rowDividerAlpha) + line.snp.makeConstraints { make in + make.width.equalTo(width - Metric.rowDividerSideMargin * 2) + make.height.equalTo(Metric.rowDividerHeight) + } + return line + } + + private func toggleEmojiPanel() { + guard let overlay = overlayView, + let bubble = menuBubbleView, + let menuContent = activeMenuContent, + let panel = activeEmojiPanel else { return } + isEmojiExpanded.toggle() + menuContent.isHidden = isEmojiExpanded + panel.isHidden = !isEmojiExpanded + + overlay.layoutIfNeeded() + let contentHeight = isEmojiExpanded ? expandedContentSize.height : collapsedContentSize.height + let targetHeight = contentHeight + Metric.arrowHeight + var frame = bubble.frame + if menuIsAbove { + frame.origin.y = frame.maxY - targetHeight + } + frame.size.height = targetHeight + + let safeTop = overlay.safeAreaInsets.top + let safeBottom = overlay.safeAreaInsets.bottom + if menuIsAbove { + frame.origin.y = max(safeTop + Metric.minEdgeSpacing, frame.origin.y) + } else { + frame.origin.y = min(frame.origin.y, overlay.bounds.height - safeBottom - Metric.minEdgeSpacing - frame.height) + } + + UIView.animate(withDuration: Metric.switchAnimationDuration, delay: 0, options: [.curveEaseInOut]) { + bubble.frame = frame + bubble.layoutIfNeeded() + } + } + + private func makeButton(action: MessageActionMenuAction, + message: MessageInfo, + style: MenuStyle) -> UIControl { + let control = MessageActionMenuButton(action: action, metrics: style.buttonMetrics) { [weak self] in + guard let self = self else { return } + if action.isReactionEntry { + self.toggleEmojiPanel() + return + } + self.dismiss() + action.handler(message, MessageActionStore.create(message: message)) + } + control.snp.makeConstraints { make in + make.width.equalTo(style.cellWidth) + make.height.equalTo(style.cellHeight) + } + return control + } + + private func layoutMenu(_ menuView: MessageActionMenuBubbleView, + around bubbleFrame: CGRect, + in overlay: UIView) { + overlay.layoutIfNeeded() + let menuWidth = collapsedContentSize.width + + let menuHeight = collapsedContentSize.height + Metric.arrowHeight + + let safeTop = overlay.safeAreaInsets.top + let safeBottom = overlay.safeAreaInsets.bottom + let overlayWidth = overlay.bounds.width + let overlayHeight = overlay.bounds.height + + let maxContentHeight = max(menuHeight, expandedContentSize.height + Metric.arrowHeight) + let spaceAbove = bubbleFrame.minY - safeTop + let showAbove = spaceAbove >= (maxContentHeight + Metric.bubbleSpacing + Metric.minEdgeSpacing) + menuIsAbove = showAbove + menuView.setArrow(showAbove: showAbove) + + let bubbleCenterX = bubbleFrame.midX + var menuX = bubbleCenterX - menuWidth / 2 + menuX = max(Metric.screenPadding, min(menuX, overlayWidth - Metric.screenPadding - menuWidth)) + let arrowOffsetX = bubbleCenterX - (menuX + menuWidth / 2) + menuView.setArrowOffset(arrowOffsetX) + + let menuY: CGFloat + if showAbove { + menuY = max(safeTop + Metric.minEdgeSpacing, + bubbleFrame.minY - Metric.bubbleSpacing - menuHeight) + } else { + let below = bubbleFrame.maxY + Metric.bubbleSpacing + menuY = min(below, overlayHeight - safeBottom - Metric.minEdgeSpacing - menuHeight) + } + + menuView.frame = CGRect(x: menuX, y: menuY, width: menuWidth, height: menuHeight) + } + + private func animateIn(_ menuView: UIView) { + menuView.alpha = 0 + menuView.transform = CGAffineTransform(scaleX: Metric.menuAppearScale, y: Metric.menuAppearScale) + UIView.animate(withDuration: Metric.menuAppearDuration, delay: 0, options: [.curveEaseOut]) { + menuView.alpha = 1 + menuView.transform = .identity + } + } + + @objc private func handleOverlayTap() { + dismiss() + } + + private static func keyWindow() -> UIWindow? { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension MessageActionMenuController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let menu = menuBubbleView else { return true } + let point = touch.location(in: menu) + return !menu.bounds.contains(point) + } +} + +// MARK: - 分页菜单容器(横向翻页 + 页码圆点) + +private final class MessageActionMenuPager: UIView, UIScrollViewDelegate { + private static let indicatorDotSize: CGFloat = 6 + + private static let indicatorDotSpacing = CGFloat(SpacingScheme.iconTextSpacing) + + private static let indicatorVerticalPadding = CGFloat(SpacingScheme.smallSpacing) + + private static let indicatorInactiveAlpha: CGFloat = 64.0 / 255.0 + + private let scrollView = UIScrollView() + + private let pagesStack = UIStackView() + + private let indicatorStack = UIStackView() + + private var indicatorDots: [UIView] = [] + + private var pageCount = 0 + + private var hasAppliedInitialPage = false + + init(pages: [[MessageActionMenuAction]], + columnCount: Int, + cardWidth: CGFloat, + pagerHeight: CGFloat, + pageBuilder: ([MessageActionMenuAction]) -> UIView) { + pageCount = pages.count + super.init(frame: .zero) + + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.delegate = self + pagesStack.axis = .horizontal + pagesStack.spacing = 0 + + addSubview(scrollView) + scrollView.addSubview(pagesStack) + for pageItems in pages { + let pageView = pageBuilder(pageItems) + pagesStack.addArrangedSubview(pageView) + pageView.snp.makeConstraints { make in + make.width.equalTo(cardWidth) + make.height.equalTo(pagerHeight) + } + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(pagerHeight) + } + pagesStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + + indicatorStack.axis = .horizontal + indicatorStack.spacing = Self.indicatorDotSpacing + indicatorStack.alignment = .center + addSubview(indicatorStack) + indicatorStack.snp.makeConstraints { make in + make.top.equalTo(scrollView.snp.bottom) + .offset(Self.indicatorVerticalPadding) + make.centerX.equalToSuperview() + make.height.equalTo(Self.indicatorDotSize) + } + buildIndicatorDots() + updateIndicator(currentPage: 0) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + guard !hasAppliedInitialPage, bounds.width > 0 else { return } + hasAppliedInitialPage = true + guard effectiveUserInterfaceLayoutDirection == .rightToLeft else { return } + let maxOffsetX = max(0, scrollView.contentSize.width - scrollView.bounds.width) + scrollView.setContentOffset(CGPoint(x: maxOffsetX, y: 0), animated: false) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.bounds.width > 0 else { return } + let rawPage = Int((scrollView.contentOffset.x / scrollView.bounds.width).rounded()) + let page = effectiveUserInterfaceLayoutDirection == .rightToLeft ? pageCount - 1 - rawPage : rawPage + updateIndicator(currentPage: page) + } + + private func buildIndicatorDots() { + for _ in 0 ..< pageCount { + let dot = UIView() + dot.layer.cornerRadius = Self.indicatorDotSize / 2 + dot.snp.makeConstraints { make in + make.width.height.equalTo(Self.indicatorDotSize) + } + indicatorStack.addArrangedSubview(dot) + indicatorDots.append(dot) + } + } + + private func updateIndicator(currentPage: Int) { + let activeColor = ChatUIKitTheme.colors.textColorPrimary + for (index, dot) in indicatorDots.enumerated() { + dot.backgroundColor = activeColor.withAlphaComponent( + index == currentPage ? 1.0 : Self.indicatorInactiveAlpha + ) + } + } +} + +// MARK: - 菜单气泡容器(圆角 + 箭头 + 阴影) + +final class MessageActionMenuBubbleView: UIView { + private let contentContainer = UIView() + + private let shapeLayer = CAShapeLayer() + + private var showAbove = true + + private var arrowOffsetX: CGFloat = 0 + + private enum Metric { + static let cornerRadius: CGFloat = 7 + static let arrowWidth: CGFloat = 12 + static let arrowHeight: CGFloat = 6 + + static let contentTopPadding: CGFloat = 0 + static let contentBottomPadding: CGFloat = 0 + static let contentSidePadding: CGFloat = 0 + + static let shadowAlpha: CGFloat = 0.2 + static let shadowRadius: CGFloat = 8 + static let shadowOffset = CGSize(width: 0, height: 2) + static let minBubbleWidth: CGFloat = 60 + } + + override init(frame: CGRect) { + super.init(frame: frame) + layer.addSublayer(shapeLayer) + shapeLayer.fillColor = ChatUIKitTheme.colors.dropdownColorDefault.cgColor + layer.shadowColor = UIColor(white: 0, alpha: Metric.shadowAlpha).cgColor + layer.shadowRadius = Metric.shadowRadius + layer.shadowOpacity = 1 + layer.shadowOffset = Metric.shadowOffset + addSubview(contentContainer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setContent(_ content: UIView) { + contentContainer.addSubview(content) + + content.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + } + + func setArrow(showAbove: Bool) { + self.showAbove = showAbove + setNeedsLayout() + } + + func setArrowOffset(_ offset: CGFloat) { + + let half = bounds.width / 2 + let limit = max(0, half - Metric.cornerRadius - Metric.arrowWidth / 2) + arrowOffsetX = max(-limit, min(offset, limit)) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + let topInset = (showAbove ? 0 : Metric.arrowHeight) + Metric.contentTopPadding + let bottomInset = (showAbove ? Metric.arrowHeight : 0) + Metric.contentBottomPadding + contentContainer.frame = CGRect( + x: Metric.contentSidePadding, + y: topInset, + width: bounds.width - Metric.contentSidePadding * 2, + height: bounds.height - topInset - bottomInset + ) + shapeLayer.path = bubblePath().cgPath + layer.shadowPath = shapeLayer.path + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize { + let contentSize = contentContainer.subviews.first? + .systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) ?? .zero + let width = contentSize.width + Metric.contentSidePadding * 2 + let height = contentSize.height + Metric.contentTopPadding + Metric.contentBottomPadding + Metric.arrowHeight + return CGSize(width: max(width, Metric.minBubbleWidth), height: height) + } + + private func bubblePath() -> UIBezierPath { + let rect = bounds + let radius = Metric.cornerRadius + let arrowWidth = Metric.arrowWidth + let arrowHeight = Metric.arrowHeight + let centerX = rect.width / 2 + arrowOffsetX + let path = UIBezierPath() + + if showAbove { + let bodyBottom = rect.maxY - arrowHeight + let bodyRect = CGRect(x: 0, y: 0, width: rect.width, height: bodyBottom) + path.append(UIBezierPath(roundedRect: bodyRect, cornerRadius: radius)) + let arrow = UIBezierPath() + arrow.move(to: CGPoint(x: centerX - arrowWidth / 2, y: bodyBottom)) + arrow.addLine(to: CGPoint(x: centerX, y: rect.maxY)) + arrow.addLine(to: CGPoint(x: centerX + arrowWidth / 2, y: bodyBottom)) + arrow.close() + path.append(arrow) + } else { + let bodyRect = CGRect(x: 0, y: arrowHeight, width: rect.width, height: rect.height - arrowHeight) + path.append(UIBezierPath(roundedRect: bodyRect, cornerRadius: radius)) + let arrow = UIBezierPath() + arrow.move(to: CGPoint(x: centerX - arrowWidth / 2, y: arrowHeight)) + arrow.addLine(to: CGPoint(x: centerX, y: rect.minY)) + arrow.addLine(to: CGPoint(x: centerX + arrowWidth / 2, y: arrowHeight)) + arrow.close() + path.append(arrow) + } + return path + } +} + +// MARK: - 单个动作按钮(图标 + 文案) + +struct MessageActionMenuButtonMetrics { + let iconSize: CGFloat + let iconTopMargin: CGFloat + let iconTitleSpacing: CGFloat + let titleBottomMargin: CGFloat + + static let standard = MessageActionMenuButtonMetrics( + iconSize: 20, + iconTopMargin: CGFloat(SpacingScheme.bubbleSpacing), + iconTitleSpacing: CGFloat(SpacingScheme.iconTextSpacing), + titleBottomMargin: CGFloat(SpacingScheme.smallSpacing) + ) + + static let compact = MessageActionMenuButtonMetrics( + iconSize: 20, + iconTopMargin: CGFloat(SpacingScheme.smallSpacing), + iconTitleSpacing: CGFloat(SpacingScheme.iconTextSpacing), + titleBottomMargin: CGFloat(SpacingScheme.smallSpacing) + ) +} + +final class MessageActionMenuButton: UIControl { + private static let titleMaxLines = 2 + + private let iconView = UIImageView() + + private let titleLabel = UILabel() + + private let tapHandler: () -> Void + + init(action: MessageActionMenuAction, metrics: MessageActionMenuButtonMetrics, tapHandler: @escaping () -> Void) { + self.tapHandler = tapHandler + super.init(frame: .zero) + buildUI(action: action, metrics: metrics) + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func buildUI(action: MessageActionMenuAction, metrics: MessageActionMenuButtonMetrics) { + let colors = ChatUIKitTheme.colors + iconView.contentMode = .scaleAspectFit + iconView.tintColor = colors.textColorPrimary + iconView.image = Self.icon(named: action.iconName, fallback: action.systemIconFallback) + titleLabel.font = FontScheme.caption3Regular + titleLabel.textColor = colors.textColorPrimary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = Self.titleMaxLines + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.text = action.label + + addSubview(iconView) + addSubview(titleLabel) + + iconView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(metrics.iconTopMargin) + make.centerX.equalToSuperview() + make.width.height.equalTo(metrics.iconSize) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconView.snp.bottom).offset(metrics.iconTitleSpacing) + make.leading.trailing.equalToSuperview() + make.bottom.lessThanOrEqualToSuperview().offset(-metrics.titleBottomMargin) + } + } + + private static func icon(named name: String, fallback: String) -> UIImage? { + if !name.isEmpty, + let image = AtomicXChatResources.image(named: name) { + return image.withRenderingMode(.alwaysTemplate) + } + return UIImage(systemName: fallback) + } + + @objc private func handleTap() { + tapHandler() + } +} + +// MARK: - 数组分块工具 + +private extension Array { + func chunked(into size: Int) -> [[Element]] { + guard size > 0 else { return [self] } + return stride(from: 0, to: count, by: size).map { + Array(self[$0.. Void)? + + private enum Metric { + static let maxDisplayReactions = 5 + static let chipHorizontalSpacing = CGFloat(SpacingScheme.smallSpacing) + static let chipVerticalSpacing: CGFloat = 6 + static let chipHorizontalPadding = CGFloat(SpacingScheme.smallSpacing) + static let chipVerticalPadding = CGFloat(SpacingScheme.iconTextSpacing) + static let chipCornerRadius = CGFloat(RadiusScheme.alertRadius) + static let chipEmojiSize: CGFloat = 16 + static let chipDividerWidth: CGFloat = 1 + static let chipDividerHeight: CGFloat = 14 + static let chipTextMaxWidth: CGFloat = 120 + static let chipMaxWidth: CGFloat = 180 + static let minRowWidth: CGFloat = 160 + static let fadeInDuration: TimeInterval = 0.2 + static let selfChipBgAlpha: CGFloat = 24.0 / 255.0 + static let otherChipBgAlpha: CGFloat = 16.0 / 255.0 + static let selfChipDividerAlpha: CGFloat = 64.0 / 255.0 + static let otherChipDividerAlpha: CGFloat = 32.0 / 255.0 + static let chipTextMaxLines = 1 + static let spacerHeight: CGFloat = 1 + } + + private let rowsStack = UIStackView() + + private var lastBoundMsgID: String? + + private var lastReactionSignature = "" + + private var contentWidth: CGFloat = 0 { + didSet { + if contentWidth != oldValue { + invalidateIntrinsicContentSize() + } + } + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: contentWidth, height: UIView.noIntrinsicMetric) + } + + init() { + super.init(frame: .zero) + setContentCompressionResistancePriority(.required, for: .horizontal) + constructViewHierarchy() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Bind + + func bind(message: MessageInfo, maxWidth: CGFloat, isLeft: Bool) { + let signature = Self.reactionSignature(message) + + if !message.reactionList.isEmpty, + message.msgID == lastBoundMsgID, + signature == lastReactionSignature { + isHidden = false + return + } + + rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + guard !message.reactionList.isEmpty else { + isHidden = true + contentWidth = 0 + lastBoundMsgID = message.msgID + lastReactionSignature = "" + return + } + + let shouldFadeIn = !message.msgID.isEmpty + && message.msgID == lastBoundMsgID + && signature != lastReactionSignature + lastBoundMsgID = message.msgID + lastReactionSignature = signature + + isHidden = false + rowsStack.alignment = isLeft ? .leading : .trailing + + let maxRowWidth = max(maxWidth, Metric.minRowWidth) + let displayReactions = Array(message.reactionList.prefix(Metric.maxDisplayReactions)) + buildRows(reactions: displayReactions, isSelf: message.isSentBySelf, maxRowWidth: maxRowWidth) + + layer.removeAllAnimations() + if shouldFadeIn { + alpha = 0 + UIView.animate(withDuration: Metric.fadeInDuration) { + self.alpha = 1 + } + } else { + alpha = 1 + } + } + + // MARK: - Label + + private func constructViewHierarchy() { + addSubview(rowsStack) + rowsStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func setupViewStyle() { + rowsStack.axis = .vertical + rowsStack.spacing = Metric.chipVerticalSpacing + rowsStack.distribution = .fill + } + + private func bindInteraction() { + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) + } + + @objc private func handleTap() { + onTap?() + } + + private static func reactionSignature(_ message: MessageInfo) -> String { + return message.reactionList.map { "\($0.reactionID):\($0.totalUserCount)" }.joined(separator: "|") + } + + private func buildRows(reactions: [MessageReaction], isSelf: Bool, maxRowWidth: CGFloat) { + var currentRow = makeRowStack() + var currentRowWidth: CGFloat = 0 + var widestRowWidth: CGFloat = 0 + + for reaction in reactions { + let chip = makeChip(reaction: reaction, isSelf: isSelf) + let chipSize = chip.systemLayoutSizeFitting( + CGSize(width: maxRowWidth, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .fittingSizeLevel, + verticalFittingPriority: .fittingSizeLevel + ) + let chipWidth = min(chipSize.width, Metric.chipMaxWidth) + + if !currentRow.arrangedSubviews.isEmpty, + currentRowWidth + Metric.chipHorizontalSpacing + chipWidth > maxRowWidth { + rowsStack.addArrangedSubview(currentRow) + widestRowWidth = max(widestRowWidth, currentRowWidth) + currentRow = makeRowStack() + currentRowWidth = 0 + } + + let spacing = currentRow.arrangedSubviews.isEmpty ? 0 : Metric.chipHorizontalSpacing + currentRow.addArrangedSubview(chip) + currentRowWidth += spacing + chipWidth + } + + if !currentRow.arrangedSubviews.isEmpty { + rowsStack.addArrangedSubview(currentRow) + widestRowWidth = max(widestRowWidth, currentRowWidth) + } + contentWidth = widestRowWidth + } + + private func makeRowStack() -> UIStackView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = Metric.chipHorizontalSpacing + return row + } + + private func makeChip(reaction: MessageReaction, isSelf: Bool) -> UIView { + let colors = ChatUIKitTheme.colors + let baseTextColor = isSelf ? colors.textColorAntiPrimary : colors.textColorPrimary + + let chip = UIView() + chip.backgroundColor = baseTextColor.withAlphaComponent( + isSelf ? Metric.selfChipBgAlpha : Metric.otherChipBgAlpha + ) + chip.layer.cornerRadius = Metric.chipCornerRadius + chip.snp.makeConstraints { make in + make.width.lessThanOrEqualTo(Metric.chipMaxWidth) + } + + let content = UIStackView() + content.axis = .horizontal + content.alignment = .center + content.spacing = 0 + chip.addSubview(content) + content.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: Metric.chipVerticalPadding, + left: Metric.chipHorizontalPadding, + bottom: Metric.chipVerticalPadding, + right: Metric.chipHorizontalPadding + )) + } + + let emojiImage = Self.emojiImage(for: reaction.reactionID) + if let emojiImage = emojiImage { + let imageView = UIImageView(image: emojiImage) + imageView.contentMode = .scaleAspectFit + content.addArrangedSubview(imageView) + imageView.snp.makeConstraints { make in + make.width.height.equalTo(Metric.chipEmojiSize) + } + } + + let labelText = Self.reactionLabel(reaction) + if !labelText.isEmpty { + if emojiImage != nil { + content.addArrangedSubview(makeSpacer(width: Metric.chipHorizontalSpacing / 2)) + let divider = UIView() + divider.backgroundColor = baseTextColor.withAlphaComponent( + isSelf ? Metric.selfChipDividerAlpha : Metric.otherChipDividerAlpha + ) + content.addArrangedSubview(divider) + divider.snp.makeConstraints { make in + make.width.equalTo(Metric.chipDividerWidth) + make.height.equalTo(Metric.chipDividerHeight) + } + content.addArrangedSubview(makeSpacer(width: Metric.chipHorizontalSpacing / 2)) + } + let label = UILabel() + label.font = FontScheme.caption3Regular + label.textColor = isSelf ? colors.textColorAntiPrimary : colors.textColorSecondary + label.text = labelText + label.numberOfLines = Metric.chipTextMaxLines + label.lineBreakMode = .byTruncatingTail + content.addArrangedSubview(label) + label.snp.makeConstraints { make in + make.width.lessThanOrEqualTo(Metric.chipTextMaxWidth) + } + } + return chip + } + + private static func emojiImage(for reactionID: String) -> UIImage? { + guard let image = ReactionEmojiRenderer.image(for: reactionID), image.size != .zero else { + return nil + } + return image + } + + private func makeSpacer(width: CGFloat) -> UIView { + let spacer = UIView() + spacer.snp.makeConstraints { make in + make.width.equalTo(width) + make.height.equalTo(Metric.spacerHeight) + } + return spacer + } + + private static func reactionLabel(_ reaction: MessageReaction) -> String { + let totalUserCount = reaction.totalUserCount + guard totalUserCount > 0 else { return "" } + + let firstUser = reaction.partialUserList.first + var displayName: String? + if let nickname = firstUser?.nickname, + !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + displayName = nickname + } else if let userID = firstUser?.userID, !userID.isEmpty { + displayName = userID + } + guard let name = displayName else { + return "\(totalUserCount)" + } + if totalUserCount == 1 { + return name + } + + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let isChinese = LanguageHelper.getCurrentLanguage().hasPrefix("zh") + let count = isChinese ? totalUserCount : max(totalUserCount - 1, 1) + let suffix = String(format: LocalizedChatString("MessageReactionUserSuffix"), count) + return isChinese ? trimmed + suffix : trimmed + " " + suffix + } +} diff --git a/chat/uikit/components/messagelist/reaction/ReactionDetailViewController.swift b/chat/uikit/components/messagelist/reaction/ReactionDetailViewController.swift new file mode 100644 index 000000000..d8085acac --- /dev/null +++ b/chat/uikit/components/messagelist/reaction/ReactionDetailViewController.swift @@ -0,0 +1,312 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class ReactionDetailViewController: UIViewController { + private static let tabSpacing = CGFloat(SpacingScheme.smallSpacing) + + private static let tabBarTopInset: CGFloat = 26 + + private static let tabBarHeight: CGFloat = 32 + + private static let contentHorizontalMargin = CGFloat(SpacingScheme.bubbleSpacing) + + private static let tableTopSpacing = CGFloat(SpacingScheme.bubbleSpacing) + + private static let userRowHeight: CGFloat = 52 + + private static let reactionUserPageCount = 20 + + private let message: MessageInfo + + private let currentUserID: String? + + private let actionStore: MessageActionStore + + private var reactionList: [MessageReaction] + + private var selectedReactionID: String = "" + + private var users: [UserProfile] = [] + + private var cancellables = Set() + + private let tabScrollView = UIScrollView() + + private let tabStack = UIStackView() + + private let tableView = UITableView(frame: .zero, style: .plain) + + private var selectedReaction: MessageReaction? { + return reactionList.first { $0.reactionID == selectedReactionID } + } + + private var orderedUsers: [UserProfile] { + var list = users + guard let reaction = selectedReaction, reaction.reactedByMyself, + let currentUserID = currentUserID, + let selfIndex = list.firstIndex(where: { $0.userID == currentUserID }), selfIndex > 0 else { + return list + } + let selfUser = list.remove(at: selfIndex) + list.insert(selfUser, at: 0) + return list + } + + // MARK: - Init + + init(message: MessageInfo, currentUserID: String?) { + self.message = message + self.currentUserID = currentUserID + self.reactionList = message.reactionList + self.actionStore = MessageActionStore.create(message: message) + super.init(nibName: nil, bundle: nil) + if #available(iOS 16.0, *) { + if let sheet = sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + subscribeState() + if let first = reactionList.first { + selectTab(reactionID: first.reactionID) + } + } + + // MARK: - User Ordering (自己置顶) + + private func setupUI() { + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + + tabScrollView.showsHorizontalScrollIndicator = false + tabStack.axis = .horizontal + tabStack.spacing = Self.tabSpacing + tabStack.alignment = .center + + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = Self.userRowHeight + tableView.register(ReactionUserCell.self, forCellReuseIdentifier: ReactionUserCell.reuseID) + + view.addSubview(tabScrollView) + tabScrollView.addSubview(tabStack) + view.addSubview(tableView) + + tabScrollView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.tabBarTopInset) + make.leading.trailing.equalToSuperview().inset(Self.contentHorizontalMargin) + make.height.equalTo(Self.tabBarHeight) + } + tabStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.top.equalTo(tabScrollView.snp.bottom).offset(Self.tableTopSpacing) + make.leading.trailing.equalToSuperview().inset(Self.contentHorizontalMargin) + make.bottom.equalToSuperview() + } + rebuildTabs() + } + + private func subscribeState() { + actionStore.state + .subscribe(StatePublisherSelector(keyPath: \MessageActionState.reactionUserList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.users = list + self?.tableView.reloadData() + } + .store(in: &cancellables) + } + + private func rebuildTabs() { + tabStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for reaction in reactionList { + let tab = ReactionTabButton( + reaction: reaction, + isSelected: reaction.reactionID == selectedReactionID + ) { [weak self] in + self?.selectTab(reactionID: reaction.reactionID) + } + tabStack.addArrangedSubview(tab) + } + } + + private func selectTab(reactionID: String) { + selectedReactionID = reactionID + rebuildTabs() + users = [] + tableView.reloadData() + actionStore.loadReactionUsers(reactionID: reactionID, count: Self.reactionUserPageCount) { _ in } + } + + private func isSelf(_ user: UserProfile) -> Bool { + return user.userID == currentUserID && (selectedReaction?.reactedByMyself ?? false) + } + + private func removeSelfReaction() { + guard !selectedReactionID.isEmpty else { return } + actionStore.removeReaction(reactionID: selectedReactionID) { [weak self] _ in + DispatchQueue.main.async { self?.dismiss(animated: true) } + } + } +} + +// MARK: - UITableViewDataSource / Delegate + +extension ReactionDetailViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return orderedUsers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: ReactionUserCell.reuseID, for: indexPath) as? ReactionUserCell else { + return UITableViewCell() + } + let user = orderedUsers[indexPath.row] + cell.configure(user: user, isSelf: isSelf(user)) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + let user = orderedUsers[indexPath.row] + if isSelf(user) { + removeSelfReaction() + } + } +} + +// MARK: - Tab Button + +private final class ReactionTabButton: UIControl { + private static let cornerRadius = CGFloat(RadiusScheme.largeRadius) + + private static let selectedBorderWidth: CGFloat = 1 + + private static let selectedBackgroundAlpha: CGFloat = 0.1 + + private static let contentSpacing = CGFloat(SpacingScheme.iconTextSpacing) + + private static let emojiSize: CGFloat = 18 + + private static let contentInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) + + private let handler: () -> Void + + init(reaction: MessageReaction, isSelected: Bool, handler: @escaping () -> Void) { + self.handler = handler + super.init(frame: .zero) + buildUI(reaction: reaction, isSelected: isSelected) + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func buildUI(reaction: MessageReaction, isSelected: Bool) { + let colors = ChatUIKitTheme.colors + layer.cornerRadius = Self.cornerRadius + layer.borderWidth = isSelected ? Self.selectedBorderWidth : 0 + layer.borderColor = isSelected ? colors.buttonColorPrimaryDefault.cgColor : UIColor.clear.cgColor + backgroundColor = isSelected + ? colors.buttonColorPrimaryDefault.withAlphaComponent(Self.selectedBackgroundAlpha) + : colors.bgColorInput + + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = ReactionEmojiRenderer.image(for: reaction.reactionID) ?? UIImage(systemName: "face.smiling") + + let countLabel = UILabel() + countLabel.font = FontScheme.caption2Regular + countLabel.textColor = isSelected ? colors.buttonColorPrimaryDefault : colors.textColorSecondary + countLabel.text = "\(reaction.totalUserCount)" + + let stack = UIStackView(arrangedSubviews: [imageView, countLabel]) + stack.axis = .horizontal + stack.spacing = Self.contentSpacing + stack.alignment = .center + stack.isUserInteractionEnabled = false + addSubview(stack) + imageView.snp.makeConstraints { make in make.width.height.equalTo(Self.emojiSize) } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.contentInsets) + } + } + + @objc private func handleTap() { handler() } +} + +// MARK: - User Cell + +private final class ReactionUserCell: UITableViewCell { + static let reuseID = "ReactionUserCell" + + private static let avatarSize: CGFloat = 36 + + private static let avatarNameSpacing = CGFloat(SpacingScheme.iconIconSpacing) + + private static let nameTopOffset: CGFloat = 2 + + private static let hintTopSpacing: CGFloat = 2 + + private let avatarView = ChatAvatarView(size: .m, isRound: true) + + private let nameLabel = UILabel() + + private let hintLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + let colors = ChatUIKitTheme.colors + nameLabel.font = FontScheme.caption2Medium + nameLabel.textColor = colors.textColorPrimary + hintLabel.font = FontScheme.caption3Regular + hintLabel.textColor = colors.textColorTertiary + hintLabel.text = LocalizedChatString("ChatTap2Remove") + + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + contentView.addSubview(hintLabel) + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.avatarSize) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarNameSpacing) + make.top.equalTo(avatarView).offset(Self.nameTopOffset) + make.trailing.lessThanOrEqualToSuperview() + } + hintLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.top.equalTo(nameLabel.snp.bottom).offset(Self.hintTopSpacing) + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(user: UserProfile, isSelf: Bool) { + let name = user.nickname ?? user.userID + nameLabel.text = name + hintLabel.isHidden = !isSelf + avatarView.configure(avatarURL: user.avatarURL, fallbackName: name) + } +} diff --git a/chat/uikit/components/messagelist/reaction/ReactionEmojiPanelView.swift b/chat/uikit/components/messagelist/reaction/ReactionEmojiPanelView.swift new file mode 100644 index 000000000..6af75f5ed --- /dev/null +++ b/chat/uikit/components/messagelist/reaction/ReactionEmojiPanelView.swift @@ -0,0 +1,252 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class ReactionEmojiPanelView: UIView { + static let panelWidth: CGFloat = horizontalPadding * 2 + cellSize * CGFloat(columns) + + static let panelHeight: CGFloat = topPadding + headerHeight + headerGap + gridHeight + bottomPadding + + var onCollapse: (() -> Void)? + + var onMore: (() -> Void)? + + var onToggleEmoji: ((EmojiData) -> Void)? + + private static let columns = 6 + + private static let rows = 2 + + private static let maxQuickEmojiCount = 11 + + private static let cellSize: CGFloat = 40 + + private static let emojiGlyphInset: CGFloat = 6 + + private static let horizontalPadding = CGFloat(SpacingScheme.iconIconSpacing) + + private static let topPadding = CGFloat(SpacingScheme.iconIconSpacing) + + private static let headerHeight: CGFloat = 16 + + private static let headerGap = CGFloat(SpacingScheme.smallSpacing) + + private static let bottomPadding = CGFloat(SpacingScheme.smallSpacing) + + private static let collapseIconSize: CGFloat = 16 + + private static let rowDividerLineHeight: CGFloat = 1 + + private static let rowDividerVerticalMargin = CGFloat(SpacingScheme.iconTextSpacing) + + private static let rowDividerAlpha: CGFloat = 140.0 / 255.0 + + private static let moreButtonSize: CGFloat = 28 + + private static let moreButtonGlyphInset: CGFloat = 6 + + private static var gridHeight: CGFloat { + return cellSize * CGFloat(rows) + rowDividerLineHeight + rowDividerVerticalMargin * 2 + } + + private let message: MessageInfo + + init(message: MessageInfo) { + self.message = message + super.init(frame: .zero) + constructViewHierarchy() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func constructViewHierarchy() { + let header = buildHeader() + addSubview(header) + header.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.topPadding) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.headerHeight) + } + + let grid = buildEmojiGrid() + addSubview(grid) + grid.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(Self.headerGap) + make.centerX.equalToSuperview() + make.width.equalTo(Self.panelWidth) + make.height.equalTo(Self.gridHeight) + } + + snp.makeConstraints { make in + make.width.equalTo(Self.panelWidth) + make.height.equalTo(Self.panelHeight) + } + } + + private func buildHeader() -> UIView { + let header = UIView() + + let titleLabel = UILabel() + titleLabel.text = LocalizedChatString("Reaction") + titleLabel.font = FontScheme.caption3Regular + titleLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + header.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.centerY.equalToSuperview() + } + + let collapseButton = TappableControl { [weak self] in self?.onCollapse?() } + let collapseIcon = UIImageView() + collapseIcon.contentMode = .scaleAspectFit + collapseIcon.tintColor = ChatUIKitTheme.colors.textColorPrimary + collapseIcon.image = Self.icon(named: "message_reaction_collapse", fallback: "chevron.up") + collapseButton.addSubview(collapseIcon) + collapseIcon.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + header.addSubview(collapseButton) + collapseButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.collapseIconSize) + } + return header + } + + private func buildEmojiGrid() -> UIView { + let container = UIView() + let quickEmojis = quickEmojiList() + let lastIndex = Self.columns * Self.rows - 1 + + var previousRow: UIStackView? + for rowIndex in 0 ..< Self.rows { + let rowStack = UIStackView() + rowStack.axis = .horizontal + rowStack.spacing = 0 + for column in 0 ..< Self.columns { + let index = rowIndex * Self.columns + column + let cell: UIView + if index == lastIndex { + cell = buildMoreCell() + } else if index < quickEmojis.count { + cell = buildEmojiCell(quickEmojis[index]) + } else { + cell = UIView() + } + cell.snp.makeConstraints { make in + make.width.height.equalTo(Self.cellSize) + } + rowStack.addArrangedSubview(cell) + } + container.addSubview(rowStack) + rowStack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.height.equalTo(Self.cellSize) + if let previousRow = previousRow { + make.top.equalTo(previousRow.snp.bottom) + .offset(Self.rowDividerLineHeight + Self.rowDividerVerticalMargin * 2) + } else { + make.top.equalToSuperview() + } + } + + if rowIndex < Self.rows - 1 { + let divider = UIView() + divider.backgroundColor = ChatUIKitTheme.colors.strokeColorPrimary + .withAlphaComponent(Self.rowDividerAlpha) + container.addSubview(divider) + divider.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.top.equalTo(rowStack.snp.bottom).offset(Self.rowDividerVerticalMargin) + make.height.equalTo(Self.rowDividerLineHeight) + } + } + previousRow = rowStack + } + return container + } + + private func buildEmojiCell(_ emoji: EmojiData) -> UIView { + let control = TappableControl { [weak self] in self?.onToggleEmoji?(emoji) } + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = ReactionEmojiRenderer.image(for: emoji) ?? UIImage(systemName: "face.smiling") + control.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.emojiGlyphInset) + } + return control + } + + private func buildMoreCell() -> UIView { + let control = TappableControl { [weak self] in self?.onMore?() } + let circle = UIView() + circle.backgroundColor = ChatUIKitTheme.colors.dropdownColorHover + circle.layer.cornerRadius = Self.moreButtonSize / 2 + circle.layer.masksToBounds = true + circle.isUserInteractionEnabled = false + let icon = UIImageView() + icon.contentMode = .scaleAspectFit + icon.tintColor = ChatUIKitTheme.colors.textColorPrimary + icon.image = Self.icon(named: "message_more", fallback: "ellipsis") + circle.addSubview(icon) + icon.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Self.moreButtonGlyphInset) + } + control.addSubview(circle) + circle.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(Self.moreButtonSize) + } + return control + } + + private func quickEmojiList() -> [EmojiData] { + let allEmojis = EmojiManager.shared.getPickerEmojis() + guard !allEmojis.isEmpty else { return [] } + let emojiByName = Dictionary(uniqueKeysWithValues: allEmojis.compactMap { emoji -> (String, EmojiData)? in + guard let name = emoji.name else { return nil } + return (name, emoji) + }) + var orderedNames: [String] = [] + orderedNames += message.reactionList.filter { $0.reactedByMyself }.map { $0.reactionID } + let recentGroupID = EmojiManager.shared.reactionGroupID() + orderedNames += EmojiManager.shared.getRecentEmojis(groupID: recentGroupID) + orderedNames += allEmojis.compactMap { $0.name } + + var result: [EmojiData] = [] + var usedNames = Set() + for name in orderedNames { + guard let emoji = emojiByName[name], usedNames.insert(name).inserted else { continue } + result.append(emoji) + if result.count >= Self.maxQuickEmojiCount { break } + } + return result + } + + private static func icon(named name: String, fallback: String) -> UIImage? { + if let image = AtomicXChatResources.image(named: name) { + return image.withRenderingMode(.alwaysTemplate) + } + return UIImage(systemName: fallback) + } +} + +// MARK: - 轻量可点击容器(闭包回调) + +private final class TappableControl: UIControl { + private let handler: () -> Void + + init(handler: @escaping () -> Void) { + self.handler = handler + super.init(frame: .zero) + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + @objc private func handleTap() { handler() } +} diff --git a/chat/uikit/components/messagelist/reaction/ReactionEmojiPickerViewController.swift b/chat/uikit/components/messagelist/reaction/ReactionEmojiPickerViewController.swift new file mode 100644 index 000000000..b1813aa1f --- /dev/null +++ b/chat/uikit/components/messagelist/reaction/ReactionEmojiPickerViewController.swift @@ -0,0 +1,396 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class ReactionEmojiPickerViewController: UIViewController { + private static let heightScreenRatio: CGFloat = 0.6 + + private static let minHeight: CGFloat = 400 + + private static let maxHeight: CGFloat = 520 + + private static let topCornerRadius = CGFloat(RadiusScheme.superLargeRadius) + + private static let horizontalPadding = CGFloat(SpacingScheme.bubbleSpacing) + + private static let bottomPadding = CGFloat(SpacingScheme.normalSpacing) + + private static let dragHandleAreaHeight: CGFloat = 32 + + private static let dragHandleWidth: CGFloat = 40 + + private static let dragHandleHeight: CGFloat = 16 + + private static let dismissDragThresholdRatio: CGFloat = 0.15 + + private static let dragAnimationDuration: TimeInterval = 0.18 + + private static let presentAnimationDuration: TimeInterval = 0.25 + + private static let backdropDimAlpha: CGFloat = 0.32 + + private static let columns: CGFloat = 8 + + private static let cellHeight: CGFloat = 44 + + private static let emojiGlyphSize: CGFloat = 32 + + private static let headerVerticalPadding = CGFloat(SpacingScheme.smallSpacing) + + private static let headerHeight: CGFloat = 12 + headerVerticalPadding * 2 + + private let message: MessageInfo + + private let backdropView = UIView() + + private let sheetView = UIView() + + private let dragHandleArea = UIView() + + private var collectionView: UICollectionView! + + private var sections: [(title: String, emojis: [EmojiData])] = [] + + private var sheetHeight: CGFloat = 0 + + private var hasAnimatedIn = false + + static func present(message: MessageInfo) { + guard let presenter = topViewController() else { return } + let controller = ReactionEmojiPickerViewController(message: message) + controller.modalPresentationStyle = .overFullScreen + presenter.present(controller, animated: false) + } + + private init(message: MessageInfo) { + self.message = message + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + buildSections() + constructViewHierarchy() + setupViewStyle() + bindInteraction() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + animateIn() + } + + private func buildSections() { + let allEmojis = EmojiManager.shared.getPickerEmojis() + let emojiByName = Dictionary(uniqueKeysWithValues: allEmojis.compactMap { emoji -> (String, EmojiData)? in + guard let name = emoji.name else { return nil } + return (name, emoji) + }) + let recentNames = EmojiManager.shared.getRecentEmojis(groupID: EmojiManager.shared.reactionGroupID()) + let recentEmojis = recentNames.compactMap { emojiByName[$0] } + if !recentEmojis.isEmpty { + sections.append((LocalizedChatString("ReactionRecentUsed"), recentEmojis)) + } + if !allEmojis.isEmpty { + sections.append((LocalizedChatString("ReactionAllEmojis"), allEmojis)) + } + } + + private func constructViewHierarchy() { + view.addSubview(backdropView) + backdropView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + view.addSubview(sheetView) + sheetView.addSubview(dragHandleArea) + + let handleIcon = UIImageView() + handleIcon.contentMode = .scaleAspectFit + handleIcon.tintColor = ChatUIKitTheme.colors.strokeColorPrimary + handleIcon.image = AtomicXChatResources.image(named: "message_chevron_down")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.down") + handleIcon.isUserInteractionEnabled = false + dragHandleArea.addSubview(handleIcon) + handleIcon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.equalTo(Self.dragHandleWidth) + make.height.equalTo(Self.dragHandleHeight) + } + + let layout = ReactionPickerFlowLayout() + layout.scrollDirection = .vertical + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.register(ReactionPickerEmojiCell.self, forCellWithReuseIdentifier: ReactionPickerEmojiCell.reuseID) + collectionView.register( + ReactionPickerHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: ReactionPickerHeaderView.reuseID + ) + sheetView.addSubview(collectionView) + + dragHandleArea.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(Self.dragHandleAreaHeight) + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(dragHandleArea.snp.bottom) + make.leading.trailing.equalToSuperview().inset(Self.horizontalPadding) + make.bottom.equalToSuperview().offset(-Self.bottomPadding) + } + } + + private func setupViewStyle() { + backdropView.backgroundColor = UIColor.black.withAlphaComponent(Self.backdropDimAlpha) + backdropView.alpha = 0 + sheetView.backgroundColor = ChatUIKitTheme.colors.bgColorDialog + sheetView.layer.cornerRadius = Self.topCornerRadius + sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + sheetView.layer.masksToBounds = true + collectionView.backgroundColor = .clear + collectionView.alwaysBounceVertical = true + collectionView.showsVerticalScrollIndicator = false + } + + private func bindInteraction() { + collectionView.dataSource = self + collectionView.delegate = self + backdropView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleBackdropTap))) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleHandleTap)) + dragHandleArea.addGestureRecognizer(tap) + let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDragPan(_:))) + dragHandleArea.addGestureRecognizer(pan) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let windowHeight = view.bounds.height + guard windowHeight > 0 else { return } + let target = min(max(windowHeight * Self.heightScreenRatio, Self.minHeight), Self.maxHeight) + if sheetHeight != target { + sheetHeight = target + if sheetView.frame == .zero { + sheetView.frame = CGRect(x: 0, y: windowHeight, width: view.bounds.width, height: sheetHeight) + } else if hasAnimatedIn { + sheetView.frame = CGRect(x: 0, y: windowHeight - sheetHeight, width: view.bounds.width, height: sheetHeight) + } + } + } + + private func animateIn() { + hasAnimatedIn = true + backdropView.alpha = 0 + UIView.animate(withDuration: Self.presentAnimationDuration, delay: 0, options: [.curveEaseOut]) { + self.backdropView.alpha = 1 + self.sheetView.frame = CGRect( + x: 0, + y: self.view.bounds.height - self.sheetHeight, + width: self.view.bounds.width, + height: self.sheetHeight + ) + } + } + + private func animateOut(completion: @escaping () -> Void) { + UIView.animate(withDuration: Self.dragAnimationDuration, delay: 0, options: [.curveEaseIn]) { + self.backdropView.alpha = 0 + self.sheetView.frame = CGRect( + x: 0, + y: self.view.bounds.height, + width: self.view.bounds.width, + height: self.sheetHeight + ) + } completion: { _ in + completion() + } + } + + @objc private func handleBackdropTap() { + animateOut { [weak self] in self?.dismiss(animated: false) } + } + + @objc private func handleHandleTap() { + animateOut { [weak self] in self?.dismiss(animated: false) } + } + + @objc private func handleDragPan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + switch gesture.state { + case .changed: + guard translation.y > 0 else { return } + sheetView.frame.origin.y = view.bounds.height - sheetHeight + translation.y + case .ended, .cancelled: + let dismissed = translation.y >= sheetHeight * Self.dismissDragThresholdRatio + if dismissed { + animateOut { [weak self] in self?.dismiss(animated: false) } + } else { + UIView.animate(withDuration: Self.dragAnimationDuration) { + self.sheetView.frame.origin.y = self.view.bounds.height - self.sheetHeight + } + } + default: + break + } + } + + private func handleEmojiSelected(_ emoji: EmojiData) { + guard let name = emoji.name else { return } + let hasReacted = message.reactionList.contains { $0.reactionID == name && $0.reactedByMyself } + if !hasReacted { + EmojiManager.shared.addRecentEmoji(emoji, groupID: EmojiManager.shared.reactionGroupID()) + } + NotificationCenter.default.post( + name: NSNotification.Name("messageReactionToggle"), + object: nil, + userInfo: ["message": message, "reactionID": name] + ) + animateOut { [weak self] in self?.dismiss(animated: false) } + } + + private static func topViewController() -> UIViewController? { + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first + var top = window?.rootViewController + while let presented = top?.presentedViewController { + top = presented + } + return top + } +} + +// MARK: - UICollectionViewDataSource + +extension ReactionEmojiPickerViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return sections[section].emojis.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ReactionPickerEmojiCell.reuseID, for: indexPath + ) as? ReactionPickerEmojiCell ?? ReactionPickerEmojiCell() + cell.configure(with: sections[indexPath.section].emojis[indexPath.item]) + return cell + } + + func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: ReactionPickerHeaderView.reuseID, + for: indexPath + ) as? ReactionPickerHeaderView ?? ReactionPickerHeaderView() + header.configure(title: sections[indexPath.section].title) + return header + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension ReactionEmojiPickerViewController: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width = floor((collectionView.bounds.width) / Self.columns) + return CGSize(width: width, height: Self.cellHeight) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForHeaderInSection section: Int + ) -> CGSize { + return CGSize(width: collectionView.bounds.width, height: Self.headerHeight) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + handleEmojiSelected(sections[indexPath.section].emojis[indexPath.item]) + } +} + +// MARK: - Cell / Header / Layout + +private final class ReactionPickerEmojiCell: UICollectionViewCell { + static let reuseID = "ReactionPickerEmojiCell" + + private static let glyphSize: CGFloat = 32 + + private let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + imageView.contentMode = .scaleAspectFit + contentView.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.glyphSize) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with emoji: EmojiData) { + imageView.image = ReactionEmojiRenderer.image(for: emoji) + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + } +} + +private final class ReactionPickerHeaderView: UICollectionReusableView { + static let reuseID = "ReactionPickerHeaderView" + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = FontScheme.caption3Regular + titleLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.centerY.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + titleLabel.text = title + } +} + +private final class ReactionPickerFlowLayout: UICollectionViewFlowLayout { + override var flipsHorizontallyInOppositeLayoutDirection: Bool { + return true + } +} diff --git a/chat/uikit/components/messagelist/reaction/ReactionEmojiRenderer.swift b/chat/uikit/components/messagelist/reaction/ReactionEmojiRenderer.swift new file mode 100644 index 000000000..8477b0741 --- /dev/null +++ b/chat/uikit/components/messagelist/reaction/ReactionEmojiRenderer.swift @@ -0,0 +1,44 @@ +import UIKit + +enum ReactionEmojiRenderer { + + static let quickEmojiCount = 6 + + static func image(for reactionID: String) -> UIImage? { + guard let emoji = EmojiManager.shared.getAllEmojis().first(where: { $0.name == reactionID }) else { + return nil + } + return image(for: emoji) + } + + static func image(for emoji: EmojiData) -> UIImage? { + let attributed = EmojiManager.shared.createAttributedStringFromEmojiData(emoji) + guard attributed.length > 0, + let attachment = attributed.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment else { + return nil + } + return attachment.image + } + + static func quickEmojis() -> [EmojiData] { + let all = EmojiManager.shared.getPickerEmojis() + guard !all.isEmpty else { return [] } + + var result: [EmojiData] = [] + let recentIDs = EmojiManager.shared.getRecentEmojis(groupID: EmojiManager.shared.reactionGroupID()) + for id in recentIDs.prefix(quickEmojiCount) { + if let emoji = all.first(where: { $0.name == id }) { + result.append(emoji) + } + } + if result.count < quickEmojiCount { + for emoji in all { + if result.count >= quickEmojiCount { break } + if !result.contains(where: { $0.name == emoji.name }) { + result.append(emoji) + } + } + } + return Array(result.prefix(quickEmojiCount)) + } +} diff --git a/chat/uikit/components/messagelist/readreceipt/MessageReadReceiptHelper.swift b/chat/uikit/components/messagelist/readreceipt/MessageReadReceiptHelper.swift new file mode 100644 index 000000000..f6c293720 --- /dev/null +++ b/chat/uikit/components/messagelist/readreceipt/MessageReadReceiptHelper.swift @@ -0,0 +1,54 @@ +import UIKit +import AtomicXCore + +enum MessageReadReceiptDisplayState { + case unread + case read + case allRead +} + +enum MessageReadReceiptHelper { + + static func shouldShowReadReceipt(_ message: MessageInfo) -> Bool { + return AppBuilderConfig.shared.enableReadReceipt + && message.isSentBySelf + && message.needReadReceipt + && message.status == .sendSuccess + && message.messageType != .tips + } + + static func displayState(_ message: MessageInfo) -> MessageReadReceiptDisplayState { + let receipt = message.readReceiptInfo + if message.conversationType != .group { + return receipt?.isPeerRead == true ? .read : .unread + } + if receipt != nil && (receipt?.unreadCount ?? 0) == 0 { + return .allRead + } + if (receipt?.readCount ?? 0) > 0 { + return .read + } + return .unread + } + + static func receiptText(_ message: MessageInfo) -> String { + switch displayState(message) { + case .unread: + return LocalizedChatString("MessageReadC2CUnRead") + case .read: + if message.conversationType != .group { + return LocalizedChatString("MessageReadC2CRead") + } + let readCount = message.readReceiptInfo?.readCount ?? 0 + return "\(readCount) \(LocalizedChatString("MessageReadPartRead"))" + case .allRead: + return LocalizedChatString("MessageReadAllRead") + } + } + + static func displayName(for member: GroupMember) -> String { + if let nameCard = member.nameCard, !nameCard.isEmpty { return nameCard } + if let nickname = member.nickname, !nickname.isEmpty { return nickname } + return member.userID + } +} diff --git a/chat/uikit/components/messagelist/readreceipt/MessageReadReceiptViewController.swift b/chat/uikit/components/messagelist/readreceipt/MessageReadReceiptViewController.swift new file mode 100644 index 000000000..0dfa4d4a8 --- /dev/null +++ b/chat/uikit/components/messagelist/readreceipt/MessageReadReceiptViewController.swift @@ -0,0 +1,313 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class MessageReadReceiptViewController: UIViewController, SystemNavigationBarPage { + fileprivate static let backIconPointSize: CGFloat = 18 + + fileprivate static let contentHorizontalInset = CGFloat(SpacingScheme.contentSpacing) + + fileprivate static let dividerHeight: CGFloat = 0.5 + + fileprivate static let contentTopInset = CGFloat(SpacingScheme.bubbleSpacing) + + fileprivate static let tabSpacing = CGFloat(SpacingScheme.iconIconSpacing) + + fileprivate static let tabVerticalPadding = CGFloat(SpacingScheme.smallSpacing) + + fileprivate static let tabHorizontalPadding = CGFloat(SpacingScheme.iconIconSpacing) + + fileprivate static let tabCornerRadius = CGFloat(RadiusScheme.superLargeRadius) + + fileprivate static let listTopSpacing = CGFloat(SpacingScheme.iconIconSpacing) + + fileprivate static let rowAvatarSize: CGFloat = 36 + + fileprivate static let rowNameSpacing = CGFloat(SpacingScheme.iconIconSpacing) + + fileprivate static let rowHorizontalInset = CGFloat(SpacingScheme.iconTextSpacing) + + fileprivate static let rowVerticalInset = CGFloat(SpacingScheme.smallSpacing) + + fileprivate static let receiptMemberPageCount = 20 + + fileprivate static let loadMoreThresholdRows = 5 + + private let message: MessageInfo + + private let actionStore: MessageActionStore + + private let onUserClick: ((String) -> Void)? + + private var cancellables = Set() + + private let dividerView = UIView() + + private let tabStack = UIStackView() + + private let readTabButton = UIButton(type: .system) + + private let unreadTabButton = UIButton(type: .system) + + private let tableView = UITableView() + + private var readMembers: [GroupMember] = [] + + private var unreadMembers: [GroupMember] = [] + + private var hasMoreReadMembers = false + + private var hasMoreUnreadMembers = false + + private var isLoadingMoreRead = false + + private var isLoadingMoreUnread = false + + private var selectedReadTab: Bool + + private var displayedMembers: [GroupMember] { + selectedReadTab ? readMembers : unreadMembers + } + + init(message: MessageInfo, + actionStore: MessageActionStore, + onUserClick: ((String) -> Void)? = nil) { + self.message = message + self.actionStore = actionStore + self.onUserClick = onUserClick + self.selectedReadTab = (message.readReceiptInfo?.readCount ?? 0) > 0 + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = LocalizedChatString("MessageReadDetail") + view.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + setupNavigationBar() + setupTabRow() + setupTableView() + subscribeState() + loadInitialData() + refreshTabState() + } + + private func setupNavigationBar() { + let backIcon = AtomicXChatResources.image(named: "contact_info_back")? + .withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.backIconPointSize, weight: .semibold)) + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: backIcon, + style: .plain, + target: self, + action: #selector(handleClose) + ) + navigationItem.leftBarButtonItem?.tintColor = ChatUIKitTheme.colors.textColorPrimary + } + + private func setupTabRow() { + tabStack.axis = .horizontal + tabStack.distribution = .fillEqually + tabStack.spacing = Self.tabSpacing + view.addSubview(tabStack) + tabStack.addArrangedSubview(readTabButton) + tabStack.addArrangedSubview(unreadTabButton) + for button in [readTabButton, unreadTabButton] { + button.titleLabel?.font = FontScheme.caption2Regular + button.layer.cornerRadius = Self.tabCornerRadius + button.layer.masksToBounds = true + button.contentEdgeInsets = UIEdgeInsets( + top: Self.tabVerticalPadding, + left: Self.tabHorizontalPadding, + bottom: Self.tabVerticalPadding, + right: Self.tabHorizontalPadding + ) + } + readTabButton.addTarget(self, action: #selector(handleReadTabTap), for: .touchUpInside) + unreadTabButton.addTarget(self, action: #selector(handleUnreadTabTap), for: .touchUpInside) + dividerView.backgroundColor = ChatUIKitTheme.colors.strokeColorSecondary + view.addSubview(dividerView) + dividerView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(Self.dividerHeight) + } + tabStack.snp.makeConstraints { make in + make.top.equalTo(dividerView.snp.bottom).offset(Self.contentTopInset) + make.leading.equalToSuperview().offset(Self.contentHorizontalInset) + make.trailing.equalToSuperview().offset(-Self.contentHorizontalInset) + } + } + + private func setupTableView() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(ReceiptMemberCell.self, forCellReuseIdentifier: ReceiptMemberCell.reuseID) + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.top.equalTo(tabStack.snp.bottom).offset(Self.listTopSpacing) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func subscribeState() { + actionStore.state + .subscribe(StatePublisherSelector(keyPath: \MessageActionState.readMemberList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] members in + self?.readMembers = Self.distinctMembers(members) + self?.refreshTabState() + } + .store(in: &cancellables) + actionStore.state + .subscribe(StatePublisherSelector(keyPath: \MessageActionState.unreadMemberList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] members in + self?.unreadMembers = Self.distinctMembers(members) + self?.refreshTabState() + } + .store(in: &cancellables) + actionStore.state + .subscribe(StatePublisherSelector(keyPath: \MessageActionState.hasMoreReadMembers)) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in self?.hasMoreReadMembers = value } + .store(in: &cancellables) + actionStore.state + .subscribe(StatePublisherSelector(keyPath: \MessageActionState.hasMoreUnreadMembers)) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in self?.hasMoreUnreadMembers = value } + .store(in: &cancellables) + } + + private static func distinctMembers(_ members: [GroupMember]) -> [GroupMember] { + var seen = Set() + return members.filter { member in + guard !member.userID.isEmpty, !seen.contains(member.userID) else { return false } + seen.insert(member.userID) + return true + } + } + + private func loadInitialData() { + actionStore.loadReadMembers(count: Self.receiptMemberPageCount) { _ in } + actionStore.loadUnreadMembers(count: Self.receiptMemberPageCount) { _ in } + } + + private func maybeLoadMore() { + let members = displayedMembers + guard !members.isEmpty else { return } + let lastVisibleRow = tableView.indexPathsForVisibleRows?.map(\.row).max() ?? -1 + guard lastVisibleRow >= members.count - Self.loadMoreThresholdRows else { return } + if selectedReadTab { + guard hasMoreReadMembers, !isLoadingMoreRead else { return } + isLoadingMoreRead = true + actionStore.loadMoreMembers(isRead: true) { [weak self] _ in + self?.isLoadingMoreRead = false + } + } else { + guard hasMoreUnreadMembers, !isLoadingMoreUnread else { return } + isLoadingMoreUnread = true + actionStore.loadMoreMembers(isRead: false) { [weak self] _ in + self?.isLoadingMoreUnread = false + } + } + } + + private func refreshTabState() { + let colors = ChatUIKitTheme.colors + readTabButton.setTitle("\(LocalizedChatString("MessageReadC2CRead")) (\(readMembers.count))", for: .normal) + unreadTabButton.setTitle("\(LocalizedChatString("MessageReadC2CUnRead")) (\(unreadMembers.count))", for: .normal) + readTabButton.backgroundColor = selectedReadTab ? colors.bgColorAvatar : colors.bgColorDefault + unreadTabButton.backgroundColor = selectedReadTab ? colors.bgColorDefault : colors.bgColorAvatar + readTabButton.setTitleColor(selectedReadTab ? colors.textColorPrimary : colors.textColorSecondary, for: .normal) + unreadTabButton.setTitleColor(selectedReadTab ? colors.textColorSecondary : colors.textColorPrimary, for: .normal) + tableView.reloadData() + } + + @objc private func handleReadTabTap() { + guard !selectedReadTab else { return } + selectedReadTab = true + refreshTabState() + } + + @objc private func handleUnreadTabTap() { + guard selectedReadTab else { return } + selectedReadTab = false + refreshTabState() + } + + @objc private func handleClose() { + closePage() + } +} + +// MARK: - UITableViewDataSource & UITableViewDelegate + +extension MessageReadReceiptViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return displayedMembers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: ReceiptMemberCell.reuseID, for: indexPath) as! ReceiptMemberCell + cell.configure(member: displayedMembers[indexPath.row]) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + onUserClick?(displayedMembers[indexPath.row].userID) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + maybeLoadMore() + } +} + +// MARK: - Member Cell + +private final class ReceiptMemberCell: UITableViewCell { + static let reuseID = "ReceiptMemberCell" + + private let avatarView = ChatAvatarView(size: .m, isRound: false) + + private let nameLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.addSubview(avatarView) + contentView.addSubview(nameLabel) + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(MessageReadReceiptViewController.contentHorizontalInset + MessageReadReceiptViewController.rowHorizontalInset) + make.top.equalToSuperview().offset(MessageReadReceiptViewController.rowVerticalInset) + make.bottom.equalToSuperview().offset(-MessageReadReceiptViewController.rowVerticalInset) + make.width.height.equalTo(MessageReadReceiptViewController.rowAvatarSize) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(MessageReadReceiptViewController.rowNameSpacing) + make.centerY.equalTo(avatarView) + make.trailing.lessThanOrEqualToSuperview().offset(-(MessageReadReceiptViewController.contentHorizontalInset + MessageReadReceiptViewController.rowHorizontalInset)) + } + nameLabel.font = FontScheme.caption2Regular + nameLabel.textColor = ChatUIKitTheme.colors.textColorPrimary + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(member: GroupMember) { + let name = MessageReadReceiptHelper.displayName(for: member) + nameLabel.text = name + avatarView.configure(avatarURL: member.avatarURL, fallbackName: name) + } +} diff --git a/chat/uikit/components/messagelist/tongue/MessageListFloatingEntry.swift b/chat/uikit/components/messagelist/tongue/MessageListFloatingEntry.swift new file mode 100644 index 000000000..9895b62fa --- /dev/null +++ b/chat/uikit/components/messagelist/tongue/MessageListFloatingEntry.swift @@ -0,0 +1,206 @@ +import UIKit +import AtomicXCore + +enum MessageListFloatingEntry { + + case backToLatest + + case newMessages(count: Int, firstMessage: MessageInfo) + + case mention(target: MessageListMentionTarget) + + case backToQuote(returnMessage: MessageInfo) +} + +enum MessageListMentionKind { + case atMe + case atAll +} + +struct MessageListMentionTarget: Equatable { + let sequence: Int64 + let kind: MessageListMentionKind +} + +enum MessageListMentionTargetVisibility { + case unknown + case visible + case hidden +} + +enum MessageListFloatingEntryPolicy { + + private static let backToLatestThresholdScreenMultiplier: CGFloat = 1.5 + + static func shouldShowBackToLatest(distanceFromLatest: CGFloat, viewportHeight: CGFloat) -> Bool { + guard viewportHeight > 0, distanceFromLatest > 0 else { return false } + return distanceFromLatest >= viewportHeight * backToLatestThresholdScreenMultiplier + } + + static func findOldestMentionTarget(_ groupAtInfoList: [GroupAtInfo]) -> MessageListMentionTarget? { + guard let oldest = groupAtInfoList.min(by: { $0.msgSeq < $1.msgSeq }) else { return nil } + let kind: MessageListMentionKind + switch oldest.atType { + case .atAll, .atAllAtMe: + kind = .atAll + case .atMe: + kind = .atMe + } + return MessageListMentionTarget(sequence: oldest.msgSeq, kind: kind) + } + + static func iconRotationDegrees(_ entry: MessageListFloatingEntry) -> CGFloat { + switch entry { + case .mention: + return 180 + case .backToLatest, .backToQuote, .newMessages: + return 0 + } + } +} + +final class MessageListFloatingEntryStateController { + private var firstNewMessage: MessageInfo? + + private var newMessageCount: Int = 0 + + private var mentionTarget: MessageListMentionTarget? + + private var mentionTargetVisibility: MessageListMentionTargetVisibility = .unknown + + private var shouldShowBackToLatest: Bool = false + + private var isBeyondDisplayThreshold: Bool = false + + private var backToQuoteReturnMessage: MessageInfo? + + private var showBackToQuote: Bool = false + + func reset() { + firstNewMessage = nil + newMessageCount = 0 + mentionTarget = nil + mentionTargetVisibility = .unknown + shouldShowBackToLatest = false + isBeyondDisplayThreshold = false + backToQuoteReturnMessage = nil + showBackToQuote = false + } + + // MARK: - Quote + + func onQuoteNavigated(returnMessage: MessageInfo) { + guard !returnMessage.msgID.isEmpty else { return } + backToQuoteReturnMessage = returnMessage + showBackToQuote = true + } + + func currentBackToQuoteReturnMessage() -> MessageInfo? { + return showBackToQuote ? backToQuoteReturnMessage : nil + } + + // MARK: - Mention + + func onInitialMentionTarget(_ target: MessageListMentionTarget?, + visibility: MessageListMentionTargetVisibility = .hidden) { + if target == nil || visibility == .visible { + mentionTarget = nil + mentionTargetVisibility = .unknown + return + } + mentionTarget = target + mentionTargetVisibility = visibility + } + + func onMentionTargetVisibilityChanged(_ visibility: MessageListMentionTargetVisibility) { + guard mentionTarget != nil else { + mentionTargetVisibility = .unknown + return + } + if visibility == .visible { + mentionTarget = nil + mentionTargetVisibility = .unknown + } else { + mentionTargetVisibility = visibility + } + } + + func currentMentionTarget() -> MessageListMentionTarget? { + return mentionTarget + } + + // MARK: - New Messages / Scroll + + func onNewMessage(_ message: MessageInfo, isLatestCompletelyVisible: Bool) { + guard !message.msgID.isEmpty else { return } + if isLatestCompletelyVisible { + clearNewMessages() + return + } + if firstNewMessage == nil { + firstNewMessage = message + } + newMessageCount += 1 + } + + func onScroll(distanceFromLatest: CGFloat, + viewportHeight: CGFloat, + isLatestCompletelyVisible: Bool, + isReturnMessageCompletelyVisible: Bool) { + if isReturnMessageCompletelyVisible { + backToQuoteReturnMessage = nil + showBackToQuote = false + } + if isLatestCompletelyVisible { + clearNewMessages() + isBeyondDisplayThreshold = false + shouldShowBackToLatest = false + return + } + if MessageListFloatingEntryPolicy.shouldShowBackToLatest( + distanceFromLatest: distanceFromLatest, + viewportHeight: viewportHeight + ) { + isBeyondDisplayThreshold = true + } + shouldShowBackToLatest = isBeyondDisplayThreshold + } + + // MARK: - Current Entry / Consume + + func currentEntry() -> MessageListFloatingEntry? { + if showBackToQuote, let returnMessage = backToQuoteReturnMessage { + return .backToQuote(returnMessage: returnMessage) + } + if let target = mentionTarget, mentionTargetVisibility != .visible { + return .mention(target: target) + } + if isBeyondDisplayThreshold, + newMessageCount > 0, + let newMessage = firstNewMessage, + !newMessage.msgID.isEmpty { + return .newMessages(count: newMessageCount, firstMessage: newMessage) + } + guard isBeyondDisplayThreshold else { return nil } + return shouldShowBackToLatest ? .backToLatest : nil + } + + func consume(_ entry: MessageListFloatingEntry) { + switch entry { + case .backToLatest: + break + case .newMessages: + clearNewMessages() + case .mention: + mentionTarget = nil + case .backToQuote: + backToQuoteReturnMessage = nil + showBackToQuote = false + } + } + + private func clearNewMessages() { + firstNewMessage = nil + newMessageCount = 0 + } +} diff --git a/chat/uikit/components/messagelist/tongue/MessageTongueView.swift b/chat/uikit/components/messagelist/tongue/MessageTongueView.swift new file mode 100644 index 000000000..229c73bec --- /dev/null +++ b/chat/uikit/components/messagelist/tongue/MessageTongueView.swift @@ -0,0 +1,137 @@ +import UIKit +import SnapKit + +final class MessageTongueView: UIControl { + private enum Style { + static let minWidth: CGFloat = 94 + static let height: CGFloat = 35 + static let cornerRadius: CGFloat = 7 + static let horizontalPadding: CGFloat = 10 + static let iconWidth: CGFloat = 12 + static let iconHeight: CGFloat = 11 + static let iconTextSpacing: CGFloat = 6 + static let shadowOpacity: Float = 0.12 + static let shadowRadius: CGFloat = 6 + static let shadowOffset = CGSize(width: 0, height: 2) + static let iconViewportWidth: CGFloat = 24 + static let iconViewportHeight: CGFloat = 22 + } + + private let iconView = UIImageView() + + private let textLabel = UILabel() + + private let stack = UIStackView() + + // MARK: - Init + + init() { + super.init(frame: .zero) + buildUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + func configure(entry: MessageListFloatingEntry) { + textLabel.text = Self.text(for: entry) + iconView.transform = CGAffineTransform( + rotationAngle: MessageListFloatingEntryPolicy.iconRotationDegrees(entry) * .pi / 180 + ) + } + + private func buildUI() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.floatingColorDefault + layer.cornerRadius = Style.cornerRadius + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = Style.shadowOpacity + layer.shadowRadius = Style.shadowRadius + layer.shadowOffset = Style.shadowOffset + + iconView.image = Self.makeDoubleArrowImage() + iconView.tintColor = colors.textColorLink + iconView.contentMode = .scaleAspectFit + + textLabel.font = FontScheme.caption2Regular + textLabel.textColor = colors.textColorLink + textLabel.textAlignment = .center + + stack.axis = .horizontal + stack.spacing = Style.iconTextSpacing + stack.alignment = .center + stack.isUserInteractionEnabled = false + stack.addArrangedSubview(iconView) + stack.addArrangedSubview(textLabel) + addSubview(stack) + + iconView.snp.makeConstraints { make in + make.width.equalTo(Style.iconWidth) + make.height.equalTo(Style.iconHeight) + } + stack.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(Style.horizontalPadding) + make.trailing.equalToSuperview().offset(-Style.horizontalPadding) + } + snp.makeConstraints { make in + make.height.equalTo(Style.height) + make.width.greaterThanOrEqualTo(Style.minWidth) + } + } + + private static func makeDoubleArrowImage() -> UIImage { + let size = CGSize(width: Style.iconWidth, height: Style.iconHeight) + let transform = CGAffineTransform(scaleX: Style.iconWidth / Style.iconViewportWidth, y: Style.iconHeight / Style.iconViewportHeight) + + let lowerChevron = UIBezierPath() + lowerChevron.move(to: CGPoint(x: 11.9802, y: 18.72669)) + lowerChevron.addLine(to: CGPoint(x: 1.6453, y: 9.69699)) + lowerChevron.addLine(to: CGPoint(x: 0, y: 11.53559)) + lowerChevron.addLine(to: CGPoint(x: 11.977, y: 22)) + lowerChevron.addLine(to: CGPoint(x: 24, y: 11.53729)) + lowerChevron.addLine(to: CGPoint(x: 22.3582, y: 9.69529)) + lowerChevron.close() + + let upperChevron = UIBezierPath() + upperChevron.move(to: CGPoint(x: 24, y: 1.842)) + upperChevron.addLine(to: CGPoint(x: 22.3582, y: 0)) + upperChevron.addLine(to: CGPoint(x: 11.9802, y: 9.0314)) + upperChevron.addLine(to: CGPoint(x: 1.6453, y: 0.0017)) + upperChevron.addLine(to: CGPoint(x: 0, y: 1.8406)) + upperChevron.addLine(to: CGPoint(x: 11.977, y: 12.305)) + upperChevron.close() + + lowerChevron.apply(transform) + upperChevron.apply(transform) + + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + UIColor.white.setFill() + lowerChevron.fill() + upperChevron.fill() + } + return image.withRenderingMode(.alwaysTemplate) + } + + private static func text(for entry: MessageListFloatingEntry) -> String { + switch entry { + case .backToLatest: + return LocalizedChatString("ChatBackToLatestLocation") + case .newMessages(let count, _): + return String(format: LocalizedChatString("ChatNewMessages"), "\(count)") + case .mention(let target): + switch target.kind { + case .atAll: + return LocalizedChatString("MentionAtAllTag") + case .atMe: + return LocalizedChatString("MentionAtMeTag") + } + case .backToQuote: + return LocalizedChatString("ChatBackToQuoteLocation") + } + } +} diff --git a/chat/uikit/components/messagelist/view/MergedMessageDetailViewController.swift b/chat/uikit/components/messagelist/view/MergedMessageDetailViewController.swift new file mode 100644 index 000000000..53a2d4fad --- /dev/null +++ b/chat/uikit/components/messagelist/view/MergedMessageDetailViewController.swift @@ -0,0 +1,499 @@ +import UIKit +import SnapKit +import AtomicXCore + +final class MergedMessageDetailViewController: UIViewController, SystemNavigationBarPage { + private let mergedMessage: MessageInfo + + private let config: MessageListConfigProtocol + + private let messageListStore: MessageListStore + + private let quoteResolveStore: MessageListStore + + private let conversationID: String + + private var messages: [MessageInfo] = [] + + private var actionStore: MessageActionStore? + + private var pendingHighlightIndex: Int? + + private var mediaObservers: [NSObjectProtocol] = [] + + private static let mediaDownloadNotificationName = Notification.Name(rawValue: "messageMediaDownload") + + private static let mediaDownloadProgressNotificationName = Notification.Name(rawValue: "messageMediaDownloadProgress") + + private static let estimatedRowHeight: CGFloat = 60 + + private static let backIconPointSize: CGFloat = 18 + + private static let downloadProgressComplete = 100 + + private static let quoteToastDuration: TimeInterval = 3 + + private static let quoteResolvePageCount = 20 + + private static let highlightFallbackDelay: TimeInterval = 1.0 + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.keyboardDismissMode = .onDrag + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = Self.estimatedRowHeight + table.allowsSelection = false + return table + }() + + private lazy var loadingView: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + private let emptyLabel: UILabel = { + let label = UILabel() + label.font = FontScheme.caption2Regular + label.textColor = ChatUIKitTheme.colors.textColorSecondary + label.text = LocalizedChatString("RelayNoMessage") + label.isHidden = true + return label + }() + + // MARK: - Init + + init(mergedMessage: MessageInfo, conversationID: String, config: MessageListConfigProtocol = ChatMessageListConfig(alignment: LanguageHelper.isRTL ? 2 : 1)) { + self.mergedMessage = mergedMessage + self.conversationID = conversationID + self.config = config + self.messageListStore = MessageListStore.create(conversationID: "") + self.quoteResolveStore = MessageListStore.create(conversationID: conversationID) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigation() + constructViewHierarchy() + activateConstraints() + setupViewStyle() + registerCells() + bindInteraction() + observeMediaDownloadNotifications() + loadMergedMessages() + } + + deinit { + mediaObservers.forEach { NotificationCenter.default.removeObserver($0) } + } + + // MARK: - Cell Context + + private func setupNavigation() { + title = Self.title(from: mergedMessage) + let backIcon = AtomicXChatResources.image(named: "contact_info_back")? + .withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.backIconPointSize, weight: .semibold)) + let back = UIBarButtonItem( + image: backIcon, + style: .plain, + target: self, + action: #selector(handleBack) + ) + back.tintColor = ChatUIKitTheme.colors.textColorPrimary + navigationItem.leftBarButtonItem = back + } + + @objc private func handleBack() { + closePage() + } + + private static func title(from message: MessageInfo) -> String { + if case .merged(let payload) = message.messagePayload, !payload.title.isEmpty { + return payload.title + } + return LocalizedChatString("RelayChatHistory") + } + + private func constructViewHierarchy() { + view.addSubview(tableView) + view.addSubview(loadingView) + view.addSubview(emptyLabel) + } + + private func activateConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + loadingView.snp.makeConstraints { make in + make.center.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + private func setupViewStyle() { + let listColor = ChatUIKitTheme.colors.listColorDefault + view.backgroundColor = listColor + tableView.backgroundColor = listColor + } + + private func registerCells() { + for identifier in MessageContentKind.allReuseIdentifiers { + tableView.register(MessageBaseCell.self, forCellReuseIdentifier: identifier) + } + tableView.register(MessageCenteredTextCell.self, + forCellReuseIdentifier: MessageCellRegistry.systemTipReuseIdentifier) + tableView.register(MessageCenteredTextCell.self, + forCellReuseIdentifier: MessageCellRegistry.revokedReuseIdentifier) + } + + private func bindInteraction() { + tableView.dataSource = self + tableView.delegate = self + } + + private func loadMergedMessages() { + loadingView.startAnimating() + let store = MessageActionStore.create(message: mergedMessage) + actionStore = store + store.downloadMergedMessageList(completion: MergedDetailCompletionHandler( + onSuccess: { [weak self] list in + DispatchQueue.main.async { self?.applyMessages(list) } + }, + onFailure: { [weak self] _, _ in + DispatchQueue.main.async { self?.applyMessages([]) } + } + )) + } + + private func applyMessages(_ list: [MessageInfo]) { + loadingView.stopAnimating() + messages = list.filter { $0.status != .revoked } + tableView.reloadData() + emptyLabel.isHidden = !messages.isEmpty + resolveQuotesFromCloudIfNeeded() + } + + private func resolveQuotesFromCloudIfNeeded() { + let pending = messages.enumerated().compactMap { index, message -> (Int, MessageQuoteInfo)? in + guard let quote = message.quoteInfo, + !quote.msgID.isEmpty, + quote.messagePayload == nil, + quote.timestamp > 0 + else { return nil } + return (index, quote) + } + resolveNextQuote(pending: pending, position: 0) + } + + private func resolveNextQuote(pending: [(Int, MessageQuoteInfo)], position: Int) { + guard position < pending.count else { return } + let (index, quote) = pending[position] + var anchor = MessageInfo() + anchor.timestamp = quote.timestamp + var option = MessageLoadOption() + option.cursor = anchor + option.direction = .older + option.pageCount = Self.quoteResolvePageCount + quoteResolveStore.loadMessages(option: option) { [weak self] result in + guard let self = self else { return } + var found: MessageInfo? = nil + if case .success = result { + found = self.quoteResolveStore.state.value.messageList.first { $0.msgID == quote.msgID } + } + DispatchQueue.main.async { + self.applyResolvedQuote(found: found, at: index, partialQuote: quote) + self.resolveNextQuote(pending: pending, position: position + 1) + } + } + } + + private func applyResolvedQuote(found: MessageInfo?, at index: Int, partialQuote: MessageQuoteInfo) { + guard index < messages.count, var quote = messages[index].quoteInfo, quote.messagePayload == nil else { return } + if let found = found { + quote.status = found.status + quote.sender = found.from + quote.messageType = found.messageType + quote.messagePayload = found.messagePayload + } else { + quote.status = .deleted + } + messages[index].quoteInfo = quote + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .none) + } + + private func isLeft(for message: MessageInfo) -> Bool { + switch config.alignment { + case 1: return true + case 2: return false + default: return !message.isSentBySelf + } + } + + private func makeContext(for message: MessageInfo) -> MessageContentContext { + return MessageContentContext( + config: config, + isLeft: isLeft(for: message), + isSelf: message.isSentBySelf, + showsReadReceipt: false, + isGroupChat: true, + detailTimeText: detailTimeString(for: message), + messageListStore: messageListStore, + onMediaTap: { [weak self] tapped in self?.presentImageViewer(for: tapped) }, + onMergedMessageTap: { [weak self] tapped in + guard tapped.status != .violation else { return } + self?.openInnerMergedDetail(tapped) + } + ) + } + + private func detailTimeString(for message: MessageInfo) -> String? { + guard let date = Self.date(from: message.timestamp) else { return nil } + return DateHelper.convertDateToMessageTimeStr(date) + } + + private func openInnerMergedDetail(_ message: MessageInfo) { + let detail = MergedMessageDetailViewController(mergedMessage: message, conversationID: conversationID, config: config) + navigationController?.pushViewController(detail, animated: true) + } + + private func presentImageViewer(for message: MessageInfo) { + + let mediaMessages = messages.filter { $0.messageType == .image || $0.messageType == .video } + let provider = ImageViewerDataProvider(mediaMessages: mediaMessages, currentMessage: message) + provider.loadInitial { [weak self] elements, index in + guard let self = self else { return } + let viewer = ImageViewerController(elements: elements, initialIndex: index, provider: provider) + self.present(viewer, animated: true) + } + } + + private func observeMediaDownloadNotifications() { + let center = NotificationCenter.default + let progressObserver = center.addObserver( + forName: Self.mediaDownloadProgressNotificationName, object: nil, queue: .main + ) { [weak self] notification in + self?.handleMediaDownloadProgress(notification) + } + let finishedObserver = center.addObserver( + forName: Self.mediaDownloadNotificationName, object: nil, queue: .main + ) { [weak self] notification in + self?.handleMediaDownloadFinished(notification) + } + mediaObservers = [progressObserver, finishedObserver] + } + + private func handleMediaDownloadProgress(_ notification: Notification) { + guard let data = notification.userInfo?["data"], + let messageID = Self.stringField("messageID", from: data), !messageID.isEmpty, + let progress = Self.intField("downloadMediaProgress", from: data), + let index = messages.firstIndex(where: { $0.msgID == messageID }) else { return } + var message = messages[index] + guard message.downloadMediaProgress != progress else { return } + message.downloadMediaProgress = progress + messages[index] = message + updateVisibleCellProgress(message, at: index) + } + + private func handleMediaDownloadFinished(_ notification: Notification) { + guard let data = notification.userInfo?["data"], + let messageID = Self.stringField("messageID", from: data), !messageID.isEmpty, + let payload = Self.payloadField("messagePayload", from: data), + let index = messages.firstIndex(where: { $0.msgID == messageID }) else { return } + var message = messages[index] + message.messagePayload = payload + message.downloadMediaProgress = Self.downloadProgressComplete + messages[index] = message + updateVisibleCellProgress(message, at: index) + } + + private func updateVisibleCellProgress(_ message: MessageInfo, at index: Int) { + guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? MessageBaseCell else { return } + cell.updateMediaProgressIfNeeded(message) + } + + private static func stringField(_ label: String, from data: Any) -> String? { + return Mirror(reflecting: data).children.first(where: { $0.label == label })?.value as? String + } + + private static func intField(_ label: String, from data: Any) -> Int? { + return Mirror(reflecting: data).children.first(where: { $0.label == label })?.value as? Int + } + + private static func payloadField(_ label: String, from data: Any) -> MessagePayload? { + return Mirror(reflecting: data).children.first(where: { $0.label == label })?.value as? MessagePayload + } + + private func handleQuotePreviewTap(_ hostMessage: MessageInfo) { + guard let quoteInfo = hostMessage.quoteInfo else { return } + guard !isQuotedOriginalUnreachable(quoteInfo), + let index = indexOfLoadedQuotedMessage(quoteInfo) else { + showQuoteUnreachableToast() + return + } + if isRowFullyVisible(index) { + playHighlight(at: index) + } else { + scrollAndHighlight(at: index) + } + } + + private func isQuotedOriginalUnreachable(_ quoteInfo: MessageQuoteInfo) -> Bool { + if quoteInfo.status == .revoked { return true } + if quoteInfo.status == .deleted, quoteInfo.messagePayload == nil { return true } + return false + } + + private func indexOfLoadedQuotedMessage(_ quoteInfo: MessageQuoteInfo) -> Int? { + if !quoteInfo.msgID.isEmpty, + let index = messages.firstIndex(where: { $0.msgID == quoteInfo.msgID }) { + return index + } + guard quoteInfo.sequence > 0 else { return nil } + return messages.firstIndex { $0.sequence == quoteInfo.sequence } + } + + private func showQuoteUnreachableToast() { + WindowToastManager.shared.show( + LocalizedChatString("ReplyMessageNotFoundOriginMessage"), + type: .info, + duration: Self.quoteToastDuration + ) + } + + private func isRowFullyVisible(_ index: Int) -> Bool { + let indexPath = IndexPath(row: index, section: 0) + guard let visible = tableView.indexPathsForVisibleRows, visible.contains(indexPath) else { return false } + let rect = tableView.rectForRow(at: indexPath) + let viewport = CGRect(x: 0, y: tableView.contentOffset.y, + width: tableView.bounds.width, height: tableView.bounds.height) + return viewport.contains(rect) + } + + private func scrollAndHighlight(at index: Int) { + guard index >= 0, index < messages.count else { return } + pendingHighlightIndex = index + let indexPath = IndexPath(row: index, section: 0) + tableView.layoutIfNeeded() + tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.highlightFallbackDelay) { [weak self] in + self?.playPendingHighlightIfNeeded(at: index) + } + } + + private func playPendingHighlightIfNeeded(at index: Int) { + guard pendingHighlightIndex == index else { return } + pendingHighlightIndex = nil + playHighlight(at: index) + } + + private func playHighlight(at index: Int) { + guard index >= 0, index < messages.count, + let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? MessageBaseCell else { return } + cell.playHighlightAnimation() + } + + private static func date(from timestamp: Int64?) -> Date? { + guard let timestamp = timestamp else { return nil } + return Date(timeIntervalSince1970: TimeInterval(timestamp)) + } +} + +// MARK: - UITableViewDataSource + +extension MergedMessageDetailViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return messages.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let message = messages[indexPath.row] + let kind = MessageCellRegistry.shared.renderKind(for: message) + switch kind { + case .systemTip, .revoked: + return centeredTextCell(for: message, kind: kind, timeString: nil, at: indexPath) + case .bubble(let contentKind): + return bubbleCell(for: message, contentKind: contentKind, timeString: nil, at: indexPath) + case .custom: + return bubbleCell(for: message, contentKind: .unsupported, timeString: nil, at: indexPath) + } + } + + private func centeredTextCell(for message: MessageInfo, + kind: MessageRenderKind, + timeString: String?, + at indexPath: IndexPath) -> UITableViewCell { + let identifier = MessageCellRegistry.shared.reuseIdentifier(for: kind) + guard let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as? MessageCenteredTextCell else { + return UITableViewCell() + } + let text = MessageCellRegistry.shared.centeredText(for: message, kind: kind, config: config) + cell.configure(text: text, timeString: timeString, showTime: config.isShowTimeMessage) + return cell + } + + private func bubbleCell(for message: MessageInfo, + contentKind: MessageContentKind, + timeString: String?, + at indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: contentKind.reuseIdentifier, for: indexPath) as? MessageBaseCell else { + return UITableViewCell() + } + cell.configure( + message: message, + contentKind: contentKind, + timeString: timeString, + context: makeContext(for: message), + isMultiSelectMode: false, + isSelected: false, + auxiliaryTextState: .hidden + ) + cell.onQuotePreviewTap = { [weak self] host in + self?.handleQuotePreviewTap(host) + } + return cell + } +} + +// MARK: - UITableViewDelegate + +extension MergedMessageDetailViewController: UITableViewDelegate { + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard let index = pendingHighlightIndex else { return } + playPendingHighlightIfNeeded(at: index) + } +} + +// MARK: - Completion Handler + +private final class MergedDetailCompletionHandler: MergedMessageListCompletionHandler { + private let onSuccessHandler: ([MessageInfo]) -> Void + + private let onFailureHandler: (Int, String) -> Void + + init(onSuccess: @escaping ([MessageInfo]) -> Void, onFailure: @escaping (Int, String) -> Void) { + self.onSuccessHandler = onSuccess + self.onFailureHandler = onFailure + } + + func onSuccess(messageList: [MessageInfo]) { + onSuccessHandler(messageList) + } + + func onFailure(code: Int, desc: String) { + onFailureHandler(code, desc) + } +} diff --git a/chat/uikit/components/messagelist/view/MessageListMultiSelectBar.swift b/chat/uikit/components/messagelist/view/MessageListMultiSelectBar.swift new file mode 100644 index 000000000..62634b09e --- /dev/null +++ b/chat/uikit/components/messagelist/view/MessageListMultiSelectBar.swift @@ -0,0 +1,164 @@ +import UIKit +import SnapKit + +final class MessageListMultiSelectBar: UIView { + static let barHeight: CGFloat = verticalPadding * 2 + iconSize + iconLabelSpacing + labelHeight + + var onForwardSeparate: (() -> Void)? + + var onForwardMerge: (() -> Void)? + + var onDelete: (() -> Void)? + + private static let verticalPadding = CGFloat(SpacingScheme.iconIconSpacing) + + private static let horizontalPadding = CGFloat(SpacingScheme.bubbleSpacing) + + private static let iconSize: CGFloat = 40 + + private static let iconLabelSpacing: CGFloat = 6 + + private static let labelHeight: CGFloat = 15 + + private let separateForwardItem = MultiSelectBarActionItem() + + private let mergeForwardItem = MultiSelectBarActionItem() + + private let deleteItem = MultiSelectBarActionItem() + + private var bottomPaddingConstraint: Constraint? + + private var heightConstraint: Constraint? + + // MARK: - Init + + init() { + super.init(frame: .zero) + constructViewHierarchy() + setupViewStyle() + bindInteraction() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + bottomPaddingConstraint?.update(offset: -(Self.verticalPadding + safeAreaInsets.bottom)) + heightConstraint?.update(offset: Self.barHeight + safeAreaInsets.bottom) + } + + // MARK: - Configure + + func configure(selectedCount: Int) { + let enabled = selectedCount > 0 + separateForwardItem.setEnabledState(enabled) + mergeForwardItem.setEnabledState(enabled) + deleteItem.setEnabledState(enabled) + } + + private func constructViewHierarchy() { + let stack = UIStackView(arrangedSubviews: [separateForwardItem, mergeForwardItem, deleteItem]) + stack.axis = .horizontal + stack.distribution = .fillEqually + stack.alignment = .fill + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.verticalPadding) + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + bottomPaddingConstraint = make.bottom.equalToSuperview() + .offset(-Self.verticalPadding).constraint + } + snp.makeConstraints { make in + heightConstraint = make.height.equalTo(Self.barHeight).constraint + } + } + + private func setupViewStyle() { + backgroundColor = ChatUIKitTheme.colors.bgColorBottomBar + separateForwardItem.configure( + icon: Self.icon(named: "message_multi_forward_separate"), + title: LocalizedChatString("RelayOneByOneForward") + ) + mergeForwardItem.configure( + icon: Self.icon(named: "message_multi_forward_merge"), + title: LocalizedChatString("RelayCombineForwad") + ) + deleteItem.configure( + icon: Self.icon(named: "message_multi_delete"), + title: LocalizedChatString("Delete") + ) + } + + private static func icon(named name: String) -> UIImage? { + return AtomicXChatResources.image(named: name)? + .withRenderingMode(.alwaysTemplate) + } + + private func bindInteraction() { + separateForwardItem.onTap = { [weak self] in self?.onForwardSeparate?() } + mergeForwardItem.onTap = { [weak self] in self?.onForwardMerge?() } + deleteItem.onTap = { [weak self] in self?.onDelete?() } + } +} + +// MARK: - Action Item + +private final class MultiSelectBarActionItem: UIControl { + var onTap: (() -> Void)? + + private static let iconLabelSpacing: CGFloat = 6 + + private static let iconSize: CGFloat = 40 + + private static let enabledAlpha: CGFloat = 1.0 + + private static let disabledAlpha: CGFloat = 0.4 + + private let iconView = UIImageView() + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = Self.iconLabelSpacing + stack.isUserInteractionEnabled = false + addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(Self.iconSize) + } + iconView.contentMode = .scaleAspectFit + iconView.tintColor = ChatUIKitTheme.colors.textColorSecondary + titleLabel.font = FontScheme.caption3Regular + titleLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 1 + addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(icon: UIImage?, title: String) { + iconView.image = icon + titleLabel.text = title + } + + func setEnabledState(_ enabled: Bool) { + isEnabled = enabled + alpha = enabled ? Self.enabledAlpha : Self.disabledAlpha + } + + @objc private func handleTap() { + onTap?() + } +} diff --git a/chat/uikit/components/messagelist/view/MessageListViewImpl.swift b/chat/uikit/components/messagelist/view/MessageListViewImpl.swift new file mode 100644 index 000000000..752c0b16e --- /dev/null +++ b/chat/uikit/components/messagelist/view/MessageListViewImpl.swift @@ -0,0 +1,1604 @@ +import UIKit +import Combine +import SnapKit +import Kingfisher +import AtomicXCore + +final class MessageListViewImpl: RTCBaseView { + var onMultiSelectModeChange: ((Bool) -> Void)? + + private static let loadMoreThreshold: CGFloat = 240 + + private static let nearBottomThreshold: CGFloat = 80 + + private static let forwardSeparateCountLimit = 30 + + private static let estimatedRowHeight: CGFloat = 60 + + private static let listenBarTrailingInset = CGFloat(SpacingScheme.smallSpacing) + + private static let listenBarCenterYRatio: CGFloat = 0.8 + + private static let listenBarMinTopMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let listenBarBottomMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let listenBarMaxWidth: CGFloat = 320 + + private static let tongueTrailingInset = CGFloat(SpacingScheme.bubbleSpacing) + + private static let tongueBottomInset = CGFloat(SpacingScheme.bubbleSpacing) + + private static let loadingIndicatorMargin = CGFloat(SpacingScheme.smallSpacing) + + private static let loadingIndicatorSize: CGFloat = 24 + + private static let toastShortDuration: TimeInterval = 2 + + private static let toastLongDuration: TimeInterval = 3 + + private static let highlightAnimationDelay: TimeInterval = 0.15 + + private static let highlightArmTimeout: TimeInterval = 2.0 + + private static let scrollOffsetTolerance: CGFloat = 1 + + private static let glueOffsetTolerance: CGFloat = 0.5 + + private static let reactionBarFadeDuration: TimeInterval = 0.2 + + private static let mentionLocateMaxLoadCount = 20 + + private static let latestFullyVisibleThreshold: CGFloat = 4 + + private static let jumpScrollFallbackDelay: TimeInterval = 1.0 + + private static let jumpRecenterTolerance = CGFloat(SpacingScheme.smallSpacing) + + private let onUserClick: ((String) -> Void)? + + private let viewModel: MessageListViewModel + + private var messages: [MessageInfo] = [] + + private var cancellables = Set() + + private var hasPerformedInitialScroll = false + + private var userHasInteracted = false + + private var isUserScrollSession = false + + private var deferredPrependMessages: [MessageInfo]? + + private var pendingInitialScroll = false + + private var scrollToLatestAfterReload = false + + private var lastTableBoundsHeight: CGFloat = 0 + + private var isLoadingOlder = false { + didSet { updateLoadingIndicators() } + } + + private var isLoadingNewer = false { + didSet { updateLoadingIndicators() } + } + + private var pendingHighlightMsgID: String? + + private var pendingHighlightPlayed = false + + private var pendingInitialLocateMsgID: String? + + private var pendingScrollToQuoteInfo: MessageQuoteInfo? + + private var isJumpScrolling: Bool = false + + private let floatingEntryController = MessageListFloatingEntryStateController() + + private var pendingFloatingScrollToMsgID: String? + + private var pendingMentionSequence: Int64? + + private static let unreadClearDebounceInterval: TimeInterval = 0.3 + + private var isHostVisible = false + + private var isAppActive = true + + private var isResumedAndShown = false + + private var hasPendingDebouncedUnreadClear = false + + private var debounceUnreadClearWorkItem: DispatchWorkItem? + + private var activeBackgroundImage: UIImage? + + private lazy var backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.isHidden = true + return imageView + }() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.keyboardDismissMode = .onDrag + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = Self.estimatedRowHeight + table.allowsSelection = true + return table + }() + + private lazy var tongueView: MessageTongueView = { + let view = MessageTongueView() + view.isHidden = true + return view + }() + + private lazy var topLoadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + indicator.color = ChatUIKitTheme.colors.textColorSecondary + return indicator + }() + + private lazy var bottomLoadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + indicator.color = ChatUIKitTheme.colors.textColorSecondary + return indicator + }() + + private lazy var multiSelectBar: MessageListMultiSelectBar = { + let bar = MessageListMultiSelectBar() + bar.isHidden = true + return bar + }() + + private lazy var listenPlaybackBar: ListenPlaybackBar = { + let bar = ListenPlaybackBar() + bar.isHidden = true + return bar + }() + + private let listenController = ListenFromHereController() + + // MARK: - Init + + init(conversationID: String, + config: (MessageListConfigProtocol & MessageActionConfigProtocol) = ChatMessageListConfig(), + locateMessage: MessageInfo? = nil, + onUserClick: ((String) -> Void)? = nil) { + self.onUserClick = onUserClick + self.viewModel = MessageListViewModel( + conversationID: conversationID, + config: config, + locateMessage: locateMessage + ) + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - RTCBaseView Lifecycle + + public override func constructViewHierarchy() { + LanguageHelper.applyLayoutDirection(to: self) + addSubview(backgroundImageView) + addSubview(tableView) + addSubview(topLoadingIndicator) + addSubview(bottomLoadingIndicator) + addSubview(tongueView) + addSubview(multiSelectBar) + addSubview(listenPlaybackBar) + } + + public override func activateConstraints() { + backgroundImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + listenPlaybackBar.snp.makeConstraints { make in + + make.trailing.equalToSuperview().offset(-Self.listenBarTrailingInset) + make.centerY.equalToSuperview().multipliedBy(Self.listenBarCenterYRatio) + make.top.greaterThanOrEqualTo(safeAreaLayoutGuide).offset(Self.listenBarMinTopMargin) + make.bottom.lessThanOrEqualTo(multiSelectBar.snp.top).offset(-Self.listenBarBottomMargin) + make.width.lessThanOrEqualTo(Self.listenBarMaxWidth) + } + tongueView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.tongueTrailingInset) + make.bottom.equalTo(safeAreaLayoutGuide).offset(-Self.tongueBottomInset) + } + topLoadingIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalTo(safeAreaLayoutGuide).offset(Self.loadingIndicatorMargin) + make.width.height.equalTo(Self.loadingIndicatorSize) + } + bottomLoadingIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(safeAreaLayoutGuide).offset(-Self.loadingIndicatorMargin) + make.width.height.equalTo(Self.loadingIndicatorSize) + } + multiSelectBar.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + + make.bottom.equalToSuperview() + } + } + + public override func bindInteraction() { + hasPerformedInitialScroll = false + pendingInitialScroll = false + userHasInteracted = false + isUserScrollSession = false + deferredPrependMessages = nil + listenController.stop() + tableView.dataSource = self + tableView.delegate = self + tongueView.addTarget(self, action: #selector(handleTongueTap), for: .touchUpInside) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTableViewTap)) + tapGesture.cancelsTouchesInView = false + tableView.addGestureRecognizer(tapGesture) + bindMultiSelectBar() + registerCells() + subscribeViewModel() + subscribeInputInteract() + requestInitialMentionEntry() + if let locateMsgID = viewModel.locateMessage?.msgID, !locateMsgID.isEmpty { + pendingInitialLocateMsgID = locateMsgID + } + viewModel.fetchInitialMessages() + } + + public override func setupViewStyle() { + applyBackgroundState() + } + + // MARK: - Unread Clear (对齐 Android 清除未读数逻辑) + + override func didMoveToWindow() { + super.didMoveToWindow() + refreshResumedAndShown() + } + + func hostVisibilityDidChange(_ isVisible: Bool) { + isHostVisible = isVisible + refreshResumedAndShown() + } + + private func refreshResumedAndShown() { + let active = isHostVisible && isAppActive && window != nil + guard active != isResumedAndShown else { return } + isResumedAndShown = active + if active { + cancelDebouncedUnreadClear() + viewModel.syncConversationAsRead() + } else { + flushDebouncedUnreadClear() + } + } + + private func scheduleDebouncedUnreadClear() { + guard isResumedAndShown else { return } + hasPendingDebouncedUnreadClear = true + debounceUnreadClearWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.hasPendingDebouncedUnreadClear = false + if self.isResumedAndShown { + self.viewModel.clearConversationUnreadCount() + } + } + debounceUnreadClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.unreadClearDebounceInterval, execute: workItem) + } + + private func flushDebouncedUnreadClear() { + debounceUnreadClearWorkItem?.cancel() + debounceUnreadClearWorkItem = nil + guard hasPendingDebouncedUnreadClear else { return } + hasPendingDebouncedUnreadClear = false + viewModel.clearConversationUnreadCount() + } + + private func cancelDebouncedUnreadClear() { + debounceUnreadClearWorkItem?.cancel() + debounceUnreadClearWorkItem = nil + hasPendingDebouncedUnreadClear = false + } + + // MARK: - Chat Background + + func applyChatBackgroundImage(uri: String?) { + guard let uri, let url = URL(string: uri) else { + activeBackgroundImage = nil + showDefaultListBackground() + return + } + backgroundImageView.kf.setImage(with: url) { [weak self] result in + guard let self else { return } + switch result { + case .success(let imageResult): + self.activeBackgroundImage = imageResult.image + self.applyBackgroundState() + case .failure: + self.activeBackgroundImage = nil + self.showDefaultListBackground() + } + } + } + + // MARK: - Multi-Select Bar (多选底部操作栏,对齐声明式 MultiSelectBottomBar) + + func exitMultiSelectMode() { + viewModel.exitMultiSelectMode() + } + + // MARK: - List Update & Scroll Management + + override func layoutSubviews() { + super.layoutSubviews() + glueContentToBottomForBoundsChange() + performInitialScrollToBottomIfNeeded() + } + + // MARK: - Forward (转发) + + private func showDefaultListBackground() { + backgroundImageView.kf.cancelDownloadTask() + backgroundImageView.isHidden = true + backgroundImageView.image = nil + applyOpaqueListBackground() + } + + private func applyBackgroundState() { + if let image = activeBackgroundImage { + backgroundImageView.image = image + backgroundImageView.isHidden = false + backgroundColor = .clear + tableView.backgroundColor = .clear + } else { + applyOpaqueListBackground() + } + } + + private func applyOpaqueListBackground() { + let listColor = ChatUIKitTheme.colors.bgColorOperate + backgroundColor = listColor + tableView.backgroundColor = listColor + } + + private func updateLoadingIndicators() { + if isLoadingOlder { + topLoadingIndicator.startAnimating() + } else { + topLoadingIndicator.stopAnimating() + } + if isLoadingNewer { + bottomLoadingIndicator.startAnimating() + } else { + bottomLoadingIndicator.stopAnimating() + } + } + + private func registerCells() { + for identifier in MessageContentKind.allReuseIdentifiers { + tableView.register(MessageBaseCell.self, forCellReuseIdentifier: identifier) + } + for identifier in MessageCellRegistry.shared.customReuseIdentifiers { + tableView.register(MessageBaseCell.self, forCellReuseIdentifier: identifier) + } + tableView.register(MessageCenteredTextCell.self, forCellReuseIdentifier: MessageCellRegistry.systemTipReuseIdentifier) + tableView.register(MessageCenteredTextCell.self, forCellReuseIdentifier: MessageCellRegistry.revokedReuseIdentifier) + } + + private func subscribeViewModel() { + viewModel.$messageList + .receive(on: DispatchQueue.main) + .sink { [weak self] list in self?.applyMessageList(list) } + .store(in: &cancellables) + + viewModel.$isMultiSelectMode + .receive(on: DispatchQueue.main) + .dropFirst() + .sink { [weak self] isMultiSelect in self?.applyMultiSelectMode(isMultiSelect) } + .store(in: &cancellables) + + viewModel.$selectedMessageIDs + .receive(on: DispatchQueue.main) + .dropFirst() + .sink { [weak self] ids in + self?.updateVisibleCellsMultiSelectState() + self?.multiSelectBar.configure(selectedCount: ids.count) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("enterMultiSelectMode")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + let initialMessage = notification.userInfo?["initialMessage"] as? MessageInfo + self?.viewModel.enterMultiSelectMode(initialMessageID: initialMessage?.msgID) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("showForwardTargetSelector")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.handleShowForwardTargetSelector(from: notification) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("listenFromHereNotification")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.handleListenFromHere(from: notification) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: MessageInputView.voiceRecordStartNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.listenController.stop() + } + .store(in: &cancellables) + + listenPlaybackBar.onClose = { [weak self] in + self?.listenController.stop() + } + listenController.onStateChange = { [weak self] state in + guard let self else { return } + if state.isActive { + self.listenPlaybackBar.render(loading: state.isLoading, text: state.currentText) + self.listenPlaybackBar.isHidden = false + } else { + self.listenPlaybackBar.collapse() + self.listenPlaybackBar.isHidden = true + } + } + + NotificationCenter.default.publisher(for: NSNotification.Name("deleteMessageNotification")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let message = notification.userInfo?["message"] as? MessageInfo else { return } + self?.handleSingleMessageDelete(message) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("convertVoiceToText")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let message = notification.userInfo?["message"] as? MessageInfo else { return } + self?.viewModel.convertVoiceToText(message) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("translateTextMessage")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let message = notification.userInfo?["message"] as? MessageInfo else { return } + self?.viewModel.translateTextMessage(message) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("messageReactionToggle")) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let message = notification.userInfo?["message"] as? MessageInfo, + let reactionID = notification.userInfo?["reactionID"] as? String else { return } + self?.viewModel.toggleReaction(message: message, reactionID: reactionID) + } + .store(in: &cancellables) + + viewModel.$processingAuxiliaryTextIDs + .receive(on: DispatchQueue.main) + .dropFirst() + .sink { [weak self] _ in self?.updateVisibleCellsAuxiliaryTextState() } + .store(in: &cancellables) + + viewModel.$hiddenAuxiliaryTextIDs + .receive(on: DispatchQueue.main) + .dropFirst() + .sink { [weak self] _ in self?.updateVisibleCellsAuxiliaryTextState() } + .store(in: &cancellables) + + viewModel.messageEventPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self = self else { return } + if case .onReceiveNewMessage(let message) = event { + self.scheduleDebouncedUnreadClear() + self.floatingEntryController.onNewMessage( + message, + isLatestCompletelyVisible: self.isLatestMessageCompletelyVisible() + ) + self.refreshTongue() + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification) + .sink { [weak self] _ in + guard let self = self else { return } + self.isAppActive = false + self.refreshResumedAndShown() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + guard let self = self else { return } + self.isAppActive = true + self.refreshResumedAndShown() + } + .store(in: &cancellables) + } + + private func subscribeInputInteract() { + NotificationCenter.default.publisher(for: MessageInputView.inputInteractNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self, self.hasPerformedInitialScroll else { return } + self.handleBackToLatest() + DispatchQueue.main.async { + self.updateFloatingEntryForScroll() + } + } + .store(in: &cancellables) + } + + private func requestInitialMentionEntry() { + guard viewModel.isGroupChat else { return } + viewModel.fetchGroupAtInfoList { [weak self] atInfoList in + guard let self = self else { return } + let target = MessageListFloatingEntryPolicy.findOldestMentionTarget(atInfoList) + self.floatingEntryController.onInitialMentionTarget( + target, + visibility: target.map { self.mentionTargetVisibility($0) } ?? .unknown + ) + self.refreshTongue() + if target != nil { + DispatchQueue.main.async { [weak self] in + self?.updateFloatingEntryForScroll() + } + } + } + } + + private func bindMultiSelectBar() { + multiSelectBar.onDelete = { [weak self] in self?.handleMultiSelectDelete() } + multiSelectBar.onForwardSeparate = { [weak self] in self?.handleMultiSelectForward(type: .separate) } + multiSelectBar.onForwardMerge = { [weak self] in self?.handleMultiSelectForward(type: .merged) } + } + + private func applyMultiSelectMode(_ isMultiSelect: Bool) { + UIView.performWithoutAnimation { + multiSelectBar.isHidden = !isMultiSelect + if isMultiSelect { + multiSelectBar.configure(selectedCount: viewModel.selectedMessageIDs.count) + } + updateVisibleCellsMultiSelectState() + onMultiSelectModeChange?(isMultiSelect) + } + DispatchQueue.main.async { [weak self] in + self?.updateLayoutForMultiSelectMode(isMultiSelect) + } + } + + private func updateLayoutForMultiSelectMode(_ isMultiSelect: Bool) { + UIView.performWithoutAnimation { + tableView.contentInset.bottom = isMultiSelect ? multiSelectBar.frame.height : 0 + if isMultiSelect { + revealMultiSelectAnchorAboveBar() + } + } + } + + private func revealMultiSelectAnchorAboveBar() { + guard let anchorID = viewModel.multiSelectAnchorMessageID, + let index = messages.firstIndex(where: { $0.msgID == anchorID }) else { return } + let rowRect = tableView.rectForRow(at: IndexPath(row: index, section: 0)) + let visibleBottomY = tableView.contentOffset.y + tableView.bounds.height - tableView.contentInset.bottom + guard rowRect.maxY > visibleBottomY else { return } + let targetOffsetY = rowRect.maxY - tableView.bounds.height + tableView.contentInset.bottom + let minOffsetY = -tableView.contentInset.top + let maxOffsetY = max(minOffsetY, tableView.contentSize.height + tableView.contentInset.bottom - tableView.bounds.height) + let clampedOffsetY = min(max(targetOffsetY, minOffsetY), maxOffsetY) + guard clampedOffsetY > tableView.contentOffset.y else { return } + tableView.setContentOffset(CGPoint(x: 0, y: clampedOffsetY), animated: false) + } + + private func handleSingleMessageDelete(_ message: MessageInfo) { + let alert = UIAlertController( + title: LocalizedChatString("ConfirmDeleteMessage"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: LocalizedChatString("Confirm"), style: .destructive) { [weak self] _ in + self?.viewModel.deleteMessages([message]) + }) + findViewController()?.present(alert, animated: true) + } + + private func handleMultiSelectDelete() { + let selected = viewModel.getSelectedMessages() + guard !selected.isEmpty else { return } + let alert = UIAlertController( + title: LocalizedChatString("ConfirmDeleteMessage"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: LocalizedChatString("Confirm"), style: .destructive) { [weak self] _ in + self?.viewModel.deleteMessages(selected) { + self?.viewModel.exitMultiSelectMode() + } + }) + findViewController()?.present(alert, animated: true) + } + + private func handleMultiSelectForward(type: MessageForwardType) { + let selected = viewModel.getSelectedMessages() + guard !selected.isEmpty else { return } + if selected.contains(where: { $0.status != .sendSuccess }) { + WindowToastManager.shared.show( + LocalizedChatString("RelayUnsupportForward"), + type: .error, + duration: Self.toastShortDuration + ) + return + } + if type == .separate && selected.count > Self.forwardSeparateCountLimit { + WindowToastManager.shared.show( + LocalizedChatString("RelayOneByOnyOverLimit"), + type: .error, + duration: Self.toastShortDuration + ) + return + } + let selector = ForwardTargetSelectorViewController { [weak self] conversationIDs in + self?.performForward(messages: selected, conversationIDs: conversationIDs, type: type) + } + findViewController()?.navigationController?.pushViewController(selector, animated: true) + } + + private func applyMessageList(_ list: [MessageInfo]) { + + let exclusionMatchers = viewModel.config.messageExclusionMatchers + let list = list.filter { message in + if exclusionMatchers.contains(where: { $0(message) }) { return false } + guard message.messageType == .custom, + let callModel = CallMessageParser.parse(message) else { return true } + return !callModel.isExcludeFromHistory + } + let oldMessages = messages + + if let progressMsgIDs = mediaProgressOnlyChangedMsgIDs(old: oldMessages, new: list) { + messages = list + updateVisibleCellsMediaProgress(msgIDs: progressMsgIDs) + return + } + + if let reactionMsgIDs = reactionOnlyChangedMsgIDs(old: oldMessages, new: list) { + messages = list + updateVisibleCellsReaction(msgIDs: reactionMsgIDs) + return + } + let anchor = topAnchor(using: oldMessages) + let prepended = isPrepend(old: oldMessages, new: list) + let wasLatestCompletelyVisible = isLatestMessageCompletelyVisible() + let oldLatestMsgID = oldMessages.last?.msgID ?? "" + let newLatestMsgID = list.last?.msgID ?? "" + let latestMessageChanged = !oldLatestMsgID.isEmpty && !newLatestMsgID.isEmpty && oldLatestMsgID != newLatestMsgID + + let hasPendingJump = pendingScrollToQuoteInfo != nil || pendingFloatingScrollToMsgID != nil || pendingMentionSequence != nil + + if prepended, isUserScrollSession, !hasPendingJump, !scrollToLatestAfterReload { + deferredPrependMessages = list + return + } + deferredPrependMessages = nil + + messages = list + tableView.reloadData() + tableView.layoutIfNeeded() + + if scrollToLatestAfterReload { + scrollToLatestAfterReload = false + scrollToBottom(animated: false) + } else if hasPendingJump { + + } else if !userHasInteracted { + + if let locateMsgID = pendingInitialLocateMsgID, + let locateIndex = messages.firstIndex(where: { $0.msgID == locateMsgID }), + tableView.bounds.height > 0 { + pendingInitialLocateMsgID = nil + hasPerformedInitialScroll = true + userHasInteracted = true + let indexPath = IndexPath(row: locateIndex, section: 0) + tableView.scrollToRow(at: indexPath, at: .middle, animated: false) + armPendingHighlight(locateMsgID) + performHighlightReload(msgID: locateMsgID, at: indexPath, delay: Self.highlightAnimationDelay) + } else if !messages.isEmpty { + if tableView.bounds.height > 0 { + scrollToBottom(animated: false) + hasPerformedInitialScroll = true + } else { + pendingInitialScroll = true + setNeedsLayout() + DispatchQueue.main.async { [weak self] in + self?.performInitialScrollToBottomIfNeeded() + } + } + } + } else if prepended, let anchor = anchor { + restore(anchor: anchor) + } else if latestMessageChanged, !isUserScrollSession, !isLoadingNewer { + let isOwnLatestMessage = list.last?.isSentBySelf == true + if isOwnLatestMessage || wasLatestCompletelyVisible { + scrollToBottom(animated: true) + } + } + isLoadingOlder = false + isLoadingNewer = false + + performPendingQuoteScrollIfNeeded() + performPendingFloatingScrollIfNeeded() + performPendingMentionScrollIfNeeded() + updateFloatingEntryForScroll() + DispatchQueue.main.async { [weak self] in + self?.syncVisibleMessageReadReceipts() + } + } + + private func syncVisibleMessageReadReceipts() { + guard let indexPaths = tableView.indexPathsForVisibleRows, !indexPaths.isEmpty else { return } + let visibleMessages = indexPaths.compactMap { indexPath -> MessageInfo? in + guard indexPath.row < messages.count else { return nil } + return messages[indexPath.row] + } + viewModel.sendReadReceipts(for: visibleMessages) + } + + private func performPendingMentionScrollIfNeeded() { + guard let sequence = pendingMentionSequence, + let index = messages.firstIndex(where: { $0.sequence == sequence }) else { return } + pendingMentionSequence = nil + let msgID = messages[index].msgID + DispatchQueue.main.async { [weak self] in + self?.scrollAndHighlight(msgID: msgID, at: index) + } + } + + private func performPendingQuoteScrollIfNeeded() { + guard let quoteInfo = pendingScrollToQuoteInfo else { + return + } + guard let index = indexOfLoadedQuoteTarget(quoteInfo) else { + return + } + pendingScrollToQuoteInfo = nil + let msgID = messages[index].msgID + DispatchQueue.main.async { [weak self] in + self?.scrollAndHighlight(msgID: msgID, at: index) + } + } + + private func performPendingFloatingScrollIfNeeded() { + guard let msgID = pendingFloatingScrollToMsgID, + let index = messages.firstIndex(where: { $0.msgID == msgID }) else { return } + pendingFloatingScrollToMsgID = nil + DispatchQueue.main.async { [weak self] in + self?.scrollAndHighlight(msgID: msgID, at: index) + } + } + + private func isPrepend(old: [MessageInfo], new: [MessageInfo]) -> Bool { + guard !old.isEmpty, new.count > old.count else { return false } + return old.first?.msgID != new.first?.msgID + } + + private func topAnchor(using currentMessages: [MessageInfo]) -> (msgID: String, delta: CGFloat)? { + guard let indexPath = tableView.indexPathsForVisibleRows?.first, + indexPath.row < currentMessages.count else { return nil } + let msgID = currentMessages[indexPath.row].msgID + guard !msgID.isEmpty else { return nil } + let rect = tableView.rectForRow(at: indexPath) + return (msgID, rect.minY - tableView.contentOffset.y) + } + + private func restore(anchor: (msgID: String, delta: CGFloat)) { + guard let index = messages.firstIndex(where: { $0.msgID == anchor.msgID }) else { return } + let rect = tableView.rectForRow(at: IndexPath(row: index, section: 0)) + var offset = tableView.contentOffset + offset.y = max(rect.minY - anchor.delta, 0) + tableView.setContentOffset(offset, animated: false) + } + + private func isNearBottom() -> Bool { + let distanceFromBottom = tableView.contentSize.height - (tableView.contentOffset.y + tableView.bounds.height) + return distanceFromBottom <= Self.nearBottomThreshold + } + + private func scrollToBottom(animated: Bool) { + guard !messages.isEmpty else { return } + let lastIndex = IndexPath(row: messages.count - 1, section: 0) + tableView.scrollToRow(at: lastIndex, at: .bottom, animated: false) + } + + private func glueContentToBottomForBoundsChange() { + let newHeight = tableView.bounds.height + let oldHeight = lastTableBoundsHeight + lastTableBoundsHeight = newHeight + guard oldHeight > 0, newHeight > 0, newHeight != oldHeight, !messages.isEmpty else { return } + let wasAtBottom = tableView.contentOffset.y + oldHeight >= tableView.contentSize.height - Self.nearBottomThreshold + guard wasAtBottom else { return } + let delta = newHeight - oldHeight + let minOffset = -tableView.adjustedContentInset.top + let maxOffset = max(minOffset, tableView.contentSize.height - newHeight + tableView.adjustedContentInset.bottom) + let glued = min(max(tableView.contentOffset.y - delta, minOffset), maxOffset) + if abs(glued - tableView.contentOffset.y) > Self.glueOffsetTolerance { + tableView.setContentOffset(CGPoint(x: 0, y: glued), animated: false) + } + } + + private func performInitialScrollToBottomIfNeeded() { + guard pendingInitialScroll, !hasPerformedInitialScroll, + !messages.isEmpty, tableView.bounds.height > 0 else { return } + scrollToBottom(animated: false) + hasPerformedInitialScroll = true + pendingInitialScroll = false + updateFloatingEntryForScroll() + } + + private func updateVisibleCellsMultiSelectState() { + for case let cell as MessageBaseCell in tableView.visibleCells { + guard let indexPath = tableView.indexPath(for: cell), + indexPath.row < messages.count else { continue } + let message = messages[indexPath.row] + cell.updateMultiSelectState( + isMultiSelectMode: viewModel.isMultiSelectMode, + isSelected: viewModel.isSelected(message) + ) + } + } + + private func updateVisibleCellsAuxiliaryTextState() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + UIView.performWithoutAnimation { + self.tableView.beginUpdates() + for case let cell as MessageBaseCell in self.tableView.visibleCells { + guard let indexPath = self.tableView.indexPath(for: cell), + indexPath.row < self.messages.count else { continue } + let message = self.messages[indexPath.row] + let state = self.viewModel.auxiliaryTextState(for: message) + cell.updateAuxiliaryTextState(state: state) + } + self.tableView.endUpdates() + self.tableView.layoutIfNeeded() + } + } + } + + private func mediaProgressOnlyChangedMsgIDs(old: [MessageInfo], new: [MessageInfo]) -> Set? { + guard !new.isEmpty, old.count == new.count else { return nil } + var changed: Set = [] + for (o, n) in zip(old, new) { + guard o.msgID == n.msgID, o.status == n.status else { return nil } + if o.uploadMediaProgress != n.uploadMediaProgress || o.downloadMediaProgress != n.downloadMediaProgress { + changed.insert(n.msgID) + } + } + return changed.isEmpty ? nil : changed + } + + private func updateVisibleCellsMediaProgress(msgIDs: Set) { + for case let cell as MessageBaseCell in tableView.visibleCells { + guard let indexPath = tableView.indexPath(for: cell), + indexPath.row < messages.count else { continue } + let message = messages[indexPath.row] + guard msgIDs.contains(message.msgID) else { continue } + cell.updateMediaProgressIfNeeded(message) + } + } + + private func reactionOnlyChangedMsgIDs(old: [MessageInfo], new: [MessageInfo]) -> Set? { + guard !new.isEmpty, old.count == new.count else { return nil } + var changed: Set = [] + for (o, n) in zip(old, new) { + guard o.msgID == n.msgID else { return nil } + if !reactionListsEqual(o.reactionList, n.reactionList) { + changed.insert(n.msgID) + } + } + return changed.isEmpty ? nil : changed + } + + private func reactionListsEqual(_ a: [MessageReaction], _ b: [MessageReaction]) -> Bool { + guard a.count == b.count else { return false } + for (ra, rb) in zip(a, b) { + if ra.reactionID != rb.reactionID + || ra.totalUserCount != rb.totalUserCount + || ra.reactedByMyself != rb.reactedByMyself { + return false + } + } + return true + } + + private func updateVisibleCellsReaction(msgIDs: Set) { + guard !msgIDs.isEmpty else { return } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let wasNearBottom = self.isNearBottom() + + var newlyShownReactionBars: [MessageReactionBarView] = [] + UIView.performWithoutAnimation { + self.tableView.beginUpdates() + for case let cell as MessageBaseCell in self.tableView.visibleCells { + guard let indexPath = self.tableView.indexPath(for: cell), + indexPath.row < self.messages.count else { continue } + let message = self.messages[indexPath.row] + guard msgIDs.contains(message.msgID) else { continue } + let wasHidden = cell.reactionBar.isHidden + cell.updateReaction(message: message) + if wasHidden, !cell.reactionBar.isHidden { + cell.reactionBar.alpha = 0 + newlyShownReactionBars.append(cell.reactionBar) + } + } + self.tableView.endUpdates() + self.tableView.layoutIfNeeded() + } + if wasNearBottom, !self.isUserScrollSession { + self.scrollToBottom(animated: false) + } + guard !newlyShownReactionBars.isEmpty else { return } + UIView.animate(withDuration: Self.reactionBarFadeDuration) { + for bar in newlyShownReactionBars { + bar.alpha = 1 + } + } + } + } + + private func isLeft(for message: MessageInfo) -> Bool { + switch viewModel.config.alignment { + case 1: return true + case 2: return false + default: return !message.isSentBySelf + } + } + + private func makeContext(for message: MessageInfo) -> MessageContentContext { + return MessageContentContext( + config: viewModel.config, + isLeft: isLeft(for: message), + isSelf: message.isSentBySelf, + isGroupChat: viewModel.isGroupChat, + isMultiSelectMode: viewModel.isMultiSelectMode, + messageListStore: viewModel.messageListStore, + onMediaTap: { [weak self] tapped in self?.presentImageViewer(for: tapped) }, + onMergedMessageTap: { [weak self] tapped in + guard tapped.status != .violation else { return } + self?.openMergedDetail(tapped) + } + ) + } + + private func openMergedDetail(_ message: MessageInfo) { + let detail = MergedMessageDetailViewController(mergedMessage: message, conversationID: viewModel.conversationID) + findViewController()?.navigationController?.pushViewController(detail, animated: true) + } + + private func presentImageViewer(for message: MessageInfo) { + let provider = ImageViewerDataProvider(conversationID: viewModel.conversationID, currentMessage: message) + provider.loadInitial { [weak self] elements, index in + guard let self = self, !elements.isEmpty else { return } + let viewer = ImageViewerController(elements: elements, initialIndex: index, provider: provider) + self.findViewController()?.present(viewer, animated: true) + } + } + + private func presentReactionDetail(for message: MessageInfo) { + guard !message.reactionList.isEmpty else { return } + let detail = ReactionDetailViewController( + message: message, + currentUserID: viewModel.currentUserID + ) + findViewController()?.present(detail, animated: true) + } + + private func presentReadReceiptDetail(message: MessageInfo) { + let detail = MessageReadReceiptViewController( + message: message, + actionStore: MessageActionStore.create(message: message), + onUserClick: { [weak self] userID in self?.onUserClick?(userID) } + ) + findViewController()?.navigationController?.pushViewController(detail, animated: true) + } + + private func handleBubbleLongPress(_ message: MessageInfo, bubbleFrameInWindow: CGRect) { + NSLog("[LongPressDebug] list handler, isMultiSelectMode=\(viewModel.isMultiSelectMode)") + guard !viewModel.isMultiSelectMode else { return } + + let latestMessage = messages.first(where: { $0.msgID == message.msgID }) ?? message + MessageActionMenuController.shared.show( + for: latestMessage, + bubbleFrameInWindow: bubbleFrameInWindow, + config: viewModel.config, + auxiliaryHiddenIDs: viewModel.hiddenAuxiliaryTextIDs + ) + } + + private func handleQuotePreviewTap(_ hostMessage: MessageInfo) { + guard let quoteInfo = hostMessage.quoteInfo else { return } + if viewModel.isQuotedOriginalUnreachable(quoteInfo) { + showQuoteToast(LocalizedChatString("ReplyMessageNotFoundOriginMessage")) + return + } + if let index = indexOfLoadedQuoteTarget(quoteInfo) { + let msgID = messages[index].msgID + if isRowFullyVisible(index) { + highlightOnly(msgID: msgID, at: index) + return + } + floatingEntryController.onQuoteNavigated(returnMessage: hostMessage) + refreshTongue() + scrollAndHighlight(msgID: msgID, at: index) + return + } + if let rawMessage = rawQuoteTargetMessage(quoteInfo), isMessageExcludedFromDisplay(rawMessage) { + showQuoteToast(LocalizedChatString("QuoteOriginalFiltered")) + return + } + let hasUsableAnchor = viewModel.isGroupChat ? quoteInfo.sequence > 0 : quoteInfo.timestamp > 0 + guard hasUsableAnchor else { return } + pendingScrollToQuoteInfo = quoteInfo + viewModel.loadMessagesAroundMessage(quoteCursorMessage(quoteInfo)) { [weak self] in + self?.handleQuoteAroundLoadCompleted(quoteInfo, hostMessage: hostMessage) + } + } + + private func indexOfLoadedQuoteTarget(_ quoteInfo: MessageQuoteInfo) -> Int? { + if !quoteInfo.msgID.isEmpty, + let index = messages.firstIndex(where: { $0.msgID == quoteInfo.msgID }) { + return index + } + guard quoteInfo.sequence > 0 else { return nil } + return messages.firstIndex(where: { $0.sequence == quoteInfo.sequence }) + } + + private func rawQuoteTargetMessage(_ quoteInfo: MessageQuoteInfo) -> MessageInfo? { + if !quoteInfo.msgID.isEmpty, + let message = viewModel.messageList.first(where: { $0.msgID == quoteInfo.msgID }) { + return message + } + guard quoteInfo.sequence > 0 else { return nil } + return viewModel.messageList.first(where: { $0.sequence == quoteInfo.sequence }) + } + + private func quoteCursorMessage(_ quoteInfo: MessageQuoteInfo) -> MessageInfo { + var cursor = MessageInfo() + cursor.msgID = quoteInfo.msgID + if viewModel.isGroupChat { + cursor.sequence = quoteInfo.sequence + cursor.timestamp = quoteInfo.timestamp + } else { + cursor.timestamp = quoteInfo.timestamp + } + return cursor + } + + private func handleQuoteAroundLoadCompleted(_ quoteInfo: MessageQuoteInfo, hostMessage: MessageInfo) { + guard let rawMessage = rawQuoteTargetMessage(quoteInfo) else { + pendingScrollToQuoteInfo = nil + showQuoteToast(LocalizedChatString("ReplyMessageNotFoundOriginMessage")) + return + } + if isMessageExcludedFromDisplay(rawMessage) { + pendingScrollToQuoteInfo = nil + showQuoteToast(LocalizedChatString("QuoteOriginalFiltered")) + return + } + floatingEntryController.onQuoteNavigated(returnMessage: hostMessage) + refreshTongue() + performPendingQuoteScrollIfNeeded() + } + + private func showQuoteToast(_ text: String) { + WindowToastManager.shared.show(text, type: .info, duration: Self.toastLongDuration) + } + + private func isMessageExcludedFromDisplay(_ message: MessageInfo) -> Bool { + if viewModel.config.messageExclusionMatchers.contains(where: { $0(message) }) { return true } + guard message.messageType == .custom, + let callModel = CallMessageParser.parse(message) else { return false } + return callModel.isExcludeFromHistory + } + + private func updateFloatingEntryForScroll() { + floatingEntryController.onMentionTargetVisibilityChanged(currentMentionTargetVisibility()) + floatingEntryController.onScroll( + distanceFromLatest: distanceFromLatestMessage(), + viewportHeight: tableView.bounds.height, + isLatestCompletelyVisible: isLatestMessageCompletelyVisible(), + isReturnMessageCompletelyVisible: isBackToQuoteReturnMessageCompletelyVisible() + ) + refreshTongue() + } + + private func refreshTongue() { + guard let entry = floatingEntryController.currentEntry() else { + tongueView.isHidden = true + return + } + tongueView.configure(entry: entry) + tongueView.isHidden = false + } + + private func distanceFromLatestMessage() -> CGFloat { + if isLatestMessageCompletelyVisible() { return 0 } + return max(tableView.contentSize.height - (tableView.contentOffset.y + tableView.bounds.height), 0) + } + + private func isLatestMessageCompletelyVisible() -> Bool { + guard !messages.isEmpty else { return true } + let distanceFromBottom = tableView.contentSize.height - (tableView.contentOffset.y + tableView.bounds.height) + return distanceFromBottom <= Self.latestFullyVisibleThreshold + } + + private func currentMentionTargetVisibility() -> MessageListMentionTargetVisibility { + guard let target = floatingEntryController.currentMentionTarget() else { return .unknown } + return mentionTargetVisibility(target) + } + + private func mentionTargetVisibility(_ target: MessageListMentionTarget) -> MessageListMentionTargetVisibility { + guard let index = messages.firstIndex(where: { $0.sequence == target.sequence }) else { + return messages.isEmpty ? .unknown : .hidden + } + return isRowVisible(index) ? .visible : .hidden + } + + private func isRowVisible(_ index: Int) -> Bool { + let indexPath = IndexPath(row: index, section: 0) + return tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false + } + + private func isBackToQuoteReturnMessageCompletelyVisible() -> Bool { + guard let msgID = floatingEntryController.currentBackToQuoteReturnMessage()?.msgID, !msgID.isEmpty, + let index = messages.firstIndex(where: { $0.msgID == msgID }) else { + return false + } + return isRowFullyVisible(index) + } + + private func isRowFullyVisible(_ index: Int) -> Bool { + let indexPath = IndexPath(row: index, section: 0) + guard let visible = tableView.indexPathsForVisibleRows, visible.contains(indexPath) else { return false } + let rect = tableView.rectForRow(at: indexPath) + let viewport = CGRect(x: 0, y: tableView.contentOffset.y, + width: tableView.bounds.width, height: tableView.bounds.height) + return viewport.contains(rect) + } + + @objc private func handleTableViewTap() { + window?.endEditing(true) + + NotificationCenter.default.post(name: MessageListView.blankAreaClickNotification, object: nil) + + listenPlaybackBar.collapse() + } + + @objc private func handleTongueTap() { + guard let entry = floatingEntryController.currentEntry() else { return } + floatingEntryController.consume(entry) + refreshTongue() + switch entry { + case .backToLatest: + handleBackToLatest() + case .newMessages(_, let firstMessage): + handleNewMessages(firstMessage: firstMessage) + case .mention(let target): + handleMention(target) + case .backToQuote(let returnMessage): + handleBackToQuote(returnMessage) + } + } + + private func handleBackToLatest() { + if viewModel.hasMoreNewerMessage { + scrollToLatestAfterReload = true + viewModel.reloadLatestMessages { [weak self] success in + if !success { + self?.scrollToLatestAfterReload = false + } + } + } else { + scrollToBottom(animated: true) + } + } + + private func handleNewMessages(firstMessage: MessageInfo) { + let msgID = firstMessage.msgID + guard !msgID.isEmpty else { return } + if let index = messages.firstIndex(where: { $0.msgID == msgID }) { + scrollAndHighlight(msgID: msgID, at: index) + } else { + pendingFloatingScrollToMsgID = msgID + viewModel.loadMessagesAroundMessage(firstMessage) + } + } + + private func handleMention(_ target: MessageListMentionTarget) { + if let index = messages.firstIndex(where: { $0.sequence == target.sequence }) { + scrollAndHighlight(msgID: messages[index].msgID, at: index) + updateFloatingEntryForScroll() + return + } + pendingMentionSequence = target.sequence + viewModel.loadOlderMessagesUntilSequence(target.sequence, maxLoadCount: Self.mentionLocateMaxLoadCount) { [weak self] in + guard let self = self else { return } + if let sequence = self.pendingMentionSequence, + !self.messages.contains(where: { $0.sequence == sequence }) { + self.pendingMentionSequence = nil + } + self.updateFloatingEntryForScroll() + } + } + + private func handleBackToQuote(_ returnMessage: MessageInfo) { + let msgID = returnMessage.msgID + guard !msgID.isEmpty else { return } + if let index = messages.firstIndex(where: { $0.msgID == msgID }) { + scrollAndHighlight(msgID: msgID, at: index) + updateFloatingEntryForScroll() + } else { + pendingFloatingScrollToMsgID = msgID + viewModel.loadMessagesAroundMessage(returnMessage) + } + } + + private func armPendingHighlight(_ msgID: String) { + pendingHighlightMsgID = msgID + pendingHighlightPlayed = false + } + + private func performHighlightReload(msgID: String, at indexPath: IndexPath, delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, self.pendingHighlightMsgID == msgID else { return } + self.tryPlayPendingHighlight(msgID: msgID) + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay + Self.highlightArmTimeout) { [weak self] in + guard let self = self, self.pendingHighlightMsgID == msgID else { return } + self.pendingHighlightMsgID = nil + self.pendingHighlightPlayed = false + } + } + + private func tryPlayPendingHighlight(msgID: String) { + guard pendingHighlightMsgID == msgID, !pendingHighlightPlayed, + let index = messages.firstIndex(where: { $0.msgID == msgID }), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? MessageBaseCell else { return } + pendingHighlightPlayed = true + cell.playHighlightAnimation() + } + + private func highlightOnly(msgID: String, at index: Int) { + guard index >= 0, index < messages.count else { return } + armPendingHighlight(msgID) + let indexPath = IndexPath(row: index, section: 0) + performHighlightReload(msgID: msgID, at: indexPath, delay: Self.highlightAnimationDelay) + } + + private func scrollAndHighlight(msgID: String, at index: Int) { + guard index >= 0, index < messages.count else { + return + } + userHasInteracted = true + hasPerformedInitialScroll = true + pendingInitialScroll = false + armPendingHighlight(msgID) + isJumpScrolling = true + let indexPath = IndexPath(row: index, section: 0) + tableView.layoutIfNeeded() + tableView.scrollToRow(at: indexPath, at: .middle, animated: false) + DispatchQueue.main.async { [weak self] in + self?.recenterJumpTargetIfNeeded(msgID: msgID, indexPath: indexPath) + } + DispatchQueue.main.asyncAfter(deadline: .now() + Self.jumpScrollFallbackDelay) { [weak self] in + guard let self = self, self.isJumpScrolling else { return } + self.finishJumpScrollIfNeeded(msgID: msgID) + } + } + + private func recenterJumpTargetIfNeeded(msgID: String, indexPath: IndexPath) { + guard isJumpScrolling, pendingHighlightMsgID == msgID else { return } + defer { finishJumpScrollIfNeeded(msgID: msgID) } + guard tableView.cellForRow(at: indexPath) != nil else { return } + let frame = tableView.rectForRow(at: indexPath) + let inset = tableView.adjustedContentInset + let viewportHeight = tableView.bounds.height - inset.top - inset.bottom + guard viewportHeight > 0, frame.height > 0, frame.height < viewportHeight else { return } + let targetY = frame.midY - inset.top - viewportHeight / 2 + let minY = -inset.top + let maxY = max(minY, tableView.contentSize.height - viewportHeight + inset.bottom) + let clampedY = min(max(targetY, minY), maxY) + let tolerance = max(frame.height / 2, Self.jumpRecenterTolerance) + guard abs(tableView.contentOffset.y - clampedY) > tolerance else { return } + tableView.setContentOffset(CGPoint(x: 0, y: clampedY), animated: false) + } + + private func finishJumpScrollIfNeeded(msgID: String) { + isJumpScrolling = false + guard let index = messages.firstIndex(where: { $0.msgID == msgID }), index < messages.count else { + return + } + performHighlightReload(msgID: msgID, at: IndexPath(row: index, section: 0), delay: 0) + } + + private func requestResend(_ message: MessageInfo) { + let alert = UIAlertController( + title: LocalizedChatString("TipsConfirmResendMessage"), + message: nil, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedChatString("Cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: LocalizedChatString("Confirm"), style: .default) { [weak self] _ in + self?.viewModel.resendMessage(message) + }) + findViewController()?.present(alert, animated: true) + } + + private func postMentionNotification(for message: MessageInfo) { + guard viewModel.isGroupChat else { return } + NotificationCenter.default.post( + name: NSNotification.Name("mentionUserNotification"), + object: nil, + userInfo: [ + "userID": message.from.userID, + "nickname": message.from.nickname ?? "" + ] + ) + } + + private func handleAuxiliaryTextLongPress(message: MessageInfo, text: String, anchorFrame: CGRect) { + guard !viewModel.isMultiSelectMode else { return } + let copyAction = MessageActionMenuAction( + ID: MessageActionIDs.copy, + iconName: "message_copy", + systemIconFallback: "doc.on.doc", + label: LocalizedChatString("Copy") + ) { _, _ in + UIPasteboard.general.string = text + WindowToastManager.shared.show(LocalizedChatString("Copied"), type: .success, duration: Self.toastShortDuration) + } + let forwardAction = MessageActionMenuAction( + ID: MessageActionIDs.forward, + iconName: "message_forward", + systemIconFallback: "arrowshape.turn.up.right", + label: LocalizedChatString("Forward") + ) { [weak self] _, _ in + self?.handleForwardAuxiliaryText(text) + } + MessageActionMenuController.shared.showAuxiliaryMenu( + actions: [copyAction, forwardAction], + message: message, + anchorFrameInWindow: anchorFrame + ) + } + + private func handleForwardAuxiliaryText(_ text: String) { + let selector = ForwardTargetSelectorViewController { [weak self] conversationIDs in + guard let self = self else { return } + for conversationID in conversationIDs { + let inputStore = MessageInputStore.create(conversationID: conversationID) + let payload = TextSendMessagePayload(text: text) + inputStore.sendMessage(payload: .text(payload), option: SendMessageOption(), completion: nil) + } + } + findViewController()?.navigationController?.pushViewController(selector, animated: true) + } + + private func handleListenFromHere(from notification: Notification) { + guard let message = notification.userInfo?["message"] as? MessageInfo, + let startIndex = messages.firstIndex(where: { $0.msgID == message.msgID }) else { return } + + let range = Array(messages[startIndex...]) + let plan = ListenPlanBuilder.build(messages: range) + listenController.start(plan: plan) + } + + private func handleShowForwardTargetSelector(from notification: Notification) { + guard let messages = notification.userInfo?["messages"] as? [MessageInfo], !messages.isEmpty else { return } + let selector = ForwardTargetSelectorViewController { [weak self] conversationIDs in + self?.performForward(messages: messages, conversationIDs: conversationIDs) + } + findViewController()?.navigationController?.pushViewController(selector, animated: true) + } + + private func performForward(messages: [MessageInfo], conversationIDs: [String], type: MessageForwardType = .separate) { + let enableReadReceipt = AppBuilderConfig.shared.enableReadReceipt + let preparedMessages = messages.map { message -> MessageInfo in + var copy = message + copy.needReadReceipt = enableReadReceipt + return copy + } + for conversationID in conversationIDs { + var option = ForwardMessageOption() + option.forwardType = type + var sendOption = SendMessageOption() + sendOption.needReadReceipt = enableReadReceipt + option.sendMessageOption = sendOption + if type == .merged { + option.mergedForwardInfo = viewModel.makeMergedForwardInfo(messages: preparedMessages) + } + viewModel.forwardMessages(preparedMessages, option: option, toConversationID: conversationID) { success in + if !success { + WindowToastManager.shared.error(LocalizedChatString("RelayUnsupportForward")) + } + } + } + if viewModel.isMultiSelectMode { + viewModel.exitMultiSelectMode() + } + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } +} + +// MARK: - UITableViewDataSource + +extension MessageListViewImpl: UITableViewDataSource { + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return messages.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let message = messages[indexPath.row] + let kind = MessageCellRegistry.shared.renderKind(for: message) + let timeString = viewModel.messageTimeString(at: indexPath.row) + + switch kind { + case .systemTip, .revoked: + return centeredTextCell(for: message, kind: kind, timeString: timeString, at: indexPath) + case .bubble(let contentKind): + return bubbleCell(for: message, contentKind: contentKind, timeString: timeString, at: indexPath) + case .custom(let businessID): + return bubbleCell(for: message, contentKind: .custom(businessID), timeString: timeString, at: indexPath) + } + } + + private func centeredTextCell(for message: MessageInfo, + kind: MessageRenderKind, + timeString: String?, + at indexPath: IndexPath) -> UITableViewCell { + let identifier = MessageCellRegistry.shared.reuseIdentifier(for: kind) + guard let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as? MessageCenteredTextCell else { + return UITableViewCell() + } + let text = MessageCellRegistry.shared.centeredText(for: message, kind: kind, config: viewModel.config) + cell.configure(text: text, timeString: timeString, showTime: viewModel.config.isShowTimeMessage) + return cell + } + + private func bubbleCell(for message: MessageInfo, + contentKind: MessageContentKind, + timeString: String?, + at indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: contentKind.reuseIdentifier, for: indexPath) as? MessageBaseCell else { + return UITableViewCell() + } + cell.configure( + message: message, + contentKind: contentKind, + timeString: timeString, + context: makeContext(for: message), + isMultiSelectMode: viewModel.isMultiSelectMode, + isSelected: viewModel.isSelected(message), + auxiliaryTextState: viewModel.auxiliaryTextState(for: message) + ) + cell.onAvatarTap = { [weak self] tapped in self?.onUserClick?(tapped.from.userID) } + cell.onAvatarLongPress = { [weak self] longPressed in self?.postMentionNotification(for: longPressed) } + cell.onResendRequested = { [weak self] failed in self?.requestResend(failed) } + cell.onQuotePreviewTap = { [weak self] host in self?.handleQuotePreviewTap(host) } + cell.onBubbleLongPress = { [weak self] pressed, frameInWindow in + self?.handleBubbleLongPress(pressed, bubbleFrameInWindow: frameInWindow) + } + cell.onReactionBarTap = { [weak self] tapped in self?.presentReactionDetail(for: tapped) } + cell.onAuxiliaryTextLongPress = { [weak self] msg, text, anchorFrame in + self?.handleAuxiliaryTextLongPress(message: msg, text: text, anchorFrame: anchorFrame) + } + cell.onReadReceiptTap = { [weak self] tapped in self?.presentReadReceiptDetail(message: tapped) } + return cell + } +} + +// MARK: - UITableViewDelegate + +extension MessageListViewImpl: UITableViewDelegate { + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + guard viewModel.isMultiSelectMode, indexPath.row < messages.count else { return } + viewModel.toggleSelection(messages[indexPath.row]) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + userHasInteracted = true + isUserScrollSession = true + pendingInitialScroll = false + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard hasPerformedInitialScroll else { return } + + let suppressPagination = isJumpScrolling || pendingScrollToQuoteInfo != nil || pendingFloatingScrollToMsgID != nil || pendingMentionSequence != nil + if scrollView.contentOffset.y <= Self.loadMoreThreshold, + viewModel.hasMoreOlderMessage, !isLoadingOlder, !suppressPagination { + isLoadingOlder = true + viewModel.loadOlderMessages() + } + let distanceFromBottom = scrollView.contentSize.height - (scrollView.contentOffset.y + scrollView.bounds.height) + if distanceFromBottom <= Self.loadMoreThreshold, + viewModel.hasMoreNewerMessage, !isLoadingNewer, !suppressPagination { + isLoadingNewer = true + viewModel.loadNewerMessages() + } + updateFloatingEntryForScroll() + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard isJumpScrolling, let msgID = pendingHighlightMsgID, !msgID.isEmpty else { return } + finishJumpScrollIfNeeded(msgID: msgID) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + isUserScrollSession = false + applyDeferredPrependIfNeeded() + syncVisibleMessageReadReceipts() + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + isUserScrollSession = false + applyDeferredPrependIfNeeded() + syncVisibleMessageReadReceipts() + } + + private func applyDeferredPrependIfNeeded() { + guard !isUserScrollSession, let list = deferredPrependMessages else { return } + applyMessageList(list) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let msgID = pendingHighlightMsgID, + indexPath.row < messages.count, + messages[indexPath.row].msgID == msgID, + let messageCell = cell as? MessageBaseCell else { return } + pendingHighlightPlayed = true + messageCell.playHighlightAnimation() + } +} diff --git a/chat/uikit/components/messagelist/viewmodel/MessageListViewModel.swift b/chat/uikit/components/messagelist/viewmodel/MessageListViewModel.swift new file mode 100644 index 000000000..a7183b7da --- /dev/null +++ b/chat/uikit/components/messagelist/viewmodel/MessageListViewModel.swift @@ -0,0 +1,580 @@ +import Foundation +import Combine +import AtomicXCore + +final class MessageListViewModel { + private static let groupIDPrefixLength = 6 + + private static let locateMessagePageCount = 10 + + private static let initialLoadPageCount = 20 + + private static let latestLoadPageCount = 20 + + private static let aroundMessagePageCount = 20 + + private static let timeGroupingInterval: TimeInterval = 300 + + let conversationID: String + + let config: MessageListConfigProtocol & MessageActionConfigProtocol + + let locateMessage: MessageInfo? + + @Published private(set) var messageList: [MessageInfo] = [] + + @Published private(set) var hasMoreOlderMessage: Bool = false + + @Published private(set) var hasMoreNewerMessage: Bool = false + + @Published private(set) var isMultiSelectMode: Bool = false + + @Published private(set) var selectedMessageIDs: Set = [] + + @Published private(set) var processingAuxiliaryTextIDs: Set = [] + + @Published private(set) var hiddenAuxiliaryTextIDs: Set = [] + + var messageEventPublisher: AnyPublisher { + store.messageEventPublisher.eraseToAnyPublisher() + } + + var messageListStore: MessageListStore { store } + + var currentUserID: String? { + let uid = LoginStore.shared.state.value.loginUserInfo?.userID + return (uid?.isEmpty == false) ? uid : nil + } + + var isGroupChat: Bool { conversationID.hasPrefix("group_") } + + var groupID: String { isGroupChat ? String(conversationID.dropFirst(Self.groupIDPrefixLength)) : conversationID } + + private let store: MessageListStore + + private let conversationStore: ConversationListStore + + private var cancellables = Set() + + private var storeMessages: [MessageInfo] = [] + + private var processingMessages: [MessageInfo] = [] + + private var lastVisiblePlaceholderSignatures: [String] = [] + + // MARK: - Init + + init(conversationID: String, + config: MessageListConfigProtocol & MessageActionConfigProtocol = ChatMessageListConfig(), + locateMessage: MessageInfo? = nil, + conversationStore: ConversationListStore = ConversationListStore.create()) { + self.conversationID = conversationID + self.config = config + self.locateMessage = locateMessage + self.conversationStore = conversationStore + self.store = MessageListStore.create(conversationID: conversationID) + subscribeState() + } + + // MARK: - Load (分页拉取,对齐声明式 fetchMessages / loadMoreOlder / loadMoreNewer) + + func fetchInitialMessages(completion: (() -> Void)? = nil) { + var option = MessageLoadOption() + if let locate = locateMessage, !locate.msgID.isEmpty { + option.cursor = locate + option.direction = .both + option.pageCount = Self.locateMessagePageCount + } else { + option.direction = .older + option.pageCount = Self.initialLoadPageCount + } + store.loadMessages(option: option) { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "fetchInitialMessages") + completion?() + } + } + } + + func loadOlderMessages(completion: (() -> Void)? = nil) { + store.loadOlderMessages { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "loadOlderMessages") + completion?() + } + } + } + + func loadNewerMessages(completion: (() -> Void)? = nil) { + store.loadNewerMessages { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "loadNewerMessages") + completion?() + } + } + } + + func reloadLatestMessages(completion: ((Bool) -> Void)? = nil) { + var option = MessageLoadOption() + option.direction = .older + option.pageCount = Self.latestLoadPageCount + store.loadMessages(option: option) { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "reloadLatestMessages") + if case .success = result { + completion?(true) + } else { + completion?(false) + } + } + } + } + + func loadMessagesAroundMessage(_ message: MessageInfo, completion: (() -> Void)? = nil) { + guard !message.msgID.isEmpty || (message.sequence ?? 0) > 0 else { completion?(); return } + var option = MessageLoadOption() + option.cursor = message + option.direction = .both + option.pageCount = Self.aroundMessagePageCount + store.loadMessages(option: option) { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "loadMessagesAroundMessage") + completion?() + } + } + } + + func loadOlderMessagesUntilSequence(_ sequence: Int64, + maxLoadCount: Int, + completion: (() -> Void)? = nil) { + if messageList.contains(where: { $0.sequence == sequence }) || maxLoadCount <= 0 || !hasMoreOlderMessage { + completion?() + return + } + loadOlderMessages { [weak self] in + guard let self = self else { completion?(); return } + self.loadOlderMessagesUntilSequence(sequence, maxLoadCount: maxLoadCount - 1, completion: completion) + } + } + + func fetchGroupAtInfoList(completion: @escaping ([GroupAtInfo]) -> Void) { + guard isGroupChat else { completion([]); return } + let handler = GroupAtInfoFetchHandler { atInfoList in + DispatchQueue.main.async { completion(atInfoList) } + } + conversationStore.getConversationInfo(conversationID: conversationID, completion: handler) + } + + func isQuotedOriginalUnreachable(_ quoteInfo: MessageQuoteInfo) -> Bool { + if quoteInfo.status == .revoked { return true } + if quoteInfo.status == .deleted, quoteInfo.messagePayload == nil { return true } + return false + } + + // MARK: - Reaction (乐观更新:先本地插入/移除 chip,失败回滚,server 推送后以服务端数据为准) + + func toggleReaction(message: MessageInfo, reactionID: String) { + guard !reactionID.isEmpty, !message.msgID.isEmpty else { return } + guard let index = messageList.firstIndex(where: { $0.msgID == message.msgID }) else { return } + let current = messageList[index] + let isAdd = !(current.reactionList.first(where: { $0.reactionID == reactionID })?.reactedByMyself ?? false) + + let snapshot = current.reactionList + var optimistic = current + optimistic.reactionList = makeOptimisticReactionList(from: current.reactionList, reactionID: reactionID, add: isAdd) + messageList[index] = optimistic + + let actionStore = MessageActionStore.create(message: current) + let completion: (Result) -> Void = { [weak self] result in + guard let self = self else { return } + if case .failure = result { + DispatchQueue.main.async { + guard let idx = self.messageList.firstIndex(where: { $0.msgID == message.msgID }) else { return } + self.messageList[idx].reactionList = snapshot + } + } + } + if isAdd { + actionStore.addReaction(reactionID: reactionID, completion: completion) + } else { + actionStore.removeReaction(reactionID: reactionID, completion: completion) + } + } + + // MARK: - Delete / Forward / Read Receipt (转发给核心 store) + + func deleteMessages(_ messages: [MessageInfo], completion: (() -> Void)? = nil) { + guard !messages.isEmpty else { completion?(); return } + store.deleteMessages(messageList: messages) { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "deleteMessages") + if case .success = result { completion?() } + } + } + } + + func forwardMessages(_ messages: [MessageInfo], + option: ForwardMessageOption, + toConversationID targetConversationID: String, + completion: @escaping (Bool) -> Void) { + store.forwardMessages(messageList: messages, option: option, conversationID: targetConversationID) { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "forwardMessages") + if case .success = result { completion(true) } else { completion(false) } + } + } + } + + func makeMergedForwardInfo(messages: [MessageInfo]) -> MergedForwardInfo { + var info = MergedForwardInfo() + info.title = MessageListHelper.generateMergedTitle(messages: messages, conversationID: conversationID) + info.abstractList = MessageListHelper.generateAbstractList(messages: messages) + info.compatibleText = "Merged messages" + return info + } + + func syncConversationAsRead() { + clearConversationUnreadCount() + conversationStore.markConversation( + conversationIDList: [conversationID], + markType: .unread, + enable: false, + completion: nil + ) + } + + func clearConversationUnreadCount() { + conversationStore.clearConversationUnreadCount(conversationID: conversationID, completion: nil) + } + + func sendReadReceipts(for messages: [MessageInfo]) { + let receiptMessages = messages.filter { !$0.isSentBySelf && $0.needReadReceipt } + guard !receiptMessages.isEmpty else { return } + store.sendMessageReadReceipts(messageList: receiptMessages, completion: nil) + } + + func resendMessage(_ message: MessageInfo) { + guard let payload = Self.sendPayload(from: message) else { return } + var option = SendMessageOption() + option.needReadReceipt = message.needReadReceipt + option.atUserList = message.atUserList.isEmpty ? nil : message.atUserList + option.isExtensionEnabled = message.isExtensionEnabled + option.offlinePushInfo = message.offlinePushInfo + store.deleteMessages(messageList: [message]) { _ in } + let inputStore = MessageInputStore.create(conversationID: conversationID) + inputStore.sendMessage(payload: payload, option: option) { [weak self] result in + DispatchQueue.main.async { + self?.logIfFailed(result, action: "resendMessage") + } + } + } + + // MARK: - Multi-Select (对齐声明式 MultiSelectManager) + + private(set) var multiSelectAnchorMessageID: String? + + func enterMultiSelectMode(initialMessageID: String?) { + if let id = initialMessageID, !id.isEmpty { + selectedMessageIDs.insert(id) + multiSelectAnchorMessageID = id + } + isMultiSelectMode = true + } + + func exitMultiSelectMode() { + isMultiSelectMode = false + selectedMessageIDs.removeAll() + multiSelectAnchorMessageID = nil + } + + func toggleSelection(_ message: MessageInfo) { + let msgID = message.msgID + guard !msgID.isEmpty else { return } + if selectedMessageIDs.contains(msgID) { + selectedMessageIDs.remove(msgID) + } else { + selectedMessageIDs.insert(msgID) + } + } + + func isSelected(_ message: MessageInfo) -> Bool { + return !message.msgID.isEmpty && selectedMessageIDs.contains(message.msgID) + } + + func getSelectedMessages() -> [MessageInfo] { + return messageList.filter { !$0.msgID.isEmpty && selectedMessageIDs.contains($0.msgID) } + } + + // MARK: - Auxiliary Text (语音转文字 / 翻译,对齐 Android MessageListViewModel) + + func convertVoiceToText(_ message: MessageInfo) { + let msgID = message.msgID + guard !msgID.isEmpty else { return } + guard !processingAuxiliaryTextIDs.contains(msgID) else { return } + hiddenAuxiliaryTextIDs.remove(msgID) + processingAuxiliaryTextIDs.insert(msgID) + let actionStore = MessageActionStore.create(message: message) + actionStore.convertVoiceToText(language: "") { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.processingAuxiliaryTextIDs.remove(msgID) + if case .failure = result { + WindowToastManager.shared.error(LocalizedChatString("ConvertToTextFailed")) + } + } + } + } + + func translateTextMessage(_ message: MessageInfo) { + let msgID = message.msgID + guard !msgID.isEmpty else { return } + guard !processingAuxiliaryTextIDs.contains(msgID) else { return } + guard case .text(let payload) = message.messagePayload else { return } + let text = payload.text + guard !text.isEmpty else { return } + hiddenAuxiliaryTextIDs.remove(msgID) + processingAuxiliaryTextIDs.insert(msgID) + let targetLanguage = AppBuilderConfig.shared.translateTargetLanguage.isEmpty + ? "en" : AppBuilderConfig.shared.translateTargetLanguage + let actionStore = MessageActionStore.create(message: message) + actionStore.translateText(sourceTextList: [text], sourceLanguage: nil, targetLanguage: targetLanguage) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.processingAuxiliaryTextIDs.remove(msgID) + if case .failure = result { + WindowToastManager.shared.error(LocalizedChatString("TranslateFailed")) + } + } + } + } + + func auxiliaryTextState(for message: MessageInfo) -> AuxiliaryTextDisplayState { + let msgID = message.msgID + guard !msgID.isEmpty else { return .hidden } + if hiddenAuxiliaryTextIDs.contains(msgID) { return .hidden } + let isProcessing = processingAuxiliaryTextIDs.contains(msgID) + if isProcessing { return .loading } + switch message.messagePayload { + case .audio(let payload): + if let asrText = payload.asrText, !asrText.isEmpty { + return .text(content: asrText, footer: nil) + } + case .text(let payload): + if let translatedText = payload.translatedText, !translatedText.isEmpty { + let targetLanguage = AppBuilderConfig.shared.translateTargetLanguage.isEmpty + ? "en" : AppBuilderConfig.shared.translateTargetLanguage + let displayText = translatedText[targetLanguage] ?? translatedText.values.first ?? "" + if !displayText.isEmpty { + return .text(content: displayText, footer: LocalizedChatString("TranslateDefaultTips")) + } + } + default: + break + } + return .hidden + } + + // MARK: - Time Grouping (对齐声明式 getMessageTimeString:300s 聚合窗口) + + func messageTimeString(at index: Int) -> String? { + guard index >= 0, index < messageList.count else { return nil } + let message = messageList[index] + guard let messageDate = Self.date(from: message.timestamp) else { return nil } + switch MessageCellRegistry.shared.renderKind(for: message) { + case .systemTip, .revoked: + return nil + default: + break + } + if index == 0 { + return DateHelper.convertDateToMessageTimeStr(messageDate) + } + let previous = messageList[index - 1] + guard let previousDate = Self.date(from: previous.timestamp) else { return nil } + if messageDate.timeIntervalSince(previousDate) > Self.timeGroupingInterval { + return DateHelper.convertDateToMessageTimeStr(messageDate) + } + return nil + } + + // MARK: - Private Helpers + + private func subscribeState() { + store.state + .subscribe(StatePublisherSelector(keyPath: \MessageListState.messageList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.storeMessages = list + self?.mergeMessageList() + } + .store(in: &cancellables) + + AlbumPickerPlaceholderStore.shared.placeholdersByConversation + .receive(on: DispatchQueue.main) + .sink { [weak self] placeholdersByConversation in + guard let self = self else { return } + self.processingMessages = placeholdersByConversation[self.conversationID] ?? [] + + guard self.visiblePlaceholderSignatures() != self.lastVisiblePlaceholderSignatures else { return } + self.mergeMessageList() + } + .store(in: &cancellables) + + store.state + .subscribe(StatePublisherSelector(keyPath: \MessageListState.hasOlderMessages)) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in self?.hasMoreOlderMessage = value } + .store(in: &cancellables) + + store.state + .subscribe(StatePublisherSelector(keyPath: \MessageListState.hasNewerMessages)) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in self?.hasMoreNewerMessage = value } + .store(in: &cancellables) + } + + private func mergeMessageList() { + let visiblePlaceholders = self.visiblePlaceholders() + lastVisiblePlaceholderSignatures = visiblePlaceholders.map { placeholderSignature($0) } + guard !visiblePlaceholders.isEmpty else { + messageList = storeMessages + return + } + messageList = storeMessages + visiblePlaceholders + } + + private func visiblePlaceholders() -> [MessageInfo] { + guard !processingMessages.isEmpty else { return [] } + let sentMediaPaths = Set(storeMessages.compactMap { AlbumPickerPlaceholderStore.localMediaPath(of: $0) }) + guard !sentMediaPaths.isEmpty else { return processingMessages } + return processingMessages.filter { placeholder in + guard let mediaPath = AlbumPickerPlaceholderStore.localMediaPath(of: placeholder) else { return true } + return !sentMediaPaths.contains(mediaPath) + } + } + + private func placeholderSignature(_ message: MessageInfo) -> String { + let mediaPath = AlbumPickerPlaceholderStore.localMediaPath(of: message) ?? "" + let thumbnailPath: String + switch message.messagePayload { + case .video(let payload): thumbnailPath = payload.videoSnapshotPath ?? "" + case .image(let payload): thumbnailPath = payload.largeImagePath ?? "" + default: thumbnailPath = "" + } + return "\(message.msgID)|\(thumbnailPath)|\(mediaPath)|\(message.uploadMediaProgress)" + } + + private func visiblePlaceholderSignatures() -> [String] { + return visiblePlaceholders().map { placeholderSignature($0) } + } + + private func makeOptimisticReactionList(from list: [MessageReaction], reactionID: String, add: Bool) -> [MessageReaction] { + var list = list + guard let index = list.firstIndex(where: { $0.reactionID == reactionID }) else { + if add { + var reaction = MessageReaction(reactionID: reactionID) + reaction.reactedByMyself = true + reaction.totalUserCount = 1 + if let me = selfUserProfile() { reaction.partialUserList = [me] } + list.append(reaction) + } + return list + } + + var reaction = list[index] + if add, !reaction.reactedByMyself { + reaction.reactedByMyself = true + reaction.totalUserCount += 1 + + if let me = selfUserProfile() { reaction.partialUserList.append(me) } + } else if !add, reaction.reactedByMyself { + reaction.reactedByMyself = false + reaction.totalUserCount = max(reaction.totalUserCount - 1, 0) + if let myID = currentUserID { reaction.partialUserList.removeAll { $0.userID == myID } } + } + + if reaction.totalUserCount <= 0 { + list.remove(at: index) + } else { + list[index] = reaction + } + return list + } + + private func selfUserProfile() -> UserProfile? { + return LoginStore.shared.state.value.loginUserInfo + } + + private static func date(from timestamp: Int64?) -> Date? { + guard let timestamp = timestamp else { return nil } + return Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + private static func sendPayload(from message: MessageInfo) -> SendMessagePayload? { + guard let payload = message.messagePayload else { return nil } + switch payload { + case .text(let text): + return .text(TextSendMessagePayload(text: text.text)) + case .custom(let custom): + return .custom(CustomSendMessagePayload( + customData: custom.customData, + description: custom.description, + extensionInfo: custom.extensionInfo + )) + case .image(let image): + guard let imagePath = image.originalImagePath else { return nil } + return .image(ImageSendMessagePayload( + imagePath: imagePath, + imageWidth: image.originalImageWidth, + imageHeight: image.originalImageHeight + )) + case .audio(let audio): + guard let audioPath = audio.audioPath else { return nil } + return .audio(AudioSendMessagePayload(audioFilePath: audioPath, duration: audio.audioDuration)) + case .video(let video): + guard let videoPath = video.videoPath, + let snapshotPath = video.videoSnapshotPath else { return nil } + return .video(VideoSendMessagePayload( + videoFilePath: videoPath, + videoType: video.videoType ?? "mp4", + duration: video.videoDuration, + snapshotPath: snapshotPath, + snapshotWidth: video.videoSnapshotWidth, + snapshotHeight: video.videoSnapshotHeight + )) + case .file(let file): + guard let filePath = file.filePath else { return nil } + return .file(FileSendMessagePayload( + filePath: filePath, + fileName: file.fileName ?? URL(fileURLWithPath: filePath).lastPathComponent, + fileSize: file.fileSize + )) + case .face(let face): + return .face(FaceSendMessagePayload(index: face.faceIndex, data: face.faceData ?? "")) + case .tips, .merged, .stream: + return nil + } + } + + private func logIfFailed(_ result: Result, action: String) { + if case .failure(let error) = result { + print("MessageListViewModel: \(action) failed: \(error.code), \(error.message)") + } + } +} + +private final class GroupAtInfoFetchHandler: GetConversationInfoCompletionHandler { + private let onResult: ([GroupAtInfo]) -> Void + + init(onResult: @escaping ([GroupAtInfo]) -> Void) { + self.onResult = onResult + } + + func onSuccess(conversationInfo: ConversationInfo) { + onResult(conversationInfo.groupAtInfoList ?? []) + } + + func onFailure(code: Int, desc: String) { + onResult([]) + } +} diff --git a/chat/uikit/components/search/SearchEntryBarView.swift b/chat/uikit/components/search/SearchEntryBarView.swift new file mode 100644 index 000000000..28632a085 --- /dev/null +++ b/chat/uikit/components/search/SearchEntryBarView.swift @@ -0,0 +1,102 @@ +import UIKit +import SnapKit + +final class SearchEntryBarView: UIView { + private let onTapItem: SearchResultHandler + + private static let barHeight: CGFloat = 36 + + private static let barHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let barCornerRadius: CGFloat = 10 + + private static let iconSize: CGFloat = 16 + + private static let iconTextGap: CGFloat = 6 + + private let inputContainer = UIView() + + private let centerStack = UIStackView() + + private let iconView = UIImageView() + + private let placeholderLabel = UILabel() + + init(onTapItem: @escaping SearchResultHandler) { + self.onTapItem = onTapItem + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func constructViewHierarchy() { + addSubview(inputContainer) + centerStack.addArrangedSubview(iconView) + centerStack.addArrangedSubview(placeholderLabel) + inputContainer.addSubview(centerStack) + } + + private func activateConstraints() { + inputContainer.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.barHorizontalInset) + make.trailing.equalToSuperview().offset(-Self.barHorizontalInset) + make.top.bottom.equalToSuperview() + make.height.equalTo(Self.barHeight) + } + centerStack.axis = .horizontal + centerStack.spacing = Self.iconTextGap + centerStack.alignment = .center + centerStack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(Self.barHorizontalInset / 2) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.barHorizontalInset / 2) + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(Self.iconSize) + } + } + + private func bindInteraction() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.clearColor + inputContainer.backgroundColor = colors.bgColorInput + inputContainer.layer.cornerRadius = Self.barCornerRadius + inputContainer.layer.masksToBounds = true + + iconView.image = UIImage(systemName: "magnifyingglass") + iconView.tintColor = colors.textColorSecondary + iconView.contentMode = .scaleAspectFit + + placeholderLabel.text = LocalizedChatString("Search") + placeholderLabel.font = FontScheme.caption2Regular + placeholderLabel.textColor = colors.textColorSecondary + } + + @objc private func handleTap() { + let resultController = SearchResultViewController(onTapItem: onTapItem) + resultController.hidesBottomBarWhenPushed = true + findViewController()?.navigationController?.pushViewController(resultController, animated: true) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let viewController = next as? UIViewController { + return viewController + } + responder = next + } + return nil + } +} diff --git a/chat/uikit/components/search/utils/SearchDisplayHelper.swift b/chat/uikit/components/search/utils/SearchDisplayHelper.swift new file mode 100644 index 000000000..3722bacca --- /dev/null +++ b/chat/uikit/components/search/utils/SearchDisplayHelper.swift @@ -0,0 +1,20 @@ +import Foundation +import AtomicXCore + +enum SearchDisplayHelper { + + static func friendDisplayName(_ friend: FriendSearchInfo) -> String { + if let remark = friend.friendRemark, !remark.isEmpty { + return remark + } + if let nickname = friend.userInfo?.nickname, !nickname.isEmpty { + return nickname + } + return friend.userID + } + + static func groupDisplayName(_ group: GroupSearchInfo) -> String { + let name = group.groupName ?? "" + return name.isEmpty ? group.groupID : name + } +} diff --git a/chat/uikit/components/search/utils/SearchHighlightBuilder.swift b/chat/uikit/components/search/utils/SearchHighlightBuilder.swift new file mode 100644 index 000000000..f4a5227d9 --- /dev/null +++ b/chat/uikit/components/search/utils/SearchHighlightBuilder.swift @@ -0,0 +1,67 @@ +import UIKit + +enum SearchHighlightBuilder { + + static func attributedText(text: String, + keyword: String, + font: UIFont, + normalColor: UIColor, + highlightColor: UIColor) -> NSAttributedString { + let normalAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: normalColor, + .font: font + ] + let lowercasedText = text.lowercased() + let lowercasedKeyword = keyword.lowercased() + if keyword.isEmpty || !lowercasedText.contains(lowercasedKeyword) { + return NSAttributedString(string: text, attributes: normalAttributes) + } + + let attributedString = NSMutableAttributedString(string: text, attributes: normalAttributes) + let highlightAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: highlightColor, + .font: font + ] + let nsText = lowercasedText as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while searchRange.location < nsText.length { + let foundRange = nsText.range(of: lowercasedKeyword, options: [], range: searchRange) + if foundRange.location == NSNotFound { + break + } + attributedString.addAttributes(highlightAttributes, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } + return attributedString + } + + static func attributedTextWithEmoji(text: String, + keyword: String, + font: UIFont, + normalColor: UIColor, + highlightColor: UIColor) -> NSAttributedString { + let base = NSMutableAttributedString( + attributedString: EmojiManager.shared.createStyledAttributedString( + fromEmojiCodes: text, font: font, textColor: normalColor + ) + ) + let lowercasedText = base.string.lowercased() + let lowercasedKeyword = keyword.lowercased() + guard !lowercasedKeyword.isEmpty, lowercasedText.contains(lowercasedKeyword) else { + return base + } + let nsText = lowercasedText as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while searchRange.location < nsText.length { + let foundRange = nsText.range(of: lowercasedKeyword, options: [], range: searchRange) + if foundRange.location == NSNotFound { + break + } + base.addAttributes([.foregroundColor: highlightColor], range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } + return base + } +} diff --git a/chat/uikit/components/search/utils/SearchResultCellConfigurator.swift b/chat/uikit/components/search/utils/SearchResultCellConfigurator.swift new file mode 100644 index 000000000..58a9b13f3 --- /dev/null +++ b/chat/uikit/components/search/utils/SearchResultCellConfigurator.swift @@ -0,0 +1,80 @@ +import UIKit +import AtomicXCore + +enum SearchResultCellConfigurator { + + private static var titleFont: UIFont { FontScheme.caption1Regular } + private static var subtitleFont: UIFont { FontScheme.caption2Regular } + + static func configureFriend(_ cell: SearchResultCell, + friend: FriendSearchInfo, + keyword: String, + showDivider: Bool) { + let colors = ChatUIKitTheme.colors + let displayName = SearchDisplayHelper.friendDisplayName(friend) + let title = SearchHighlightBuilder.attributedText( + text: displayName, keyword: keyword, font: titleFont, + normalColor: colors.textColorPrimary, highlightColor: colors.textColorLink + ) + let subtitle = SearchHighlightBuilder.attributedText( + text: "ID: \(friend.userID)", keyword: keyword, font: subtitleFont, + normalColor: colors.textColorTertiary, highlightColor: colors.textColorLink + ) + cell.configure(avatarURL: friend.userInfo?.avatarURL, avatarName: displayName, + title: title, subtitle: subtitle, showDivider: showDivider) + } + + static func configureGroup(_ cell: SearchResultCell, + group: GroupSearchInfo, + keyword: String, + showDivider: Bool) { + let colors = ChatUIKitTheme.colors + let displayName = SearchDisplayHelper.groupDisplayName(group) + let title = SearchHighlightBuilder.attributedText( + text: displayName, keyword: keyword, font: titleFont, + normalColor: colors.textColorPrimary, highlightColor: colors.textColorLink + ) + let subtitle = SearchHighlightBuilder.attributedText( + text: String(format: LocalizedChatString("SearchResultGroupIDFormat"), group.groupID), + keyword: keyword, font: subtitleFont, + normalColor: colors.textColorTertiary, highlightColor: colors.textColorLink + ) + cell.configure(avatarURL: group.groupAvatarURL, avatarName: displayName, + title: title, subtitle: subtitle, showDivider: showDivider) + } + + static func configureMessage(_ cell: SearchResultCell, + item: MessageSearchResultItem, + keyword: String, + showDivider: Bool) { + let colors = ChatUIKitTheme.colors + let title = SearchHighlightBuilder.attributedText( + text: item.conversationShowName, keyword: keyword, font: titleFont, + normalColor: colors.textColorPrimary, highlightColor: colors.textColorLink + ) + let subtitle: NSAttributedString + if item.messageCount > 1 { + subtitle = NSAttributedString( + string: String(format: LocalizedChatString("SearchResultDisplayChatHistoryCountFormat"), item.messageCount), + attributes: [.foregroundColor: colors.textColorTertiary, .font: subtitleFont] + ) + } else { + let abstract = item.messageList.first.map { + MessageListHelper.getMessageAbstract($0, showMergedTitle: true) + } ?? "" + if abstract.contains("[TUIEmoji_") { + subtitle = SearchHighlightBuilder.attributedTextWithEmoji( + text: abstract, keyword: keyword, font: subtitleFont, + normalColor: colors.textColorTertiary, highlightColor: colors.textColorLink + ) + } else { + subtitle = SearchHighlightBuilder.attributedText( + text: abstract, keyword: keyword, font: subtitleFont, + normalColor: colors.textColorTertiary, highlightColor: colors.textColorLink + ) + } + } + cell.configure(avatarURL: item.conversationAvatarURL, avatarName: item.conversationShowName, + title: title, subtitle: subtitle, showDivider: showDivider) + } +} diff --git a/chat/uikit/components/search/view/SearchFieldBar.swift b/chat/uikit/components/search/view/SearchFieldBar.swift new file mode 100644 index 000000000..98be3e109 --- /dev/null +++ b/chat/uikit/components/search/view/SearchFieldBar.swift @@ -0,0 +1,169 @@ +import UIKit +import SnapKit + +final class SearchFieldBar: UIView { + var onTextChanged: ((String) -> Void)? + + var onCancel: (() -> Void)? + + let textField = UITextField() + + private static let barHorizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let barTopPadding: CGFloat = 10 + + private static let barBottomPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let inputHeight: CGFloat = 40 + + private static let inputCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let iconSize: CGFloat = 15 + + private static let iconLeading: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let textLeadingGap: CGFloat = 3 + + private static let clearButtonSize: CGFloat = 16 + + private static let clearButtonTrailing: CGFloat = 10 + + private static let cancelLeadingGap: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private let inputContainer = UIView() + + private let iconView = UIImageView() + + private let clearButton = UIButton(type: .custom) + + private let cancelButton = UIButton(type: .system) + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - API + + func setText(_ text: String) { + textField.text = text + updateClearButtonVisibility() + } + + @discardableResult + + override func becomeFirstResponder() -> Bool { + return textField.becomeFirstResponder() + } + + private func constructViewHierarchy() { + addSubview(inputContainer) + inputContainer.addSubview(iconView) + inputContainer.addSubview(textField) + inputContainer.addSubview(clearButton) + addSubview(cancelButton) + } + + private func activateConstraints() { + inputContainer.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.barHorizontalPadding) + make.top.equalToSuperview().offset(Self.barTopPadding) + make.bottom.equalToSuperview().offset(-Self.barBottomPadding) + make.height.equalTo(Self.inputHeight) + } + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.iconLeading) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.iconSize) + } + textField.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(Self.textLeadingGap) + make.trailing.equalTo(clearButton.snp.leading).offset(-Self.textLeadingGap) + make.centerY.equalToSuperview() + } + clearButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.clearButtonTrailing) + make.centerY.equalToSuperview() + make.width.height.equalTo(Self.clearButtonSize) + } + cancelButton.snp.makeConstraints { make in + make.leading.equalTo(inputContainer.snp.trailing).offset(Self.cancelLeadingGap) + make.trailing.equalToSuperview().offset(-Self.barHorizontalPadding) + make.centerY.equalTo(inputContainer) + } + cancelButton.setContentHuggingPriority(.required, for: .horizontal) + cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + private func bindInteraction() { + textField.delegate = self + textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + clearButton.addTarget(self, action: #selector(handleClearTapped), for: .touchUpInside) + cancelButton.addTarget(self, action: #selector(handleCancel), for: .touchUpInside) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorOperate + inputContainer.backgroundColor = colors.bgColorInput + inputContainer.layer.cornerRadius = Self.inputCornerRadius + inputContainer.layer.masksToBounds = true + + iconView.image = UIImage(systemName: "magnifyingglass") + iconView.tintColor = colors.textColorTertiary + iconView.contentMode = .scaleAspectFit + + textField.font = FontScheme.caption2Regular + textField.textColor = colors.textColorPrimary + textField.tintColor = colors.textColorLink + textField.returnKeyType = .search + textField.attributedPlaceholder = NSAttributedString( + string: LocalizedChatString("Search"), + attributes: [.foregroundColor: colors.textColorTertiary] + ) + + clearButton.setImage(AtomicXChatResources.image(named: "contact_search_clear"), for: .normal) + clearButton.tintColor = colors.textColorPrimary + clearButton.isHidden = true + + cancelButton.setTitle(LocalizedChatString("Cancel"), for: .normal) + cancelButton.setTitleColor(colors.textColorPrimary, for: .normal) + cancelButton.titleLabel?.font = FontScheme.caption1Regular + } + + private func updateClearButtonVisibility() { + clearButton.isHidden = (textField.text ?? "").isEmpty + } + + @objc private func textFieldDidChange() { + updateClearButtonVisibility() + onTextChanged?(textField.text ?? "") + } + + @objc private func handleClearTapped() { + textField.text = "" + updateClearButtonVisibility() + onTextChanged?("") + } + + @objc private func handleCancel() { + onCancel?() + } +} + +// MARK: - UITextFieldDelegate + +extension SearchFieldBar: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/chat/uikit/components/search/view/SearchMessageDetailViewController.swift b/chat/uikit/components/search/view/SearchMessageDetailViewController.swift new file mode 100644 index 000000000..47a46aa8b --- /dev/null +++ b/chat/uikit/components/search/view/SearchMessageDetailViewController.swift @@ -0,0 +1,338 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class SearchMessageDetailViewController: UIViewController { + private static let estimatedRowHeight: CGFloat = 64 + + private static let dividerHorizontalInset: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let dividerHeight: CGFloat = 0.5 + + private static let loadMorePrefetchThreshold = 3 + + private let conversationID: String + + private let conversationName: String + + private let conversationAvatar: String? + + private let initialKeyword: String + + private let onTapItem: SearchResultHandler + + private let viewModel: SearchViewModel + + private var cancellables = Set() + + private var messages: [MessageInfo] = [] + + private var currentKeyword: String + + private let searchBar = SearchFieldBar() + + private let headerView = SearchConversationHeaderView() + + private let dividerView = UIView() + + private let statusView = SearchStatusView() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = Self.estimatedRowHeight + table.keyboardDismissMode = .onDrag + table.register(SearchResultCell.self, forCellReuseIdentifier: SearchResultCell.reuseIdentifier) + return table + }() + + init(conversationID: String, + conversationName: String, + conversationAvatar: String?, + keyword: String, + onTapItem: @escaping SearchResultHandler) { + self.conversationID = conversationID + self.conversationName = conversationName + self.conversationAvatar = conversationAvatar + self.initialKeyword = keyword + self.currentKeyword = keyword + self.onTapItem = onTapItem + self.viewModel = SearchViewModel(scene: .conversation(conversationID)) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindViewModel() + searchBar.setText(initialKeyword) + if !initialKeyword.isEmpty { + viewModel.searchImmediately(initialKeyword) + } + } + + private func setupHierarchy() { + searchBar.onTextChanged = { [weak self] text in + self?.currentKeyword = text + self?.viewModel.updateKeyword(text) + } + searchBar.onCancel = { [weak self] in + self?.searchBar.setText("") + self?.viewModel.searchImmediately("") + self?.dismissOrPop() + } + headerView.onTap = { [weak self] in self?.handleHeaderTap() } + + view.addSubview(searchBar) + view.addSubview(headerView) + view.addSubview(dividerView) + view.addSubview(tableView) + view.addSubview(statusView) + + searchBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + headerView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.equalToSuperview() + } + dividerView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom) + make.leading.equalToSuperview().offset(Self.dividerHorizontalInset) + make.trailing.equalToSuperview().offset(-Self.dividerHorizontalInset) + make.height.equalTo(Self.dividerHeight) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(dividerView.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + statusView.snp.makeConstraints { make in + make.edges.equalTo(tableView) + } + tableView.dataSource = self + tableView.delegate = self + } + + private func setupStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + tableView.backgroundColor = colors.bgColorOperate + dividerView.backgroundColor = colors.strokeColorPrimary + headerView.configure(name: conversationName, avatarURL: conversationAvatar) + } + + private func bindViewModel() { + viewModel.$messageResults + .receive(on: DispatchQueue.main) + .sink { [weak self] results in + self?.messages = results.flatMap { $0.messageList } + self?.tableView.reloadData() + self?.updateStatus() + } + .store(in: &cancellables) + + viewModel.$isSearching + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.updateStatus() } + .store(in: &cancellables) + } + + private func updateStatus() { + if viewModel.isSearching && messages.isEmpty { + statusView.setStatus(.loading) + } else { + statusView.setStatus(.hidden) + } + } + + private func dismissOrPop() { + if let nav = navigationController, nav.viewControllers.count > 1 { + nav.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } + + private func handleHeaderTap() { + let info: [String: Any] = [ + "conversationID": conversationID, + "conversationName": conversationName, + "conversationAvatar": conversationAvatar as Any + ] + onTapItem(info) + } + + private func handleMessageTap(_ message: MessageInfo) { + let result: [String: Any] = [ + "message": message, + "conversationID": conversationID, + "conversationName": conversationName, + "conversationAvatar": conversationAvatar as Any + ] + onTapItem(result) + } +} + +// MARK: - UITableViewDataSource + +extension SearchMessageDetailViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return messages.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: SearchResultCell.reuseIdentifier, for: indexPath + ) as? SearchResultCell else { + return UITableViewCell() + } + let message = messages[indexPath.row] + let colors = ChatUIKitTheme.colors + let senderName = ChatUtil.getMessageSenderName(message) + let abstract = MessageListHelper.getMessageAbstract(message, showMergedTitle: true) + let title = SearchHighlightBuilder.attributedText( + text: senderName, + keyword: "", + font: FontScheme.caption2Bold, + normalColor: colors.textColorPrimary, + highlightColor: colors.textColorLink + ) + let subtitle: NSAttributedString + if abstract.contains("[TUIEmoji_") { + subtitle = SearchHighlightBuilder.attributedTextWithEmoji( + text: abstract, + keyword: currentKeyword, + font: FontScheme.caption3Regular, + normalColor: colors.textColorSecondary, + highlightColor: colors.textColorLink + ) + } else { + subtitle = SearchHighlightBuilder.attributedText( + text: abstract, + keyword: currentKeyword, + font: FontScheme.caption3Regular, + normalColor: colors.textColorSecondary, + highlightColor: colors.textColorLink + ) + } + cell.configure( + avatarURL: message.from.avatarURL, + avatarName: senderName, + title: title, + subtitle: subtitle, + showDivider: true + ) + return cell + } +} + +// MARK: - UITableViewDelegate + +extension SearchMessageDetailViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + handleMessageTap(messages[indexPath.row]) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if indexPath.row == messages.count - Self.loadMorePrefetchThreshold { + viewModel.loadMoreMessages() + } + } +} + +final class SearchConversationHeaderView: UIView { + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let verticalPadding: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let avatarNameSpacing: CGFloat = CGFloat(SpacingScheme.iconIconSpacing) + + private static let nameChevronSpacing: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let chevronWidth: CGFloat = 8 + + private static let chevronHeight: CGFloat = 13 + + var onTap: (() -> Void)? + + private let avatarView = ChatAvatarView(size: .m, isRound: true) + + private let nameLabel = UILabel() + + private let chevronView = UIImageView() + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + bindInteraction() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(name: String, avatarURL: String?) { + nameLabel.text = name + avatarView.configure(avatarURL: avatarURL, fallbackName: name) + } + + private func constructViewHierarchy() { + addSubview(avatarView) + addSubview(nameLabel) + addSubview(chevronView) + } + + private func activateConstraints() { + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + make.width.height.equalTo(ChatAvatarSize.m.size) + } + chevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.centerY.equalToSuperview() + make.width.equalTo(Self.chevronWidth) + make.height.equalTo(Self.chevronHeight) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarNameSpacing) + make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-Self.nameChevronSpacing) + make.centerY.equalToSuperview() + } + } + + private func bindInteraction() { + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + backgroundColor = colors.bgColorTopBar + nameLabel.font = FontScheme.caption1Bold + nameLabel.textColor = colors.textColorPrimary + nameLabel.numberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + chevronView.image = UIImage(systemName: "chevron.right") + chevronView.tintColor = colors.textColorSecondary + chevronView.contentMode = .scaleAspectFit + } + + @objc private func handleTap() { + onTap?() + } +} diff --git a/chat/uikit/components/search/view/SearchResultCell.swift b/chat/uikit/components/search/view/SearchResultCell.swift new file mode 100644 index 000000000..1660582f5 --- /dev/null +++ b/chat/uikit/components/search/view/SearchResultCell.swift @@ -0,0 +1,118 @@ +import UIKit +import SnapKit + +final class SearchResultCell: UITableViewCell { + static let reuseIdentifier = "SearchResultCell" + + private static let avatarSize: CGFloat = 36 + + private static let avatarCornerRadius: CGFloat = CGFloat(RadiusScheme.tipsRadius) + + private static let avatarPlaceholderFontSize: CGFloat = 16 + + private static let horizontalPadding: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let verticalPadding: CGFloat = 12.5 + + private static let avatarTextGap: CGFloat = CGFloat(SpacingScheme.smallSpacing) + + private static let titleSubtitleGap: CGFloat = 2 + + private static let dividerHeight: CGFloat = 0.5 + + private static let dividerBottomInset: CGFloat = 3 + + private let avatarView = ChatAvatarView(cornerRadius: SearchResultCell.avatarCornerRadius, fontSize: SearchResultCell.avatarPlaceholderFontSize) + + private let titleLabel = UILabel() + + private let subtitleLabel = UILabel() + + private let dividerView = UIView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + + func configure(avatarURL: String?, + avatarName: String, + title: NSAttributedString, + subtitle: NSAttributedString?, + showDivider: Bool) { + configureAvatar(url: avatarURL, name: avatarName) + titleLabel.attributedText = title + if let subtitle = subtitle { + subtitleLabel.attributedText = subtitle + subtitleLabel.isHidden = false + } else { + subtitleLabel.attributedText = nil + subtitleLabel.isHidden = true + } + dividerView.isHidden = !showDivider + } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.attributedText = nil + subtitleLabel.attributedText = nil + subtitleLabel.isHidden = false + dividerView.isHidden = true + avatarView.reset() + } + + private func constructViewHierarchy() { + selectionStyle = .default + contentView.addSubview(avatarView) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(dividerView) + } + + private func activateConstraints() { + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.top.equalToSuperview().offset(Self.verticalPadding) + make.bottom.equalToSuperview().offset(-Self.verticalPadding) + make.width.height.equalTo(Self.avatarSize) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(Self.avatarTextGap) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.top.equalTo(avatarView) + } + subtitleLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.top.equalTo(titleLabel.snp.bottom).offset(Self.titleSubtitleGap) + } + dividerView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.horizontalPadding) + make.trailing.equalToSuperview().offset(-Self.horizontalPadding) + make.bottom.equalToSuperview().offset(-Self.dividerBottomInset) + make.height.equalTo(Self.dividerHeight) + } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + contentView.backgroundColor = colors.bgColorOperate + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + subtitleLabel.numberOfLines = 1 + subtitleLabel.lineBreakMode = .byTruncatingTail + dividerView.backgroundColor = colors.strokeColorPrimary + } + + private func configureAvatar(url: String?, name: String) { + avatarView.configure(avatarURL: url, fallbackName: name) + } +} diff --git a/chat/uikit/components/search/view/SearchResultMoreViewController.swift b/chat/uikit/components/search/view/SearchResultMoreViewController.swift new file mode 100644 index 000000000..4e2c41638 --- /dev/null +++ b/chat/uikit/components/search/view/SearchResultMoreViewController.swift @@ -0,0 +1,229 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class SearchResultMoreViewController: UIViewController { + private let searchType: SearchType + + private let initialKeyword: String + + private let onTapItem: SearchResultHandler + + private let viewModel: SearchViewModel + + private var cancellables = Set() + + private var currentKeyword: String + + private var friendSnapshot: [FriendSearchInfo] = [] + + private var groupSnapshot: [GroupSearchInfo] = [] + + private var messageSnapshot: [MessageSearchResultItem] = [] + + private let searchBar = SearchFieldBar() + + private let statusView = SearchStatusView() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = 64 + table.keyboardDismissMode = .onDrag + table.register(SearchResultCell.self, forCellReuseIdentifier: SearchResultCell.reuseIdentifier) + return table + }() + + private var currentResultCount: Int { + if searchType == .friend { + return friendSnapshot.count + } else if searchType == .group { + return groupSnapshot.count + } else { + return messageSnapshot.count + } + } + + init(searchType: SearchType, + keyword: String, + onTapItem: @escaping SearchResultHandler) { + self.searchType = searchType + self.initialKeyword = keyword + self.currentKeyword = keyword + self.onTapItem = onTapItem + self.viewModel = SearchViewModel(scene: .singleType(searchType)) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindViewModel() + searchBar.setText(initialKeyword) + if !initialKeyword.isEmpty { + viewModel.searchImmediately(initialKeyword) + } + } + + // MARK: - Navigation + + private func setupHierarchy() { + searchBar.onTextChanged = { [weak self] text in + self?.currentKeyword = text + self?.viewModel.updateKeyword(text) + self?.updateStatus() + } + searchBar.onCancel = { [weak self] in + self?.searchBar.setText("") + self?.viewModel.searchImmediately("") + self?.dismissOrPop() + } + + view.addSubview(searchBar) + view.addSubview(tableView) + view.addSubview(statusView) + + searchBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + statusView.snp.makeConstraints { make in + make.edges.equalTo(tableView) + } + tableView.dataSource = self + tableView.delegate = self + } + + private func setupStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorOperate + tableView.backgroundColor = colors.bgColorOperate + } + + private func bindViewModel() { + Publishers.CombineLatest3( + viewModel.$friendList, + viewModel.$groupList, + viewModel.$messageResults + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _, _, _ in + self?.reloadResults() + } + .store(in: &cancellables) + + viewModel.$isSearching + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.updateStatus() } + .store(in: &cancellables) + } + + private func reloadResults() { + friendSnapshot = viewModel.friendList + groupSnapshot = viewModel.groupList + messageSnapshot = viewModel.messageResults + tableView.reloadData() + updateStatus() + } + + private func updateStatus() { + if viewModel.isSearching && currentResultCount == 0 { + statusView.setStatus(.loading) + } else { + statusView.setStatus(.hidden) + } + } + + private func dismissOrPop() { + if let nav = navigationController, nav.viewControllers.count > 1 { + nav.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } + + private func pushMessageDetail(_ item: MessageSearchResultItem) { + let controller = SearchMessageDetailViewController( + conversationID: item.conversationID, + conversationName: item.conversationShowName, + conversationAvatar: item.conversationAvatarURL, + keyword: currentKeyword, + onTapItem: onTapItem + ) + navigationController?.pushViewController(controller, animated: true) + } +} + +// MARK: - UITableViewDataSource + +extension SearchResultMoreViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return currentResultCount + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: SearchResultCell.reuseIdentifier, for: indexPath + ) as? SearchResultCell else { + return UITableViewCell() + } + if searchType == .friend { + guard indexPath.row < friendSnapshot.count else { return cell } + SearchResultCellConfigurator.configureFriend( + cell, friend: friendSnapshot[indexPath.row], + keyword: currentKeyword, showDivider: true + ) + } else if searchType == .group { + guard indexPath.row < groupSnapshot.count else { return cell } + SearchResultCellConfigurator.configureGroup( + cell, group: groupSnapshot[indexPath.row], + keyword: currentKeyword, showDivider: true + ) + } else { + guard indexPath.row < messageSnapshot.count else { return cell } + SearchResultCellConfigurator.configureMessage( + cell, item: messageSnapshot[indexPath.row], + keyword: currentKeyword, showDivider: true + ) + } + return cell + } +} + +// MARK: - UITableViewDelegate + +extension SearchResultMoreViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if searchType == .friend { + guard indexPath.row < friendSnapshot.count else { return } + onTapItem(friendSnapshot[indexPath.row]) + } else if searchType == .group { + guard indexPath.row < groupSnapshot.count else { return } + onTapItem(groupSnapshot[indexPath.row]) + } else { + guard indexPath.row < messageSnapshot.count else { return } + pushMessageDetail(messageSnapshot[indexPath.row]) + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard searchType == .message else { return } + if indexPath.row == messageSnapshot.count - 3 { + viewModel.loadMoreMessages() + } + } +} diff --git a/chat/uikit/components/search/view/SearchResultViewController.swift b/chat/uikit/components/search/view/SearchResultViewController.swift new file mode 100644 index 000000000..8d39484b2 --- /dev/null +++ b/chat/uikit/components/search/view/SearchResultViewController.swift @@ -0,0 +1,420 @@ +import UIKit +import Combine +import SnapKit +import AtomicXCore + +final class SearchResultViewController: UIViewController { + private enum ResultSection { + case friend + case group + case message + } + + private static let estimatedRowHeight: CGFloat = 58 + + private static let searchBarFocusDelay: TimeInterval = 0.1 + + private static let maxPreviewRowCount = 3 + + private let onTapItem: SearchResultHandler + + private let viewModel = SearchViewModel(scene: .all) + + private var cancellables = Set() + + private var currentKeyword: String = "" + + private var activeSections: [ResultSection] = [] + + private var friendSnapshot: [FriendSearchInfo] = [] + + private var groupSnapshot: [GroupSearchInfo] = [] + + private var messageSnapshot: [MessageSearchResultItem] = [] + + private var hasAutoFocusedSearchBar = false + + private let searchBar = SearchFieldBar() + + private let topBackdropView = UIView() + + private let statusView = SearchStatusView() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.separatorStyle = .none + table.rowHeight = UITableView.automaticDimension + table.estimatedRowHeight = Self.estimatedRowHeight + table.keyboardDismissMode = .onDrag + table.register(SearchResultCell.self, forCellReuseIdentifier: SearchResultCell.reuseIdentifier) + table.register(SearchSectionMoreCell.self, forCellReuseIdentifier: SearchSectionMoreCell.reuseID) + return table + }() + + init(onTapItem: @escaping SearchResultHandler) { + self.onTapItem = onTapItem + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHierarchy() + setupStyle() + bindViewModel() + updateBodyState() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !hasAutoFocusedSearchBar else { return } + hasAutoFocusedSearchBar = true + DispatchQueue.main.asyncAfter(deadline: .now() + Self.searchBarFocusDelay) { [weak self] in + self?.searchBar.becomeFirstResponder() + } + } + + // MARK: - Navigation + + private func setupHierarchy() { + searchBar.onTextChanged = { [weak self] text in + self?.currentKeyword = text + self?.viewModel.updateKeyword(text) + self?.updateBodyState() + } + searchBar.onCancel = { [weak self] in + self?.searchBar.setText("") + self?.viewModel.searchImmediately("") + self?.dismissOrPop() + } + + view.addSubview(topBackdropView) + view.addSubview(searchBar) + view.addSubview(tableView) + view.addSubview(statusView) + + searchBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + topBackdropView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(searchBar.snp.bottom) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + statusView.snp.makeConstraints { make in + make.edges.equalTo(tableView) + } + tableView.dataSource = self + tableView.delegate = self + } + + private func setupStyle() { + let colors = ChatUIKitTheme.colors + view.backgroundColor = colors.bgColorDefault + topBackdropView.backgroundColor = colors.bgColorOperate + tableView.backgroundColor = colors.bgColorDefault + } + + private func bindViewModel() { + Publishers.CombineLatest4( + viewModel.$friendList, + viewModel.$groupList, + viewModel.$messageResults, + viewModel.$isSearching + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _, _, _, _ in + self?.reloadSections() + } + .store(in: &cancellables) + + viewModel.$hasSearched + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.updateBodyState() } + .store(in: &cancellables) + } + + private func reloadSections() { + friendSnapshot = viewModel.friendList + groupSnapshot = viewModel.groupList + messageSnapshot = viewModel.messageResults + var sections: [ResultSection] = [] + if !friendSnapshot.isEmpty { sections.append(.friend) } + if !groupSnapshot.isEmpty { sections.append(.group) } + if !messageSnapshot.isEmpty { sections.append(.message) } + activeSections = sections + tableView.reloadData() + updateBodyState() + } + + private func updateBodyState() { + let hasResults = !viewModel.friendList.isEmpty + || !viewModel.groupList.isEmpty + || !viewModel.messageResults.isEmpty + if currentKeyword.isEmpty { + tableView.isHidden = true + statusView.setStatus(.hidden) + } else if viewModel.isSearching { + tableView.isHidden = true + statusView.setStatus(.loading) + } else if hasResults { + tableView.isHidden = false + statusView.setStatus(.hidden) + } else if viewModel.hasSearched { + tableView.isHidden = true + statusView.setStatus(.empty) + } else { + tableView.isHidden = true + statusView.setStatus(.loading) + } + } + + private func dismissOrPop() { + if let nav = navigationController, nav.viewControllers.count > 1 { + nav.popViewController(animated: true) + } else { + dismiss(animated: true) + } + } + + private func deliverResult(_ result: Any) { + onTapItem(result) + } + + private func pushMore(_ searchType: SearchType) { + let controller = SearchResultMoreViewController( + searchType: searchType, + keyword: currentKeyword, + onTapItem: { [weak self] result in self?.deliverResult(result) } + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func pushMessageDetail(_ item: MessageSearchResultItem) { + let controller = SearchMessageDetailViewController( + conversationID: item.conversationID, + conversationName: item.conversationShowName, + conversationAvatar: item.conversationAvatarURL, + keyword: currentKeyword, + onTapItem: { [weak self] result in self?.deliverResult(result) } + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func rowCount(for section: ResultSection) -> Int { + switch section { + case .friend: + return min(friendSnapshot.count, Self.maxPreviewRowCount) + case .group: + return min(groupSnapshot.count, Self.maxPreviewRowCount) + case .message: + return min(messageSnapshot.count, Self.maxPreviewRowCount) + } + } + + private func totalCount(for section: ResultSection) -> Int { + switch section { + case .friend: return friendSnapshot.count + case .group: return groupSnapshot.count + case .message: return messageSnapshot.count + } + } + + private func hasMoreFooter(for section: ResultSection) -> Bool { + return totalCount(for: section) > Self.maxPreviewRowCount + } + + private func isMoreRow(at indexPath: IndexPath) -> Bool { + let section = activeSections[indexPath.section] + return hasMoreFooter(for: section) && indexPath.row == rowCount(for: section) + } + + private func headerTitle(for section: ResultSection) -> String { + switch section { + case .friend: return LocalizedChatString("SearchItemHeaderTitleContact") + case .group: return LocalizedChatString("SearchItemHeaderTitleGroup") + case .message: return LocalizedChatString("SearchItemHeaderTitleChatHistory") + } + } + + private func footerTitle(for section: ResultSection) -> String { + switch section { + case .friend: return LocalizedChatString("SearchItemFooterTitleContact") + case .group: return LocalizedChatString("SearchItemFooterTitleGroup") + case .message: return LocalizedChatString("SearchItemFooterTitleChatHistory") + } + } +} + +// MARK: - UITableViewDataSource + +extension SearchResultViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return activeSections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let resultSection = activeSections[section] + return rowCount(for: resultSection) + (hasMoreFooter(for: resultSection) ? 1 : 0) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard indexPath.section < activeSections.count else { return UITableViewCell() } + if isMoreRow(at: indexPath) { + let cell = tableView.dequeueReusableCell( + withIdentifier: SearchSectionMoreCell.reuseID, for: indexPath + ) as! SearchSectionMoreCell + cell.configure(title: footerTitle(for: activeSections[indexPath.section])) + return cell + } + guard let cell = tableView.dequeueReusableCell( + withIdentifier: SearchResultCell.reuseIdentifier, for: indexPath + ) as? SearchResultCell else { + return UITableViewCell() + } + switch activeSections[indexPath.section] { + case .friend: + guard indexPath.row < friendSnapshot.count else { return cell } + SearchResultCellConfigurator.configureFriend( + cell, friend: friendSnapshot[indexPath.row], + keyword: currentKeyword, showDivider: true + ) + case .group: + guard indexPath.row < groupSnapshot.count else { return cell } + SearchResultCellConfigurator.configureGroup( + cell, group: groupSnapshot[indexPath.row], + keyword: currentKeyword, showDivider: true + ) + case .message: + guard indexPath.row < messageSnapshot.count else { return cell } + SearchResultCellConfigurator.configureMessage( + cell, item: messageSnapshot[indexPath.row], + keyword: currentKeyword, showDivider: true + ) + } + return cell + } +} + +// MARK: - UITableViewDelegate + +extension SearchResultViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let header = SearchSectionHeaderView() + header.configure(title: headerTitle(for: activeSections[section])) + return header + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return SearchSectionHeaderView.headerHeight + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.section < activeSections.count else { return } + if isMoreRow(at: indexPath) { + switch activeSections[indexPath.section] { + case .friend: pushMore(.friend) + case .group: pushMore(.group) + case .message: pushMore(.message) + } + return + } + switch activeSections[indexPath.section] { + case .friend: + guard indexPath.row < friendSnapshot.count else { return } + deliverResult(friendSnapshot[indexPath.row]) + case .group: + guard indexPath.row < groupSnapshot.count else { return } + deliverResult(groupSnapshot[indexPath.row]) + case .message: + guard indexPath.row < messageSnapshot.count else { return } + pushMessageDetail(messageSnapshot[indexPath.row]) + } + } +} + +final class SearchSectionHeaderView: UIView { + static let headerHeight: CGFloat = 33 + + private static let sectionTopGap: CGFloat = 1 + + private static let titleLeadingPadding: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let titleTopPadding: CGFloat = 10 + + private static let titleBottomPadding: CGFloat = 2 + + private let contentBackground = UIView() + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = ChatUIKitTheme.colors.bgColorDefault + contentBackground.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + addSubview(contentBackground) + contentBackground.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Self.sectionTopGap) + make.leading.trailing.bottom.equalToSuperview() + } + titleLabel.font = FontScheme.caption2Regular + titleLabel.textColor = ChatUIKitTheme.colors.textColorSecondary + contentBackground.addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.titleLeadingPadding) + make.top.equalToSuperview().offset(Self.titleTopPadding) + make.bottom.equalToSuperview().offset(-Self.titleBottomPadding) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + titleLabel.text = title + } +} + +final class SearchSectionMoreCell: UITableViewCell { + static let reuseID = "SearchSectionMoreCell" + + private static let moreLeadingPadding: CGFloat = CGFloat(SpacingScheme.contentSpacing) + + private static let moreVerticalPadding: CGFloat = 10 + + private let moreLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = ChatUIKitTheme.colors.bgColorOperate + contentView.backgroundColor = ChatUIKitTheme.colors.bgColorOperate + moreLabel.font = FontScheme.caption2Regular + moreLabel.textColor = ChatUIKitTheme.colors.textColorLink + contentView.addSubview(moreLabel) + moreLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Self.moreLeadingPadding) + make.top.equalToSuperview().offset(Self.moreVerticalPadding) + make.bottom.equalToSuperview().offset(-Self.moreVerticalPadding) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + moreLabel.text = title + } +} diff --git a/chat/uikit/components/search/view/SearchStatusView.swift b/chat/uikit/components/search/view/SearchStatusView.swift new file mode 100644 index 000000000..afe5a3847 --- /dev/null +++ b/chat/uikit/components/search/view/SearchStatusView.swift @@ -0,0 +1,135 @@ +import UIKit +import SnapKit + +public typealias SearchResultHandler = (Any) -> Void + +final class SearchStatusView: UIView { + private static let horizontalMargin: CGFloat = CGFloat(SpacingScheme.normalSpacing) + + private static let emptyIconSize: CGFloat = 64 + + private static let emptyStackSpacing: CGFloat = CGFloat(SpacingScheme.bubbleSpacing) + + private static let defaultKeyboardAnimationDuration: TimeInterval = 0.25 + + enum Status { + case hidden + case loading + case empty + } + + private let indicator = UIActivityIndicatorView(style: .medium) + + private let iconView = UIImageView() + + private let messageLabel = UILabel() + + private let emptyStack = UIStackView() + + private var indicatorCenterYConstraint: Constraint? + + private var emptyStackCenterYConstraint: Constraint? + + init() { + super.init(frame: .zero) + constructViewHierarchy() + activateConstraints() + setupViewStyle() + observeKeyboard() + setStatus(.hidden) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func setStatus(_ status: Status) { + switch status { + case .hidden: + isHidden = true + indicator.stopAnimating() + emptyStack.isHidden = true + case .loading: + isHidden = false + indicator.startAnimating() + emptyStack.isHidden = true + case .empty: + isHidden = false + indicator.stopAnimating() + emptyStack.isHidden = false + } + } + + private func constructViewHierarchy() { + addSubview(indicator) + emptyStack.addArrangedSubview(iconView) + emptyStack.addArrangedSubview(messageLabel) + addSubview(emptyStack) + } + + private func activateConstraints() { + indicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + self.indicatorCenterYConstraint = make.centerY.equalToSuperview().constraint + } + emptyStack.snp.makeConstraints { make in + make.centerX.equalToSuperview() + self.emptyStackCenterYConstraint = make.centerY.equalToSuperview().constraint + make.leading.greaterThanOrEqualToSuperview().offset(Self.horizontalMargin) + make.trailing.lessThanOrEqualToSuperview().offset(-Self.horizontalMargin) + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(Self.emptyIconSize) + } + } + + private func observeKeyboard() { + NotificationCenter.default.addObserver( + self, selector: #selector(handleKeyboardChange(_:)), + name: UIResponder.keyboardWillShowNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(handleKeyboardChange(_:)), + name: UIResponder.keyboardWillHideNotification, object: nil + ) + } + + @objc private func handleKeyboardChange(_ notification: Notification) { + let isHide = notification.name == UIResponder.keyboardWillHideNotification + var keyboardHeight: CGFloat = 0 + if !isHide, + let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardFrame = convert(frame.cgRectValue, from: nil) + keyboardHeight = max(0, bounds.maxY - keyboardFrame.minY) + } + let offset = -keyboardHeight / 2 + indicatorCenterYConstraint?.update(offset: offset) + emptyStackCenterYConstraint?.update(offset: offset) + let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? Self.defaultKeyboardAnimationDuration + UIView.animate(withDuration: duration) { self.layoutIfNeeded() } + } + + private func setupViewStyle() { + let colors = ChatUIKitTheme.colors + indicator.color = colors.textColorSecondary + indicator.hidesWhenStopped = true + + emptyStack.axis = .vertical + emptyStack.alignment = .center + emptyStack.spacing = Self.emptyStackSpacing + + iconView.image = UIImage(systemName: "magnifyingglass") + iconView.tintColor = colors.textColorSecondary + iconView.contentMode = .scaleAspectFit + + messageLabel.text = LocalizedChatString("SearchNoResultInConversation") + messageLabel.font = FontScheme.caption1Regular + messageLabel.textColor = colors.textColorSecondary + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 0 + } +} diff --git a/chat/uikit/components/search/viewmodel/SearchViewModel.swift b/chat/uikit/components/search/viewmodel/SearchViewModel.swift new file mode 100644 index 000000000..8a3ff28b3 --- /dev/null +++ b/chat/uikit/components/search/viewmodel/SearchViewModel.swift @@ -0,0 +1,145 @@ +import Foundation +import Combine +import AtomicXCore + +final class SearchViewModel { + enum Scene { + case all + case singleType(SearchType) + case conversation(String) + } + + @Published private(set) var friendList: [FriendSearchInfo] = [] + + @Published private(set) var groupList: [GroupSearchInfo] = [] + + @Published private(set) var messageResults: [MessageSearchResultItem] = [] + + @Published private(set) var hasMoreMessageResults: Bool = true + + @Published private(set) var isSearching: Bool = false + + @Published private(set) var hasSearched: Bool = false + + private let scene: Scene + + private let store: SearchStore + + private var cancellables = Set() + + private var debounceWorkItem: DispatchWorkItem? + + private var isLoadingMore: Bool = false + + private let debounceInterval: TimeInterval = 0.3 + + private let pageSize: Int + + // MARK: - Init + + init(scene: Scene, store: SearchStore = SearchStore.create()) { + self.scene = scene + self.store = store + switch scene { + case .conversation: + self.pageSize = 10 + case .all, .singleType: + self.pageSize = 20 + } + subscribeState() + } + + // MARK: - Actions + + func updateKeyword(_ text: String) { + debounceWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.performSearch(text) + } + debounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) + } + + func searchImmediately(_ text: String) { + debounceWorkItem?.cancel() + performSearch(text) + } + + func loadMoreMessages() { + guard hasMoreMessageResults, !isLoadingMore else { return } + isLoadingMore = true + store.searchMore(searchType: .message) { _ in } + } + + private func subscribeState() { + store.state + .subscribe(StatePublisherSelector(keyPath: \SearchState.friendList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in self?.friendList = list } + .store(in: &cancellables) + + store.state + .subscribe(StatePublisherSelector(keyPath: \SearchState.groupList)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in self?.groupList = list } + .store(in: &cancellables) + + store.state + .subscribe(StatePublisherSelector(keyPath: \SearchState.messageResults)) + .receive(on: DispatchQueue.main) + .sink { [weak self] list in + self?.messageResults = list + self?.isSearching = false + } + .store(in: &cancellables) + + store.state + .subscribe(StatePublisherSelector(keyPath: \SearchState.hasMoreMessageResults)) + .receive(on: DispatchQueue.main) + .sink { [weak self] hasMore in + self?.hasMoreMessageResults = hasMore + self?.isLoadingMore = false + } + .store(in: &cancellables) + } + + private func performSearch(_ text: String) { + if text.isEmpty { + clearResults() + return + } + isSearching = true + hasSearched = false + store.search(keywordList: [text], option: buildOption()) { [weak self] _ in + DispatchQueue.main.async { + self?.isSearching = false + self?.hasSearched = true + } + } + } + + private func clearResults() { + friendList = [] + groupList = [] + messageResults = [] + isSearching = false + hasSearched = false + } + + private func buildOption() -> SearchOption { + var option = SearchOption() + option.pageSize = pageSize + switch scene { + case .all: + option.searchScope = [.friend, .group, .message] + case .singleType(let type): + option.searchScope = [type] + case .conversation(let conversationID): + option.searchScope = [.message] + var messageFilter = MessageSearchFilter() + messageFilter.conversationID = conversationID + option.messageFilter = messageFilter + } + return option + } +} diff --git a/chat/uikit/components/videorecorder/Common/VideoRecorderCommon.h b/chat/uikit/components/videorecorder/Common/VideoRecorderCommon.h new file mode 100644 index 000000000..a49f6673a --- /dev/null +++ b/chat/uikit/components/videorecorder/Common/VideoRecorderCommon.h @@ -0,0 +1,29 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#define VideoRecorderConstCGSize(w, h) ((CGSize){(CGFloat)(w), (CGFloat)(h)}) +#define VideoRecorderDynamicColor(colorKey, defaultHex) [VideoRecorderCommon dynamicColor:colorKey defaultColor:defaultHex] +#define VideoRecorderBundleThemeImage(imageName) [VideoRecorderCommon bundleImageByName:imageName] + +@interface VideoRecorderCommon : NSObject +@property(class, readonly) NSBundle *assetsBundle; +@property(class, readonly) NSBundle *stringBundle; ++ (NSBundle *)modleNSBundle; ++ (nullable UIImage *)bundleImageByName:(NSString *)name; ++ (nullable UIImage *)bundleRawImageByName:(NSString *)name; ++ (NSString *)localizedStringForKey:(NSString *)key; ++ (NSString *)getPreferredLanguage; ++ (UIColor *)colorFromHex:(NSString *)hex; ++ (NSArray *)sortedBundleResourcesIn:(NSString *)directory withExtension:(NSString *)ext; ++ (NSURL *)getURLByResourcePath:(NSString *)path; ++ (UIColor *__nullable)dynamicColor:(NSString *)colorKey defaultColor:(NSString *)hex; ++ (UIImage *__nullable)dynamicImage:(NSString *)imageKey defaultImageName:(NSString *)image; ++ (UIColor *)tui_colorWithHex:(NSString *)hex; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Common/VideoRecorderCommon.m b/chat/uikit/components/videorecorder/Common/VideoRecorderCommon.m new file mode 100644 index 000000000..8099a81f7 --- /dev/null +++ b/chat/uikit/components/videorecorder/Common/VideoRecorderCommon.m @@ -0,0 +1,222 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderCommon.h" +#import "VideoRecorderNSArray+Functional.h" + +#define ChatEngineLanguageKey @"AtomicXLanguageKey" +#define BundleResourceUrlPrefix @"file:///asset/" + +@interface NSString (TUIHexColorPrivate) +- (NSUInteger)_hexValue; +@end + +@implementation NSString (TUIHexColorPrivate) +- (NSUInteger)_hexValue { + NSUInteger result = 0; + sscanf([self UTF8String], "%lx", &result); + return result; +} +@end + +@implementation VideoRecorderCommon + +@dynamic assetsBundle; + ++ (NSBundle *)assetsBundle { + NSBundle *modleBundle = [self modleNSBundle]; + if (!modleBundle) { + NSLog(@"error: can not find ChatUIKitBundle"); + return nil; + } + NSURL *videoRecorderURL = [modleBundle URLForResource:@"VideoRecorder" + withExtension:@"bundle"]; + + if (!videoRecorderURL) { + NSLog(@"error: can not find VideoRecorder.bundle"); + return nil; + } + + NSBundle *videoRecorderBundle = [NSBundle bundleWithURL:videoRecorderURL]; + if (!videoRecorderBundle) { + NSLog(@"error: can not load VideoRecorder.bundle"); + return nil; + } + + return videoRecorderBundle; +} + ++ (NSBundle *)stringBundle { + NSBundle *modleBundle = [self modleNSBundle]; + if (!modleBundle) { + NSLog(@"error: can not find ChatUIKitBundle"); + return nil; + } + + NSURL *videoRecorderLocalizableURL = [modleBundle URLForResource:@"VideoRecorderLocalizable" + withExtension:@"bundle"]; + + if (!videoRecorderLocalizableURL) { + NSLog(@"error: can not find VideoRecorderLocalizable.bundle"); + return nil; + } + + NSBundle *videoRecorderLocalizableBundle = [NSBundle bundleWithURL:videoRecorderLocalizableURL]; + if (!videoRecorderLocalizableBundle) { + NSLog(@"error: can not load VideoRecorderLocalizable.bundle"); + return nil; + } + + return videoRecorderLocalizableBundle; +} + ++ (NSBundle *)modleNSBundle { + NSBundle *mainBundle = [NSBundle bundleForClass:[self class]]; + NSURL *atomicBundleURL = [mainBundle URLForResource:@"ChatUIKitBundle" withExtension:@"bundle"]; + if (!atomicBundleURL) { + return nil; + } + NSBundle *atomicBundle = [NSBundle bundleWithURL:atomicBundleURL]; + return atomicBundle; +} + ++ (UIImage *)bundleImageByName:(NSString *)name { + if (self.modleNSBundle == nil) { + return nil; + } + return [UIImage imageNamed:name inBundle:self.modleNSBundle compatibleWithTraitCollection:nil]; +} + ++ (UIImage *)bundleRawImageByName:(NSString *)name { + if (self.assetsBundle == nil) { + return nil; + } + NSString *path = [NSString stringWithFormat:@"%@/%@", [self assetsBundle].resourcePath, name]; + return [UIImage imageWithContentsOfFile:path]; +} + ++ (NSString *)localizedStringForKey:(NSString *)key { + NSString *lang = [self getPreferredLanguage]; + NSString *path = [self.stringBundle pathForResource:lang ofType:@"lproj"]; + if (path == nil) { + path = [self.stringBundle pathForResource:@"en" ofType:@"lproj"]; + } + NSBundle *langBundle = [NSBundle bundleWithPath:path]; + NSString *value = [langBundle localizedStringForKey:key value:nil table:nil]; + return value ?: key; +} + ++ (UIColor *)colorFromHex:(NSString *)hex { + return [VideoRecorderCommon tui_colorWithHex:hex]; +} + ++ (NSArray *)sortedBundleResourcesIn:(NSString *)directory withExtension:(NSString *)ext { + if (VideoRecorderCommon.assetsBundle == nil) { + return nil; + } + + NSArray *res = [VideoRecorderCommon.assetsBundle pathsForResourcesOfType:ext inDirectory:directory]; + NSString *basePath = VideoRecorderCommon.assetsBundle.resourcePath; + res = [res video_recorder_map:^NSString *(NSString *path) { + return [path substringFromIndex:basePath.length + 1]; + }]; + return [res sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + return [a compare:b]; + }]; +} + ++ (NSURL *)getURLByResourcePath:(NSString *)path { + if (path == nil || VideoRecorderCommon.assetsBundle == nil) { + return nil; + } + + if (![path hasPrefix:BundleResourceUrlPrefix]) { + NSURL *url = [NSURL URLWithString:path]; + if (url.scheme == nil) { + return [NSURL fileURLWithPath:path]; + } + return url; + } + NSURL *bundleUrl = [VideoRecorderCommon.assetsBundle resourceURL]; + NSURL *url = [[NSURL alloc] initWithString:[path substringFromIndex:BundleResourceUrlPrefix.length] relativeToURL:bundleUrl]; + return url; +} + ++ (UIImage *__nullable)dynamicImage:(NSString *)imageKey defaultImageName:(NSString *)image { + if (VideoRecorderCommon.modleNSBundle == nil) { + return nil; + } + + + return [UIImage imageNamed:image inBundle:VideoRecorderCommon.modleNSBundle compatibleWithTraitCollection:nil]; +} + ++ (UIColor *)dynamicColor:(NSString *)colorKey defaultColor:(NSString *)hex { + return [VideoRecorderCommon tui_colorWithHex:hex]; +} + ++ (UIColor *)tui_colorWithHex:(NSString *)hex { + if ([hex isEqualToString:@""]) { + return [UIColor clearColor]; + } + + // Remove `#` and `0x` + if ([hex hasPrefix:@"#"]) { + hex = [hex substringFromIndex:1]; + } else if ([hex hasPrefix:@"0x"]) { + hex = [hex substringFromIndex:2]; + } + + // Invalid if not 3, 6, or 8 characters + NSUInteger length = [hex length]; + if (length != 3 && length != 6 && length != 8) { + return [[UIColor alloc] init]; + } + + // Make the string 8 characters long for easier parsing + if (length == 3) { + NSString *r = [hex substringWithRange:NSMakeRange(0, 1)]; + NSString *g = [hex substringWithRange:NSMakeRange(1, 1)]; + NSString *b = [hex substringWithRange:NSMakeRange(2, 1)]; + hex = [NSString stringWithFormat:@"%@%@%@%@%@%@ff", r, r, g, g, b, b]; + } else if (length == 6) { + hex = [hex stringByAppendingString:@"ff"]; + } + + CGFloat red = [[hex substringWithRange:NSMakeRange(0, 2)] _hexValue] / 255.0f; + CGFloat green = [[hex substringWithRange:NSMakeRange(2, 2)] _hexValue] / 255.0f; + CGFloat blue = [[hex substringWithRange:NSMakeRange(4, 2)] _hexValue] / 255.0f; + CGFloat alpha = [[hex substringWithRange:NSMakeRange(6, 2)] _hexValue] / 255.0f; + + return [UIColor colorWithRed:red green:green blue:blue alpha:alpha]; +} + ++ (NSString *)getPreferredLanguage { + NSString* gCustomLanguage = [NSUserDefaults.standardUserDefaults objectForKey:ChatEngineLanguageKey]; + if (gCustomLanguage != nil && gCustomLanguage.length > 0) { + return gCustomLanguage; + } + + // Follow system changes by default + NSString *language = [NSLocale preferredLanguages].firstObject; + if ([language hasPrefix:@"en"]) { + language = @"en"; + } else if ([language hasPrefix:@"zh"]) { + if ([language rangeOfString:@"Hans"].location != NSNotFound) { + // Simplified Chinese + language = @"zh-Hans"; + } else { + // Traditional Chinese + language = @"zh-Hant"; + } + } else if ([language hasPrefix:@"ar"]) { + language = @"ar"; + } + else { + language = @"en"; + } + + return language; +} + +@end diff --git a/chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.h b/chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.h new file mode 100644 index 000000000..daddfec38 --- /dev/null +++ b/chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.h @@ -0,0 +1,14 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderImageUtil : NSObject ++ (UIImage *)imageFromImage:(UIImage *)img withTintColor:(UIColor *)tintColor; ++ (UIImage *)createBlueCircleWithWhiteBorder:(CGSize)size withColor:(UIColor*) color; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.m b/chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.m new file mode 100644 index 000000000..518a40472 --- /dev/null +++ b/chat/uikit/components/videorecorder/Common/VideoRecorderImageUtil.m @@ -0,0 +1,68 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderImageUtil.h" + +@implementation VideoRecorderImageUtil + ++ (UIImage *)simpleImageFromImage:(UIImage *)img withTintColor:(UIColor *)tintColor { + UIGraphicsBeginImageContextWithOptions(img.size, NO, 0.0f); + [tintColor setFill]; + CGRect bounds = CGRectMake(0, 0, img.size.width, img.size.height); + UIRectFill(bounds); + + [img drawInRect:bounds blendMode:kCGBlendModeDestinationIn alpha:1.0f]; + UIImage *tintedImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return tintedImage; +} + ++ (UIImage *)imageFromImage:(UIImage *)img withTintColor:(UIColor *)tintColor { + UIImage *imgWithTintColor = [self simpleImageFromImage:img withTintColor:tintColor]; + if (img.imageAsset == nil) { + return imgWithTintColor; + } + UITraitCollection *const scaleTraitCollection = [UITraitCollection currentTraitCollection]; + UITraitCollection *const darkUnscaledTraitCollection = [UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark]; + UITraitCollection *const darkScaledTraitCollection = + [UITraitCollection traitCollectionWithTraitsFromCollections:@[ scaleTraitCollection, darkUnscaledTraitCollection ]]; + + UIImage *imageDark = [img.imageAsset imageWithTraitCollection:darkScaledTraitCollection]; + if (img != imageDark) { + [imgWithTintColor.imageAsset registerImage:[self simpleImageFromImage:imageDark withTintColor:tintColor] withTraitCollection:darkScaledTraitCollection]; + } + + return imgWithTintColor; +} + ++ (UIImage *)createBlueCircleWithWhiteBorder : (CGSize)size withColor:(UIColor*) color{ + UIGraphicsBeginImageContextWithOptions(size, NO, 0.0); + CGContextRef context = UIGraphicsGetCurrentContext(); + if (!context) return nil; + + + CGContextSetFillColorWithColor(context, [UIColor yellowColor].CGColor); + + + CGFloat centerX = size.width / 2; + CGFloat centerY = size.width / 2; + CGFloat radius = size.width / 2; + + CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor); + CGContextSetLineWidth(context, 0); + CGContextAddArc(context, centerX, centerY, radius, 0, 2 * M_PI, 0); + CGContextDrawPath(context, kCGPathFillStroke); + + + CGContextSetFillColorWithColor(context, color.CGColor); + CGContextSetLineWidth(context, 0); + CGContextAddArc(context, centerX, centerY, 5, 0, 2 * M_PI, 0); + CGContextDrawPath(context, kCGPathFillStroke); + + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return image; +} +@end diff --git a/chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.h b/chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.h new file mode 100644 index 000000000..4ad388a78 --- /dev/null +++ b/chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.h @@ -0,0 +1,13 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSArray <__covariant ObjectType>(Functional) + +- (NSArray *)video_recorder_map:(id (^)(ObjectType))f; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.m b/chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.m new file mode 100644 index 000000000..d85af2077 --- /dev/null +++ b/chat/uikit/components/videorecorder/Common/VideoRecorderNSArray+Functional.m @@ -0,0 +1,15 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderNSArray+Functional.h" + +@implementation NSArray (Functional) + +- (NSArray *)video_recorder_map:(id (^)(id))f { + NSMutableArray *array = [NSMutableArray arrayWithCapacity:self.count]; + for (id x in self) { + [array addObject:f(x)]; + } + return array; +} +@end diff --git a/chat/uikit/components/videorecorder/Core/ReflectUtil.h b/chat/uikit/components/videorecorder/Core/ReflectUtil.h new file mode 100644 index 000000000..7866fd1c6 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/ReflectUtil.h @@ -0,0 +1,11 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +NS_ASSUME_NONNULL_BEGIN + +@interface ReflectUtil : NSObject ++ (id)safeValueForProperty:(id) instance propertyName:(NSString *)propertyName; ++ (id)invokeMethod:(id) instance methodName:(NSString *)methodName withArguments:(NSArray *)arguments; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/ReflectUtil.m b/chat/uikit/components/videorecorder/Core/ReflectUtil.m new file mode 100644 index 000000000..647e1fef8 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/ReflectUtil.m @@ -0,0 +1,86 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import "ReflectUtil.h" + +@implementation ReflectUtil + ++ (id)invokeMethod:(id) instance methodName:(NSString *)methodName withArguments:(NSArray *)arguments { + NSLog(@"invokeMethod methodName:%@",methodName); + + SEL selector = NSSelectorFromString(methodName); + if (instance == nil || ![instance respondsToSelector:selector]) { + return nil; + } + + NSMethodSignature *signature = [instance methodSignatureForSelector:selector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:selector]; + [invocation setTarget:instance]; + + for (NSUInteger i = 0; i < arguments.count; i++) { + id arg = arguments[i]; + NSUInteger argIndex = i + 2; + const char *argType = [signature getArgumentTypeAtIndex:argIndex]; + if (strcmp(argType, @encode(id)) == 0) { + [invocation setArgument:&arg atIndex:argIndex]; + } else if (strcmp(argType, @encode(int)) == 0) { + int value = [arg intValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(NSInteger)) == 0) { + NSInteger value = [arg integerValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(BOOL)) == 0) { + BOOL value = [arg boolValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(float)) == 0) { + float value = [arg floatValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(double)) == 0) { + double value = [arg doubleValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(CGFloat)) == 0) { + CGFloat value = [arg doubleValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else { + [invocation setArgument:&arg atIndex:argIndex]; + } + } + [invocation invoke]; + + if (signature.methodReturnLength > 0) { + const char *returnType = signature.methodReturnType; + if (strcmp(returnType, "@") == 0) { + __unsafe_unretained id returnObj; + [invocation getReturnValue:&returnObj]; + return returnObj; + }else if (strcmp(returnType, @encode(int)) == 0) { + int value; + [invocation getReturnValue:&value]; + return @(value); + } + else if (strcmp(returnType, @encode(BOOL)) == 0) { + BOOL value; + [invocation getReturnValue:&value]; + return @(value); + } + } + return nil; +} + + ++ (id)safeValueForProperty:(id) instance propertyName:(NSString *)propertyName { + @try { + if ([instance respondsToSelector:NSSelectorFromString(propertyName)]) { + return [instance valueForKey:propertyName]; + } + return nil; + } @catch (NSException *exception) { + NSLog(@"Failed to securely retrieve attributes: %@", exception); + return nil; + } +} + +@end + + diff --git a/chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.h b/chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.h new file mode 100644 index 000000000..8ad230a5e --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.h @@ -0,0 +1,11 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecordCore.h" +NS_ASSUME_NONNULL_BEGIN + +@interface SystemVideoRecordCore : VideoRecordCore + +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.m b/chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.m new file mode 100644 index 000000000..a9239cfee --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/SystemVideoRecordCore.m @@ -0,0 +1,447 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import +#import "SystemVideoRecordCore.h" + +@interface SystemVideoRecordCore() { + AVCaptureSession *_captureSession; + AVCaptureDeviceInput *_videoInput; + AVCaptureDeviceInput *_audioInput; + AVCaptureStillImageOutput *_stillImageOutput; + AVCaptureMovieFileOutput *_movieFileOutput; + AVCaptureVideoPreviewLayer *_previewLayer; + AVCaptureDevice *_currentCamera; + NSInteger _recordTime; + NSTimer *_recordTimer; + NSURL *_outputFileURL; + + NSDictionary* _configDict; + + int _minDuration; + int _maxDuration; + int _videoBitrate; + int _videoFPS; + BOOL _isFrontCamera; + CGSize _videoResolution; +} +@end + +@implementation SystemVideoRecordCore + +- (instancetype)init { + return [super init]; +} + +- (int)startCameraCustom:(NSDictionary *)configDict preview:(UIView *)preview { + NSLog(@"start camera custom"); + if (_captureSession) { + NSLog(@"start camera already"); + return 0; + } + + _isFrontCamera = [configDict[@"frontCamera"] boolValue]; + _minDuration = [configDict[@"minDuration"] intValue] * 1000; + _maxDuration = [configDict[@"maxDuration"] intValue] * 1000; + _videoFPS = [configDict[@"videoFPS"] intValue]; + _videoBitrate = [configDict[@"videoBitratePIN"] intValue] * 1024; + int compress = [configDict[@"videoResolution"] intValue]; + _videoResolution = [self getResolutionFromCompress:(VideoRecordResolution)compress]; + + _captureSession = [[AVCaptureSession alloc] init]; + [_captureSession setSessionPreset:AVCaptureSessionPresetHigh]; + + [_captureSession beginConfiguration]; + if(![self setupVideoInput] || ![self setupAudioInput] || ![self setupVideoOutput] || ![self setupImageOutput]) { + [_captureSession commitConfiguration]; + NSLog(@"start camera setup fail"); + return -1; + } + [self setVideoResolution:_videoResolution]; + [self setVideoFrameRate:_videoFPS]; + [_captureSession commitConfiguration]; + + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + [strongSelf setupPreview:preview]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [strongSelf->_captureSession startRunning]; + }); + }); + return 0; +} + +- (BOOL)switchCamera:(BOOL)isFront { + NSLog(@"switch camera isFront:%b",isFront); + + if (isFront == _isFrontCamera) { + return TRUE; + } + _isFrontCamera = isFront; + + if (!_captureSession.isRunning) { + return FALSE; + } + + [_captureSession beginConfiguration]; + [_captureSession removeInput:_videoInput]; + _currentCamera = nil; + if (![self setupVideoInput]) { + NSLog(@"Failed to switch to %s camera, restore to %s camera",(isFront ? "front" : "back"), (!isFront ? "front" : "back")); + _isFrontCamera = !isFront; + [self setupVideoInput]; + } + [_captureSession commitConfiguration]; + //_currentCamera.videoZoomFactor = 1.0; + return YES; +} + +- (BOOL)toggleTorch:(BOOL)enable { + if ([_currentCamera hasTorch]) { + NSError *error; + if ([_currentCamera lockForConfiguration:&error]) { + _currentCamera.torchMode = enable ? AVCaptureTorchModeOn : AVCaptureTorchModeOff; + [_currentCamera unlockForConfiguration]; + return YES; + } + } + return NO; +} + +- (void)setZoom:(CGFloat)distance { + CGFloat maxZoom = _currentCamera.activeFormat.videoMaxZoomFactor; + CGFloat zoomFactor = MIN(MAX(distance, 1.0), maxZoom); + + NSError *error; + if ([_currentCamera lockForConfiguration:&error]) { + _currentCamera.videoZoomFactor = zoomFactor; + [_currentCamera unlockForConfiguration]; + } +} + +- (void)stopCameraPreview { + NSLog(@"stopCameraPreview"); + if ([_captureSession isRunning]) { + [_captureSession stopRunning]; + } + _captureSession = nil; + _currentCamera = nil; + _movieFileOutput = nil; + _stillImageOutput = nil; +} + +- (int)startRecord:(NSString*) path { + if (_movieFileOutput.isRecording) { + return -1; + } + + _outputFileURL = [NSURL fileURLWithPath:path]; + if ([[NSFileManager defaultManager] fileExistsAtPath:path]) { + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + } + + [self setVideoBitrate:_videoBitrate]; + [_movieFileOutput startRecordingToOutputFileURL:_outputFileURL recordingDelegate:self]; + + _recordTime = 0; + _recordTimer = [NSTimer scheduledTimerWithTimeInterval:0.1 + target:self + selector:@selector(updateRecordTime) + userInfo:nil + repeats:YES]; + return 0; +} + +- (int)stopRecord { + if (!_movieFileOutput.isRecording) { + return -1; + } + + [_movieFileOutput stopRecording]; + [_recordTimer invalidate]; + _recordTimer = nil; + + return 0; +} + +- (int)snapshot:(void (^)(UIImage *))snapshotCompletionBlock { + if (!_captureSession.isRunning) { + if (snapshotCompletionBlock) snapshotCompletionBlock(nil); + return -1; + } + + if (!_stillImageOutput) { + if (snapshotCompletionBlock) snapshotCompletionBlock(nil); + return -2; + } + + AVCaptureConnection *videoConnection = nil; + for (AVCaptureConnection *connection in _stillImageOutput.connections) { + for (AVCaptureInputPort *port in connection.inputPorts) { + if ([port.mediaType isEqual:AVMediaTypeVideo]) { + videoConnection = connection; + break; + } + } + if (videoConnection) break; + } + + if (!videoConnection) { + if (snapshotCompletionBlock) snapshotCompletionBlock(nil); + return -3; + } + + videoConnection.videoMirrored = _isFrontCamera; + [_stillImageOutput captureStillImageAsynchronouslyFromConnection:videoConnection + completionHandler:^(CMSampleBufferRef imageDataSampleBuffer, NSError *error) { + UIImage *image = nil; + + if (!error && imageDataSampleBuffer) { + NSData *imageData = [AVCaptureStillImageOutput jpegStillImageNSDataRepresentation:imageDataSampleBuffer]; + image = [UIImage imageWithData:imageData]; + } + + if (snapshotCompletionBlock) { + snapshotCompletionBlock(image); + } + }]; + + return 0; +} + +#pragma mark - Private Methods + +- (void)updateRecordTime { + _recordTime += 100; + if (self.recordDelegate != nil) { + [self.recordDelegate onRecordProgress:_recordTime]; + } + + if (_recordTime > _maxDuration) { + NSLog(@"Reaching maximum recording time"); + [self stopRecord]; + } +} + +#pragma mark - AVCaptureFileOutputRecordingDelegate + +- (void)captureOutput:(AVCaptureFileOutput *)output +didFinishRecordingToOutputFileAtURL:(NSURL *)outputFileURL + fromConnections:(NSArray *)connections + error:(NSError *)error { + [_recordTimer invalidate]; + _recordTimer = nil; + + VideoRecordResult* videoRecordResult = [[VideoRecordResult alloc] init]; + videoRecordResult.retCode = _recordTime > _maxDuration ? VIDEO_RECORD_RESULT_OK_BEYOND_MAXDURATION : VIDEO_RECORD_RESULT_OK; + videoRecordResult.videoPath = [outputFileURL path]; + + if (error) { + videoRecordResult.retCode = VIDEO_RECORD_RESULT_FAILED; + videoRecordResult.videoPath = nil; + } + + + if (self.recordDelegate != nil) { + [self.recordDelegate onRecordComplete:videoRecordResult]; + } +} + +- (BOOL) setupVideoInput { + if (!_captureSession) { + return FALSE; + } + + if (_currentCamera) { + return TRUE; + } + + _currentCamera = [self getCaptureDevice:_isFrontCamera]; + if (!_currentCamera) { + return FALSE; + } + + NSError *error; + _videoInput = [AVCaptureDeviceInput deviceInputWithDevice:_currentCamera error:&error]; + if (error || !_videoInput) { + return FALSE; + } + + if ([_captureSession canAddInput:_videoInput]) { + [_captureSession addInput:_videoInput]; + return TRUE; + } + + return FALSE; +} + +- (AVCaptureDevice *)getCaptureDevice:(BOOL) isFront { + AVCaptureDevicePosition position = isFront ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack; + + NSArray *deviceTypes = @[]; + if (@available(iOS 10.2, *)) { + deviceTypes = @[ + AVCaptureDeviceTypeBuiltInDualCamera, + AVCaptureDeviceTypeBuiltInWideAngleCamera, + AVCaptureDeviceTypeBuiltInTelephotoCamera + ]; + } else { + deviceTypes = @[AVCaptureDeviceTypeBuiltInWideAngleCamera]; + } + + AVCaptureDeviceDiscoverySession *session = [AVCaptureDeviceDiscoverySession + discoverySessionWithDeviceTypes:deviceTypes + mediaType:AVMediaTypeVideo + position:position]; + return session.devices.firstObject; +} + +- (BOOL) setupAudioInput { + if (!_captureSession) { + return FALSE; + } + + AVCaptureDevice *audioDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio]; + if (!audioDevice) { + [_captureSession commitConfiguration]; + return FALSE; + } + + NSError *error; + _audioInput = [AVCaptureDeviceInput deviceInputWithDevice:audioDevice error:&error]; + if (error || !_audioInput) { + return FALSE; + } + + if ([_captureSession canAddInput:_audioInput]) { + [_captureSession addInput:_audioInput]; + return TRUE; + } + + return FALSE; +} + +- (BOOL) setupVideoOutput { + if (_movieFileOutput) { + return TRUE; + } + + _movieFileOutput = [[AVCaptureMovieFileOutput alloc] init]; + if ([_captureSession canAddOutput:_movieFileOutput]) { + [_captureSession addOutput:_movieFileOutput]; + return TRUE; + } + + return FALSE; +} + +- (BOOL) setupImageOutput { + if (_stillImageOutput) { + return TRUE; + } + + _stillImageOutput = [[AVCaptureStillImageOutput alloc] init]; + NSDictionary *outputSettings = @{ + AVVideoCodecKey: AVVideoCodecJPEG, + AVVideoQualityKey: @(0.9) + }; + [_stillImageOutput setOutputSettings:outputSettings]; + + if ([_captureSession canAddOutput:_stillImageOutput]) { + [_captureSession addOutput:_stillImageOutput]; + return TRUE; + } + + return FALSE; +} + +- (void)setupPreview:(UIView *)preview { + _previewLayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession:_captureSession]; + _previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill; + _previewLayer.frame = preview.bounds; + [preview.layer addSublayer:_previewLayer]; + [preview.layer setMasksToBounds:YES]; +} + +- (void)setVideoResolution:(CGSize)resolution { + if (CGSizeEqualToSize(resolution, CGSizeMake(1920, 1080))) { + [_captureSession setSessionPreset:AVCaptureSessionPreset1920x1080]; + } else if (CGSizeEqualToSize(resolution, CGSizeMake(1280, 720))) { + [_captureSession setSessionPreset:AVCaptureSessionPreset1280x720]; + } else { + [_captureSession setSessionPreset:AVCaptureSessionPresetInputPriority]; + + AVCaptureDevice *camera = _currentCamera; + if ([camera lockForConfiguration:nil]) { + for (AVCaptureDeviceFormat *format in camera.formats) { + CMVideoDimensions dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription); + if (dims.width == resolution.width && dims.height == resolution.height) { + camera.activeFormat = format; + break; + } + } + [camera unlockForConfiguration]; + } + } +} + +- (void)setVideoBitrate:(NSInteger)bitrate { + AVCaptureConnection *videoConnection = [_movieFileOutput connectionWithMediaType:AVMediaTypeVideo]; + + NSDictionary *videoSettings = @{ + AVVideoCodecKey: AVVideoCodecH264, + AVVideoCompressionPropertiesKey: @{ + AVVideoAverageBitRateKey: @(bitrate), + //AVVideoProfileLevelKey: AVVideoProfileLevelH264BaselineAutoLevel, + //AVVideoH264EntropyModeKey: AVVideoH264EntropyModeCAVLC + } + }; + + if ([_movieFileOutput respondsToSelector:@selector(setOutputSettings:forConnection:)]) { + [_movieFileOutput setOutputSettings:videoSettings forConnection:videoConnection]; + } +} + +- (void)setVideoFrameRate:(NSInteger)frameRate { + NSError *error; + if (![_currentCamera lockForConfiguration:&error]) { + NSLog(@"lock for configuration fail.error: %@", error); + [_currentCamera unlockForConfiguration]; + return; + } + + AVFrameRateRange *desiredRange = nil; + for (AVFrameRateRange *range in _currentCamera.activeFormat.videoSupportedFrameRateRanges) { + if (range.minFrameRate <= frameRate && frameRate <= range.maxFrameRate) { + desiredRange = range; + break; + } + } + + if (desiredRange) { + _currentCamera.activeVideoMinFrameDuration = CMTimeMake(1, (int32_t)frameRate); + _currentCamera.activeVideoMaxFrameDuration = CMTimeMake(1, (int32_t)frameRate); + } else { + NSLog(@"do not surpport %ld fps", (long)frameRate); + } + [_currentCamera unlockForConfiguration]; +} + + +-(CGSize) getResolutionFromCompress:(VideoRecordResolution)compress { + switch (compress) { + case VIDEO_RESOLUTION_360_640: + return CGSizeMake(640, 360); + case VIDEO_RESOLUTION_540_960: + return CGSizeMake(960, 540); + case VIDEO_RESOLUTION_720_1280: + return CGSizeMake(1280, 720); + case VIDEO_RESOLUTION_1080_1920: + default: + return CGSizeMake(1920, 1080); + } +} +@end diff --git a/chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.h b/chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.h new file mode 100644 index 000000000..ed4be4ac2 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.h @@ -0,0 +1,11 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecordCore.h" +NS_ASSUME_NONNULL_BEGIN + +@interface UGCReflectVideoRecordCore : VideoRecordCore + +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.m b/chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.m new file mode 100644 index 000000000..7e419e4a6 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/UGCReflectVideoRecordCore.m @@ -0,0 +1,180 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import "UGCReflectVideoRecordCore.h" + +#include +#import "ReflectUtil.h" +#import "VideoRecorderBeautyManager.h" + +@interface UGCReflectVideoRecordCore () { + id _txUGCRecorderInstance; + VideodioRecorderListenerProxy *_listenerProxy; +} +@end + +@implementation UGCReflectVideoRecordCore + +- (instancetype)init { + if (self = [super init]) { + Class txClass = NSClassFromString(@"TXUGCRecord"); + if (txClass == nil) { + NSLog(@"can not find TXUGCRecord"); + return nil; + } + + SEL shareSelector = NSSelectorFromString(@"shareInstance"); + if ([txClass respondsToSelector:shareSelector]) { + NSMethodSignature *signature = [txClass methodSignatureForSelector:shareSelector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:shareSelector]; + [invocation setTarget:txClass]; + [invocation invoke]; + + __unsafe_unretained id temp = nil; + [invocation getReturnValue:&temp]; + _txUGCRecorderInstance = temp; + } + } + return _txUGCRecorderInstance ? self : nil; +} + +- (void)dealloc { + +} + +- (void) setRecordDelegate:(id)recordDelegate { + _listenerProxy = [[VideodioRecorderListenerProxy alloc] init]; + _listenerProxy.recordDelegate = recordDelegate; + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"setRecordDelegate:" withArguments:@[_listenerProxy]]; +} + +- (int)startCameraCustom:(NSDictionary *)configDict preview:(UIView *)preview { + Class configClass = NSClassFromString(@"TXUGCCustomConfig"); + id customConfig = [configClass new]; + [configDict enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { + if ([customConfig respondsToSelector:NSSelectorFromString(key)]) { + [customConfig setValue:value forKey:key]; + } + }]; + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"startCameraCustom:preview:" withArguments:@[customConfig, preview]]; + return 1; +} + +- (void)setZoom:(CGFloat)distance { + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"setZoom:" withArguments:@[@(distance)]]; +} + +- (BOOL)switchCamera:(BOOL)isFront { + id ret = [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"switchCamera:" withArguments:@[@(isFront)]]; + return (ret == nil) ? NO : [ret boolValue]; +} + +- (BOOL)toggleTorch:(BOOL)enable { + NSLog(@"toggleTorch @%d", enable); + id ret = [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"toggleTorch:" withArguments:@[@(enable)]]; + return (ret == nil) ? NO : [ret boolValue]; +} + +- (void)stopCameraPreview { + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"stopCameraPreview" withArguments:@[]]; +} + +- (void)setAspectRatio:(VideoRecordAspectRatio)videoRatio { + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"setAspectRatio:" withArguments:@[@(videoRatio)]]; +} + +- (int)startRecord:(NSString*) path { + if (path == nil) { + return -1; + } + + NSString *withoutExtension = [path stringByDeletingPathExtension]; + NSString* coverPath = [withoutExtension stringByAppendingPathExtension:@"jpg"]; + + id ret = [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"startRecord:coverPath:" withArguments:@[path,coverPath]]; + return (ret == nil) ? -1 : [ret intValue]; +} + +- (int)stopRecord { + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"stopRecord" withArguments:@[]]; + return 1; +} + +- (VideoRecorderBeautyManager *)getBeautyManager { + id ugcBeauty = [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"getBeautyManager" withArguments:@[]]; + VideoRecorderBeautyManager* beautyManager = [[VideoRecorderBeautyManager alloc] initWithUGCBeauty:ugcBeauty]; + return beautyManager; +} + +- (void)setFilter:(UIImage *)leftFilter + leftIntensity:(CGFloat)leftIntensity + rightFilter:(UIImage *)rightFilter + rightIntensity:(CGFloat)rightIntensity + leftRatio:(CGFloat)leftRatio { + [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"setFilter:leftIntensity:rightFilter:rightIntensity:leftRatio:" + withArguments:@[@(leftIntensity), rightFilter, @(rightIntensity),@(leftRatio)]]; + return; +} + +- (int)snapshot:(void (^)(UIImage *))snapshotCompletionBlock { + id ret = [ReflectUtil invokeMethod:_txUGCRecorderInstance methodName:@"snapshot:" withArguments:@[snapshotCompletionBlock]]; + return (ret == nil) ? -1 : [ret intValue]; +} + +-(void) deleteTempFile { + id partManager = [ReflectUtil safeValueForProperty:_txUGCRecorderInstance propertyName:@"partsManager"]; + [ReflectUtil invokeMethod:partManager methodName:@"deleteAllParts" withArguments:@[]]; +} +@end + +@implementation VideodioRecorderListenerProxy + +- (void)onRecordProgress:(NSInteger)milliSecond { + NSLog(@"onRecordProgress:%d", milliSecond); + if (_recordDelegate) { + [_recordDelegate onRecordProgress:milliSecond]; + } +} + +- (void)onRecordComplete:(id)result { + if (_recordDelegate == nil) { + return; + } + + NSInteger retCode = -1; + NSString *descMsg = @""; + NSString *videoPath = @""; + + if ([result respondsToSelector:@selector(valueForKey:)]) { + id retCodeValue = [result valueForKey:@"retCode"]; + if ([retCodeValue isKindOfClass:[NSNumber class]]) { + retCode = [(NSNumber *)retCodeValue integerValue]; + } + + id descMsgValue = [result valueForKey:@"descMsg"]; + if ([descMsgValue isKindOfClass:[NSString class]]) { + descMsg = (NSString *)descMsgValue; + } + + id videoPathValue = [result valueForKey:@"videoPath"]; + if ([videoPathValue isKindOfClass:[NSString class]]) { + videoPath = (NSString *)videoPathValue; + } + } + + NSLog(@"on record complete. retcode:%ld descMsg:%@ videoPath:%@", (long)retCode, descMsg, videoPath); + + VideoRecordResult* videoRecordResult = [[VideoRecordResult alloc] init]; + videoRecordResult.retCode = (VideoRecordResultCode)retCode; + videoRecordResult.videoPath = videoPath; + videoRecordResult.descMsg = descMsg; + + [_recordDelegate onRecordComplete:videoRecordResult]; +} + +- (void)onRecordEvent:(id)evt { + +} + +@end diff --git a/chat/uikit/components/videorecorder/Core/VideoRecordCore.h b/chat/uikit/components/videorecorder/Core/VideoRecordCore.h new file mode 100644 index 000000000..47c78bef9 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecordCore.h @@ -0,0 +1,60 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import +#import +#import "VideoRecorderCommon.h" +#import "VideoRecorderTypeDef.h" + +@class VideoRecorderBeautyManager; + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecordCore : NSObject + +@property(nonatomic, weak) id recordDelegate; + +- (instancetype)init; + +- (int)startCameraCustom:(NSDictionary *)configDict preview:(UIView *)preview; + +- (void)setZoom:(CGFloat)distance; + +- (BOOL)switchCamera:(BOOL)isFront; + +- (BOOL)toggleTorch:(BOOL)enable; + +- (void)stopCameraPreview; + +- (void)setAspectRatio:(VideoRecordAspectRatio)videoRatio; + +- (int)startRecord:(NSString*) path; + +- (int)stopRecord; + +- (VideoRecorderBeautyManager *)getBeautyManager; + +-(void) deleteTempFile; + +- (void)setFilter:(UIImage *)leftFilter + leftIntensity:(CGFloat)leftIntensity + rightFilter:(UIImage *)rightFilter + rightIntensity:(CGFloat)rightIntensity + leftRatio:(CGFloat)leftRatio; + +- (int)snapshot:(void (^)(UIImage *))snapshotCompletionBlock; + +@end + + +@interface VideodioRecorderListenerProxy : NSObject + +@property(nonatomic, weak) id recordDelegate; +- (void)onRecordProgress:(NSInteger)milliSecond; +- (void)onRecordComplete:(id)result; +- (void)onRecordEvent:(id)evt; +@end + + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/VideoRecordCore.m b/chat/uikit/components/videorecorder/Core/VideoRecordCore.m new file mode 100644 index 000000000..38dc0553f --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecordCore.m @@ -0,0 +1,106 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecordCore.h" +#include +#import "VideoRecorderBeautyManager.h" + + +@implementation VideoRecordCustomConfig +- (instancetype)init { + self = [super init]; + return self; +} + +@end + +@interface VideoRecordCore () { + id _instance; + VideodioRecorderListenerProxy *_listenerProxy; +} + + +@end + +@implementation VideoRecordCore + + +- (instancetype)init { + return [super init]; +} + +- (void)dealloc { + +} + +- (void) setRecordDelegate:(id)recordDelegate { + _recordDelegate = recordDelegate; +} + +- (int)startCameraCustom:(NSDictionary *)configDict preview:(UIView *)preview { + NSLog(@"recorder currently not supported start Camera Custom"); + return 1; +} + +- (void)stopCameraPreview { + NSLog(@"recorder currently not supported stop camera preview"); + return; +} + +- (void)setZoom:(CGFloat)distance { + NSLog(@"recorder currently not supported set zoom"); +} + +- (BOOL)switchCamera:(BOOL)isFront { + NSLog(@"recorder currently not supported switch camera"); + return false; +} + +- (BOOL)toggleTorch:(BOOL)enable { + NSLog(@"recorder currently not supported toggle torch"); + return false; +} + +- (void)setAspectRatio:(VideoRecordAspectRatio)videoRatio { + NSLog(@"recorder currently not supported set aspect ratio"); +} + +- (int)startRecord:(NSString*) path { + NSLog(@"recorder currently not supported start record"); + return 1; +} + +- (int)stopRecord { + NSLog(@"recorder currently not supported stop record"); + return 1; +} + +- (VideoRecorderBeautyManager *)getBeautyManager { + NSLog(@"recorder currently not supported get beauty manager"); + return nil; +} + +-(void) deleteTempFile { + NSLog(@"recorder currently not supported delete temp file"); + return; +} + +- (void)setFilter:(UIImage *)leftFilter + leftIntensity:(CGFloat)leftIntensity + rightFilter:(UIImage *)rightFilter + rightIntensity:(CGFloat)rightIntensity + leftRatio:(CGFloat)leftRatio { + NSLog(@"recorder currently not supported delete set filter"); + return; +} + +- (int)snapshot:(void (^)(UIImage *))snapshotCompletionBlock { + NSLog(@"recorder currently not supported delete snapshot"); + return 1; +} +@end + + +@implementation VideoRecordResult + +@end diff --git a/chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.h b/chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.h new file mode 100644 index 000000000..2f4d2abd8 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.h @@ -0,0 +1,22 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, VideoRecordSignatureResultCode) { + VIDEO_RECORD_SIGNATURE_SUCCESS = 0, + VIDEO_RECORD_SIGNATURE_ERROR_NO_LITEAV_SDK = -7, + VIDEO_RECORD_SIGNATURE_ERROR_NO_IM_SDK = -8, + VIDEO_RECORD_SIGNATURE_ERROR_APP_ID_EMPTY = -9, + VIDEO_RECORD_SIGNATURE_ERROR_NO_SIGNATURE = -10 +}; + +@interface VideoRecordSignatureChecker : NSObject ++ (instancetype)shareInstance; +- (void)startUpdateSignature:(NSString*)sdkAppId; +- (VideoRecordSignatureResultCode)getSetSignatureResult; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.m b/chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.m new file mode 100644 index 000000000..25a0d3371 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecordSignatureChecker.m @@ -0,0 +1,204 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecordSignatureChecker.h" +#import "VideoRecorderReflectUtil.h" +#import + +#define SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_START 0.f +#define SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_SUCCESS 3600 * 1000.0f +#define SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_FAIL 1000.0f +#define SCHEDULE_UPDATE_SIGNATURE_RETRY_TIMES 3 +#define ERR_SDK_INTERFACE_NOT_SUPPORT 7013 +#define ERR_SDK_NOT_INITIALIZED 6013 + +@interface VideoRecordSignatureChecker () { + NSTimer* _timer; + NSInvocation *getSignatureInvocation; + + NSString* _signature; + NSInteger _expiredTime; + NSString* _sdkAppId; + VideoRecordSignatureResultCode _resultCode; + + void (^_succ)(NSObject *result); + void (^_fail)(int code, NSString *desc); +} +@property (nonatomic, assign) NSInteger retryCount; +@end + +@implementation VideoRecordSignatureChecker + ++ (instancetype)shareInstance { + static id instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _retryCount = 0; + _expiredTime = 0; + _resultCode = VIDEO_RECORD_SIGNATURE_ERROR_NO_SIGNATURE; + + } + return self; +} + +- (void)startUpdateSignature :(NSString*)sdkAppId{ + NSLog(@"VideoRecorderSignatureChecker startUpdateSignature"); + if (![self createGetSignatureInvocation]) { + _resultCode = VIDEO_RECORD_SIGNATURE_ERROR_NO_IM_SDK; + return; + } + _sdkAppId = sdkAppId; + [self scheduleUpdateSignature:SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_START]; +} + +- (Boolean)setSignatureToSDK{ + NSLog(@"setSignatureToSDK %@", _sdkAppId); + if (_signature == nil || [_signature length] == 0) { + return NO; + } + + if (_sdkAppId == nil || [_sdkAppId length] == 0) { + NSLog(@"sdk appid is empty"); + _resultCode = VIDEO_RECORD_SIGNATURE_ERROR_APP_ID_EMPTY; + return NO; + } + + NSDictionary *param = @{ + @"api" : @"setSignature", + @"params" : @{ + @"appid" : _sdkAppId, + @"signature" : _signature, + }, + }; + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:param options:0 error:&error]; + if (error != nil) { + NSLog(@"VideoRecorderSignatureChecker GetMultimediaIsSupport Error:%@", error); + } + NSString *paramStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSLog(@"VideoRecorderSignatureChecker: setSignature:%@", paramStr); + + if (![self setSignatureToUGCSdk:paramStr]) { + NSLog(@"callExperimentalAPI: method is not available."); + _resultCode = VIDEO_RECORD_SIGNATURE_ERROR_NO_LITEAV_SDK; + return false; + } + + _resultCode = VIDEO_RECORD_SIGNATURE_SUCCESS; + NSLog(@"VideoRecorderSignatureChecker: set signature to sdk success"); + return true; +} + +- (VideoRecordSignatureResultCode)getSetSignatureResult { + return _resultCode; +} + +- (void)updateSignatureIfNeed { + if ([NSDate.now timeIntervalSince1970] < _expiredTime && _resultCode == VIDEO_RECORD_SIGNATURE_SUCCESS) { + [self scheduleUpdateSignature:(_expiredTime - [NSDate.now timeIntervalSince1970]) *1000]; + return; + } + _resultCode = VIDEO_RECORD_SIGNATURE_ERROR_NO_SIGNATURE; + + if (getSignatureInvocation == nil) { + NSLog(@"getSignatureInvocation is empty"); + _resultCode = VIDEO_RECORD_SIGNATURE_ERROR_NO_IM_SDK; + return; + } + + [getSignatureInvocation invoke]; +} + +- (void)onGetSignatureSucc:(NSObject *) result { + if (result == nil || ![result isKindOfClass:NSDictionary.class]) { + NSLog(@"getVideoEditSignature: data = nil"); + return; + } + NSDictionary *data = (NSDictionary *)result; + NSLog(@"getVideoEditSignature: data = %@", data); + NSNumber *expiredTime = data[@"expired_time"]; + NSString *signature = data[@"signature"]; + if (![expiredTime isKindOfClass:NSNumber.class]) { + NSLog(@"getVideoEditSignature: expiredTime type error"); + return; + } + if (![signature isKindOfClass:NSString.class]) { + NSLog(@"getVideoEditSignature: signature type error"); + return; + } + + self.retryCount = 0; + self->_expiredTime = [expiredTime integerValue]; + self->_signature = signature; + + [self setSignatureToSDK]; + + NSLog(@"getVideoEditSignature: succeed. signature=%@, expiredTime=%@", self->_signature, @(self->_expiredTime)); + [self scheduleUpdateSignature:([expiredTime integerValue] - [NSDate.now timeIntervalSince1970]) * 1000]; +} + +- (void) onGetSignatureFail:(int) code desc:(NSString*) desc { + NSLog(@"getVideoEditSignature: failed. code=%@, desc=%@", @(code), desc); + if (code == ERR_SDK_INTERFACE_NOT_SUPPORT) { + [self cancelTimer]; + return; + } + if (code == ERR_SDK_NOT_INITIALIZED) { + self.retryCount = 0; + } + + if (self.retryCount ++ > SCHEDULE_UPDATE_SIGNATURE_RETRY_TIMES) { + self.retryCount = 0; + return; + } else { + NSLog(@"getVideoEditSignature: Attempting to get signature for the %ld time",self.retryCount); + [self scheduleUpdateSignature:SCHEDULE_UPDATE_SIGNATURE_INTERVAL_WHEN_FAIL]; + } +} + +-(void)scheduleUpdateSignature:(float)interval { + [self cancelTimer]; + interval = interval / 1000; + _timer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(updateSignatureIfNeed) userInfo:nil repeats:NO]; +} + +- (void)cancelTimer { + if (_timer != nil) { + [_timer invalidate]; + _timer = nil; + } +} + +- (Boolean) createGetSignatureInvocation { + id V2TIMManagerInstance = [VideoRecorderReflectUtil invokeStaticMethod:@"V2TIMManager" methodName:@"sharedInstance" withArguments:@[]]; + + if (!V2TIMManagerInstance) { + NSLog(@"VideoRecorderSignatureChecker can not get V2TIMManager instance"); + return NO; + } + + NSString* param = @"signature"; + id _succ = ^(NSObject *result) { + [self onGetSignatureSucc:result]; + }; + id _fail = ^(int code, NSString *desc) { + [self onGetSignatureFail:code desc:desc]; + }; + [VideoRecorderReflectUtil invokeMethod:V2TIMManagerInstance methodName:@"callExperimentalAPI:param:succ:fail:" withArguments:@[@"getVideoEditSignature", param, _succ, _fail]]; + return YES; +} + +- (Boolean) setSignatureToUGCSdk:(NSString *)param { + id result = [VideoRecorderReflectUtil invokeStaticMethod:@"TXUGCBase" methodName:@"callExperimentalAPI:" withArguments:@[param]]; + return result != nil; +} + +@end diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.h b/chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.h new file mode 100644 index 000000000..6c6243f6d --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.h @@ -0,0 +1,33 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, TXBeautyStyle) { + TXBeautyStyleSmooth = 0, + TXBeautyStyleNature = 1, + TXBeautyStylePitu = 2 +}; + +@interface VideoRecorderBeautyManager : NSObject + +- (instancetype) initWithUGCBeauty:(id) ugcBeauty; + +- (void)setBeautyStyle:(TXBeautyStyle)beautyStyle; + +- (void)setBeautyLevel:(float)beautyLevel; + +- (void)setWhitenessLevel:(float)whitenessLevel; + +- (void)setRuddyLevel:(float)ruddyLevel; + +- (void)setFilter:(nullable UIImage *)image; + +- (void)setFilterStrength:(float)strength; + +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.m b/chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.m new file mode 100644 index 000000000..389ea530e --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderBeautyManager.m @@ -0,0 +1,50 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderBeautyManager.h" + +#import "ReflectUtil.h" + +@interface VideoRecorderBeautyManager () { + id _txUGCBeautyManagerInstance; +} +@end + +@implementation VideoRecorderBeautyManager + +- (instancetype) initWithUGCBeauty:(id) ugcBeauty { + self = [super init]; + if (self) { + _txUGCBeautyManagerInstance = ugcBeauty; + } + return self; +} + +- (void)setBeautyStyle:(TXBeautyStyle)beautyStyle { + [ReflectUtil invokeMethod:_txUGCBeautyManagerInstance methodName:@"setBeautyStyle:" withArguments:@[@(beautyStyle)]]; +} + +- (void)setBeautyLevel:(float)beautyLevel { + [ReflectUtil invokeMethod:_txUGCBeautyManagerInstance methodName:@"setBeautyLevel:" withArguments:@[@(beautyLevel)]]; +} + +- (void)setWhitenessLevel:(float)whitenessLevel { + [ReflectUtil invokeMethod:_txUGCBeautyManagerInstance methodName:@"setWhitenessLevel:" withArguments:@[@(whitenessLevel)]]; +} + +- (void)setRuddyLevel:(float)ruddyLevel { + [ReflectUtil invokeMethod:_txUGCBeautyManagerInstance methodName:@"setRuddyLevel:" withArguments:@[@(ruddyLevel)]]; +} + +- (void)setFilter:(nullable UIImage *)image { + if (image == nil) { + return; + } + [ReflectUtil invokeMethod:_txUGCBeautyManagerInstance methodName:@"setFilter:" withArguments:@[image]]; +} + +- (void)setFilterStrength:(float)strength { + [ReflectUtil invokeMethod:_txUGCBeautyManagerInstance methodName:@"setFilterStrength:" withArguments:@[@(strength)]]; +} + +@end diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.h b/chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.h new file mode 100644 index 000000000..f031fd22c --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.h @@ -0,0 +1,24 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Created by eddardliu on 2024/10/21. + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderConfigInternal : NSObject ++ (instancetype)sharedInstance; +- (void)setCustomConfig:(NSString*)jsonString; +- (BOOL)isSupportRecordBeauty; +- (BOOL)isSupportRecordAspect; +- (BOOL)isSupportRecordTorch; +- (BOOL)isSupportRecordScrollFilter; + +- (UIColor*)getThemeColor; +- (int)getMaxRecordDurationMs; +- (int)getMinRecordDurationMs; +- (int)getVideoQuality; +- (int)getRecordeMode; +- (BOOL)isDefaultFrontCamera; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.m b/chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.m new file mode 100644 index 000000000..9de769c20 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderConfigInternal.m @@ -0,0 +1,142 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Created by eddardliu on 2024/10/21. + + +#import "VideoRecorderConfigInternal.h" +#import "VideoRecorderCommon.h" + +#define DEFAULT_CONFIG_FILE @"video_recorder_config" + +@interface VideoRecorderConfigInternal() { + NSDictionary * _jsonDicFromConfigFile; + NSDictionary * _jsonDicFromSetting; +} +@end + +@implementation VideoRecorderConfigInternal + ++ (instancetype)sharedInstance { + static VideoRecorderConfigInternal *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + +- (instancetype)init{ + self = [super init]; + NSBundle* modleNSBundle = VideoRecorderCommon.modleNSBundle; + if (modleNSBundle == nil) { + return self; + } + + NSData *jsonData = [NSData dataWithContentsOfFile:[modleNSBundle pathForResource:DEFAULT_CONFIG_FILE ofType:@"json"]]; + if (jsonData == nil) { + return self; + } + NSLog(@"jsonData : %@" , jsonData); + + NSError *err = nil; + _jsonDicFromConfigFile = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:&err]; + if (err || ![_jsonDicFromConfigFile isKindOfClass:[NSDictionary class]]) { + NSLog(@"[VideoRecorder] Json parse failed: %@", err); + _jsonDicFromConfigFile = nil; + } + return self; +} + +- (void)setCustomConfig:(NSString*)jsonString { + if (jsonString == nil) { + return; + } + + NSError *err = nil; + NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + if (jsonData) { + _jsonDicFromSetting = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers error:&err]; + if (err || ![_jsonDicFromSetting isKindOfClass:[NSDictionary class]]) { + NSLog(@"[VideoRecorderConfig setConfig] Json parse failed: %@", err); + _jsonDicFromSetting = nil; + } + } else { + NSLog(@"Error converting string to data"); + } +} + +- (BOOL)isSupportRecordBeauty { + return [self getBoolFromDic:@"support_record_beauty" defaultValue:YES]; +} + +- (BOOL)isSupportRecordAspect { + return [self getBoolFromDic:@"support_record_aspect" defaultValue:YES]; +} + +- (BOOL)isSupportRecordTorch { + return [self getBoolFromDic:@"support_record_torch" defaultValue:YES]; +} + +- (BOOL)isSupportRecordScrollFilter { + return [self getBoolFromDic:@"support_record_scroll_filter" defaultValue:YES]; +} + +- (UIColor *)getThemeColor { + NSString* colorName = [self getStringFromDic:@"primary_theme_color" defaultValue:@"#147AFF"]; + return [VideoRecorderCommon tui_colorWithHex:colorName]; +} + +- (int)getVideoQuality { + return [self getIntFromDic:@"video_quality" defaultValue:2]; +} + +- (BOOL)isDefaultFrontCamera { + return [self getBoolFromDic:@"is_default_front_camera" defaultValue:false]; +} + +- (int)getMaxRecordDurationMs { + return [self getIntFromDic:@"max_record_duration_ms" defaultValue:15000]; +} + +- (int)getMinRecordDurationMs { + return [self getIntFromDic:@"min_record_duration_ms" defaultValue:2000]; +} + +// record mode. 0:mixed, 1:onlyPhoto 2:onlyVideo +- (int) getRecordeMode { + return [self getIntFromDic:@"record_mode" defaultValue:0]; +} + +-(BOOL) getBoolFromDic:(NSString*) dicKey defaultValue:(BOOL) defaultValue{ + if (_jsonDicFromSetting != nil && [_jsonDicFromSetting valueForKey:dicKey]) { + return [_jsonDicFromSetting[dicKey] caseInsensitiveCompare:@"true"] == NSOrderedSame; + } + + if (_jsonDicFromConfigFile != nil && [_jsonDicFromConfigFile valueForKey:dicKey]) { + return [_jsonDicFromConfigFile[dicKey] caseInsensitiveCompare:@"true"] == NSOrderedSame; + } + return defaultValue; +} + +-(int) getIntFromDic:(NSString*) dicKey defaultValue:(int) defaultValue{ + if (_jsonDicFromSetting != nil && [_jsonDicFromSetting valueForKey:dicKey] != nil) { + return [(NSNumber *)_jsonDicFromSetting[dicKey] intValue]; + } + + if (_jsonDicFromConfigFile != nil && [_jsonDicFromConfigFile valueForKey:dicKey] != nil) { + return [(NSNumber *)_jsonDicFromConfigFile[dicKey] intValue]; + } + return defaultValue; +} + +-(NSString*) getStringFromDic:(NSString*) dicKey defaultValue:(NSString*) defaultValue{ + if (_jsonDicFromSetting != nil && [_jsonDicFromSetting valueForKey:dicKey] != nil) { + return _jsonDicFromSetting[dicKey]; + } + + if (_jsonDicFromConfigFile != nil && [_jsonDicFromConfigFile valueForKey:dicKey] != nil) { + return _jsonDicFromConfigFile[dicKey]; + } + return defaultValue; +} + +@end diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.h b/chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.h new file mode 100644 index 000000000..ad15149ea --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.h @@ -0,0 +1,18 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import "VideoRecordCore.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderEncodeConfig : NSObject +@property(nonatomic) int fps; +@property(nonatomic) int bitrate; + +- (instancetype)initWithVideoQuality:(int)videoQuality; +- (VideoRecordCompressed)getVideoEditCompressed; +- (VideoRecordResolution)getVideoRecordResolution; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.m b/chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.m new file mode 100644 index 000000000..6e026f976 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderEncodeConfig.m @@ -0,0 +1,61 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderEncodeConfig.h" + +#define VideoRecorder_VideoQuality_Low 1 +#define VideoRecorder_VideoQuality_Medium 2 +#define VideoRecorder_VideoQuality_High 3 + +@interface VideoRecorderEncodeConfig () { + int _videoQuality; +} +@end + +@implementation VideoRecorderEncodeConfig +- (instancetype)init { + return [self initWithVideoQuality:VideoRecorder_VideoQuality_Medium]; +} + +- (instancetype)initWithVideoQuality:(int)videoQuality { + switch (videoQuality) { + case VideoRecorder_VideoQuality_Low: + _bitrate = 1000; + _fps = 25; + break; + case VideoRecorder_VideoQuality_High: + _bitrate = 5000; + _fps = 30; + break; + case VideoRecorder_VideoQuality_Medium: + default: + _bitrate = 3000; + _fps = 25; + break; + } + _videoQuality = videoQuality; + return self; +} + +- (VideoRecordCompressed)getVideoEditCompressed { + switch (_videoQuality) { + case VideoRecorder_VideoQuality_High: + return VIDEO_COMPRESSED_1080P; + case VideoRecorder_VideoQuality_Low: + case VideoRecorder_VideoQuality_Medium: + default: + return VIDEO_COMPRESSED_720P; + } +} + +- (VideoRecordResolution)getVideoRecordResolution { + switch (_videoQuality) { + case VideoRecorder_VideoQuality_High: + return VIDEO_RESOLUTION_1080_1920; + case VideoRecorder_VideoQuality_Low: + case VideoRecorder_VideoQuality_Medium: + default: + return VIDEO_RESOLUTION_720_1280; + } +} +@end diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.h b/chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.h new file mode 100644 index 000000000..5b182e8db --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.h @@ -0,0 +1,14 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderReflectUtil : NSObject ++ (id) createClassInstance:(NSString *)className; ++ (id) invokeStaticMethod:(NSString *)className methodName:(NSString *)methodName + withArguments:(NSArray *)arguments; ++ (id) safeValueForProperty:(id) instance propertyName:(NSString *)propertyName; ++ (id) invokeMethod:(id) instance methodName:(NSString *)methodName withArguments:(NSArray *)arguments; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.m b/chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.m new file mode 100644 index 000000000..43ba43a62 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderReflectUtil.m @@ -0,0 +1,129 @@ +// Copyright (c) 2021 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderReflectUtil.h" + +@implementation VideoRecorderReflectUtil + ++ (id) createClassInstance:(NSString *)className { + Class myClass = NSClassFromString(className); + if (myClass) { + return [[myClass alloc] init]; + } else { + NSLog(@"Class %@ not found", className); + return nil; + } +} + ++ (id) invokeStaticMethod:(NSString *)className methodName:(NSString *)methodName + withArguments:(NSArray *)arguments { + Class reflectClass = NSClassFromString(className); + if (reflectClass == nil) { + NSLog(@"can not find class:%@", className); + return nil; + } + + SEL shareSelector = NSSelectorFromString(methodName); + if (shareSelector == nil || ![reflectClass respondsToSelector:shareSelector]) { + NSLog(@"can not find method:%@", methodName); + return nil; + } + + NSMethodSignature *signature = [reflectClass methodSignatureForSelector:shareSelector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:shareSelector]; + [invocation setTarget:reflectClass]; + [VideoRecorderReflectUtil setInvocationArgument:invocation signature:signature withArguments:arguments]; + [invocation invoke]; + return [VideoRecorderReflectUtil getInvocationResult:invocation signature:signature]; +} + ++ (id)invokeMethod:(id) instance methodName:(NSString *)methodName withArguments:(NSArray *)arguments { + NSLog(@"invokeMethod methodName:%@",methodName); + + SEL selector = NSSelectorFromString(methodName); + if (instance == nil || ![instance respondsToSelector:selector]) { + NSLog(@"instance is nil or Method:%@ is not exist",methodName); + return nil; + } + + NSMethodSignature *signature = [instance methodSignatureForSelector:selector]; + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + [invocation setSelector:selector]; + [invocation setTarget:instance]; + [VideoRecorderReflectUtil setInvocationArgument:invocation signature:signature withArguments:arguments]; + [invocation invoke]; + return [VideoRecorderReflectUtil getInvocationResult:invocation signature:signature]; +} + + ++ (id)safeValueForProperty:(id) instance propertyName:(NSString *)propertyName { + @try { + if ([instance respondsToSelector:NSSelectorFromString(propertyName)]) { + return [instance valueForKey:propertyName]; + } + return nil; + } @catch (NSException *exception) { + NSLog(@"Failed to securely retrieve attributes: %@", exception); + return nil; + } +} + ++ (void)setInvocationArgument:(NSInvocation *)invocation signature:(NSMethodSignature *)signature withArguments:(NSArray *)arguments { + for (NSUInteger i = 0; i < arguments.count; i++) { + id arg = arguments[i]; + NSUInteger argIndex = i + 2; + const char *argType = [signature getArgumentTypeAtIndex:argIndex]; + if (strcmp(argType, @encode(id)) == 0) { + [invocation setArgument:&arg atIndex:argIndex]; + } else if (strcmp(argType, @encode(int)) == 0) { + int value = [arg intValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(NSInteger)) == 0) { + NSInteger value = [arg integerValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(BOOL)) == 0) { + BOOL value = [arg boolValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(float)) == 0) { + float value = [arg floatValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(double)) == 0) { + double value = [arg doubleValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(CGFloat)) == 0) { + CGFloat value = [arg doubleValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else if (strcmp(argType, @encode(CGRect)) == 0) { + CGRect value = [arg CGRectValue]; + [invocation setArgument:&value atIndex:argIndex]; + } else { + [invocation setArgument:&arg atIndex:argIndex]; + } + } +} + ++ (id)getInvocationResult:(NSInvocation *)invocation signature:(NSMethodSignature *)signature { + if (signature.methodReturnLength > 0) { + const char *returnType = signature.methodReturnType; + if (strcmp(returnType, "@") == 0) { + __unsafe_unretained id returnObj; + [invocation getReturnValue:&returnObj]; + return returnObj; + }else if (strcmp(returnType, @encode(int)) == 0) { + int value; + [invocation getReturnValue:&value]; + return @(value); + } + else if (strcmp(returnType, @encode(BOOL)) == 0) { + BOOL value; + [invocation getReturnValue:&value]; + return @(value); + } + } + return nil; +} + +@end + + diff --git a/chat/uikit/components/videorecorder/Core/VideoRecorderTypeDef.h b/chat/uikit/components/videorecorder/Core/VideoRecorderTypeDef.h new file mode 100644 index 000000000..610536e15 --- /dev/null +++ b/chat/uikit/components/videorecorder/Core/VideoRecorderTypeDef.h @@ -0,0 +1,68 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +typedef NS_ENUM(NSInteger, VideoRecordCompressed) { + VIDEO_COMPRESSED_360P = 0, + VIDEO_COMPRESSED_480P = 1, + VIDEO_COMPRESSED_540P = 2, + VIDEO_COMPRESSED_720P = 3, + VIDEO_COMPRESSED_1080P = 4, +}; + +typedef NS_ENUM(NSInteger, VideoRecordResolution) { + VIDEO_RESOLUTION_360_640 = 0, + VIDEO_RESOLUTION_540_960 = 1, + VIDEO_RESOLUTION_720_1280 = 2, + VIDEO_RESOLUTION_1080_1920 = 3, +}; + + +typedef NS_ENUM(NSInteger, VideoRecordResultCode) { + VIDEO_RECORD_RESULT_OK = 0, + VIDEO_RECORD_RESULT_OK_INTERRUPT = 1, + VIDEO_RECORD_RESULT_OK_UNREACH_MINDURATION = 2, + VIDEO_RECORD_RESULT_OK_BEYOND_MAXDURATION = 3, + VIDEO_RECORD_RESULT_FAILED = 1001, +}; + +typedef NS_ENUM(NSInteger, VideoRecordAspectRatio) { + VIDEO_ASPECT_RATIO_3_4, + VIDEO_ASPECT_RATIO_9_16, + VIDEO_ASPECT_RATIO_1_1, + VIDEO_ASPECT_RATIO_16_9, + VIDEO_ASPECT_RATIO_4_3 +}; + +typedef NS_ENUM(NSInteger, VideoRecordBeautyStyle) { + VIDOE_BEAUTY_STYLE_SMOOTH = 0, + VIDOE_BEAUTY_STYLE_NATURE = 1, + VIDOE_BEAUTY_STYLE_PITU = 2, +}; + +@interface VideoRecordResult : NSObject +@property(nonatomic, assign) VideoRecordResultCode retCode; +@property(nonatomic, strong) NSString* descMsg; +@property(nonatomic, strong) NSString* videoPath; +@property(nonatomic, strong) UIImage* coverImage; +@end + + + +@protocol VideoRecorderCoreListener +@optional +- (void)onRecordProgress:(NSInteger)milliSecond; +@optional +- (void)onRecordComplete:(VideoRecordResult*)result; +@optional +- (void)onRecordEvent:(NSDictionary*)evt; +@end + + +@interface VideoRecordCustomConfig : NSObject +@property(nonatomic, assign) VideoRecordResolution videoResolution; +@property(nonatomic, assign) int videoFPS; +@property(nonatomic, assign) int videoBitratePIN; +@property(nonatomic, assign) BOOL frontCamera; +@property(nonatomic, assign) float minDuration; +@property(nonatomic, assign) float maxDuration; +@end diff --git a/chat/uikit/components/videorecorder/README.md b/chat/uikit/components/videorecorder/README.md new file mode 100644 index 000000000..4f359fd14 --- /dev/null +++ b/chat/uikit/components/videorecorder/README.md @@ -0,0 +1,72 @@ +以下为转为 Markdown 的两份文档:中文版本与英文版本。 + +中文版本 +# 开启美颜/纵横比设置 + +美颜和纵横比设置是 VideoRecorder 的高级功能。启用需满足以下条件: + +## 1. 依赖 TXLiteAVSDK_Professional + +在项目或任一模块的 Podfile 中添加依赖: + +```ruby +pod 'TXLiteAVSDK_Professional' +``` + +- 如果工程任一模块已依赖 TXLiteAVSDK_TRTC,可将其替换为 TXLiteAVSDK_Professional(不会影响其他模块的正常使用)。 +- 依赖 TXLiteAVSDK_Professional 后,可使用纵横比设置;同时在兼容性与画质方面会有更好的表现。 + +## 2. 开通“多媒体高级功能”权限 + +当前仍需通过内测申请开通“多媒体插件高级功能”使用权限(包含视频录制、音频录制、图片/视频编辑等能力)。 + +- 申请入口:多媒体插件高级功能内测申请地址(https://cloud.tencent.com/apply/p/wlav0nzz7dp)。 + +### 注意事项 +1. 提交后通常在 1 个工作日内完成审核。建议使用企业认证的腾讯云账号申请,以提升通过率。 +2. 内测使用日期截至 2026 年 7 月 1 日,届时所有内测使用权限将失效。 +3. 内测截止日期之前,将上线高级功能的付费购买方案(购买方式与插件市场其他插件一致,详见“插件市场概述及开通指引”)。若到期未购买,美颜功能将被屏蔽(不显示美颜按钮);购买后将自动恢复显示(除非在配置中被强制屏蔽)。 + +## 3. 不同构建配置下的行为说明 + +- Release: + - 若不满足启用条件,即使在配置中开启,高级功能也不会生效(相关按钮将自动隐藏)。 +- Debug: + - 点击不支持的功能时,会在 UI 中弹窗提示。 + - 如需在 Debug 版本中也屏蔽这些功能,可在 Config 或配置文件中关闭对应开关(详见配置描述)。 + + +English Version +# Enable Beauty/Aspect Ratio Settings + +Beauty and aspect ratio are advanced features of VideoRecorder. To enable them, the following conditions must be met: + +## 1. Depend on TXLiteAVSDK_Professional + +Add the dependency in your project or any module’s Podfile: + +```ruby +pod 'TXLiteAVSDK_Professional' +``` + +- If any module already depends on TXLiteAVSDK_TRTC, replace it with TXLiteAVSDK_Professional (this change will not affect other modules). +- After switching to TXLiteAVSDK_Professional, you can enable aspect ratio settings, with improved compatibility and image quality. + +## 2. Enable “Advanced Multimedia Features” Permission + +You currently need to apply for internal testing access to the “Advanced Multimedia Plugin Features” (including video recording, audio recording, and photo/video editing). + +- Application entry: Advanced Multimedia Plugin Features Early Access (https://cloud.tencent.com/apply/p/wlav0nzz7dp). + +### Notes +1. Reviews are typically completed within one business day. We recommend applying with a Tencent Cloud enterprise-verified account to increase the approval rate. +2. The internal testing access is valid until July 1, 2026. After that date, all early-access permissions will expire. +3. Before the end of the early-access period, a paid plan for advanced features will be available (purchasing follows the same process as other plugins; see “Plugin Marketplace Overview and Activation Guide”). If not purchased upon expiration, the beauty feature will be disabled (the beauty button will be hidden). It will automatically be re-enabled after purchase (unless explicitly disabled via configuration). + +## 3. Behavior in Different Build Configurations + +- Release: + - If prerequisites are not met, advanced features will not work even if enabled in the configuration (related buttons will be automatically hidden). +- Debug: + - Tapping unsupported features will trigger a UI dialog/toast message. + - If you also want to hide these features in Debug, disable the corresponding switches in the Config or configuration file (see configuration documentation). \ No newline at end of file diff --git a/chat/uikit/components/videorecorder/VideoRecorder.swift b/chat/uikit/components/videorecorder/VideoRecorder.swift new file mode 100644 index 000000000..d79dfdc07 --- /dev/null +++ b/chat/uikit/components/videorecorder/VideoRecorder.swift @@ -0,0 +1,34 @@ +import AtomicXCore +import Combine +import UIKit + +enum RecordMode: Int { + case videoPhotoMix = 0 + case photoOnly = 1 + case videoOnly = 2 +} + +func fetchVideoRecorderSignature() { + if VideoRecordSignatureChecker.shareInstance().getSetSignatureResult() == .VIDEO_RECORD_SIGNATURE_SUCCESS { + return + } + + let currentLoginStatus = LoginStore.shared.state.value.loginStatus + if currentLoginStatus == .logined { + VideoRecordSignatureChecker.shareInstance().startUpdateSignature( + NSNumber(value: LoginStore.shared.sdkAppID).stringValue) + return + } + + var cancellables = Set() + LoginStore.shared.state + .subscribe(StatePublisherSelector(keyPath: \LoginState.loginStatus)) + .first() + .sink { loginStatus in + if loginStatus == .logined { + VideoRecordSignatureChecker.shareInstance().startUpdateSignature( + NSNumber(value: LoginStore.shared.sdkAppID).stringValue) + } + } + .store(in: &cancellables) +} diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.h b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.h new file mode 100644 index 000000000..0209410ec --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.h @@ -0,0 +1,20 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN +@interface VideoRecorderCircleProgressView : UIView +@property(nonatomic) CGFloat progress; +@property(nonatomic) UIColor *progressColor; +@property(nonatomic) UIColor *progressBgColor; +@property(nonatomic) CGFloat width; +@property(nonatomic) BOOL clockwise; +@property(nonatomic) CGFloat startAngle; +@property(nonatomic) CAShapeLayerLineCap lineCap; + +- (instancetype)initWithFrame:(CGRect)frame; +- (void)setProgress:(CGFloat)progress animated:(BOOL)animated; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.m b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.m new file mode 100644 index 000000000..e627d57cb --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderCircleProgressView.m @@ -0,0 +1,100 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderCircleProgressView.h" + +@interface VideoRecorderCircleProgressView () { + CAShapeLayer *_backLayer; + CAShapeLayer *_frontLayer; +} + +@end + +@implementation VideoRecorderCircleProgressView +- (id)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self != nil) { + _clockwise = YES; + _width = 5; + _startAngle = -M_PI / 2; + _progressBgColor = UIColor.grayColor; + _progressColor = UIColor.greenColor; + _lineCap = kCALineCapRound; + [self initUI]; + } + return self; +} +- (void)initUI { + _backLayer = [CAShapeLayer layer]; + [self.layer addSublayer:_backLayer]; + _backLayer.fillColor = nil; + _backLayer.strokeColor = _progressBgColor.CGColor; + + _frontLayer = [CAShapeLayer layer]; + [self.layer addSublayer:_frontLayer]; + _frontLayer.fillColor = nil; + _frontLayer.lineCap = _lineCap; + _frontLayer.strokeColor = _progressColor.CGColor; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + CGFloat len = MIN(CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)); + CGPoint center = CGPointMake(CGRectGetWidth(self.bounds) / 2, CGRectGetHeight(self.bounds) / 2); + UIBezierPath *backgroundBezierPath = [UIBezierPath bezierPathWithArcCenter:center + radius:(len - _width) / 2.0 + startAngle:0 + endAngle:M_PI * 2 + clockwise:_clockwise]; + UIBezierPath *frontFillBezierPath = [UIBezierPath bezierPathWithArcCenter:center + radius:(len - _width) / 2.0 + startAngle:_startAngle + endAngle:_startAngle + (_clockwise ? 2 * M_PI : -2 * M_PI) + clockwise:_clockwise]; + _backLayer.path = backgroundBezierPath.CGPath; + _frontLayer.path = frontFillBezierPath.CGPath; + _frontLayer.lineWidth = _width; + _backLayer.lineWidth = _width; + _frontLayer.strokeEnd = 0; +} + +#pragma mark - setters +- (void)setProgress:(CGFloat)progress { + _progress = MAX(0, MIN(1, progress)); +} +- (void)setProgress:(CGFloat)progress animated:(BOOL)animated { + _progress = MAX(0, MIN(1, progress)); + if (animated) { + [UIView animateWithDuration:0.25 + animations:^{ + self->_frontLayer.strokeEnd = progress; + }]; + } else { + self->_frontLayer.strokeEnd = progress; + } +} +- (void)setClockwise:(BOOL)clockwise { + _clockwise = clockwise; + [self setNeedsLayout]; +} +- (void)setStartAngle:(CGFloat)startAngle { + _startAngle = startAngle; + [self setNeedsLayout]; +} +- (void)setWidth:(CGFloat)width { + _width = width; + [self setNeedsLayout]; +} +- (void)setProgressColor:(UIColor *)progressColor { + _progressColor = progressColor; + _frontLayer.strokeColor = _progressColor.CGColor; +} +- (void)setProgressBgColor:(UIColor *)progressBgColor { + _progressBgColor = progressBgColor; + _backLayer.strokeColor = _progressBgColor.CGColor; +} +- (void)setLineCap:(CAShapeLayerLineCap)lineCap { + _lineCap = lineCap; + _frontLayer.lineCap = lineCap; +} +@end diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.h b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.h new file mode 100644 index 000000000..7a922bf4f --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.h @@ -0,0 +1,13 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderIconLabelButtonView : UIButton +@property(nonatomic) CGSize iconSize; +@property(nonatomic) CGFloat iconLabelGap; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.m b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.m new file mode 100644 index 000000000..5e4bc502e --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderIconLabelButtonView.m @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderIconLabelButtonView.h" +#import + +@interface VideoRecorderIconLabelButtonView () { +} + +@end + +@implementation VideoRecorderIconLabelButtonView +- (id)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + return self; +} +- (CGRect)imageRectForContentRect:(CGRect)contentRect { + CGFloat midX = CGRectGetMidX(contentRect); + CGFloat minY = CGRectGetMinY(contentRect); + return CGRectMake(midX - _iconSize.width / 2, minY, _iconSize.width, _iconSize.height); +} +- (CGRect)titleRectForContentRect:(CGRect)contentRect { + CGRect imageRect = [self imageRectForContentRect:contentRect]; + CGSize size = self.currentAttributedTitle.size; + CGFloat midX = CGRectGetMidX(contentRect); + return CGRectMake(midX - size.width / 2, CGRectGetMaxY(imageRect) + _iconLabelGap, size.width, size.height); +} +- (CGSize)intrinsicContentSize { + CGSize titleSize = [self titleRectForContentRect:CGRectZero].size; + return CGSizeMake(MAX(_iconSize.width, titleSize.width), _iconSize.height + _iconLabelGap + titleSize.height); +} +- (void)setIconSize:(CGSize)iconSize { + _iconSize = iconSize; + [self setNeedsLayout]; +} +- (void)setIconLabelGap:(CGFloat)iconLabelGap { + _iconLabelGap = iconLabelGap; + [self setNeedsLayout]; +} +@end diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.h b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.h new file mode 100644 index 000000000..7a9ea436c --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.h @@ -0,0 +1,29 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol VideoRecorderRecordButtonDelegate; + +@interface VideoRecorderRecordButtonView : UIView +@property(nonatomic) float progress; +@property(nonatomic) float dotSizeNormal; +@property(nonatomic) float progressSizeNormal; +@property(nonatomic) float dotSizePressed; +@property(nonatomic) float progressSizePressed; +@property(weak, nullable, nonatomic) id delegate; +@property(nonatomic) BOOL isOnlySupportTakePhoto; + +- (instancetype)initWithFrame:(CGRect)frame; +@end + +@protocol VideoRecorderRecordButtonDelegate +- (void)onRecordButtonTap:(VideoRecorderRecordButtonView *)btn; +- (void)onRecordButtonLongPressBegan:(VideoRecorderRecordButtonView *)btn; +- (void)onRecordButtonLongPressEnded:(VideoRecorderRecordButtonView *)btn; +- (void)onRecordButtonLongPressCancelled:(VideoRecorderRecordButtonView *)btn; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.m b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.m new file mode 100644 index 000000000..d67fd1b4b --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderRecordButtonView.m @@ -0,0 +1,126 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderRecordButtonView.h" +#import +#import "VideoRecorderCircleProgressView.h" +#import "VideoRecorderCommon.h" +#include "videoRecorderConfigInternal.h" + +static const CGFloat RecordAnimeDuration = 0.3; + +#pragma mark - VideoRecorderRecordButton +@interface VideoRecorderRecordButtonView () { + VideoRecorderCircleProgressView *_progressView; + UIView *_dotView; + BOOL _pressed; + UILongPressGestureRecognizer *_longPressRec; +} +@end + +@implementation VideoRecorderRecordButtonView + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self != nil) { + [self initUI]; + } + return self; +} + +- (void)initUI { + _progressView = [[VideoRecorderCircleProgressView alloc] init]; + _progressView.lineCap = kCALineCapButt; + _progressView.progressBgColor = VideoRecorderDynamicColor(@"record_btn_progress_bg_color", @"#FFFFFF"); + _progressView.progressColor = [[VideoRecorderConfigInternal sharedInstance] getThemeColor]; + _progressView.width = 2; + [self addSubview:_progressView]; + + _dotView = [[UIView alloc] init]; + _dotView.backgroundColor = VideoRecorderDynamicColor(@"record_btn_dot_color", @"#FFFFFF"); + [self addSubview:_dotView]; + + UITapGestureRecognizer *tapRec = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onTap:)]; + tapRec.cancelsTouchesInView = NO; + [self addGestureRecognizer:tapRec]; + + _longPressRec = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPress:)]; + _longPressRec.cancelsTouchesInView = NO; + _longPressRec.minimumPressDuration = RecordAnimeDuration; + [self addGestureRecognizer:_longPressRec]; + [self layoutSubviews]; +} + +- (void)layoutSubviews { + CGFloat dotSize = _pressed ? _dotSizePressed : _dotSizeNormal; + + CGFloat progressSize = _pressed ? _progressSizePressed : _progressSizeNormal; + if (_isOnlySupportTakePhoto) { + progressSize = _progressSizeNormal; + } + + CGPoint center = CGPointMake(CGRectGetWidth(self.bounds) / 2, CGRectGetHeight(self.bounds) / 2); + CGFloat len = MIN(CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds)); + _progressView.center = center; + _progressView.bounds = CGRectMake(0, 0, len, len); + _progressView.transform = CGAffineTransformMakeScale(progressSize / len, progressSize / len); + + _dotView.center = center; + _dotView.bounds = CGRectMake(0, 0, dotSize, dotSize); + _dotView.layer.cornerRadius = dotSize / 2; +} + +- (void)animeStartRecord { + _progressView.width = 4; + [UIView animateWithDuration:RecordAnimeDuration + animations:^{ + self->_pressed = YES; + [self layoutSubviews]; + }]; +} +- (void)animeStopRecord { + _progressView.width = 2; + [UIView animateWithDuration:RecordAnimeDuration + animations:^{ + self->_pressed = NO; + [self layoutSubviews]; + }]; +} +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + [super touchesBegan:touches withEvent:event]; + [self animeStartRecord]; +} +- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event { + [super touchesCancelled:touches withEvent:event]; + [self animeStopRecord]; +} +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + [super touchesEnded:touches withEvent:event]; + [self animeStopRecord]; +} +- (void)onTap:(UITapGestureRecognizer *)rec { + [_delegate onRecordButtonTap:self]; +} +- (void)onLongPress:(UILongPressGestureRecognizer *)rec { + switch (rec.state) { + case UIGestureRecognizerStateBegan: { + [_delegate onRecordButtonLongPressBegan:self]; + break; + } + case UIGestureRecognizerStateEnded: { + [_delegate onRecordButtonLongPressEnded:self]; + break; + } + case UIGestureRecognizerStateCancelled: { + [self animeStopRecord]; + [_delegate onRecordButtonLongPressCancelled:self]; + } + default:; + } +} + +- (void)setProgress:(float)progress { + [_progressView setProgress:progress animated:YES]; +} + +@end diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.h b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.h new file mode 100644 index 000000000..983033e1d --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.h @@ -0,0 +1,15 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderSplitterView : UIView +@property(nonatomic) UILayoutConstraintAxis axis; +@property(nonatomic) UIColor *color; +@property(nonatomic) CGFloat lineWidth; +@property(nonatomic) CGLineCap lineCap; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.m b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.m new file mode 100644 index 000000000..9ab423f63 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderSplitterView.m @@ -0,0 +1,76 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderSplitterView.h" + +@interface VideoRecorderSplitterView () { + CAShapeLayer *_shapeLayer; +} + +@end + +@implementation VideoRecorderSplitterView +- (id)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + _color = UIColor.lightGrayColor; + _lineWidth = 1; + _lineCap = kCGLineCapRound; + self.backgroundColor = UIColor.clearColor; + + _shapeLayer = [[CAShapeLayer alloc] init]; + [self.layer addSublayer:_shapeLayer]; + + [self updateShapeLayer]; + return self; +} +- (void)updateShapeLayer { + _shapeLayer.frame = self.bounds; + CGPoint startPoint, endPoint; + CGSize size = self.bounds.size; + CGFloat inset = 0; + if (_axis == UILayoutConstraintAxisVertical) { + startPoint = CGPointMake(size.width / 2, inset); + endPoint = CGPointMake(size.width / 2, size.height - inset); + } else { + startPoint = CGPointMake(inset, size.height / 2); + endPoint = CGPointMake(size.width - inset, size.height / 2); + } + UIBezierPath *path = [UIBezierPath bezierPath]; + [path moveToPoint:startPoint]; + [path addLineToPoint:endPoint]; + _shapeLayer.path = path.CGPath; + _shapeLayer.strokeColor = _color.CGColor; + _shapeLayer.lineWidth = _lineWidth; + switch (_lineCap) { + default: + case kCGLineCapButt: + _shapeLayer.lineCap = kCALineCapButt; + break; + case kCGLineCapRound: + _shapeLayer.lineCap = kCALineCapRound; + break; + case kCGLineCapSquare: + _shapeLayer.lineCap = kCALineCapSquare; + break; + } +} +- (void)layoutSubviews { + [self updateShapeLayer]; +} +- (void)setAxis:(UILayoutConstraintAxis)axis { + _axis = axis; + [self updateShapeLayer]; +} +- (void)setColor:(UIColor *)color { + _color = color; + [self updateShapeLayer]; +} +- (void)setLineWidth:(CGFloat)lineWidth { + _lineWidth = lineWidth; + [self updateShapeLayer]; +} +- (void)setLineCap:(CGLineCap)lineCap { + _lineCap = lineCap; + [self updateShapeLayer]; +} +@end diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.h b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.h new file mode 100644 index 000000000..6c02fcb2f --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.h @@ -0,0 +1,50 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class VideoRecorderTabPanelTab; + +@protocol VideoRecorderTabPanelDelegate; + +@interface VideoRecorderTabPanelView : UIView +@property(nonatomic) NSArray *tabs; +@property(nonatomic) NSInteger selectedIndex; +@property(weak, nullable, nonatomic) id delegate; +- (id)initWithFrame:(CGRect)frame; +@end + +@protocol VideoRecorderTabPanelDelegate +- (void)tabPanel:(VideoRecorderTabPanelView *)panel selectedIndexChanged:(NSInteger)selectedIndex; +@end + +@interface VideoRecorderTabPanelTab : NSObject +@property(nonatomic) UIView *view; +@property(nullable, nonatomic) NSString *name; +@property(nullable, nonatomic) UIImage *icon; +- (instancetype)initWithName:(nullable NSString *)name icon:(nullable UIImage *)icon view:(UIView *)view; +@end + +@protocol VideoRecorderTabBarDelegate; + +@interface VideoRecorderTabBar : UIView +@property(nonatomic) NSArray *tabs; +@property(nonatomic) NSInteger selectedIndex; +@property(weak, nullable, nonatomic) id delegate; +@end + +@protocol VideoRecorderTabBarDelegate +- (void)tabBar:(VideoRecorderTabBar *)bar selectedIndexChanged:(NSInteger)index; +@end + +@interface VideoRecorderTabBarCell : UICollectionViewCell +@property(nullable, nonatomic) NSAttributedString *attributedText; +@property(nullable, nonatomic) UIImage *icon; +@property(nonatomic) CGFloat padding; +@property(nonatomic) BOOL barCellSelected; ++ (NSString *)reuseIdentifier; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.m b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.m new file mode 100644 index 000000000..9a1aca239 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BaseComponents/VideoRecorderTabPanelView.m @@ -0,0 +1,285 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderTabPanelView.h" +#import +#import "VideoRecorderNSArray+Functional.h" +#import "VideoRecorderCommon.h" +#import "VideoRecorderImageUtil.h" +#import "VideoRecorderSplitterView.h" + +@interface VideoRecorderTabPanelView () { + VideoRecorderTabBar *_bar; + VideoRecorderSplitterView *_splitter; + NSArray *_tabs; +} + +@end + +@implementation VideoRecorderTabPanelView + +@dynamic tabs; + +- (id)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + [self initUI]; + return self; +} + +- (void)initUI { + _bar = [[VideoRecorderTabBar alloc] init]; + [self addSubview:_bar]; + _bar.delegate = self; + + _splitter = [[VideoRecorderSplitterView alloc] init]; + [self addSubview:_splitter]; + _splitter.lineWidth = 1; + _splitter.color = VideoRecorderDynamicColor(@"tabpanel_splitter_color", @"#FFFFFF1A"); + + [_bar mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.top.equalTo(self).inset(5); + make.height.mas_equalTo(42); + }]; + [_splitter mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.equalTo(self); + make.top.equalTo(_bar.mas_bottom); + make.height.mas_equalTo(5); + }]; +} +- (void)layoutSubviews { + [super layoutSubviews]; +} + +#pragma mark - Properties +- (NSInteger)selectedIndex { + return _bar.selectedIndex; +} + +- (void)setSelectedIndex:(NSInteger)selectedIndex { + _bar.selectedIndex = selectedIndex; + for (int i = 0; i < _tabs.count; i++) { + VideoRecorderTabPanelTab *t = _tabs[i]; + t.view.hidden = i != selectedIndex; + } +} + +- (NSArray *)tabs { + return _tabs; +} + +- (void)setTabs:(NSArray *)value { + if (_tabs != nil) { + for (VideoRecorderTabPanelTab *t in _tabs) { + [t.view removeFromSuperview]; + } + } + _tabs = value; + _bar.selectedIndex = -1; + _bar.tabs = [value video_recorder_map:^id(VideoRecorderTabPanelTab *t) { + return t.icon == nil ? t.name : t.icon; + }]; + for (int i = 0; i < _tabs.count; i++) { + VideoRecorderTabPanelTab *t = _tabs[i]; + t.view.hidden = YES; + [self addSubview:t.view]; + [t.view mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.bottom.equalTo(self); + make.top.equalTo(_splitter.mas_bottom); + }]; + } + if (_tabs.count > 0) { + VideoRecorderTabPanelTab *t = _tabs[0]; + _bar.selectedIndex = 0; + t.view.hidden = NO; + } +} +#pragma mark - VideoRecorderTabBarDelegate protocol +- (void)tabBar:(VideoRecorderTabBar *)bar selectedIndexChanged:(NSInteger)index { + for (int i = 0; i < _tabs.count; i++) { + VideoRecorderTabPanelTab *t = _tabs[i]; + t.view.hidden = i != index; + } + [_delegate tabPanel:self selectedIndexChanged:index]; +} + +@end + +#pragma mark - VideoRecorderTabPanelTab +@implementation VideoRecorderTabPanelTab { +} +- (instancetype)initWithName:(NSString *)name icon:(UIImage *)icon view:(UIView *)view { + self = [super init]; + if (self != nil) { + _name = name; + _icon = icon; + _view = view; + } + return self; +} +@end + +#pragma mark - VideoRecorderTabBar + +@interface VideoRecorderTabBar () { + UICollectionView *_collectionView; +} +@end + +@implementation VideoRecorderTabBar + +- (id)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + [self initUI]; + return self; +} + +- (void)initUI { + UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; + layout.scrollDirection = UICollectionViewScrollDirectionHorizontal; + layout.minimumInteritemSpacing = 10; + _collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; + _collectionView.backgroundColor = UIColor.clearColor; + _collectionView.showsHorizontalScrollIndicator = NO; + _collectionView.delegate = self; + _collectionView.dataSource = self; + [_collectionView registerClass:VideoRecorderTabBarCell.class forCellWithReuseIdentifier:VideoRecorderTabBarCell.reuseIdentifier]; + [self addSubview:_collectionView]; + + [_collectionView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self); + }]; +} + +- (void)setSelectedIndex:(NSInteger)selectedIndex { + _selectedIndex = selectedIndex; + [_collectionView reloadData]; +} + +#pragma mark - UICollectionViewDelegateFlowLayout protocol +- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { + [collectionView deselectItemAtIndexPath:indexPath animated:NO]; + _selectedIndex = indexPath.item; + [collectionView reloadData]; + + [_delegate tabBar:self selectedIndexChanged:_selectedIndex]; +} + +#pragma mark - UICollectionViewDataSource protocol +- (NSAttributedString *)getAttributedString:(NSString *)str selected:(BOOL)selected { + UIColor *color; + UIFont *font; + if (selected) { + color = UIColor.whiteColor; + font = [UIFont systemFontOfSize:16 weight:UIFontWeightBold]; + } else { + color = [UIColor colorWithWhite:1 alpha:0.6]; + font = [UIFont systemFontOfSize:16 weight:UIFontWeightRegular]; + } + NSAttributedString *text = [[NSAttributedString alloc] initWithString:str + attributes:@{ + NSForegroundColorAttributeName : color, + NSFontAttributeName : font, + }]; + return text; +} +- (nonnull __kindof UICollectionViewCell *)collectionView:(nonnull UICollectionView *)collectionView cellForItemAtIndexPath:(nonnull NSIndexPath *)indexPath { + VideoRecorderTabBarCell *cell = (VideoRecorderTabBarCell *)[collectionView dequeueReusableCellWithReuseIdentifier:VideoRecorderTabBarCell.reuseIdentifier + forIndexPath:indexPath]; + cell.barCellSelected = indexPath.item == _selectedIndex; + BOOL cellSelected = indexPath.item == _selectedIndex; + id item = _tabs[indexPath.item]; + if ([item isKindOfClass:NSString.class]) { + cell.attributedText = [self getAttributedString:item selected:cellSelected]; + } else if ([item isKindOfClass:UIImage.class]) { + cell.contentView.backgroundColor = cellSelected ? [UIColor colorWithWhite:1 alpha:0.1] : UIColor.clearColor; + cell.icon = item; + cell.padding = 8; + } + + return cell; +} +- (CGSize)collectionView:(UICollectionView *)collectionView + layout:(UICollectionViewLayout *)collectionViewLayout + sizeForItemAtIndexPath:(NSIndexPath *)indexPath { + CGFloat h = _collectionView.bounds.size.height; + CGSize size = CGSizeMake(h, h); + id item = _tabs[indexPath.item]; + if ([item isKindOfClass:NSString.class]) { + NSAttributedString *text = [self getAttributedString:_tabs[indexPath.item] selected:YES]; + CGSize textSize = [text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, _collectionView.bounds.size.height) + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + context:nil] + .size; + size.width = MAX(size.width, textSize.width + 10); + size.height = MAX(size.height, textSize.height + 8); + return size; + } + return size; +} +- (NSInteger)collectionView:(nonnull UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { + return _tabs.count; +} + +@end + +#pragma mark - VideoRecorderTabBarCell +@interface VideoRecorderTabBarCell () { + UILabel *_label; + UIImageView *_imgView; +} +@end +@implementation VideoRecorderTabBarCell ++ (NSString *)reuseIdentifier { + return NSStringFromClass([self class]); +} +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self != nil) { + self.contentView.layer.cornerRadius = 5; + self.contentView.clipsToBounds = YES; + + _label = [[UILabel alloc] init]; + [self.contentView addSubview:_label]; + _label.textAlignment = NSTextAlignmentCenter; + [_label mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self); + }]; + + _imgView = [[UIImageView alloc] init]; + [self.contentView addSubview:_imgView]; + _imgView.contentMode = UIViewContentModeScaleAspectFit; + [_imgView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self); + }]; + } + return self; +} +- (void)prepareForReuse { + [super prepareForReuse]; + self.attributedText = nil; + self.icon = nil; + self.barCellSelected = NO; + self.padding = 0; +} +- (NSAttributedString *)attributedText { + return _label.attributedText; +} +- (UIImage *)icon { + return _imgView.image; +} +- (void)setAttributedText:(NSAttributedString *)attributedText { + _label.attributedText = attributedText; +} +- (void)setIcon:(UIImage *)icon { + _imgView.image = icon; +} +- (void)setPadding:(CGFloat)padding { + _padding = padding; + [_label mas_remakeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self).inset(_padding); + }]; + [_imgView mas_remakeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self).inset(_padding); + }]; +} +@end diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.h b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.h new file mode 100644 index 000000000..cce520a64 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.h @@ -0,0 +1,28 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +static const int VideoRecorderEffectItemTagNone = -1; +static const int VideoRecorderEffectItemTagSmooth = 1; +static const int VideoRecorderEffectItemTagNatural = 2; +static const int VideoRecorderEffectItemTagPitu = 3; +static const int VideoRecorderEffectItemTagWhiteness = 4; +static const int VideoRecorderEffectItemTagRuddy = 5; + + +@interface VideoRecorderBeautifyEffectItem : NSObject +@property(nonatomic) NSString *name; +@property(nonatomic) UIImage *iconImage; +@property(nullable, nonatomic) UIImage *filterMapImage; +@property(nonatomic) int strength; +@property(nonatomic) NSInteger tag; + +- (instancetype)initWithName:(NSString *)name iconImage:(UIImage *)iconImage strength:(int)strength; ++ (NSArray *)defaultBeautifyEffects; ++ (NSArray *)defaultFilterEffects; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.m b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.m new file mode 100644 index 000000000..7e492721d --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectItem.m @@ -0,0 +1,79 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderBeautifyEffectItem.h" +#import "VideoRecorderNSArray+Functional.h" +#import "VideoRecorderCommon.h" + +#define DEFAULT_FILTER_EFFECT_STRENGTH 4 + +@implementation VideoRecorderBeautifyEffectItem +- (instancetype)initWithName:(NSString *)name iconImage:(UIImage *)iconImage strength:(int)strength { + self = [super init]; + if (self != nil) { + _name = name; + _iconImage = iconImage; + _strength = strength; + } + return self; +} + ++ (VideoRecorderBeautifyEffectItem *)newWithName:(NSString *)name iconImageName:(NSString *)iconImageName tag:(NSInteger)tag { + VideoRecorderBeautifyEffectItem *item = [[VideoRecorderBeautifyEffectItem alloc] initWithName:name iconImage:[VideoRecorderCommon bundleImageByName:iconImageName] strength:0]; + item.tag = tag; + return item; +} + ++ (VideoRecorderBeautifyEffectItem *)newWithFilterName:(NSString *)filterName { + NSString *nameKey = [NSString stringWithFormat:@"filter_%@", filterName]; + NSString *name = [VideoRecorderCommon localizedStringForKey:nameKey]; + + NSString *iconImageName = [NSString stringWithFormat:@"icon_%@", filterName]; + NSString *filterMapImageName = [NSString stringWithFormat:@"filter_%@", filterName]; + VideoRecorderBeautifyEffectItem *item = [[VideoRecorderBeautifyEffectItem alloc] initWithName:name iconImage:[VideoRecorderCommon bundleImageByName:iconImageName] strength:DEFAULT_FILTER_EFFECT_STRENGTH]; + item.filterMapImage = [VideoRecorderCommon bundleImageByName:filterMapImageName]; + return item; +} + ++ (NSArray *)filterNameList { + return @[ + @"none", + @"bailan", + @"biaozhun", + @"chaotuo", + @"chunzhen", + @"fennen", + @"huaijiu", + @"landiao", + @"langman", + @"qingliang", + @"qingxin", + @"rixi", + @"weimei", + @"xiangfen", + @"yinghong", + @"yuanqi", + @"yunshang", + ]; +} + ++ (NSArray *)defaultBeautifyEffects { + return @[ + [VideoRecorderBeautifyEffectItem newWithName:[VideoRecorderCommon localizedStringForKey:@"beautify_none"] iconImageName:@"none" tag:VideoRecorderEffectItemTagNone], + [VideoRecorderBeautifyEffectItem newWithName:[VideoRecorderCommon localizedStringForKey:@"beautify_smooth"] iconImageName:@"smooth" tag:VideoRecorderEffectItemTagSmooth], + [VideoRecorderBeautifyEffectItem newWithName:[VideoRecorderCommon localizedStringForKey:@"beautify_whitness"] + iconImageName:@"whitness" + tag:VideoRecorderEffectItemTagWhiteness], + [VideoRecorderBeautifyEffectItem newWithName:[VideoRecorderCommon localizedStringForKey:@"beautify_ruddy"] iconImageName:@"ruddy" tag:VideoRecorderEffectItemTagRuddy], + ]; +} + ++ (NSArray *)defaultFilterEffects { + NSArray *list = [[VideoRecorderBeautifyEffectItem filterNameList] video_recorder_map:^(NSString *name) { + return [VideoRecorderBeautifyEffectItem newWithFilterName:name]; + }]; + list.firstObject.tag = VideoRecorderEffectItemTagNone; + return list; +} + +@end diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.h b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.h new file mode 100644 index 000000000..eb6a6e3f3 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.h @@ -0,0 +1,23 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import "VideoRecorderBeautifyEffectViewCell.h" +#import "VideoRecorderBeautifyEffectItem.h" +NS_ASSUME_NONNULL_BEGIN + +@protocol VideoRecorderEffectPanelDelegate; +@interface VideoRecorderBeautifyEffectPanelView : UIView +@property(nonatomic) NSArray *items; +@property(nonatomic) NSInteger selectedIndex; +@property(nonatomic) CGSize iconSize; +@property(nonatomic) CGSize firstIconSize; +@property(weak, nullable, nonatomic) id delegate; +- (id)initWithFrame:(CGRect)frame; +@end + +@protocol VideoRecorderEffectPanelDelegate +- (void)effectPanelSelectionChanged:(VideoRecorderBeautifyEffectPanelView *)panel; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.m b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.m new file mode 100644 index 000000000..3e10e01bc --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectPanelView.m @@ -0,0 +1,110 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import "VideoRecorderBeautifyEffectPanelView.h" +#import "VideoRecorderBeautifyEffectViewCell.h" +#import "VideoRecorderBeautifySettings.h" +#import "VideoRecorderCommon.h" + +static const CGSize EffectItemSize = VideoRecorderConstCGSize(60, 86); + +@interface VideoRecorderBeautifyEffectPanelView () { + NSArray *_items; + UICollectionView *_collectionView; +} +@end + +@implementation VideoRecorderBeautifyEffectPanelView +@dynamic items; + +- (id)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self != nil) { + _selectedIndex = -1; + _items = @[]; + [self initUI]; + } + return self; +} + +- (void)initUI { + UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; + layout.scrollDirection = UICollectionViewScrollDirectionHorizontal; + layout.minimumInteritemSpacing = 10; + layout.itemSize = EffectItemSize; + _collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; + _collectionView.backgroundColor = UIColor.clearColor; + _collectionView.showsHorizontalScrollIndicator = NO; + _collectionView.delegate = self; + _collectionView.dataSource = self; + [_collectionView registerClass:VideoRecorderBeautifyEffectViewCell.class forCellWithReuseIdentifier:VideoRecorderBeautifyEffectViewCell.reuseIdentifier]; + [self addSubview:_collectionView]; + + [_collectionView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self); + }]; +} + +- (CGSize)intrinsicContentSize { + return CGSizeMake(UIViewNoIntrinsicMetric, EffectItemSize.height /* + self.safeAreaInsets.bottom*/); +} + +#pragma mark - Properties +- (NSArray *)items { + return _items; +} +- (void)setItems:(NSArray *)value { + _items = value; + _selectedIndex = -1; + [_collectionView reloadData]; +} + +- (void)setSelectedIndex:(NSInteger)selectedIndex { + _selectedIndex = selectedIndex; + [_collectionView reloadData]; +} + +- (void)setIconSize:(CGSize)iconSize { + _iconSize = iconSize; + [_collectionView reloadData]; +} + +- (void)setFirstIconSize:(CGSize)iconSize { + _firstIconSize = iconSize; + [_collectionView reloadData]; +} + +#pragma mark - UICollectionViewDelegateFlowLayout protocol + +- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { + VideoRecorderBeautifyEffectViewCell *cell = (VideoRecorderBeautifyEffectViewCell *)[collectionView dequeueReusableCellWithReuseIdentifier:VideoRecorderBeautifyEffectViewCell.reuseIdentifier + forIndexPath:indexPath]; + cell.image = _items[indexPath.item].iconImage; + cell.text = _items[indexPath.item].name; + cell.effectSelected = (indexPath.item == _selectedIndex); + if (indexPath.item == 0 && _firstIconSize.width != 0 && _firstIconSize.height != 0) { + cell.iconSize = _firstIconSize; + } else { + cell.iconSize = _iconSize; + } + + return cell; +} + +- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView { + return 1; +} + +- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { + return _items.count; +} + +- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { + [collectionView deselectItemAtIndexPath:indexPath animated:NO]; + _selectedIndex = indexPath.item; + [collectionView reloadData]; + [_delegate effectPanelSelectionChanged:self]; +} + +@end diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.h b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.h new file mode 100644 index 000000000..c2b54124e --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.h @@ -0,0 +1,18 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface VideoRecorderBeautifyEffectViewCell : UICollectionViewCell +@property(nonatomic) UIImage *image; +@property(nonatomic) NSString *text; +@property(nonatomic) BOOL effectSelected; +@property(nonatomic) CGSize iconSize; +- (instancetype)initWithFrame:(CGRect)frame; + ++ (NSString *)reuseIdentifier; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.m b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.m new file mode 100644 index 000000000..41b676557 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyEffectViewCell.m @@ -0,0 +1,119 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderBeautifyEffectViewCell.h" +#import +#import "VideoRecorderCommon.h" +#include "videoRecorderConfigInternal.h" + +const CGFloat MaskBorderWidth = 2; +const CGFloat EffectCellRadius = 10; +const CGFloat EffectCellWidth = 60; + +const CGFloat LabelFontSize = 12; +const CGFloat LabelHeight = 18; +const CGFloat LabelInsectToBottom = 5; + +@interface VideoRecorderBeautifyEffectViewCell () { + UIView *_imgContainerView; + UIView *_highlightView; + UIImageView *_imgView; + UILabel *_label; + NSString *_text; +} +@end + +@implementation VideoRecorderBeautifyEffectViewCell + +@dynamic image; +@dynamic text; + ++ (NSString *)reuseIdentifier { + return NSStringFromClass([self class]); +} + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self != nil) { + [self initUI]; + } + return self; +} + +- (void)initUI { + _imgContainerView = [[UIView alloc] init]; + [self.contentView addSubview:_imgContainerView]; + _imgContainerView.layer.cornerRadius = EffectCellRadius; + _imgContainerView.clipsToBounds = YES; + _imgView = [[UIImageView alloc] init]; + [_imgContainerView addSubview:_imgView]; + _imgView.contentMode = UIViewContentModeScaleAspectFit; + [_imgView mas_makeConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(_iconSize); + make.center.equalTo(_imgContainerView); + }]; + + _highlightView = [[UIView alloc] init]; + [self.contentView addSubview:_highlightView]; + _highlightView.hidden = YES; + _highlightView.backgroundColor = [[VideoRecorderConfigInternal sharedInstance] getThemeColor]; + + _label = [[UILabel alloc] init]; + _label.font = [UIFont systemFontOfSize:LabelFontSize]; + _label.textColor = UIColor.whiteColor; + _label.textAlignment = NSTextAlignmentCenter; + [self.contentView addSubview:_label]; + + [_highlightView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + [_imgContainerView mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.top.equalTo(self.contentView).inset(MaskBorderWidth); + make.height.equalTo(_imgContainerView.mas_width); + }]; + [_label mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + make.top.equalTo(_imgContainerView.mas_bottom); + }]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [self addMaskToHighlightView]; +} + +- (void)addMaskToHighlightView { + UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:_highlightView.bounds cornerRadius:EffectCellRadius]; + [maskPath appendPath:[UIBezierPath bezierPathWithRoundedRect:_imgContainerView.frame cornerRadius:EffectCellRadius].bezierPathByReversingPath]; + CAShapeLayer *layer = [[CAShapeLayer alloc] init]; + layer.path = maskPath.CGPath; + _highlightView.layer.mask = layer; +} + +- (void)setIconSize:(CGSize)iconSize { + _iconSize = iconSize; + [_imgView mas_updateConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(_iconSize); + }]; +} + +- (UIImage *)image { + return _imgView.image; +} +- (void)setImage:(UIImage *)image { + _imgView.image = image; +} +- (NSString *)text { + return _text; +} +- (void)setText:(NSString *)text { + _text = text; + _label.text = text; +} +- (void)setEffectSelected:(BOOL)effectSelected { + _effectSelected = effectSelected; + _highlightView.hidden = !effectSelected; +} + +@end diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.h b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.h new file mode 100644 index 000000000..0d723f621 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.h @@ -0,0 +1,29 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import "VideoRecorderBeautifyEffectItem.h" + +@class VideoRecorderBeautyManager; + +NS_ASSUME_NONNULL_BEGIN + +static const float VideoRecorderBeautifyStrengthMin = 0; +static const float VideoRecorderBeautifyStrengthMax = 9; +static const float VideoRecorderFilterStrengthMin = 0; +static const float VideoRecorderFilterStrengthMax = 1; + +static const int VideoRecorderEffectSliderMin = 0; +static const int VideoRecorderEffectSliderMax = 9; + + +@interface VideoRecorderBeautifySettings : NSObject +@property(nonatomic) NSArray *beautifyItems; +@property(nonatomic) NSArray *filterItems; +@property(nonatomic) NSInteger activeBeautifyTag; +@property(nonatomic) NSInteger activeFilterIndex; + +- (instancetype)init; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.m b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.m new file mode 100644 index 000000000..a38b23de4 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifySettings.m @@ -0,0 +1,19 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderBeautifySettings.h" +#include "VideoRecorderCommon.h" + +@implementation VideoRecorderBeautifySettings + +- (instancetype)init { + self = [super init]; + if (self != nil) { + _activeBeautifyTag = VideoRecorderEffectItemTagNone; + _beautifyItems = [VideoRecorderBeautifyEffectItem defaultBeautifyEffects]; + _filterItems = [VideoRecorderBeautifyEffectItem defaultFilterEffects]; + } + return self; +} + +@end diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.h b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.h new file mode 100644 index 000000000..5f1d01436 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.h @@ -0,0 +1,29 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import "VideoRecorderBeautifySettings.h" + +NS_ASSUME_NONNULL_BEGIN + +@protocol VideoRecorderBeautifyViewDelegate; + +@interface VideoRecorderBeautifyView : UIView +@property(nonatomic) VideoRecorderBeautifySettings *settings; +@property(weak, nullable, nonatomic) id delegate; + +- (instancetype)initWithFrame:(CGRect)frame; +- (instancetype)initWithFrame:(CGRect)frame settings:(nullable VideoRecorderBeautifySettings *)settings; +@end + +@protocol VideoRecorderBeautifyViewDelegate +- (void)beautifyView:(VideoRecorderBeautifyView *)beautifyView onSettingsChange:(VideoRecorderBeautifySettings *)settings; +@end + +@interface VideoRecorderMarker : UIView +@property(nonatomic) NSString *text; +- (void)showForDuration:(CGFloat)showSeconds withHideAnimeDuration:(CGFloat)hideAnimeSeconds; +- (void)hide; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.m b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.m new file mode 100644 index 000000000..bc1aaf95e --- /dev/null +++ b/chat/uikit/components/videorecorder/View/BeautyView/VideoRecorderBeautifyView.m @@ -0,0 +1,256 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderBeautifyView.h" +#import +#import +#import "VideoRecorderCommon.h" +#import "VideoRecorderBeautifyEffectViewCell.h" +#import "VideoRecorderBeautifyEffectPanelView.h" +#import "VideoRecorderImageUtil.h" +#import "VideoRecorderTabPanelView.h" +#import "VideoRecorderConfigInternal.h" + +#pragma mark - VideoRecorderBeautifyView +@interface VideoRecorderBeautifyView () { + UIView *_topPanel; + UILabel *_lbStrength; + UIImageView *_imgViewCompare; + UISlider *_slider; + VideoRecorderTabPanelView *_tabPanel; + VideoRecorderBeautifyEffectPanelView *_effectPanelBeauty; + VideoRecorderBeautifyEffectPanelView *_effectPanelFilter; + VideoRecorderBeautifyEffectItem *_selectedItem; + VideoRecorderMarker *_marker; +} +@end + +@implementation VideoRecorderBeautifyView + +- (instancetype)initWithFrame:(CGRect)frame { + return [self initWithFrame:frame settings:nil]; +} + +- (instancetype)initWithFrame:(CGRect)frame settings:(VideoRecorderBeautifySettings *)settings { + self = [super initWithFrame:frame]; + _settings = settings; + if (_settings == nil) { + _settings = [[VideoRecorderBeautifySettings alloc] init]; + } + [self initUI]; + return self; +} + +- (void)initUI { + self.opaque = NO; + self.backgroundColor = UIColor.clearColor; + + _topPanel = [[UIView alloc] init]; + _topPanel.hidden = YES; + [self addSubview:_topPanel]; + [_topPanel mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.top.equalTo(self); + make.height.mas_equalTo(52); + }]; + + _marker = [[VideoRecorderMarker alloc] init]; + [_topPanel addSubview:_marker]; + + _lbStrength = [[UILabel alloc] init]; + [_topPanel addSubview:_lbStrength]; + _lbStrength.text = [VideoRecorderCommon localizedStringForKey:@"strength"]; + _lbStrength.textColor = UIColor.whiteColor; + _lbStrength.font = [UIFont systemFontOfSize:16]; + + _imgViewCompare = [[UIImageView alloc] init]; + [_topPanel addSubview:_imgViewCompare]; + _imgViewCompare.image = VideoRecorderBundleThemeImage(@"effect_compare"); + + UIColor* themeColor = [[VideoRecorderConfigInternal sharedInstance] getThemeColor]; + _slider = [[UISlider alloc] init]; + [_topPanel addSubview:_slider]; + //_slider.thumbTintColor = VideoRecorderDynamicColor(@"theme_accent_dark_color", @"#006CFF"); + _slider.minimumValue = VideoRecorderEffectSliderMin; + _slider.maximumValue = VideoRecorderEffectSliderMax; + _slider.minimumTrackTintColor = themeColor;//VideoRecorderDynamicColor(@"theme_accent_dark_color", @"#006CFF"); + _slider.maximumTrackTintColor = VideoRecorderDynamicColor(@"beautify_slider_track_bg_color", @"#F4F5F9"); + UIImage *thumbImg = [VideoRecorderImageUtil createBlueCircleWithWhiteBorder:CGSizeMake(24, 24) withColor:themeColor]; + [_slider setThumbImage:thumbImg forState:UIControlStateNormal]; + [_slider setThumbImage:thumbImg forState:UIControlStateHighlighted]; + [_slider addTarget:self action:@selector(onSliderValueChange) forControlEvents:UIControlEventValueChanged]; + + [_lbStrength mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.equalTo(_topPanel).inset(16); + make.centerY.equalTo(_topPanel); + }]; + [_imgViewCompare mas_makeConstraints:^(MASConstraintMaker *make) { + make.trailing.equalTo(_topPanel).inset(16); + make.centerY.equalTo(_topPanel); + make.width.height.mas_equalTo(30); + }]; + [_slider mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.equalTo(_lbStrength.mas_trailing).inset(8); + make.trailing.equalTo(_imgViewCompare.mas_leading).inset(8); + make.centerY.equalTo(_topPanel); + make.height.mas_equalTo(30); + }]; + + _effectPanelBeauty = [[VideoRecorderBeautifyEffectPanelView alloc] init]; + _effectPanelBeauty.iconSize = CGSizeMake(32, 32); + _effectPanelBeauty.delegate = self; + _effectPanelBeauty.items = _settings.beautifyItems; + + _effectPanelFilter = [[VideoRecorderBeautifyEffectPanelView alloc] init]; + _effectPanelFilter.iconSize = CGSizeMake(56, 56); + _effectPanelFilter.firstIconSize = CGSizeMake(32, 32); + _effectPanelFilter.delegate = self; + _effectPanelFilter.items = _settings.filterItems; + _effectPanelFilter.selectedIndex = 0; + + UIView *bottomPanel = [[UIView alloc] init]; + [self addSubview:bottomPanel]; + bottomPanel.backgroundColor = UIColor.blackColor; + + _tabPanel = [[VideoRecorderTabPanelView alloc] initWithFrame:self.bounds]; + _tabPanel.delegate = self; + _tabPanel.backgroundColor = UIColor.blackColor; + _tabPanel.tabs = @[ + [[VideoRecorderTabPanelTab alloc] initWithName:[VideoRecorderCommon localizedStringForKey:@"beautify"] icon:nil view:_effectPanelBeauty], + [[VideoRecorderTabPanelTab alloc] initWithName:[VideoRecorderCommon localizedStringForKey:@"filter"] icon:nil view:_effectPanelFilter], + ]; + [bottomPanel addSubview:_tabPanel]; + + [bottomPanel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(_topPanel.mas_bottom); + make.leading.trailing.bottom.equalTo(self); + }]; + [_tabPanel mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.top.equalTo(bottomPanel); + make.bottom.equalTo(bottomPanel.mas_safeAreaLayoutGuideBottom); + }]; +} + +- (void)effectPanelSelectionChanged:(VideoRecorderBeautifyEffectPanelView *)panel { + [_marker hide]; + if (panel.selectedIndex == -1 || panel.items[panel.selectedIndex].tag == VideoRecorderEffectItemTagNone) { + panel.selectedIndex = -1; + _selectedItem = nil; + _topPanel.hidden = YES; + if (panel == _effectPanelFilter) { + _settings.activeFilterIndex = -1; + } else { + for (VideoRecorderBeautifyEffectItem *item in _settings.beautifyItems) { + item.strength = 0; + } + } + [_delegate beautifyView:self onSettingsChange:_settings]; + return; + } + _selectedItem = panel.items[panel.selectedIndex]; + if (panel == _effectPanelFilter) { + _settings.activeFilterIndex = panel.selectedIndex; + _topPanel.hidden = NO; + _slider.value = _selectedItem.strength; + [_delegate beautifyView:self onSettingsChange:_settings]; + } else { + _topPanel.hidden = NO; + _slider.value = _selectedItem.strength; + if ([@[ @(VideoRecorderEffectItemTagSmooth), @(VideoRecorderEffectItemTagNatural), @(VideoRecorderEffectItemTagPitu) ] containsObject:@(_selectedItem.tag)]) { + _settings.activeBeautifyTag = _selectedItem.tag; + [_delegate beautifyView:self onSettingsChange:_settings]; + } + } +} + +- (void)tabPanel:(VideoRecorderTabPanelView *)panel selectedIndexChanged:(NSInteger)selectedIndex { + VideoRecorderBeautifyEffectPanelView *effectPanel = (VideoRecorderBeautifyEffectPanelView *)panel.tabs[selectedIndex].view; + [self effectPanelSelectionChanged:effectPanel]; +} + +- (void)onSliderValueChange { + _selectedItem.strength = _slider.value; + + _marker.text = [NSString stringWithFormat:@"%d", (int)_slider.value]; + CGRect trackRect = [_slider trackRectForBounds:_slider.bounds]; + CGRect thumbRect = [_slider thumbRectForBounds:_slider.bounds trackRect:trackRect value:_slider.value]; + CGRect rect = [_topPanel convertRect:thumbRect fromView:_slider]; + [_marker mas_remakeConstraints:^(MASConstraintMaker *make) { + make.width.height.mas_equalTo(34); + make.centerX.equalTo(_topPanel.mas_left).offset(CGRectGetMidX(rect)); + make.bottom.equalTo(_topPanel.mas_top).offset(rect.origin.y - 4); + }]; + [_marker showForDuration:0.5 withHideAnimeDuration:0.5]; + + [_delegate beautifyView:self onSettingsChange:_settings]; +} + +- (void)setSettings:(VideoRecorderBeautifySettings *)settings { + _effectPanelBeauty.items = _settings.beautifyItems; + _effectPanelFilter.items = _settings.filterItems; + _effectPanelFilter.selectedIndex = 0; +} +@end + +#pragma mark - VideoRecorderMarker +@interface VideoRecorderMarker () { + UIImageView *_imgView; + UILabel *_label; + dispatch_block_t hideBlock; +} +@end +@implementation VideoRecorderMarker +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + [self initUI]; + return self; +} +- (void)initUI { + _imgView = [[UIImageView alloc] init]; + [self addSubview:_imgView]; + _imgView.image = VideoRecorderBundleThemeImage(@"marker"); + [_imgView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self); + }]; + + _label = [[UILabel alloc] init]; + [self addSubview:_label]; + _label.textAlignment = NSTextAlignmentCenter; + _label.textColor = UIColor.blackColor; + _label.font = [UIFont systemFontOfSize:12]; + [_label mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.equalTo(self); + make.centerY.equalTo(self); + }]; +} +- (NSString *)text { + return _label.text; +} +- (void)setText:(NSString *)text { + _label.text = text; +} +- (void)showForDuration:(CGFloat)showSeconds withHideAnimeDuration:(CGFloat)hideAnimeSeconds { + self.alpha = 1; + self.hidden = NO; + if (hideBlock != nil) { + dispatch_block_cancel(hideBlock); + } + hideBlock = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS, ^{ + [UIView animateWithDuration:hideAnimeSeconds + animations:^{ + self.alpha = 0; + } + completion:^(BOOL finished) { + self.alpha = 1; + self.hidden = YES; + }]; + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(showSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), hideBlock); +} +- (void)hide { + if (hideBlock != nil) { + dispatch_block_cancel(hideBlock); + } + self.alpha = 1; + self.hidden = YES; +} +@end diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.h b/chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.h new file mode 100644 index 000000000..fcbc68e4c --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.h @@ -0,0 +1,20 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, AuthorizationPrompterType) { + NoSignature, + NoLiteavProSdk +}; + +@interface VideoRecorderAuthorizationPrompterController : UIViewController ++ (Boolean) isHasSignature; ++ (Boolean) isHasLiteavProSdk; ++ (void) showPrompterDialogInViewController:(UIViewController *)presentingVC prompType:(AuthorizationPrompterType) prompType; +@property (nonatomic) AuthorizationPrompterType prompType; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.m b/chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.m new file mode 100644 index 000000000..84bc39a9b --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderAuthorizationPrompterController.m @@ -0,0 +1,121 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderAuthorizationPrompterController.h" +#import +#import "VideoRecorderCommon.h" +#import "VideoRecordSignatureChecker.h" +#import "UGCReflectVideoRecordCore.h" + +#define IM_MULTIMEDIA_PLUGIN_DOCUMENT_URL @"https://cloud.tencent.com/document/product/269/113290" + +@interface VideoRecorderAuthorizationPrompterController () { + UIButton *_confirmButton; +} + +@property (nonatomic, copy) void (^dismissHandler)(void); + +@end + +@implementation VideoRecorderAuthorizationPrompterController + ++ (Boolean) isHasSignature { + return [[VideoRecordSignatureChecker shareInstance] getSetSignatureResult] == VIDEO_RECORD_SIGNATURE_SUCCESS; +} + ++ (Boolean) isHasLiteavProSdk { + return [[UGCReflectVideoRecordCore alloc] init] != nil; +} + ++ (void)showPrompterDialogInViewController:(UIViewController *)presentingVC prompType:(AuthorizationPrompterType) prompType{ +#ifndef DEBUG + return; +#endif + + VideoRecorderAuthorizationPrompterController *dialog = [[VideoRecorderAuthorizationPrompterController alloc] init]; + dialog.modalPresentationStyle = UIModalPresentationOverCurrentContext; + dialog.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + dialog.prompType = prompType; + [presentingVC presentViewController:dialog animated:YES completion:nil]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + [self setupUI]; +} + +- (void)setupUI { + self.view.backgroundColor = [UIColor colorWithWhite:0 alpha:0.5]; + + UIView *container = [[UIView alloc] init]; + container.backgroundColor = [UIColor tertiarySystemBackgroundColor]; + container.layer.cornerRadius = 16; + container.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:container]; + + UIStackView *titleStack = [[UIStackView alloc] init]; + titleStack.axis = UILayoutConstraintAxisHorizontal; + titleStack.spacing = 12; + UILabel *titleLabel = [[UILabel alloc] init]; + titleLabel.text = [VideoRecorderCommon localizedStringForKey:@"prompter"]; + titleLabel.font = [UIFont systemFontOfSize:20]; + titleLabel.textColor = [UIColor labelColor]; + [titleStack addArrangedSubview:titleLabel]; + + UILabel *prompter = [[UILabel alloc] init]; + prompter.numberOfLines = 0; + NSString* prompterKey = @"authorization_prompter_no_signature"; + if (_prompType == NoLiteavProSdk) { + prompterKey = @"authorization_prompter_no_liteav_sdk"; + } + prompter.text = [VideoRecorderCommon localizedStringForKey:prompterKey]; + prompter.font = [UIFont systemFontOfSize:14]; + prompter.textColor = [UIColor secondaryLabelColor]; + + _confirmButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_confirmButton setTitle:[VideoRecorderCommon localizedStringForKey:@"ok"] forState:UIControlStateNormal]; + [_confirmButton addTarget:self + action:@selector(confirmAction) + forControlEvents:UIControlEventTouchUpInside]; + _confirmButton.titleLabel.font = [UIFont systemFontOfSize:16]; + + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[ + titleStack, prompter, _confirmButton + ]]; + + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 6; + stack.alignment = UIStackViewAlignmentLeading; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:stack]; + + [NSLayoutConstraint activateConstraints:@[ + [container.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [container.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor], + [container.widthAnchor constraintEqualToAnchor:self.view.widthAnchor + multiplier:0.8], + + [stack.topAnchor constraintEqualToAnchor:container.topAnchor constant:20], + [stack.leadingAnchor constraintEqualToAnchor:container.leadingAnchor constant:20], + [stack.trailingAnchor constraintEqualToAnchor:container.trailingAnchor constant:-20], + [stack.bottomAnchor constraintEqualToAnchor:container.bottomAnchor constant:-20], + + [_confirmButton.leadingAnchor constraintEqualToAnchor:stack.leadingAnchor] + ]]; +} + +- (void)confirmAction { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +#pragma mark - UITextViewDelegate +- (BOOL)textView:(UITextView *)textView +shouldInteractWithURL:(NSURL *)URL + inRange:(NSRange)characterRange + interaction:(UITextItemInteraction)interaction { + SFSafariViewController *safari = [[SFSafariViewController alloc] initWithURL:URL]; + [self presentViewController:safari animated:YES completion:nil]; + return NO; +} + +@end diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderControlView.h b/chat/uikit/components/videorecorder/View/VideoRecorderControlView.h new file mode 100644 index 000000000..49cdf6115 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderControlView.h @@ -0,0 +1,47 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import +#import "VideoRecorderBeautifySettings.h" + +NS_ASSUME_NONNULL_BEGIN + +@class VideoRecorderControlView; + +typedef NS_ENUM(NSInteger, VideoRecorderAspectRatio) { + VideoRecorderRecordAspectRatio1_1, + VideoRecorderRecordAspectRatio3_4, + VideoRecorderRecordAspectRatio4_3, + VideoRecorderRecordAspectRatio9_16, + VideoRecorderRecordAspectRatio16_9, +}; +typedef void (^VideoRecorderRecordControlCallback)(VideoRecorderControlView *); + +@protocol VideoRecorderControlViewDelegate +- (void)recordControlViewOnRecordStart; +- (void)recordControlViewOnRecordFinish; +- (void)recordControlViewPhoto; +- (void)recordControlViewOnFlashStateChange:(BOOL)flashState; +- (void)recordControlViewOnAspectChange; +- (void)recordControlViewOnExit; +- (void)recordControlViewOnCameraSwicth:(BOOL)isUsingFrontCamera; +- (void)recordControlViewOnBeautify; +@end + + +@interface VideoRecorderControlView : UIView +@property(nonatomic) BOOL flashState; +@property(nonatomic) VideoRecorderAspectRatio aspectRatio; +@property(nonatomic) BOOL isUsingFrontCamera; +@property(readonly, nonatomic) UIView *previewView; +@property(nonatomic) VideoRecorderBeautifySettings *beautifySettings; +@property(nonatomic) BOOL recordTipHidden; +@property(weak, nullable, nonatomic) id delegate; + +- (instancetype)initWithFrame:(CGRect)frame; +- (instancetype)initWithFrame:(CGRect)frame beautifySettings:(VideoRecorderBeautifySettings *)beautifySettings; + +- (void)setProgress:(float)progress duration:(float)duration; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderControlView.m b/chat/uikit/components/videorecorder/View/VideoRecorderControlView.m new file mode 100644 index 000000000..aa8067f27 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderControlView.m @@ -0,0 +1,410 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderControlView.h" + +#import +#import "VideoRecorderCommon.h" +#import "VideoRecorderIconLabelButtonView.h" +#import "VideoRecorderRecordButtonView.h" +#include "videoRecorderConfigInternal.h" +#import "VideoRecorderAuthorizationPrompterController.h" + +#define MIXED_RECORD_MODE 0 +#define PHOTO_ONLY_RECORD_MODE 1 +#define VIDEO_ONLY_RECORD_MODE 2 + +#pragma mark - UI relative constants +const static CGFloat BtnStartRecordSize = 72; +const static CGFloat BtnStartRecordGapBottom = 15; +const static CGFloat BtnStartRecordDotSizeNormal = 56; +const static CGFloat BtnStartRecordDotSizePressed = 20; +const static CGFloat BtnStartRecordProgressSizeNormal = 72; +const static CGFloat BtnStartRecordProgressSizePressed = 80; + +const static CGSize BtnExitRecordSize = VideoRecorderConstCGSize(28, 28); +const static CGFloat BtnExitRecordGapToStartRecord = 55; + +const static CGSize BtnCameraSwitch = VideoRecorderConstCGSize(28, 28); +const static CGFloat BtnCameraSwitchGapToStartRecord = 55; + +const static CGFloat FunctionBtnToToTop = 64; +const static CGFloat FunctionBtnToRight = 16; + +const static CGSize BtnExtendFunctionIconSize = VideoRecorderConstCGSize(28, 28); +const static CGFloat BtnExtendFunctionIconTextGap = 4; +const static CGFloat BtnExtendFunctionGap = 24; + +const static BOOL ShowDurationLabel = YES; + +#pragma mark - VideoRecorderRecordControlView + +@interface VideoRecorderControlView () { + VideoRecorderRecordButtonView *_btnRecord; + UIButton *_btnExitRecord; + UIButton *_btnCameraSwitch; + UIButton *_btnFlash; + UIButton *_btnBeautify; + UIButton *_btnAspect; + UIButton *_lastFuncitonBtn; + UILabel *_lbDuration; + UILabel *_lbTip; + int _recordeMode; +} +@end + +@implementation VideoRecorderControlView + +- (instancetype)initWithFrame:(CGRect)frame { + return [self initWithFrame:frame beautifySettings:nil]; +} + +- (instancetype)initWithFrame:(CGRect)frame beautifySettings:(VideoRecorderBeautifySettings *)beautifySettings { + self = [super initWithFrame:frame]; + _isUsingFrontCamera = NO; + _flashState = NO; + _aspectRatio = VideoRecorderRecordAspectRatio9_16; + _beautifySettings = beautifySettings; + _recordeMode = [[VideoRecorderConfigInternal sharedInstance] getRecordeMode]; + if (_beautifySettings == nil) { + _beautifySettings = [[VideoRecorderBeautifySettings alloc] init]; + } + [self initUI]; + return self; +} + +- (void)setProgress:(float)progress duration:(float)duration { + [_btnRecord setProgress:progress]; + int m = floor(duration / 60); + int s = floor(duration - m * 60); + _lbDuration.text = [NSString stringWithFormat:@"%02d:%02d", m, s]; +} +#pragma mark - UI Init + +- (void)initUI { + _previewView = [[UIView alloc] init]; + [self addSubview:_previewView]; + [_previewView mas_remakeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self); + make.width.equalTo(self); + make.height.equalTo(_previewView.mas_width).multipliedBy(16.0 / 9.0); + }]; + + UIView *_bottomMaskView = [[UIView alloc] init]; + [self addSubview:_bottomMaskView]; + _bottomMaskView.backgroundColor = UIColor.blackColor; + + [self initControlButtons]; + [self initFunctionButtons]; + + _lbDuration = [[UILabel alloc] init]; + [self addSubview:_lbDuration]; + _lbDuration.hidden = YES; + _lbDuration.text = @"00:00"; + _lbDuration.font = [UIFont monospacedSystemFontOfSize:18 weight:UIFontWeightMedium]; + _lbDuration.textColor = UIColor.whiteColor; + [_lbDuration mas_makeConstraints:^(MASConstraintMaker *make) { + make.bottom.equalTo(_btnRecord.mas_top).inset(8); + make.centerX.equalTo(_btnRecord); + }]; + + [_bottomMaskView mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.bottom.equalTo(self); + make.top.equalTo(_lbDuration.mas_top).offset(-8); + }]; + + _lbTip = [[UILabel alloc] init]; + [self addSubview:_lbTip]; + if (_recordeMode == MIXED_RECORD_MODE) { + _lbTip.text = [VideoRecorderCommon localizedStringForKey:@"record_mode_mix_tip"]; + } else if (_recordeMode == PHOTO_ONLY_RECORD_MODE) { + _lbTip.text = [VideoRecorderCommon localizedStringForKey:@"record_mode_photo_tip"]; + } else { + _lbTip.text = [VideoRecorderCommon localizedStringForKey:@"record_mode_video_tip"]; + } + + _lbTip.font = [UIFont systemFontOfSize:16]; + _lbTip.textColor = UIColor.whiteColor; + [_lbTip mas_makeConstraints:^(MASConstraintMaker *make) { + make.bottom.lessThanOrEqualTo(_previewView).inset(16); + make.bottom.lessThanOrEqualTo(_bottomMaskView.mas_top).offset(-16); + make.centerX.equalTo(self); + }]; +} + +- (void)initControlButtons { + _btnRecord = [[VideoRecorderRecordButtonView alloc] init]; + [self addSubview:_btnRecord]; + _btnRecord.dotSizeNormal = BtnStartRecordDotSizeNormal; + _btnRecord.dotSizePressed = BtnStartRecordDotSizePressed; + _btnRecord.progressSizeNormal = BtnStartRecordProgressSizeNormal; + _btnRecord.progressSizePressed = BtnStartRecordProgressSizePressed; + _btnRecord.isOnlySupportTakePhoto = _recordeMode != PHOTO_ONLY_RECORD_MODE; + _btnRecord.delegate = self; + _btnExitRecord = [self newCustomButtonWithImage:VideoRecorderBundleThemeImage(@"cross") onTouchUpInside:@selector(onBtnExitClick)]; + _btnCameraSwitch = [self newCustomButtonWithImage:VideoRecorderBundleThemeImage(@"camera_switch") + onTouchUpInside:@selector(onBtnCameraSwitchClick)]; + + [_btnRecord mas_makeConstraints:^(MASConstraintMaker *make) { + make.width.height.mas_equalTo(BtnStartRecordSize); + make.centerX.equalTo(self); + make.bottom.equalTo(self.mas_safeAreaLayoutGuideBottom).inset(BtnStartRecordGapBottom); + }]; + [_btnExitRecord mas_makeConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(BtnExitRecordSize); + make.centerY.equalTo(_btnRecord); + make.trailing.equalTo(_btnRecord.mas_leading).inset(BtnExitRecordGapToStartRecord); + }]; + [_btnCameraSwitch mas_makeConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(BtnCameraSwitch); + make.centerY.equalTo(_btnRecord); + make.leading.equalTo(_btnRecord.mas_trailing).inset(BtnCameraSwitchGapToStartRecord); + }]; +} + +- (void)initFunctionButtons { + [self initTorchView]; + [self initBeautyView]; + [self initAspectView]; +} + +- (void) initTorchView { + if (![[VideoRecorderConfigInternal sharedInstance] isSupportRecordTorch]) { + return; + } + + _btnFlash = [self newFunctionButtonWithImage:VideoRecorderBundleThemeImage(@"flash_close") title:[VideoRecorderCommon localizedStringForKey:@"flash"] + onTouchUpInside:@selector(onBtnFlashClick)]; + + [_btnFlash mas_makeConstraints:^(MASConstraintMaker *make) { + make.trailing.equalTo(self).inset(FunctionBtnToRight); + make.top.equalTo(self).inset(FunctionBtnToToTop); + }]; + + _lastFuncitonBtn = _btnFlash; +} + +- (void) initBeautyView { + if (![[VideoRecorderConfigInternal sharedInstance] isSupportRecordBeauty]) { + return; + } + + +#ifndef DEBUG + if (![VideoRecorderAuthorizationPrompterController isHasSignature] + || ![VideoRecorderAuthorizationPrompterController isHasLiteavProSdk]) { + return; + } +#endif + + _btnBeautify = [self newFunctionButtonWithImage:VideoRecorderBundleThemeImage(@"beauty_record") + title:[VideoRecorderCommon localizedStringForKey:@"beautify"] + onTouchUpInside:@selector(onBtnBeautifyClick)]; + + [_btnBeautify mas_makeConstraints:^(MASConstraintMaker *make) { + if (_lastFuncitonBtn == nil) { + make.top.equalTo(self).inset(FunctionBtnToToTop); + make.trailing.equalTo(self).inset(FunctionBtnToRight); + } else { + make.centerX.equalTo(_lastFuncitonBtn); + make.top.equalTo(_lastFuncitonBtn.mas_bottom).inset(BtnExtendFunctionGap); + } + _lastFuncitonBtn = _btnBeautify; + }]; +} + +- (void) initAspectView { + if (![[VideoRecorderConfigInternal sharedInstance] isSupportRecordAspect]) { + return; + } + +#ifndef DEBUG + if (![VideoRecorderAuthorizationPrompterController isHasLiteavProSdk]) { + return; + } +#endif + + _btnAspect = [self newFunctionButtonWithImage:VideoRecorderBundleThemeImage(@"record_aspect_9_16") + title:[VideoRecorderCommon localizedStringForKey:@"aspect"] + onTouchUpInside:@selector(onBtnAspectClick)]; + + [_btnAspect mas_makeConstraints:^(MASConstraintMaker *make) { + if (_lastFuncitonBtn == nil) { + make.top.equalTo(self).inset(FunctionBtnToToTop); + make.trailing.equalTo(self).inset(FunctionBtnToRight); + } else { + make.centerX.equalTo(_lastFuncitonBtn); + make.top.equalTo(_lastFuncitonBtn.mas_bottom).inset(BtnExtendFunctionGap); + } + _lastFuncitonBtn = _btnBeautify; + }]; +} + +- (UIButton *)newFunctionButtonWithImage:(UIImage *)img title:(NSString *)title onTouchUpInside:(SEL)sel { + VideoRecorderIconLabelButtonView *btn = [VideoRecorderIconLabelButtonView buttonWithType:UIButtonTypeCustom]; + [btn setImage:img forState:UIControlStateNormal]; + [btn setAttributedTitle:[[NSAttributedString alloc] + initWithString:title + attributes:@{ + NSFontAttributeName : [UIFont systemFontOfSize:12], + NSForegroundColorAttributeName : VideoRecorderDynamicColor(@"record_func_btn_text_color", @"#FFFFFF"), + }] + forState:UIControlStateNormal]; + [btn addTarget:self action:sel forControlEvents:UIControlEventTouchUpInside]; + btn.iconSize = BtnExtendFunctionIconSize; + btn.iconLabelGap = BtnExtendFunctionIconTextGap; + [self addSubview:btn]; + return btn; +} +- (UIButton *)newCustomButtonWithImage:(nullable UIImage *)image onTouchUpInside:(nullable SEL)sel { + UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; + [btn setImage:image forState:UIControlStateNormal]; + if (sel != nil) { + [btn addTarget:self action:sel forControlEvents:UIControlEventTouchUpInside]; + } + [self addSubview:btn]; + return btn; +} + +#pragma mark - Actions +- (void)onBtnExitClick { + [_delegate recordControlViewOnExit]; +} + +- (void)onBtnCameraSwitchClick { + if (_isUsingFrontCamera) { + _isUsingFrontCamera = NO; + _btnFlash.enabled = YES; + } else { + _isUsingFrontCamera = YES; + _btnFlash.enabled = NO; + [self setFlashState:NO]; + } + [_delegate recordControlViewOnCameraSwicth:_isUsingFrontCamera]; +} + +- (void)onBtnFlashClick { + if (_isUsingFrontCamera) { + return; + } + [self setFlashState:!_flashState]; +} + +- (void)onBtnAspectClick { + if (_aspectRatio == VideoRecorderRecordAspectRatio9_16) { + _aspectRatio = VideoRecorderRecordAspectRatio3_4; + [_btnAspect setImage:VideoRecorderBundleThemeImage(@"record_aspect_3_4") forState:UIControlStateNormal]; + // [_previewView mas_remakeConstraints:^(MASConstraintMaker *make) { + // make.center.equalTo(self); + // make.width.equalTo(self); + // make.height.equalTo(_previewView.mas_width).multipliedBy(4.0 / 3.0); + // }]; + } else { + _aspectRatio = VideoRecorderRecordAspectRatio9_16; + [_btnAspect setImage:VideoRecorderBundleThemeImage(@"record_aspect_9_16") forState:UIControlStateNormal]; + // [_previewView mas_remakeConstraints:^(MASConstraintMaker *make) { + // make.top.equalTo(self); + // make.width.equalTo(self); + // make.height.equalTo(_previewView.mas_width).multipliedBy(16.0 / 9.0); + // }]; + } + [_delegate recordControlViewOnAspectChange]; +} + +- (void)onBtnBeautifyClick { + [_delegate recordControlViewOnBeautify]; +} + +#pragma mark - VideoRecorderRecordButtonDelegate protocol +- (void)onRecordButtonLongPressBegan:(VideoRecorderRecordButtonView *)btn { + if (_recordeMode != PHOTO_ONLY_RECORD_MODE) { + [_delegate recordControlViewOnRecordStart]; + _lbDuration.hidden = !ShowDurationLabel; + } else { + _lbDuration.hidden = YES; + } + + _lbTip.hidden = YES; + _btnExitRecord.hidden = YES; + _btnCameraSwitch.hidden = YES; + + if (_btnFlash != nil) { + _btnFlash.hidden = YES; + } + if (_btnBeautify != nil) { + _btnBeautify.hidden = YES; + } + if (_btnAspect != nil) { + _btnAspect.hidden = YES; + } +} + +- (void)onRecordButtonLongPressEnded:(VideoRecorderRecordButtonView *)btn { + if (_recordeMode != PHOTO_ONLY_RECORD_MODE) { + [_delegate recordControlViewOnRecordFinish]; + } else if (_recordeMode != VIDEO_ONLY_RECORD_MODE){ + [_delegate recordControlViewPhoto]; + } + + _lbDuration.hidden = YES; + + _lbTip.hidden = NO; + _btnExitRecord.hidden = NO; + _btnCameraSwitch.hidden = NO; + if (_btnFlash != nil) { + _btnFlash.hidden = NO; + } + if (_btnBeautify != nil) { + _btnBeautify.hidden = NO; + } + if (_btnAspect != nil) { + _btnAspect.hidden = NO; + } +} + +- (void)onRecordButtonLongPressCancelled:(VideoRecorderRecordButtonView *)btn { + if (_recordeMode != PHOTO_ONLY_RECORD_MODE) { + [_delegate recordControlViewOnRecordFinish]; + } + + _lbDuration.hidden = YES; + + _lbTip.hidden = NO; + _btnExitRecord.hidden = NO; + _btnCameraSwitch.hidden = NO; + if (_btnFlash != nil) { + _btnFlash.hidden = NO; + } + if (_btnBeautify != nil) { + _btnBeautify.hidden = NO; + } + if (_btnAspect != nil) { + _btnAspect.hidden = NO; + } +} + +- (void)onRecordButtonTap:(VideoRecorderRecordButtonView *)btn { + if (_recordeMode != VIDEO_ONLY_RECORD_MODE) { + [_delegate recordControlViewPhoto]; + } +} + +#pragma mark - Properties +- (void)setFlashState:(BOOL)flashState { + _flashState = flashState; + if (_flashState) { + [_btnFlash setImage:VideoRecorderBundleThemeImage(@"flash_open") forState:UIControlStateNormal]; + } else { + [_btnFlash setImage:VideoRecorderBundleThemeImage(@"flash_close") forState:UIControlStateNormal]; + } + [_delegate recordControlViewOnFlashStateChange:_flashState]; +} + +- (BOOL)recordTipHidden { + return _lbTip.hidden; +} + +- (void)setRecordTipHidden:(BOOL)recordTipHidden { + _lbTip.hidden = recordTipHidden; +} +@end diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderController.h b/chat/uikit/components/videorecorder/View/VideoRecorderController.h new file mode 100644 index 000000000..02ea7d662 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderController.h @@ -0,0 +1,17 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^VideoRecorderRecordResultCallback)(NSString *_Nullable videoPath, UIImage *_Nullable image, int32_t duration); + +@interface VideoRecorderController : UIViewController +@property(nullable, nonatomic, strong) NSString* recordFilePath; +@property(nullable, nonatomic) VideoRecorderRecordResultCallback resultCallback; +@property(nullable, nonatomic, copy) void (^photoPreviewOverride)(VideoRecorderController * _Nonnull controller, UIImage * _Nonnull image); +- (instancetype)init; +@end + +NS_ASSUME_NONNULL_END diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderController.m b/chat/uikit/components/videorecorder/View/VideoRecorderController.m new file mode 100644 index 000000000..8749ec1ff --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderController.m @@ -0,0 +1,430 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import "VideoRecorderController.h" + +#import +#import + +#import "VideoRecorderBeautifyView.h" +#import "VideoRecorderCommon.h" +#import "VideoRecorderControlView.h" +#include "videoRecorderConfigInternal.h" +#import "VideoRecorderEncodeConfig.h" +#import "VideoRecorderAuthorizationPrompterController.h" +#import "UGCReflectVideoRecordCore.h" +#import "SystemVideoRecordCore.h" +#import "VideoRecorderBeautyManager.h" +#import "VideoRecorderPreviewView.h" +#import "VideoRecordSignatureChecker.h" + +#define MIXED_RECORD_MODE 0 + +@interface VideoRecorderController () { + VideoRecordCore* _recordCore; + VideoRecorderBeautifyView *_beautifyView; + VideoRecorderBeautifySettings *_settings; + VideoRecorderControlView *_ctrlView; + VideoRecorderPreviewView *_videoPlayerView; + UIScreenEdgePanGestureRecognizer *_edgePanGesture; + VideoRecordResult* _recorderResult; + VideoRecorderEncodeConfig* _encodeConfig; + CGFloat _zoom; + BOOL _originNavgationBarHidden; + BOOL _recordForEdit; + BOOL _isUsingFrontCamera; + float _recordDuration; + float _minDurationSeconds; + float _maxDurationSeconds; +} + +@end + +@implementation VideoRecorderController + +- (instancetype)init { + self = [super init]; + _isUsingFrontCamera = [[VideoRecorderConfigInternal sharedInstance] isDefaultFrontCamera]; + _minDurationSeconds = [[VideoRecorderConfigInternal sharedInstance] getMinRecordDurationMs] / 1000.0f; + _maxDurationSeconds = [[VideoRecorderConfigInternal sharedInstance] getMaxRecordDurationMs] / 1000.0f; + _encodeConfig = [[VideoRecorderEncodeConfig alloc] initWithVideoQuality:[[VideoRecorderConfigInternal sharedInstance] getVideoQuality]]; + _settings = [[VideoRecorderBeautifySettings alloc] init]; + _zoom = 1.0; + _recordForEdit = YES; + _recordCore = [[UGCReflectVideoRecordCore alloc] init]; + if (_recordCore == nil) { + NSLog(@"Because it does not rely on the LiteAV module, the UGC video recorder cannot be used. Instead, the system video recorder can be used. For more details, please refer to the relevant documentation"); + _recordCore = [[SystemVideoRecordCore alloc] init]; + } + return self; +} + +- (void)viewDidLoad { + [self initUI]; +} + +#pragma mark - UI Init + +- (void)initUI { + self.view.backgroundColor = UIColor.blackColor; + + _ctrlView = [[VideoRecorderControlView alloc] initWithFrame:self.view.bounds]; + _ctrlView.delegate = self; + [self.view addSubview:_ctrlView]; + + _videoPlayerView = [[VideoRecorderPreviewView alloc] init]; + [self.view addSubview:_videoPlayerView]; + _videoPlayerView.hidden = YES; + _videoPlayerView.delegate = self; + + CGRect frame = CGRectMake(0, self.view.frame.size.height - 250, self.view.frame.size.width, 250); + _beautifyView = [[VideoRecorderBeautifyView alloc] initWithFrame:frame settings:_settings]; + _beautifyView.delegate = self; + _beautifyView.hidden = YES; + [self.view addSubview:_beautifyView]; + + [self startPreview]; + [self addGestureRecognizer]; + [self addEdgePanGesture]; + + UIPinchGestureRecognizer *pinchRec = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(onPinch:)]; + [_ctrlView addGestureRecognizer:pinchRec]; +} + +- (void)startPreview { + NSDictionary *config = @{ + @"videoResolution": @([_encodeConfig getVideoRecordResolution]), + @"videoFPS": @(_encodeConfig.fps), + @"videoBitratePIN": @(_encodeConfig.bitrate), + @"minDuration": @(_minDurationSeconds), + @"maxDuration": @(_maxDurationSeconds), + @"frontCamera":@(_isUsingFrontCamera) + }; + + [self setAspectRatio]; + [_recordCore startCameraCustom:config preview:_ctrlView.previewView]; + + [self beautifyController:_settings]; +} + +- (void)viewWillAppear:(BOOL)animated { + [self startPreview]; + if (self.navigationController != nil) { + _originNavgationBarHidden = self.navigationController.navigationBarHidden; + self.navigationController.navigationBarHidden = YES; + } + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(appDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(appWillResignActive) name:UIApplicationWillResignActiveNotification object:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [_recordCore stopCameraPreview]; + _ctrlView.flashState = NO; + if (self.navigationController != nil) { + self.navigationController.navigationBarHidden = _originNavgationBarHidden; + } + [NSNotificationCenter.defaultCenter removeObserver:self]; +} + +- (void)appDidBecomeActive { + [_videoPlayerView resume]; + [self startPreview]; +} + +- (void)appWillResignActive { + [_recordCore stopCameraPreview]; + [_videoPlayerView pause]; + _ctrlView.flashState = NO; +} + +- (void)presentSimpleAlertWithTitle:(NSString *)title message:(NSString *)message onOk:(void (^)(void))onOk { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + UIAlertAction *defaultAction = [UIAlertAction actionWithTitle:@"OK" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_Nonnull action) { + if (onOk != nil) { + onOk(); + } + }]; + [alert addAction:defaultAction]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)takePhoto { + int res = [_recordCore snapshot:^(UIImage *image) { + NSLog(@"take photo complete"); + [self privewPhoto:image]; + self->_ctrlView.flashState = NO; + }]; + if (res == -5) { + [VideoRecorderAuthorizationPrompterController showPrompterDialogInViewController:self prompType:NoSignature]; + } +} + +- (void) privewPhoto:(UIImage *)image { + if (self.photoPreviewOverride != nil) { + self.photoPreviewOverride(self, image); + return; + } + _videoPlayerView.hidden = NO; + _videoPlayerView.frame = self.view.frame; + [_videoPlayerView previewPhoto:image]; +} + +- (void) setAspectRatio { + switch (_ctrlView.aspectRatio) { + case VideoRecorderRecordAspectRatio3_4: { + [_recordCore setAspectRatio:VIDEO_ASPECT_RATIO_3_4]; + break; + } + case VideoRecorderRecordAspectRatio9_16: { + [_recordCore setAspectRatio:VIDEO_ASPECT_RATIO_9_16]; + break; + } + default:; + } +} + +#pragma mark - UIPinchGestureRecognizer +- (void)onPinch:(UIPinchGestureRecognizer *)rec { + _zoom = MIN(MAX(_zoom * rec.scale, 1.0), 5.0); + rec.scale = 1; + [_recordCore setZoom:_zoom]; +} + +#pragma mark - VideoRecorderRecordControlViewDelegate protocol +- (void)recordControlViewOnAspectChange { + if ([_recordCore isKindOfClass:SystemVideoRecordCore.class]) { + [VideoRecorderAuthorizationPrompterController showPrompterDialogInViewController:self prompType:NoLiteavProSdk]; + return; + } + [self setAspectRatio]; +} + +- (void)recordControlViewOnCameraSwicth:(BOOL)isUsingFrontCamera { + [_recordCore switchCamera:isUsingFrontCamera]; + _isUsingFrontCamera = isUsingFrontCamera; +} + +- (void)recordControlViewOnExit { + [_recordCore stopCameraPreview]; + _ctrlView.flashState = NO; + if (_resultCallback != nil) { + _resultCallback(nil, nil, 0); + } +} + +- (void)recordControlViewOnFlashStateChange:(BOOL)flashState { + [_recordCore toggleTorch:flashState]; +} + +- (void)recordControlViewOnRecordStart { + _recordCore.recordDelegate = self; + int res = [_recordCore startRecord:_recordFilePath]; + NSLog(@"record start. res = %d", res); + if (res == -5) { + [VideoRecorderAuthorizationPrompterController showPrompterDialogInViewController:self prompType:NoSignature]; + } + _recordDuration = 0; + return; +} + +- (void)recordControlViewOnRecordFinish { + [_recordCore stopRecord]; + _ctrlView.flashState = NO; +} + +- (void)recordControlViewPhoto { + [self takePhoto]; +} + +- (void)recordControlViewOnBeautify { + if (_beautifyView.hidden) { + _ctrlView.recordTipHidden = YES; + _beautifyView.hidden = NO; + } else { + _beautifyView.hidden = YES; + _ctrlView.recordTipHidden = NO; + } + + if ([_recordCore isKindOfClass:SystemVideoRecordCore.class]) { + [VideoRecorderAuthorizationPrompterController showPrompterDialogInViewController:self prompType:NoLiteavProSdk]; + return; + } + + if ([[VideoRecordSignatureChecker shareInstance] getSetSignatureResult] != VIDEO_RECORD_SIGNATURE_SUCCESS) { + [VideoRecorderAuthorizationPrompterController showPrompterDialogInViewController:self prompType:NoSignature]; + return; + } +} + +#pragma mark - TXUGCRecordListener protocol +- (void)onRecordProgress:(NSInteger)milliSecond { + if (milliSecond == 0) { + return; + } + + _recordDuration = milliSecond / 1000.0; + [_ctrlView setProgress:_recordDuration / _maxDurationSeconds duration:_recordDuration]; + if (milliSecond / 1000.0 >= _maxDurationSeconds) { + [_recordCore stopRecord]; + _ctrlView.flashState = NO; + } +} + +- (void)onRecordComplete:(VideoRecordResult *)result { + [_recordCore deleteTempFile]; + if (result.retCode == VIDEO_RECORD_RESULT_FAILED) { + NSString *title = [VideoRecorderCommon localizedStringForKey:@"record_failed"]; + [self presentSimpleAlertWithTitle:title + message:@"" + onOk:^{ + [self startPreview]; + }]; + return; + } + + NSLog(@"on record complete record duration: %f", _recordDuration); + if (_recordDuration < _minDurationSeconds) { + if ([[VideoRecorderConfigInternal sharedInstance] getRecordeMode] == MIXED_RECORD_MODE) { + [self takePhoto]; + } + return; + } + + _recorderResult = result; + _videoPlayerView.hidden = NO; + _videoPlayerView.frame = self.view.frame; + _ctrlView.hidden = YES; + [_videoPlayerView play:result.videoPath]; +} + +#pragma mark - VideoPreviewViewDelegate +- (void)previewAccept:(NSString*) videoPath image:(UIImage*) image { + if (_resultCallback != nil) { + _resultCallback(videoPath, image, self->_recordDuration * 1000); + } +} + +- (void)previewCancel { + NSLog(@"VideoPreviewViewDelegate cancle"); + _videoPlayerView.hidden = YES; + _ctrlView.hidden = NO; +} + + +#pragma mark - VideoRecorderBeautifyViewDelegate protocol +- (void)beautifyView:(VideoRecorderBeautifyView *)beautifyView onSettingsChange:(VideoRecorderBeautifySettings *)settings { + [self beautifyController:settings]; +} + +- (void)beautifyController:(VideoRecorderBeautifySettings *)settings { + VideoRecorderBeautyManager *manager = [_recordCore getBeautyManager]; + [manager setBeautyStyle:TXBeautyStyleSmooth]; + __auto_type convertStrength = ^(int strength, float min, float max) { + float ratio = ((float)strength - VideoRecorderEffectSliderMin) / (VideoRecorderEffectSliderMax - VideoRecorderEffectSliderMin); + return ratio * (max - min) + min; + }; + __auto_type convertBeautifyStrength = ^(int strength) { + return convertStrength(strength, VideoRecorderBeautifyStrengthMin, VideoRecorderBeautifyStrengthMax); + }; + __auto_type convertFilterStrength = ^(int strength) { + return convertStrength(strength, VideoRecorderFilterStrengthMin, VideoRecorderFilterStrengthMax); + }; + if (settings.activeBeautifyTag == VideoRecorderEffectItemTagNone) { + [manager setBeautyLevel:0]; + } + for (VideoRecorderBeautifyEffectItem *item in settings.beautifyItems) { + float strength = convertBeautifyStrength(item.strength); + if (settings.activeBeautifyTag == item.tag) { + switch (item.tag) { + case VideoRecorderEffectItemTagSmooth: + [manager setBeautyStyle:TXBeautyStyleSmooth]; + break; + case VideoRecorderEffectItemTagNatural: + [manager setBeautyStyle:TXBeautyStyleNature]; + break; + case VideoRecorderEffectItemTagPitu: + [manager setBeautyStyle:TXBeautyStylePitu]; + break; + } + [manager setBeautyLevel:strength]; + } + switch (item.tag) { + case VideoRecorderEffectItemTagWhiteness: + [manager setWhitenessLevel:strength]; + break; + case VideoRecorderEffectItemTagRuddy: + [manager setRuddyLevel:strength]; + break; + } + } + NSInteger idx = settings.activeFilterIndex; + if (idx >= 0 && idx < settings.filterItems.count) { + float strength = convertFilterStrength(settings.filterItems[idx].strength); + [manager setFilter:settings.filterItems[idx].filterMapImage]; + [manager setFilterStrength:strength]; + } else { + [manager setFilter:nil]; + } +} + +- (void) addGestureRecognizer { + UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] + initWithTarget:self + action:@selector(handleSingleTap:)]; + [_ctrlView.previewView addGestureRecognizer:singleTap]; +} + +- (void)handleSingleTap:(UITapGestureRecognizer *)recognizer { + if (!_beautifyView.hidden) { + _beautifyView.hidden = YES; + _ctrlView.recordTipHidden = NO; + } +} + +- (void)removeEdgePanGesture { + if (_edgePanGesture) { + [self.view removeGestureRecognizer:_edgePanGesture]; + _edgePanGesture = nil; + } +} +- (void)addEdgePanGesture { + _edgePanGesture = [[UIScreenEdgePanGestureRecognizer alloc] + initWithTarget:self + action:@selector(handleEdgePanGesture:)]; + _edgePanGesture.edges = UIRectEdgeLeft; + [self.view addGestureRecognizer:_edgePanGesture]; +} + +- (void)handleEdgePanGesture:(UIScreenEdgePanGestureRecognizer *)gesture { + CGPoint translation = [gesture translationInView:self.view]; + + switch (gesture.state) { + case UIGestureRecognizerStateBegan: + break; + case UIGestureRecognizerStateChanged: { + break; + } + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: { + CGFloat xVelocity = [gesture velocityInView:self.view].x; + CGFloat xProgress = fabs(translation.x / self.view.bounds.size.width); + + CGFloat yVelocity = [gesture velocityInView:self.view].y; + CGFloat yProgress = fabs(translation.y / self.view.bounds.size.height); + + if (xProgress > 0.5 || xVelocity > 1000 || yVelocity > 0.5 || yProgress > 1000) { + if (_resultCallback) { + _resultCallback(nil, nil, 0); + } + } + break; + } + default: + break; + } +} + + +@end diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderPhotoEditBridge.swift b/chat/uikit/components/videorecorder/View/VideoRecorderPhotoEditBridge.swift new file mode 100644 index 000000000..4471f6892 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderPhotoEditBridge.swift @@ -0,0 +1,63 @@ +import UIKit +import SnapKit +import AlbumPickerCore + +@objcMembers +final class VideoRecorderPhotoEditBridge: NSObject { + + public static func present(from source: UIViewController, + image: UIImage, + onConfirm: @escaping (UIImage) -> Void, + onCancel: @escaping () -> Void) { + AlbumPickerCoreTheme.shared.currentPrimaryColor = ChatUIKitTheme.colors.buttonColorPrimaryDefault + let editor = VideoRecorderPhotoEditViewController(image: image, onConfirm: onConfirm, onCancel: onCancel) + editor.modalPresentationStyle = .fullScreen + source.present(editor, animated: true) + } +} + +private final class VideoRecorderPhotoEditViewController: UIViewController { + override var prefersStatusBarHidden: Bool { + return true + } + + private let editView: ImageEditView + + private let onConfirm: (UIImage) -> Void + + private let onCancel: () -> Void + + init(image: UIImage, onConfirm: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { + self.editView = ImageEditView(sourceImage: image) + self.onConfirm = onConfirm + self.onCancel = onCancel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(editView) + editView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + editView.editDelegate = self + } +} + +extension VideoRecorderPhotoEditViewController: ImageEditDelegate { + + func imageEditView(_ editView: ImageEditView, didCompleteWithImage editedImage: UIImage) { + presentingViewController?.dismiss(animated: false) + onConfirm(editedImage) + } + + func imageEditViewDidCancel(_ editView: ImageEditView) { + dismiss(animated: true) { [onCancel] in + onCancel() + } + } +} diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.h b/chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.h new file mode 100644 index 000000000..88d00d04f --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.h @@ -0,0 +1,21 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#import + +@protocol VideoRecorderPreviewViewDelegate; + +@interface VideoRecorderPreviewView : UIView + +@property(weak, nullable, nonatomic) id delegate; +- (void) previewPhoto:(UIImage*_Nullable) image; +- (void) play:(NSString*_Nullable)videoPath; +- (void) stop; +- (void) pause; +- (void) resume; +@end + +@protocol VideoRecorderPreviewViewDelegate +- (void)previewAccept:(NSString*) videoPath image:(UIImage*) image; +- (void)previewCancel; +@end diff --git a/chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.m b/chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.m new file mode 100644 index 000000000..7dfd83d83 --- /dev/null +++ b/chat/uikit/components/videorecorder/View/VideoRecorderPreviewView.m @@ -0,0 +1,292 @@ +// Copyright (c) 2024 Tencent. All rights reserved. +// Author: eddardliu + +#include "VideoRecorderPreviewView.h" +#include "videoRecorderConfigInternal.h" +#include "VideoRecorderCommon.h" +#import + +#define FUNCTION_BUTTON_SIZE CGSizeMake(28, 28) +#define BUTTON_SEND_SIZE CGSizeMake(72, 34) + +@interface VideoRecorderPreviewView() { + NSString* _videoPath; + AVPlayer* _player; + UIImageView *_imgView; + UIScreenEdgePanGestureRecognizer *_edgePanGesture; + UITapGestureRecognizer *_tapGesture; + UIStackView *_stkViewButtons; + UIButton *_btnSend; + UIButton *_btnCancel; + UIImage* _image; + UIView *_bottomBackground; +} +@end + + +@implementation VideoRecorderPreviewView + +- (instancetype)init { + self = [super init]; + if (self) { + [self initUI]; + } + return self; +} + +-(void)initUI { + self.backgroundColor = [UIColor blackColor]; + + _imgView = [[UIImageView alloc] init]; + [self addSubview:_imgView]; + _imgView.hidden = YES; + + [self initFuncitonBtnStackView]; + [self initSendAndCancelBtn]; + [self addBottomBackground]; +} + +-(void) play:(NSString*)videoPath { + if (_player != nil) { + [_player pause]; + _player = nil;; + } + _videoPath = videoPath; + _image = nil; + + [self addPanGesture]; + _imgView.hidden = YES; + + NSURL *url = [NSURL fileURLWithPath:videoPath]; + _player = [AVPlayer playerWithURL:url]; + + AVPlayerLayer *playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player]; + playerLayer.frame = self.bounds; + [self.layer addSublayer:playerLayer]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(playerItemDidReachEnd:) + name:AVPlayerItemDidPlayToEndTimeNotification + object:_player.currentItem]; + + [_player play]; + [self showOperationButton]; +} + +- (void) pause { + if (_player != nil) { + [_player pause]; + } +} + +- (void) resume { + if (_player != nil) { + [_player play]; + } +} + +- (void) previewPhoto:(UIImage*) image { + if (image == nil) { + return; + } + _videoPath = nil; + _image = image; + + [self addPanGesture]; + _imgView.image = image; + float aspect = image.size.height * 1.0f / image.size.width; + int imageViewHeight = self.frame.size.width * aspect; + int top = (self.frame.size.height - imageViewHeight) / 2; + _imgView.frame = CGRectMake(0, top, self.frame.size.width, imageViewHeight); + _imgView.hidden = NO; + + [_imgView mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self).inset(top); + }]; + + [self showOperationButton]; +} + +- (void)playerItemDidReachEnd:(NSNotification *)notification { + [_player seekToTime:kCMTimeZero]; + [_player play]; +} + +- (void)removePanGesture { + if (_edgePanGesture) { + [self removeGestureRecognizer:_edgePanGesture]; + _edgePanGesture = nil; + } + + if (_tapGesture) { + [self removeGestureRecognizer:_tapGesture]; + _tapGesture = nil; + } +} + +- (void)addPanGesture { + if (_edgePanGesture == nil) { + _edgePanGesture = [[UIScreenEdgePanGestureRecognizer alloc] + initWithTarget:self + action:@selector(handleEdgePanGesture:)]; + _edgePanGesture.edges = UIRectEdgeLeft; + [self addGestureRecognizer:_edgePanGesture]; + } + + + if (_tapGesture == nil) { + _tapGesture = [[UITapGestureRecognizer alloc] + initWithTarget:self + action:@selector(handleTap:)]; + [self addGestureRecognizer:_tapGesture]; + } + +} + + +- (void)handleEdgePanGesture:(UIScreenEdgePanGestureRecognizer *)gesture { + CGPoint translation = [gesture translationInView:self]; + + switch (gesture.state) { + case UIGestureRecognizerStateChanged: { + CGFloat progress = fabs(translation.x / self.bounds.size.width); + progress = MIN(1.0, MAX(0.0, progress)); + self.transform = CGAffineTransformMakeTranslation(progress * self.bounds.size.width, 0); + break; + } + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: { + CGFloat velocity = [gesture velocityInView:self].x; + CGFloat progress = fabs(translation.x / self.bounds.size.width); + + if (progress > 0.5 || velocity > 1000) { + [UIView animateWithDuration:0.3 animations:^{ + self.transform = CGAffineTransformMakeTranslation(self.bounds.size.width, 0); + } completion:^(BOOL finished) { + [self quit:NO]; + }]; + } else { + [UIView animateWithDuration:0.3 animations:^{ + self.transform = CGAffineTransformIdentity; + }]; + } + break; + } + case UIGestureRecognizerStateBegan: + default: + break; + } +} + +- (void) initFuncitonBtnStackView { + _stkViewButtons = [[UIStackView alloc] init]; + _stkViewButtons.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_stkViewButtons]; + _stkViewButtons.axis = UILayoutConstraintAxisHorizontal; + _stkViewButtons.alignment = UIStackViewAlignmentCenter; + _stkViewButtons.distribution = UIStackViewDistributionEqualSpacing; + + [_stkViewButtons mas_makeConstraints:^(MASConstraintMaker *make) { + make.leading.trailing.equalTo(self).inset(10); + make.bottom.equalTo(self.mas_safeAreaLayoutGuideBottom); + make.height.mas_equalTo(FUNCTION_BUTTON_SIZE.height); + }]; +} + +- (void)addBottomBackground { + _bottomBackground = [[UIView alloc] init]; + _bottomBackground.backgroundColor = [UIColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:0.05]; + _bottomBackground.translatesAutoresizingMaskIntoConstraints = NO; + _bottomBackground.layer.cornerRadius = 8; + [self addSubview:_bottomBackground]; + [NSLayoutConstraint activateConstraints:@[ + [_bottomBackground.heightAnchor constraintEqualToConstant:100], + [_bottomBackground.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + [_bottomBackground.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_bottomBackground.trailingAnchor constraintEqualToAnchor:self.trailingAnchor] + ]]; +} + +- (void)handleTap:(UITapGestureRecognizer *)sender { + _btnSend.hidden = !_btnSend.isHidden; + _btnCancel.hidden = !_btnCancel.isHidden; + _bottomBackground.hidden = !_bottomBackground.hidden; +} + +- (void) initSendAndCancelBtn { + _btnSend = [UIButton buttonWithType:UIButtonTypeSystem]; + + if (_stkViewButtons.arrangedSubviews.count == 0) { + [_stkViewButtons removeFromSuperview]; + [self addSubview:_btnSend]; + [_btnSend mas_makeConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(BUTTON_SEND_SIZE); + make.trailing.equalTo(self).inset(20); + make.bottom.equalTo(self).inset(25); + }]; + } else { + [_stkViewButtons addArrangedSubview:_btnSend]; + [_btnSend mas_makeConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(BUTTON_SEND_SIZE); + }]; + } + + _btnSend.backgroundColor = [[VideoRecorderConfigInternal sharedInstance] getThemeColor]; + [_btnSend setTitleColor:UIColor.whiteColor forState:UIControlStateNormal]; + _btnSend.layer.cornerRadius = 5; + NSString* titile = [VideoRecorderCommon localizedStringForKey:@"send"]; + [_btnSend setTitle:titile forState:UIControlStateNormal]; + [_btnSend addTarget:self action:@selector(onBtnSendClicked) forControlEvents:UIControlEventTouchUpInside]; + + _btnCancel = [UIButton buttonWithType:UIButtonTypeCustom]; + [self addSubview:_btnCancel]; + [_btnCancel setImage:[VideoRecorderBundleThemeImage(@"return_arrow") imageFlippedForRightToLeftLayoutDirection] forState:UIControlStateNormal]; + [_btnCancel addTarget:self action:@selector(onBtnCancelClicked) forControlEvents:UIControlEventTouchUpInside]; + [_btnCancel mas_makeConstraints:^(MASConstraintMaker *make) { + make.size.mas_equalTo(CGSizeMake(24, 24)); + make.top.equalTo(self.mas_safeAreaLayoutGuideTop).inset(10); + make.leading.equalTo([[MASViewAttribute alloc] initWithView:self item:self.safeAreaLayoutGuide layoutAttribute:NSLayoutAttributeLeading]).offset(15); + }]; +} + +- (void)onBtnSendClicked { + [self quit:YES]; +} + +- (void)onBtnCancelClicked { + [self quit:NO]; +} + +- (void) quit : (BOOL) accept{ + [self removePanGesture]; + self.transform = CGAffineTransformIdentity; + [self cleanupPlayer]; + if (_delegate) { + accept ? [_delegate previewAccept:_videoPath image:_image] : [_delegate previewCancel]; + } +} + +-(void)showOperationButton { + [self bringSubviewToFront:_bottomBackground]; + [self bringSubviewToFront:_btnSend]; + [self bringSubviewToFront:_btnCancel]; + _btnSend.hidden = FALSE; + _btnCancel.hidden = FALSE; + _bottomBackground.hidden = FALSE; +} + +- (void)cleanupPlayer { + if (_player == nil) { + return; + } + [_player pause]; + for (NSInteger i = self.layer.sublayers.count - 1; i >= 0; i--) { + CALayer *layer = self.layer.sublayers[i]; + if ([layer isKindOfClass:[AVPlayerLayer class]]) { + [layer removeFromSuperlayer]; + } + } + _player = nil; +} + +@end diff --git a/chat/uikit/resources/assets/chat/VideoRecorder.bundle/config/video_recorder_config.json b/chat/uikit/resources/assets/chat/VideoRecorder.bundle/config/video_recorder_config.json new file mode 100644 index 000000000..3e75915d2 --- /dev/null +++ b/chat/uikit/resources/assets/chat/VideoRecorder.bundle/config/video_recorder_config.json @@ -0,0 +1,11 @@ +{ + "max_record_duration_ms": "15000", + "min_record_duration_ms": "2000", + "video_quality": "2", + "primary_theme_color": "#147AFF", + "is_default_front_camera":"false", + "record_mode":"0", + "support_record_torch": "true", + "support_record_beauty": "true", + "support_record_aspect": "true", +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/Contents.json new file mode 100644 index 000000000..a728ed919 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "avatar-contact.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/avatar-contact.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/avatar-contact.imageset/avatar-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..c5527a4ef852d4d8103905055d55e74aeeb9c829 GIT binary patch literal 941 zcmV;e15*5nP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0qRLaK~#7F?U~_G!!Qhm9~=W@1dq@S@CeZ&7Hiw02sR*0E95 z{HBmGbFELDoOX$VQOJx9E<4V(MkbIW$3Sh67zNWQN~2Osw#Lo09$^n+j)YV&^IM!5 zFDy0`aDfCUn6oK7>}U*M>?dEiyyF%x{}0jgoetmr$ttq;{)OHNR2iO44~w zOgMja&is7BNSg%%{>LKjD%mQj0r^IN-Tk^ zInLGlH}V4RO{v6b4fs+tHnba8j(acA69V>p-=e=wsf2xU1bSePVLC`at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u1Vc$gK~#7F?VUeNQ$ZNOzk3u~tOi|pE)t^Xq~PG< zpFv_UMhDSN69zZkG=ag1GHHT?OpFeiuqiCYphgB^asYKBAi;$)(S$#3p*`<=wc1n+ zZSU@V2YkPz&1;*ym;1fD`|j?$yb37e+LMjTc_D8C$ZiSH3JfxhuR7@synEcZQ2K)? z9&{(<;q}IiektWcVE&5`;`xD%%)n^k$V%`T-%#FKZ2gluJPS-EOvlsNPL$~3C#-qE z@~YwNdSeuwflz&3Yfj{U08=$mdYzt<*AGlh2*`DN>YbZztq5_`1IYq4X!U^6f(2^A z0ySZQny^4kSfC~>P!krYNdz3@U{t_`lXY<1_tbIWyVVT9*TtNA<<+qSmvBZPio10# z0=LyG9uxokj^88W-?re@Z@cByLBy6<5yHGZE-$se|EP0)cDj%daF=%jF za~p7V`uwR#u&(FtkmHzBfdu=+_bO`7{G<;iWKnphJa8o>e-EU`zthcyNd#U!9f1cb zqo$KkDuq&Hs7)Aa9p-b}4JU!H+4tUP2+kY!u7%K|Fy?QE@0piZ3MSeI@5DmQ6^9}a z3p98q`X{Q|#1F0&o<3R7N@FeO?o74~^y%PUfpXglF#wPZb)>Ph4kL5V$=?8ZrJ zr907F80S%gt2;Kgepov*4p&^E=VCqB!iqqgno&00a%^ruvU1ig2R1&IW2+V#7l={M zg`K126gI7!kI2Ra;{Ih9L3uWsvGFsux46^ym2<|z%{5-y6V2`lY-`>HP1tV&XR$Fd zuprRg8t6Cef*QErV1b&jKuuVnCdQ`>)oc>l3m6yp>}?kI6WoL~VS$>kKuuVnCM-}B z7N`je)Px0U;;99m1z>7I2$=RH$T47QLP``Uat5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u1kp)EK~#7F?VR0C6G0TmPpf>{h7v7;Vk$(UkqZ>{ zLN7G(1mK15;TwQ&pbw(L6KM2S1L4LSj6g&&f}sQi1zP9d>7>hIyE}8HADHt?Htn*r z-LwBaXXeZqvIz?DYOeUOkfaGJ(4^WOB!P-V^9i*jiJm?hODqMCCCnEkT#x#gDiA#( zp%%}|LT+NGBp}2)BzmoOZjulQJn|ILI7CdA|28G!#RpocNS!KuSKc?Mc0Q9}jq<+n zWFeuI6zoym-ej^^l+blWh4MsF`aTweNfKCyfJ{U{CL$mc5s--p$V3EWA_6iI0hx$^ zOhiB?A|R8~)PiKwL>Du9JJYWJmft1XRNKcjqF;ZgWoRh@+f<$WdOy){@9|@qF1=ko zOSJey3%P$tO{Zl-7~G-a1up`o9SrXb3||;YUCHnIv%`g! z$OAb7Vn)(Tya?!$Qz0KSAPvFv%odyz41ca2E_e~}`xaRfHXNQ5?9ZYwg$3Vs*y2S% zB%}w0`0ddavw8w&7xe4?Aq}G82IzpVoB(8;9j8Qz;0tu&M{=R-3x{w8I8(Q#phW@~T?7n7P5IP=i<>R;cNA#KG%a7$LN=t&LB0RMHAOWf*H4 zVkg0l#EZFNky;2-jqXkBX_~3|A-qjhx>guBnwF_Z=ajdM^dFtu27!5JtzXt08s34I*ss1y!5c zVKrC+;wi>80ykw!=nv|&qK4l@e+qD|s4#`?O{)jmR)390V6?3^()ysMt72E2H4n7q zl(d`$w5k50KQdYWNCTE4%_0@98I>^O9x(X1;v^uRm`x?uB)!>meZ9|sYv5WCZzD#+ z4X<@w8*w@fd6o_NJ^dM8L8~X`TISkOxb}r^0;a5*(@weO##z3!C0l;TaqE4qD}zn~ z+O9q9?d#7>0|iXAAH8-Im^HcJB%p17VLI|g{xgudR@|zK>qspBV97)TWFi7G5doQq zfJ{U{CQgGlD!?a+dfF=<$fTi*N-yW>ze7Bbi3rF<1Y{xtG7$lph=5E)Kqev}6A_S! z2*^YPWFi7Gfq+XSIDyw8;}xK@BsfueyZIr}3<*x)jpoV6V~ID)6`zw}1vJBZ^-F_@ zTEw4*JCYEK@~hyd@x3G<-U+V+92ZZF1{#_7=wm+-Zh%+AtA24>DSM+R@0d0$DJ^Ez eHiIrNc;GMI-;_ZjB$tH%0000at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0VYXAK~#7F?UYTb!Y~wt&-Fb=Zz4S1hfB~E=n`}r zBG`fyccJdU9q1Bt2OWw!aN-mkA$FFes0h^YX2f-k2tHrzFH(JSykUR*x&mZm=$RyLgn996w^0E2mZJE0U7 za}v5<>I}SNzE5ba*3fpKJ`^q4;MrX2CUY$SPiP{;QZ{*7eVR+%WUd7e71Wge$QI%q zb5*wnIE-n56p}U_4D_~;=6>RBHh*eKF5x_6P5#4sFw~P`5%fdFR@d-AT||$r3V6m bz}IsHrbFpa5j=nf00000NkvXXu0mjf-vLJj literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/Contents.json new file mode 100644 index 000000000..54f1fe72e --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "check-all.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/check-all.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-all.imageset/check-all.png new file mode 100644 index 0000000000000000000000000000000000000000..9f3d82cd5fb792167d222aa2ff757ec548ac9e76 GIT binary patch literal 696 zcmV;p0!RIcP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0QE^kK~#7F?UXGJ!axv(haZi>AiyBNf-Gl%JM1ku z1(qX$L14iks6o_XFw7*=WIE|?H)R8e`I475eeJy0O}7AsVHiKA2Wi=$mZ%%*7323I z9S)W8`5kqL@w<>dyg*$7{!{uX3OE(Mf+ie81uxsw**2!_N(VRCuUBT?swB$xj9aIMlGE eZy1L0-@E|;FjwpsQJNS40000 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/Contents.json new file mode 100644 index 000000000..35d95682a --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "check-circle-filled.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/check-circle-filled.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/check-circle-filled.imageset/check-circle-filled.png new file mode 100644 index 0000000000000000000000000000000000000000..6f6da7702af2eef8e409dfc36c26af2824b549ac GIT binary patch literal 881 zcmV-%1CIQOP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0j^0zK~#7F?N>Wa!$1%nl7h}Bz$XX?An9m;j)vp{ zRCFL3Eo&A{Tzhi^C zVZz)~&zza5_Z72Xeli!#m(UJF4a}J5%&oxqk~wGI`2u|gkmW6NL~($Tsd|bSI2t4C zMS?hxt3LK^0Yl4sG`L}IQaaGbA@iH+I%z?gg2r`5{Q`xG0xo?9-YLA;x}s1fmN08z zwnA}Wq%@W=M_`rNH^3C^G(YqanDitLfg`GmP6BFdEE%vam5$vn(`C=6#*&#P%7M!W^xK;?+LQUdx&q=;9N zfiFh`2;3VcQZg?j18<&2ritibw;6aMKXenZ;w`P%t+-TeW5lgnF>py?$SHo?p6frP z<2dyeat5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0QgBnK~#7F?Ulg^gD@08e`39R>=-T}+6_8_T|+70XmJr)nz|3N{Dv9=@yArHb&!Y6tBBm}@J8HTXyI%t~ahW)cFmxBfdfqq!GZJW>KD?g42fnA!WHT+BB7lDrwaU2H} zlO)MOlK^4OEb8K=x?&4G&x5ioFQ7qykX}sAxicCHdTyXWfRI+AD2f^@aL4Yxp9OAmaNz{R2-F hIH(XX7z~Eh@B)7lrnYO&1cv|s002ovPDHLkV1nCiDK`KB literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/Contents.json new file mode 100644 index 000000000..b3880588a --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "contact_add_circle@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "contact_add_circle@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/contact_add_circle@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_circle.imageset/contact_add_circle@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d7ab78829c0504930f559df6b6a8a31c1fa39d GIT binary patch literal 1252 zcmV$ zKWr3N5XOJIx4!s%L=-7fL^MH2CIyzT9SKD#NR&wFBBe+J1x1=v=^{nAqDq&BDpe#X zD3DSTP(+20Vn8BN3{9Xgi40OOR`8!--);1yZ^-!NZrQ!tIJ#q`r?d=)qf z%mask1Hfy*l*TuJKY(T64zLK^0{(P3l0giqYCx>$ZQv8&1Kmm%M1RbpMW7|;-yFvDL0 zp9%P;ehUUH0oQ<^fnR}#0*82C2Mz*9fOmkmWPr&Cyx#&}>o?OmG}%Tx1pFXH4}ewq z^jY9tV4{M|3B7YxGFByXT{;d$x`zfouNi(KMWH{h*^K!c&ewfQ-MFVClI3MDN#{^T zqz?Rj;1RH*89Np68Q`*H2Bq$|Q7d{bYBnCJ{SpbSEVz%ZRL}F$VfvPY zp^_0cJ0W6Rl7Fs9@U(cclE5a^$|%p&PfOQ|bYBvd(yMIB7TksnOFLHq{GgPnpOa48 z*b5Q1zGMtD!0!^U2Aqr(l#y`^_)6_qSpfW_!2e;{FJ##dl}y6&ozZxu6t8#J)GVQuq1@>?qjxuj>8LQK zy$rC~{ZyGVWXJD;yH!&A5S!SOfxIgWEny1S@Sb3Ttt8&3`esYz!8ws4bHcJE zZ2N>Y@dj8wtE#f07<@y^SxZe)kvh_SLs%NZHtR9_6|h9oQpFewvzQ)hhwL{<=m5@Cq{HN^js z8}qac?3diw0Hby1<}JT3>!>FGSIXS8`989+tu*e*X6C9Z_KIBSxZ}V60vEa(>G0Hc zv%VRJbz9QFa2a^40%}*h9e_U9;eJEUgHWn|R+t_OqZyOfHHL+;Ww#gJEHJ>zgsexK zRNr=WO*NqUVgk{lVwiPhzZ|cUZqkEgVs6O9IYDo8-uylm&rpIgs zSSEg|Te3-AI-MBc98eeKYA1wcQrMpQGRAKl19`z6B0kn3d)FbNB-uXc=ifzS;dWXW z>~M7$xl?PS1inTwNBn!o$_bAL>W`T4gLQwnqMH zaV#!($KrZ@BtCE?qyC90;U)RVHx{2+$Kpfx*!-)(*!;5x73J1`uJ{{pxW!4caDQL` O0000w9{l}CMc%H<_8Fqwf!om%MrJ-v9s1x#!+{ z?tKF}aJd5C!rCCOsJf+vO6xITjdraPgx4w1Kb7N4qOim0fRsZ=yM(xM9%=zz&T(N z_yza|_#F5YI39UcR}7VO*)W5^!@y%Q&egyGP?q?LjyTr%*i_U}a?hTSk=_R0ml3;S zs7@NF;F`dtz$V}+;ARn@az;rcqM*-wd@`8OivG4|%vg){*^j^;;0@q(9WEDzfr=s< zh&>H#1#Z$ed)ARn6lVr>Ov`X(HK~d#uTS?ZmO;uo&N~kY^(~U8{sg=Nyr%k$(}Ef( z(YdDB@*;4rGmN26@g@*I349NH4g3uJrg$pU$k+Zu447Zi2A z92gPNV~*&!t{nzmQXHyRhV@*9C@@=0D(D`o&*Ns5eOPskJ0l&_wGnajoV$*q&S4C* z7x+it=24OROTElaV5NJ`dZcC}=VtJ|nty(!>N=siN5$oLaoa1do_p6&&^g3}FMu=7 zIAi+OJ_6ReXNp~Naq9sM>s9xpxR{a7i0ciJhOQ6_Sgr~!-W-*C2O_&PBE~hFZo>}| zmWaa{aWb!-5a;ENTgOQxz-4k-^PDlY_fx>L?*4ge?+xmFR-BHBqj}O^X_#v>uZzSr zlaVOq98=rhfWPqFhNX;xX9+fH#d z{->p>zn#vcsPi*!G(4uU#>bjEwA)kJx3?O2QxncN^!MuMo_4orC-$HSpVl#{YjYwW!8ooJcMB0g)0fiNuvcp&u%HRvX5dE6 zxN9mbq!tdi+)F1gqd4IYKM`#QHnTqbC5wir@u80}<2YMxLP=TT`0T9CF| zq_wU+>KVv9<8jy2`V`^eT+|xHJt&V_BNxwY&=q zWNmL)S-NRiGZb=2pY?L{BGPb3+G^6eQrhuxC`59D#hMjLJXK`CX&0Qcj7?gZeiWMw z(kv(4%9oNQ1Q@r&(q>+>MA}dJo;o&=tsD$!5LHx^4{PIDn^!AM(A+uH5o1bag>|q_ z!uTCg9kzZpDD8)uc~HO=3R-7f`}FJJX;`hbtC_z~>(}%B(oQ1-87^_83KBZuh@@dV@TlffS(=BH>Bk^dm2&~vCa8$_JHV3}>zk0a zLDg4M85f|Qf%+p-6}RG<1r=31)8dFbHbi9(b++J^>Uogg9Dtfh z2-;a;E2fQBM4L6SxSXs_ENP)W!$5R@tzL&v&p>Bf_gB?D!y0*JnW*jnUY$db-X87l z*r5#$tp@2dn1*3JQ*}Io_J#&B;eX1Ki5n;`bz`A4WbH*fY|iIJ90We2dsr%M);XM) zcDxh#7*un^@Uj~pEOu+#Sz?wA?y?+~G!J#eSnL)JvX+;XAJSx-u!_<=9vi5h2OZG{ z%FAWd;Nq*|2b-YPxmvZsGj`p!>2(UGVU=sXMRof}vUpIq@mH6YENdc1hlwr}OoN}0 zm!x^tc&}bQs2WY=%D`=I<5MpW4UGFTX(~q(a`UEH4;U;^9#r!x>Kw9d_chpT*-NmA z^nKFgH3rS3WfaZ*_G_+Ea+$XcZr5qA!nPc5aJ_U%TKBtqnr0LW8uNUjD4%E{?|v7o zd(n%GbI|aBG$qpbi8R|H#jJ-s%*EbvV}u#?YFnH~^%~4$wrG!#4P>j*YWI+4AnS5J z0DjP-8nXBkz_o7l*(%Wh^aFv&MB)Ss++FPZ>$>zH!;mR|-Q(Y1C~TJ5=$TC4JtRhxLW z<4NY_L*m@0F1;%6*)5H8dTk-eH&CUcpmV+j??1eW^HeuCaR%hufHNS;H36y!fmk%IikO;V8mJWdMo rA6QDkLak0D?tD@IL|2X+w1s~G-uEhsKX=G<00000NkvXXu0mjf3^D55 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/Contents.json new file mode 100644 index 000000000..ecd31de0a --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "contact_add_friend@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "contact_add_friend@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/contact_add_friend@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_friend.imageset/contact_add_friend@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..783744f668ace637ef7ebfa5358ceabe7215f81f GIT binary patch literal 1016 zcmV! zO^A(A7{`C_yuM#%$zt8u%FvTg!QBD%grTYC-N`PXO z^FZyrk!8z%pQ=-robSf#D3^NdZ^@%0N4C0N5^oGqnCo0Ivb(fqu7V!YLdS zXbaE_dJ-fv7QG2=~S-Dao!B>E9^097rV3Raj)U$!;7VkIhE##Qq{*w95 z2H+j=73k4iFkvOKwI1zxCmCL5E)+Bp=$M?p59n}Otwg4+4(%C~3@@`Pjnx+{hjmJD zpL{Bq84 zd|H~CGPT%)oU+}_r1ygf^^@6-s7|La#56HHzgB{hI~P~q^kzl(9@Twb|_-5s+cG&ysbEt0pCV-;khK+Xao43cszD zmAHE&Wt64)W_QUa3!nu=r%F@stK-1A=&+)Mrgfy6LPia?k1V=%OJkz|8l^699%-B| z-%16%UMgq!RtPSmp)8(i7nfESf%V>FQ+MD}1y0FXY^8Ir4q33pTc#SoA7ZSlL}gI$ z?QP1776=yc;jAOTp*Zzrnb_>V!6o2&+M}XU(gVGRR5D*OEx5`8mrA=nXh%I*F%Jp( zl6=E|<#?&2+!?x=-J0u`nTK7j3xC%g6}St)y;vcJ*d|`8ezq$h8g0!}_Z*UNOriTZ zEUn%KuEq>ak)UVl?K&HbSa0IEo3WrPtB#|&neI+gF-Q_sk?_l#N*U4Z=lpv`fN9RR muC&qnM^>7dl)q`mN`C-(k`em1%%U>@0000tF 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_group.imageset/contact_add_group@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_add_group.imageset/contact_add_group@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..1714a270ab0be47f7c100b178f1c4da5083be643 GIT binary patch literal 1057 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 O0000na{h;HelMTsuD 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/Contents.json new file mode 100644 index 000000000..ae745e23d --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "contact_group_notification@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "contact_group_notification@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/contact_group_notification@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_group_notification.imageset/contact_group_notification@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e48ee42c1db2a1fad95634b5f4a70622f08d6fea GIT binary patch literal 2025 zcmV zUu+y#6~@1FXU6reH@1_y4hEVwO@XQ;Z6Q2VA}T_Z$4V6fD{X1>P>Gim@dPg*Aq0IX zJfsr5An^bdYUD(S!ee zK_+BFMr8Fa4=kv@eCn%cO}v136-eR)f*`vj+gYkmqzI!B(Fm~g->>0`2fte|3-JZ> zycBb%pGITH3s_xA;ZuP~IP9!fLn07_NMjH6Y7RDc`T|5=B0GVq;P{z)5Ji84b1kH? zW|cs#>!lt+B=CL-ar`kH-hZBMs0e~@qS@MkBu;C1>q(q3A}cZzQNi4q`yuiMBp9_; zc5TaCYpv7@sHQ7W{RBO57NcVgXi9oDyqimm6`6_5Iw&5oeqPP43ki&w;t@6$_aTYj z-6yPdGqEn@-}?w{3f@L1b@AP}Bxg3|w*1((Oo>EwvF%Ycz#dft>`|2+@5-A0t2z%9 zRu!JEnK~1ZN>ZoM7w~&;H?G z_~k#ZB8VK)3b|rs8W8~yIfu|I9^Tc!9k+#;2m^cYHIMNcijHE?WgS$OH0H|ht{ z(^vx*tusM>{q#lr^lw)np|dM9XlEQKK;aWtT%dUIq4(jr5ACw!iQX96d#Qj~6!Gt$ zyM(X2d4W^K8ANQsI#7y-Dt_|t2l3>daU_~`0}2q`q{Z)K9t*n=yKW2d%&7^qaN_ae!5M=WdqUvxUee6cU|LM`*ufdIL0i~L00XwRs|lXmJBg$Fc3P2TmDMjX zEvEV0zMW=+wrF!p0Ca2|QpMER2)_5gZYx@B2&CVANvm05HfW1B2jLy1+Yn9yq$$39 z?@r7#LK}wlQf%K#Op|HT25r$MZEtFT!q(EWlN{mMyC-w+D^ECZgUVS6<|Vlhypw? zF`D-S%de9pw?dn=ZF$&?0EJHqj0O&SnpuB+kgXzZ(snlTDc@d6QtRkWM0r5xAi8F` z4cesbvd1Ll_XDN-p;|vgn`Jgwm79^?tf~R_s2X68ssZ+>8eosA0rsdGV2`Q+_NW?Q zkE#Jy6`&Jvry`nxyA~ugNYN&3GY_TCL6((p>J{lbDW2Lhjz%E1{5Yt&1lpoa+GZY@ zm(8;-tl1TxX+${u;oI@;k51cshzvSF6ENF+@$&ptTv$nL9l75b>1$+yFi>Ncud%<@z0#+l(AOvwO_oag}VC;b&P0NLCMXjf8n)9;M1f zSOF|09+MIGasU6=uw+qS3=vMsS&`{GKmpJS-9RQYHzLc1WOfg&a zG}W}$aKm*(7Fe}mTN`z!A`cAAWZwE6?ce|KHqDC74y$iA9$3%MZZvqD!RXzj%g-+I zmIo!Y!u{{YNah?@8SmWX7n`;UwEh`#)t4~3&sP^NKJ55Fu_x)G^@g>Orykvl>Ctdq z&s}&Y#erAP=f$zR25M6@BDa)L0orkj`A&j;t&w#<`o^UW+VpOh)n&tu(jR(HSBsdI zL~-ogf9x2e3An|i3sB_~H30`1$-*2=5hy z>ZnoxDQg&3vjFNbj%JW=G)xuTU|+TSpACJh*IE%!FcR1{u4RJDJLt|f8CHy56#-HO zs3JtS*cVboRT5yKY9gy(E4-P-TG?*FQ|lI_8q)(8;DXIkVyYs|nJp0DTL^8p@ChRzGio;tsSt-l{4F3`x@)OIjk&qAhrTj7vq~tjTM=x&NZt@X%QjA>9hNQ*1D0Dbzfkt$V^onW=}Xgap14;@lix0 z0vB*GvW9q_)EgTzA}cbRJ&~`S)i>h4 zTWnm#8OQ%K=j_Gn-8hbOaj+qUfT9qmp(K=~C_;T`D5$6{C01yKijXL3;GqwVDpdj} zRFq1fmM1ENpf`y|Hm%x+(gF{tg0z%Nr6?vTDxyH3K)_0UTkq}6RNr^bS#Pe`$tGvc zc>dB_>)ko$eCPajX1=+7Bk*Y&)*c2$F#G?Z^^n1rpuNvS2bV*LK0p)!WIy#>PV-R> z&{NR*eF*O$MDQ|{_Z$Y-9yleNlSrKM8?6O)Ltu|k*gouG$A;|?xE)GegKWM4A%R8> zPywY4Urz0l5aCODfS(1l0&0~qq}&IMC$M+_2u2123~7no&=5k;9q`bQ#GXBh-#_*G z4e)aJA(P$65UVo;Rq+Y6mTbi7eke|7BYMUJT5*Pxo|BTBf9s7$FgCdhcWx>-*|msZ&1xu0 z@S9gxVoB#S=q&C;t)e2hl-6@_6BT7hk692CVnd9G6)_`r#PGNSLeZ~+a_I1AFQj}H zh0=OVjW-~qpTwOw%~4u4Q0($io;(1hwqbDK!zRmS4yF$XHWkZ4KE-gSC+iMw3Pc%@ zSP?T~M+}K&$h28R6LE#^{6i>qUBd?enPe?@nujvXjMx#wupW-Q-?%L`Jo;ti3x7tf z5^!Z5xyMdvk*21SLN@QAQobI81Aig5VOAcY-1_=!$QK4sDJuwhtn=lR4r!&3FGy6% zqrke2*p51R%^Ld9H&7@IphDiVgPU3rJ7P#IiK!5gOD*&r+{O~z&T>k#LWVkG3W2?E z4#2Cv0^#MM6@BUI-gHYtNC6#G5QwWJWaT>K3VH5+bZ~8uV3x$xSKB1CxS9tJT=8s= zh$S%%#AXSBH9RV$kL$K{O0S3|F%?)tUUWZ#fSSFo?zKh4GGy9Mxwh!WaxGLiK3^oj zG@6{WIZkaCGtIhs*K#?)mdgRQTn@11a)2$D18lh*V9Vt%h-rv|W+a{14-jZ=eGh>3 zfVI|W)M)j^abFPdjQul{tOZQmw9@Fw`M7OO7mEI|wb4{V;g@fZW2{OugK6dxtdzBY z31^@*9vE1LJ1*%yX__^~EWY!~e<7v7X;*NiSk?j#c#)03C7oF`sF+q%7tel28G3aw z%V{{RrLqz*ag^j(0jO&YpWe$^z*2|r*B!xUxHs&voZHU8{=~zrSK}9uI6>iGLog z;Dxand^lYL&rI_rJZT4Lfsuo$y$330x&EvxWOE+Ay0VB{F6zP;mlv=&Lkl72uK(2n zJpXYSzkPQcfBawywQ9i4sOB|Ns?-AJ2VYl^zQk>x>B9Fe>%sc2+%d(kf(|=5PH_&p zaq8@#iD|MW(Sh+Q9{%?jH=|TT!OO_hLQWlEj%+~i>H)T{DB>p@R^SUg9ZjfIsKyS~ zyjND!@}3she;b>@5B7hA7eAVU%y^*ndTDDfrwTAVcok+%{BYe;+_!ES=TORBVO`vw z>nQ%Agrx*Ev4%!J#*Ytv!j(6*nNR1qnI@SkxPeB8ka%)SKkgq`9y(W1lc!y9Np!Bb z?xi2l{p*+G$t`_+ZL*=A*E-iq1z>uflyj-O@ef-s#4Q&s;+#c_+qUOIUUY7{7CCQp z?V>N(mb5T79xxZZJoQuHw_EzKZAB+PaPGV~PuKT_;A_&gpWNKfwxlHzjq!lF7;Y&1 z^vYg*Wo3z4MEt66k>%MIW;IJhG!=+!BOA%qWGjyp0j9@9Lw&ua3~ugQ#Gg^UXPRVW6WK_% zlFjB>X&_)OiUWlkdW-1JdI&gDE{v0)x)_>myuP={Hk(w97z>y~rAmS8RuscZs3}Y$cmdh~;a5qLvN+oMuzyMZIa8m*m-IvfVUm%>vBci^A&84EhV{ z*&(36kY$_6cGG^3W&x%@K{(ry>NC@Wc8O>lnQY}5niLiTX2~$X;fQ*FM>tiC2H;JX zifm+?qX`2h$uPj7iheks%6zu$7NnV>J~~t21aZn+iYN+Viw>?ra3BU zw%JI)PVJKeY`Gj@%jE!DE(h3hIlz|70k&KYu;p@qEtdmqxg222cZMZIbO+d?Xph(+_YkD zE^4*;uScfw($Nb3HBrT{-x*JINlPL**r)hYHHBL*?Zz*zx}f=a{7Xw4+*TigbnWs^ zej)noS8pDLpOt9n1yTp|MyE>SvSOC&uW}=d3M*0yH&$$a&~i_g(uxNPyOdCUoT!sU z@tVmC&2SiBSR=3Hp6DKvRHA?_VruL?ChkSYaUoOWG|wZrDH2zrfa3t7ne!C2?tF@h zXcK&jL;zECb_%vVe0Tyk^_8%yGaGhO!Vc+ZDa$+4HNHo>SH9;37fx7d_?|?;5BDuP zYezF`jGpOfbjvx;%8ck<$_tVIbR@0v9;#GN=Ip{N~7B5*!@X6T8Vqn?Ye&s`CDz^?t$gFXYJBv$l{!hx#mCY)_*|AK@$ukxiAq%INgST2V(;OJC@QRO`c0>L@wPewd<-2+=dh%7 zwhM45&D+!6bKqkfF4wsOrLIoCds(=jmgY2+mX4ugZR=CB3NU-+p2P>`8gATo7;jD2 zn>|XrPa*9>s&&(Qr|;gH4%5;xbZpXIZ@Xm`;5cp-?S{60YzEgp|27`~&rwYC{tB(r zgTSBBca(%VqV(SZA`&5|qY8i*neyv;(W6@QCk}Yk1+48N54D<%CIhMx0Ao z3%I!r9wN{Y?AD#H2(0N5ZO>NLG?Pvj?pJaymCX(_`kabmTXXG#0UV2Zy19N`06?vG zvYd{U18lh*V9VtIF95(b>xNLA5HYP0;3;Ec;X0kULZ(wRbn+fNFWkz@$qo_Akm-B0 zp!E&-S<2BSZu{eOCL)%^RO1Z^q5nbK!O%$C8J*0@JP}J`>gksxRQ?%M%N6d+TabM{ z)=4u5j`s!*;M1kA*ujyJ9-^if1W>OhI0> zfLQxGlqW}#?~u^61D{hWMeK+nu_UHi3yBd(42l|nU7SctDym&woI24|7GlTR#Y{)y zMKfB{V5!3J=p!g~e-~5Zb(*r2sJ%|}RUO5y3?`5M9D@TpW2SLmwB`?vR=$s^@qOql z5nO`oln&LI8L=aV#4>6T&9;5HE*2WYqrH&wSrkg^F*V+RkPBi`Kx(B`1H~>M<;eq3 zY8wUzK5Vi)W;BUD7}Amx%#^Rg^wf(eb<>ub3feWFj1x1_iWw0rVn*zUVFZ_SV((R4 zA3yqP0X?M$k@0tOvg!=&e#}#x3|P3nP^EbW66Mfb2SvsN>WxP*Hn|ITZYnp~wdlm+ zml6yONj?C$3s1fNCwRI0P$+F==ejyW(-cG4;R*Su^LUdBNjh3h9zvxas$7T1&%9oH z4MFu@+`R5-PD#v;gL`@~bi&;b*dr9S4|~|LVLJqFhf>!dn=f!^)kckAN7ENw%c}iz zeg!SKLO5`sfLf&tDfdC+3GCfJf|0?1i{#zZy>as3kDs3NhP8)55zPL7Xgv^YgYd3` z4laifeSjze2xpZ$p-rj*dJ0;<58)kz&@V%Y=PAMwG{{q31Gl5waFnNK`#6`?>42*TUo$lpDY`u%qjQK?}+);0gEvUVxqzA}R+e{;qrl)-4c? z`T}sS;vdRqV6}i&L;STx;IcqG0v!uPP%Z;E1>zoPSsUO1SAVKMivoEcbV-eV5_@7TSan?0y+0mvnO4IBdF^1Hx+1@S9D zN5&=aME;L(CWQwe1J*=%3%HZ`0(fj0B8VRp!FK`>j0a;bT+X=)m&{X** zr%`Ed6X81E2O5?ka*5xI;9CU|>mDGQz){6M@y7CQor^C^ zijzcmTg9)Ed{Y)h4Fl&D5HBp3;ko#-%)VCDYYMSo86t`r1Ag8E(ULKno7v@Cwe<11yRTJY{(>a&_{m3J1~Dw%3*?N*5YXikGx@pAKWvsnRH3M7CIA2c07*qoM6N<$f(wWStN;K2 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/Contents.json new file mode 100644 index 000000000..6171b263d --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "contact_info_back@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "contact_info_back@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/contact_info_back@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/contact_info_back@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..00b539d5d6c0fe5395aeecda063fa7077b6e1a14 GIT binary patch literal 385 zcmV-{0e=38P)wH;X(d^ z;P1c(FaS1E!S`zLf&5Kxcp-n+>^%Bgo^WbA-}wQ)-6lLV{Ldwq{{`&y2s*!nS%hI0 ziKuBRUQQt*bom%%@f>GSkTfmHVx8zNq8w6`Mp2VSEdQ=7w<7w#G$!MojY$@*8Ae1W f&8Zd9xIO;{Y4VPc4V}WH00000NkvXXu0mjfg$JTz literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/contact_info_back@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_info_back.imageset/contact_info_back@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..abdb26efcbeb16f0153e1a5af451debb914625db GIT binary patch literal 589 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 We6dJz_UZ4>g+yBe_P9#TF6v*6%fuA` literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/Contents.json new file mode 100644 index 000000000..a9fc0d008 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "contact_new_contacts@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "contact_new_contacts@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/contact_new_contacts@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/contact_new_contacts@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..76216cc32ac41a00a3a27c66df2d8ef186ea4a99 GIT binary patch literal 2623 zcmV-F3c&S=P) zZH!da9map>+?n0kWf2sViY*m1l`d;xwW*~jp!7?dv{X}RwQori`#~D*8xTViLR!)k zVp^Nf5A_?d+E5~;A2eyiwpdMRY*zvc*eF&Tv>+A=%d)#Ob5HY}b1$>knDGm%+F(VwrI-9^TH5o>S9(FtxU#$>si;ancf5e$2YYHd(Px0g?V4!HCVHj*!}dTo3POjaoj^LY9|JY4q`_`z#51QSYV*okKt+J;*ZU$ zS6@vzI*Y)RUAKPD*U9j6TB0i%C_+pT*bP#8=wi#My|w+&R}rOgAff5BQhf zedMBv#If|cN#6NkJxAgSw?^CXCt=lc3s0+a!ougCOPyVc>sYBWRi63z@Z+$oeAC*dTHF0hnZpbSR1yb%@3ElTyAUSf-^A!AX zJro8YYN;8Cnfgi23qe)y-Uu@n1od=yk9iUz6?3hPLXXzBQ`K)CYl1 zgk+#N2wRs!&yyhye+}na=_9h=N2Da#4hx48(oPp@lS0NQ$*ipxw)*-&fA|oy7Q(#y zVCEf3TvrBEiGJe+c;zWLup^Mx(q1A>jws4POGo%$DCWZ-OaF^>A9Ic%eXI`xO^^WP zuhY6<@%Q1zyGpmy;Mv=_bEsc>Z+HdzwddhakHVqeaBspCxN!+wzaU6+$JL=--@A$Q zz6h^vhrT0_yW*0kNNYf#37ib}!MrcQU5_NiP(4PP3zzF0Qg56D+XKhpxgQ1Ty8R)T zGON^7Gk$stZ!;4}{a^ZJ$iuUmdrnlo?g`BI!@}>vf^UaQatNvCy;PiQjD!Dx-rvL9 zyP)rIxHj<`m_83WKL^*{60W_x3AV2*Wj-U2#Oe}gZ1A9qegF%WC$=Y%p0Pk$&5;+^ zobfBa33gD`YM&B{=s0+C*}r*ThQ%v_-TeC#@P{=cE=p7{4zy9Qd3tD(Cy7b(+_6Ba zdLnPzL$LF4h%(TARUl9k_E9dJ>zeMV&9;ZbANAI+Lf73eczi_KV5>KE*$>k?Vae*m z35>+njo^L${3z^u1}0n`G*R=%I=S~(;n2CR>7Lr`e+G8^EcCzQVQ8NkY-s&uA`b3c z1+5*4vlv72yy_ZwXE*fxI!KtWDJs@;(0T>T_(V8#?!UI1tcFlqg+a2DlRpF>`E+n_ zWlCY?2{ci7pjnIHhQ-6G&SQy0efRth1~tL151-Tybx5M5+B$TeUiW`UJ>N;#y)}%Z zI8>gxOmAF;OvDAkZn-~snX2ztj<0nwF}&TAOm8MCsQ6AqwN5O(r_Kd=+##ie`OxQ` zT|p|Z=?sCl!k#jw2`WpVF~Q?aReH7{dGHDey|R)Mhr{@qT#~(;KDQ+Caw5V5&z7!9 zZGZbMu!Rujh_sHWA#2fdn_8=|iGte9m>)jCW%zZx%1WTqLZJwzf2{PnQVW2TKPyO9 z)mjp@`J2!(v82M1JmK-rgs>%5VUpNTMGLJnJa#bH$ugZ>WhKxRLtuK<%<#G*!C?$v_fj$SO~&o| z0<=yjWjG>1hisZ^hdOkgURN8nRUh@$II8ZbCRra6=#s){QqQXt(jvgMH$(SVp#Q%m z3DOoO$}ZmeTR42CA##$LK($pFO%CVSzu}+Hg;+JMeAhmVF{KYxEP3 zJPUb6t&JIHXc9O|*sy146PE<%^2&dX zByw0kD^=?s+8R{e>4-w_{Y3s%eXLj;-=SrLnna$R$-n$prLQWeek1{N82smv4s<3>ExU+r}TFhDoiYKssI=*QO z&yU!1B(5-P#hqmt)~xJ(onpM4jM0|c$wIv*&Oq$B5l6*%x#vvW=}vPa?on*StrmV$ zX>D)5YTpJ1;%{KwQQ9&YzXiPWTb_@8HTL*I&_aE7ap1XdN5zdNPh2H?Se{Ph{66~H zXn$qzCh~>(oGLtlbN%FOMAll*ccZ`><3F=Ld+ho>7x}_`zmL8+8up70=7K+mv0-)> har5Ue%msc9k^2Au002ovPDHLkV1iqV7H$9l literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/contact_new_contacts@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_new_contacts.imageset/contact_new_contacts@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..0a90012153872bbf0a0a0991c02dd8b671ff3819 GIT binary patch literal 4285 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 P00000NkvXXu0mjfitea{ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search.imageset/contact_search@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search.imageset/contact_search@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..150821ffaea21b7be1687c146d84c729cb3e877c GIT binary patch literal 1493 zcmV;`1uFW9P)YC}jOvoTRKF+Q!1GB&d;5=}>Wb7>g9S0l( zo)=)#Xj_MCZav9?nk;b(m;%lLH~Q6jRspb`kLlPcjkS}BT%+lDMX*2?t{M0`Fc17J z@Trpe2JJ7KpS~$qaIkJ}q%}KQ^5K?_c+bGql6&q>BXTd(`q#kiD!|J?`%C+PlX8p2 z1Z-WuuK?4)fAZH`!Cmb6U5bJIz=VFI4~*cO8soFzE=5()5%34W9UO2?zYXXl@VB&I zMQXsqz;nPuAtoGnLx4Y84|oLa51DpO0rv*LKB&>x_cCYM80jRv?mY#(0^9>Eg*ob? z#}7r9rNC3r_VW8OWps&#wEGosa}yk!g?EfDj{}DUJi3Z0Wx!q2CNBzPI|Pnt<7^0+jcB)*M1W^ScQz-QZI~+;0Z0Bz@ zSfo1!0DKPkE`ZL3` zx$;b|v>Ba$QE7G9Qs{hJprdOxQ_6FHj_xr`4X4shKzD@EIlSj;V>=~V4?|vJiu#aQ zBb6*982{gj3C^pkXo`kj8n$FQ?+ldZ*B4UIb0M7<0z8%sG)x&2244`@R1&Y?$(4a( zQD0Y<+%C}j6HOPQ!7k7#nYUGoFyKNd6|XEOLMiMqHxr&&0X-jFq@}cJUpy>rXF6L+ z@jxivYFgx8NkK0u-#cGBFCJz1ax_$#$W1OLp0J|bsX%?P0Dmx;ZBUYHj>E}9B$Gm? z%3jFiBGXCK+V$7MwL!Q^7FOV|1`T_4cRmnBS3T|WQ!s-Qy7|`|$s}6|xDbZpqB;-2 znra~H^Yt`MrU;$X;f%I7PNd$)#j?S9yM(@e~_#AdeMPf zfV>^jt6O6uU^9);RIl*q&}H(rg{@$IsoG)k{>Lc+kFA4-nuOn~PZ2+St>%pbjhsIr zAKn+bOFoumzmM1I=k;npTC3{4(4uu$fztGA&Qs0`O zQ{eBafQjbwD$JX+-r3UuyM894(Qr)7%Rp}CQW~;)Llu_DR492TbgXC zWTMfKpq5pQ*%^IUQ|E>()Jn$4b-2den(iW{z^iOu*rYE5&nQEct!(1ucyG*V}SUuWycrP5ce&Mc10R20{{Al8$~HN{7yowc^)a((2yGVfGq2B9%dh_4qy{M4oCF5vxeZR%S)8stWJ-8#;oN1=n^MXJf)5OSplEh zY)URm9A%PM*gdEM+={2u+qvpqkFidaY^4~5P7bn8-t6wc83k=KSki)d&)o)^1h{R6 vT65o%lwJS*ZgY<)(Z_A?#mWI&6iodOd4AZzI2`Za00000NkvXXu0mjfSHIH? literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/Contents.json new file mode 100644 index 000000000..8c4f86d22 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "contact_search_clear@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "contact_search_clear@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/contact_search_clear@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/contact_search_clear@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d20027e4fa1439e7d5845d4f3ed134c63c63b292 GIT binary patch literal 287 zcmV+)0pR|LP)FJqr;%OEJAKBk{bB*+N4?&z7u- lrnITHoCm~9a-2Xld;sacjtOw-1#SQU002ovPDHLkV1nv)cDVom literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/contact_search_clear@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/contact_search_clear.imageset/contact_search_clear@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..c07430b809c7f70896f816907cf1f227ac13612e GIT binary patch literal 385 zcmV-{0e=38P)w|EaAp8nP02cr^0G%SRFhRMtlKUoH$4Em`;!o@VTmg(K z&O(@5uasydr{=f{cQQw~X%FB5pa(Djcp90Q68{vA57%9|wU)&QkFQe%qY&hnm=fPd z99^U#yNqI@R1@FC_bO*r`J@oeF=3f&5iE#T6K^44b@8b}8ujH~U3`j(V4xWBOHBk6 zC4PyClSFai=a?AOv0x}s{5KPWy6Sat;sf-K6H4Z#oP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0eeYAK~#7F<(I!v!!Q`dA5hSFf#3_9x46a+lxTJI9JFJ^lX#>^gh!V=6i` z&sY?h^h}h9zC}yXyS6~Bfw|~LbfnCmiWZ`$wv25C*vd!I7~Prr|$_bpd8Tf*JvW?7hrOclg6D9T> zn0r*-g|Vrl#O?u~))U_zARK~|;-%UHQ>?f31)O6Omw_3E;~WkHA?w|j$)*j&E(3k9 z`z9infsIf#kYPA7mw_J)N8&Q@34d1b+k_eoyka=Yxx3^IOZJ z`eGYk43}Viq_E<|kbs0ssTfPmL8cB!2+!<$^vjj#%}%U~2@|7JgvW(!`K{>1^`|B* ze@6F}!A4x8AuQkei-`@-aRx5O$dB*p)r^($O2%>aS(ZD_AFnY&-L{YorT_o{07*qo IM6N<$g0P`~p8x;= literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/Contents.json new file mode 100644 index 000000000..0a79a1e6f --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "group_setting_edit_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "group_setting_edit_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/group_setting_edit_icon@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_setting_edit_icon.imageset/group_setting_edit_icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6dbe986b96f8ff1a93713a47841305991e5f739c GIT binary patch literal 1410 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 N002ovPDHLkV1k)GJ&^za literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/Contents.json new file mode 100644 index 000000000..b655b8550 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "group_type_community@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "group_type_community@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/group_type_community@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/group_type_community@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f446596d6898e05fb656883c7af127fe3f09a8 GIT binary patch literal 2445 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 LNkvXXu0mjf1cING literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/group_type_community@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_community.imageset/group_type_community@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..3816798a92950607cc40771fad8bd6989c4c0e17 GIT binary patch literal 3896 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 U=jC4c+lT??Cf3F^Mjn{|0d9I(^Z)<= literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/Contents.json new file mode 100644 index 000000000..ed833fa95 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "group_type_meeting@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "group_type_meeting@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/group_type_meeting@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/group_type_meeting@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5815e80153cd916177fe985ace1a1d848f03287a GIT binary patch literal 2427 zcmV->3552EP)Nkl8LxlWKS@LWAWb9!G$ax#v_LD7@&YZqASHQ$rY#aY6)Je)38KJ5;Ryxx z4GERBfRseytx8{-Ahm)LBBYd7B`9`761${n?A`U>dS>oL-&^GR#j z-t|4-d~@!-=bn4+SRui&V;aYg8~Og!zwU!pM*)2h#(oO0{ZLAwonWm6R0Uf78!+c# z>`5H?>|2YqQXlxmiI+;)zWW%IdK#TweF%IY2ml-D+ICPHa9zOlfZ3TEtbGonBggT~ zWA#Kc1#10{B~Nr+iy)kZ4(`_o-KWsi(=8K5 zA>PWMM%0R$8ShCAWgkHpSQL)eIH(mhgZZipU@ron{k!P84MS=1LSQdLlycj~d1(-7 zR^}{Rc-~r}RJ#Ys=}`!n5`};%Q3#k4g@7r+I53w`i+;AqC)~V3D!KhUrPZA4W<$R8zrK3L*ct-2_zPQ3_#b z5CkFMY3%Opz(bq6aiG71dwV==DY@u&BL$}egPZjL?@al)RH@^&ThkbsnnN%b0heJEc_`*;Zy69OAU?#MdL*RQ z;*qU{!s)kcE5|uA7h-E~2cG%l5Wc&6Ks+r0$32d3k&J47B8dn_9>#Hy<6#1N=I=%( zaN<9=@$TdtbcfT@@v$WWY6>UnSRCCmh@bDf2fMmGc`vkgKGW!;oPUx`GKte(o%Zpw zH{Zj_;qiF(`GVAp52xc3i11QCh)_5 zzKg1F;5eLQuyF%vE#evM=yLJ$S9XbbyjUiJ+-1b$H8*g@LyK*lo?Oruhq}NVF1>C!qc#~YR~bC(`H%==i#BPS9GaQ1Yl?u1wCDgSKdsw#k89nk8aW1k_M+=byWO8y=+RnLA_Da$S~P z@cS3Xu79KiU;gG|3_6!Oqa?7=Vn&CxgaZgVNez||keB8Dg z+P$uZc9gvMeX7+ClyLaf{w=~O^2BVBX?u#5B4LOh@7;v$oCR$(6R!qwxH$hx9R*kj zmmlrjB;2yz0?JH4#W5^2_*h>F-`hP9d*Awl9LR;7$c-GclrXbv)V#BS!I2$(*i>?4 zao_A1tTA5nWQ)xuN4SxraLuaytQs;bUh*6q-dWB?o^CP7iQLGMTyrI$l6_{2PxN|t zU{mLOx+*Uq7qTHYawJ!kCjlL)6|s0|s0$^{++5>{fmVQA$cfy@kzBJ1X|n0UO3J=) zV4zceWJ)S;NH_^cDSysULN(;=b(Cb1i7dS@S%uu~mNaTB`J8ZMmL{WIS0(~le$g6R zN>2Qn>?HK!@hg3cQ47LR>&*H{Y5`R&AUA+|;*~>I^0D1D zft5II{^FKxW;I)x5hvkj43wkuBcQ|7(VUfiTVgH5_&1%7!Z)||;_TQYO78NTLiE(S zZ}Ii5y(sDBy>B5W;mA19i*km@#TaO#QT@`yto*pzsK8J5b|#b%h%t!7+fo z22)-MC*deF5ONd;+9(cu?e+}n#z@4!_D7<9&3znlsGV>^>8Lkt<+* zm*2b#O!4r0tVeE5e1Gno&F0l#MeZC*&2LzBq>0Z_mJu zH@RB{DI?KJZ2#cmSnPStvELK{EejmALNze>)~na>X0;xbTeLt!+Mq4kq-}B_muB6+ zrfuC?h7XRy*sPDQ{pBkDMo*Njlc>BcYb51uv_V_6N!#SmOy}24p9LL_8#94i5Q`s^ z^0uUCC@pNxv~sTovKQ&Lo^KXBM^eg@^yI9EaQL0}6G z8@4hy&-J#ZjpN3JtA)iGyW|v8-jE9cmqPxeS{ivMBHhp^1WbuSz?3KiOo>9kl+c-P z60{R6pPvd;+x|JTYeDqbu`0h_hU;++m4{u(4FYQ-&2X8E&R5{L%}v{W)FA71=DzhS z8rpvzGgH&@u<2qvZ15qp9z-+kKd*7tM2~qFr1?RihqZpm;ZMd{|AP6n<|pyxq<)y002ovPDHLkV1j8$t~&q# literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/group_type_meeting@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_meeting.imageset/group_type_meeting@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..c41d1f319377f053f9f24a8501245a8897c83e85 GIT binary patch literal 3596 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 F{|^UF+erWb literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/Contents.json new file mode 100644 index 000000000..0e8c32b42 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "group_type_public@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "group_type_public@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/group_type_public@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/group_type_public.imageset/group_type_public@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1e196cd5e5a12c95c1601ea4bbab79aa21c6bdf9 GIT binary patch literal 1643 zcmV-x29)`UP) zTWB0r7{`BSX0n@0v%PAqH!77zDr&J`EKXz zM3JbwTYEh{DvJNTQ!n!rPbtL)4ILV0D^ScJY0wJ#75t_(M~=Y*s1RA`Zh1% zJKv+ZqT^}rEbj`zB~a)v;-9F(3QzewQ!LP0iDGgN-W4Q_gq1M!0-lxt(HTZ)F+Hb= zNmvOpt=Smhu`~>0%X0`PE-wt_GfTw+OEWahvr@vWZayV;NmK&7BE0$oqQnulwzW!mGG)t+R~zCHk-zHbJy z-m@W<6zQ0JS!-2LFbW-Q3U&U!!O5R?m}3(8CE}N{8Ke4 zpX{P*5Wt3AaNiban_W9!Jf%GdE9u=w_C8Q2)=+`Iv(Q#D)x&2Wz}qjtPhZvYI+@m2 zVz;qzmpf`3ZUnkH5Ey$7cYa2kJakeG)laBsNu~7s#Qmr)UOL+%_FdKlL1zpu6cyl zP=G4w91FPekV&FfZH09^pfumo>U4H_WA*@*xw)VKcddh?pTLqkO!0qH$EDyVZFPws zty*tAeP%-}P$f5X|C4a<7HF@QYL?S=tW9>#w92kuQh)#1aL>{{zRpc7$5sOGotNR@ z9_a6@!4r^jo@o_@bz(JX`YXT599PzdPacO=+e}cZ?pP3zak(giH5EJ3<5ds;6b}l} z(he8S8%zB0OIWuZX3c?b4kcDOl{?Z|NXkFyIkq;zAHTzguNmua6k2NOjigLzm4oR3RJ9gV+(l01pp&=^HLgKhxzs(34<2dO(y4AP zImukG&3JqNJczv zdXl|#G8?k6yt)9?8I(?!)0hj)(E1x*iA;4lfR=IqE#&}O$^o>L186A+&{7Vdr5r#@ zIe?aO04?Q7kJ0uZGgZP)EN~!4TTP~n5@rJ)5xu@2TVV!o&6_rgVrCJ&o>F}7d#F5Z z8Oytbl`s=_UR2?|@(4qLY~*qNaAFUk^+1>jJFjQIIvHu+XfEKPN%1Wx(^Q&@uo7m% z&OM0R@jN8AVFrvO34#I0z6?MOoWXv p5>~=Y*p1@&dO$)x)@LPR>ku-mx<_Vw=rR(%0Dh# zdu43y$a*YXdn8;-nv_d9>DwC!eo39siDeQsHyy}+2Bf-bv}dELBwxw9k0`+z!mWEv z=T%M%@S(B+fSrym(h>;`cUuZ|uO~`-G^g9fPQeImI>F3M8lpdNlC(MXC0H)v5GBf) z={KsChFP+ZllM5Ng#<&}bZ8krOJO)aHy2+%Ma>Rt3`9tY*T$GXr#JD5A_%DsJ+6%x zspO;MGd51CzTF@&^?o%Va_1IG(iuZ4>&gw2o}zaox+e@O%@FQVKHb9;-G9=!LJ zN${rUr?v_`EE(B%@ViLeOQ&EMNN8sU6ZH!cl%OJykUV^_Np)Va$dIfeYt-J$n*Pya zMMJhqSyhhlnKDm^SZ1D~(U;A|9=|5VJVgr|N(Xg+IkPMm%a%TJSTrc4<94~Vs9Eid zLiO*o;TH-*+?6G5mi1q&PLDI77H)Hbu%1PJepo)y*e=m*3lcW`^?Z0ungC>mK^X`U zRYhC;V#t`JI|kwBGw?s(lr|GFe3Iq7&oIo$!Ahb!wC;Ro=Fs?ZMs9d)Qmg4n=k$X* zq3P02$)E$aA`xh(^pnXKCY#vRTZ1)a2K+yR^we5GD$23Kx&=!-9yyOT9u=3uXoQRg zP+GpoU6n2{v03vVPqmFKLY#FB=El(maY@WWyPa_Ri2vVs63!i)$f)(T$OS+5H1`Ei zyF(L!kuBGhqt6sAl|n=!fs*+rx>~yI6O$ z`IEn}A*V8zkt*GtykgvEK55cht_dSL^nQ8NZ~(y3j|&?k+OPr~f=@p00ib*y&r03@ zIEufEk)JW%Xr3c>tG*jS>K$mXsf3wLeHyK^!=yz2TSY$FO_?gruW4}CRn8`U!u9)v z&l5iCG^*Ow#vZqDO!+Gc)8paY(MO+P4|_Mtq!s+Z5usVkhM zpXWqRT8`b%aQ>zeK%)6gZgNSDbhwufURgIV-u|QmgWetvFsS#%rZaB0kJY$}MqDZ1 zx<6u1>3GTh@@LieZvJpTX$SOLM!??f_0%fG=%l(tQLCjh8V5C+s2kd+1(fhzgdXo_ zhi21>_nK@N-(rOJ*OS=-w*tG@wsOdkkP$n1%t4u6GhH_Wa2B)i%LOI6og3Zo*G?3G~I=guFh)rH7y;0k~dzwfS?sh`5Z43ks_njy?8hUhd2FVS#HSrrCg6rDdhkM4s` zDSk{~OlH#3r&U12(zk+p@mJinXZS*TOe*!oQM)qu+wqFeEk}OExUv0{AkyQ#L!{I4 z;Yp!aTS@^q z9Kl-p?2=swOZ8TDgc6f?z|tymq{=lFdo}SI|nZ`u^!lS`uMYnf6jNMK*4`JYD z7uTn>MsX@2)+}>RS3_e&TME`J1rx`M@dn3PylJ)$l*(UcUnp|?yTwfE6!>{RFP{thU=4O()NONZE1<_FU^+5zoou zdkAnBlUmJsX8g8p;&o$%3Ibkm#^E_t@%ju+|6)Fu*60xDMyr` zXRIk6?XCuKO4uh^n)eAt2M|-gTD5SG`lI=+2+7Fzq=-%E`ng!35tj{vjbPxx9PkEV zAEi5$sW4>M(vo1L#Eo-|Tm4}5Ei}UUT0*Z$yvHR}RbNaRey-fO?A!WBg1dubW}S9+ zU;o3xYUQbHhd`bW7IPZR5MlQ4M$92P1e3gaTPYbpmUn1a{of6%psj2emzH-^NgAds z3l+A+yJ{{aQd}&b>Z7nCfah+uj;ZHhY5}K&Vr{ea zGR)KTo={<60vWa4QoH1in$9bnX|(o8#gLrCbXcJtc3we=geRrVQ_7sU?d9=)_*G8I z1?$^9@PO~cba`}9JQg-+{#Ho)DDSUb|I3 zt$~nAX2r?dq|D~IKTY*d5u00pgcpsPsLq8Y>oq*K0vm%M61&OxP`8%2eEMr@; zb>%Jbl}R9pU`Wz;`Hhe>LEYGj5P%!8d5bPF*NZ9KTHe`jX%`H zA8Pvp^pEj{0w$UmAQ%O)LNy=_lD>kieaKk)nD^Y*IeVLLoqKMlFpoPuccynT=S$Ay z&e`kiwa$0<+I#J__c>P~L{E>#?%jqTuiy3d_10GpYOA+c*lQJR8Z>CwP$vv${Wsf53}M=SM+b~AZvs<6fbtjNwkC=I1uD4l@u zibKZQy;bMMXsBB+D*4@^~(c*^?g{dOk+O7S_4HU9LGtu z!7B~CAV=hioRK@#{m5JET;sl>l#_<>=(2Ik zo2nsd*IMhBJnh}AUE_YyNiN88z=+9f07@xxMa~i?u~P$fD}0Y5Nh6oX07DlAa%N#Z z?!uzm7oyFfw4ujZbZezhn746W6i9S~f{sVr2vGAqNNxgR0wW?OFd||CBO)d+ zB4Pp~A|@~*TwGx&RnO|e%3K|D3RjFkP1Y1p^(;KUW{d+QTqR7JIYQ(WAy8|eR3UK| zSGBq5>P*P_-V0T{H{ii_6w*lrGY4jvvreF+f#IUT%Bzxi_?|_$b7LNDSx3qSMt%J0 zz!08z{v1xcS3xeL;kP)6>V~f!oHb{0l)`Y?VB_jE_B^~COFNU}%DsD`f?ZFX#)0D{ z(3tKy| zY+*wBzl5Xeqp%zba*kz`p)9YF0cXAtsKU@>On5?g1}J1Tw%nMLmc~TnghnZ@wgOw$ z=j0k*+o+y7O`xNdgddfvm4lyojyGwHMK-CitTS1^@re+YFI$w9Yk2Kg`HA$*Sprqf zc8;PPWmhat$idG%Co%|b+M7mzM^~;lKHH!kHOImj1-7n$n4Nca;{H22uzE=_x*k7M z!EgTBhhM+a2ggwuDfu||zY02YE{wG@Dvt-2X(*8$E|RRkPp8uJ)GK{(wUR#5&ZRi8 z>RWvK^If?AV;z^Aziveuk3Z0b>z1eS_*11q1s8TSFjO+wzPU|q$~QrO6ow`I%yTUJ^hev}+;3kUz~7II$ZSJg z)C7d_kQU}!DWlBO`}&d31SSp@8`DD>+CaVxoj8a4ST*tKI`C` zrKx~`aRLg4BrK!Mg0#j+$)In<$5-~A!uCyV_~cCmeCX<=v?TUi=>~~{pTBeg&;G4nh-KMm$)Ms{`JaPM zxf+BotiI7+P4vv-%gm-b3A?MWJzRsO_*hAU*O8jgM$m> zIhGO3)NmkJSa8vqcg6L4`>NP~c%)uuBYI|o$Ve?v*YbJ4`b!^fzaa-z>&(Mh25%~y zF;IrG(m&O1jIsoCfDCaMG=FAO8$PkIfDNlM=*$OMn*I?V`;UxZaMYJNm^YLwYxPW} zr)3HB4J76C;+uom^M|w8^{K^|l!tL1%P2EmdWta~iMh2qi*Me0H9ov1^8r2Cl*VT_ zw+A|Xx^>N=RpWYEx(`ezHGc5>vp96BgnMsq$NH5432&b$;+cK@c=4Y@$fQ2lTZH6o zyQP5de`%S>NLYi5tvG}P-daiDNS$Z;t1?QH+_(NSxt=+Z57a7%)shK?LMCv%;Zopo zys02fPoBboW5wFM*j%bdsi42D^^G%y zrhQ$;2H6&lu)`&TV#P>nVn0Mm7!c<(ju72H;-OSA$Rsp+9_VVAJc13AgmmN_Ji2{} zxHO}HFc1j!Fp#Dmj^r~LR5vxH@7MaBqzep=8i~p1(wo=iuzX=cT=Vp~D*kbF6i>cz z4mYpO$Y4RDr;|-#z)iw7uE}8QhCJT*_b48^dl5d;oyGD+2}xX>K3~Oahljr7&~M z&zz?94STY585NB8&JTK0XMKKL`%GeBF`#pZR{AfMMlyZ&hlZK7?oalAE_E1S|skTIc4 zHnQe1+o-RxbSCRN9#l)UK*c{n=0x_kv?yp>@WoCey)y;k?QIbe6BrROfe{fC7!fgn z5fKv@5ix-g5fj*I=`1_u7_V%y>zbu|qPk}yF+C&sKsCWGsrp*bm;^wW*PE^@m`hgW zE@pcs(>oJ8;F?ZgMV1sV-qct~lA*TCYC%dPQQJExbLnX|kB|(etXLQ840D0w>7_hv zYw1nq3c#AKl;r3p+fBgq(%C^@@^j24b0Mg2$dgtzRU-*oYe(Ld^~_VvW8=K88i%TCJF}*6MP|!pxJ98O()Py!@x-Ix_FDa2scfKV08J zu+|7?r7otW{Nlhy4g8`1 zIyXT>I8xmxKZlGjFZuCd=F9!q8#BMvQmOC8NO3!a#ec8Bbp^rk^VSC3w;~QMV5m#w zy^P`j@UDMZlJxeRf3w$u|EVAn<+q>&1FxM9r91@>4HzZg1){I%Mv&ikT3j{==eZ#C zlSDyNW=>S`FPUAF&6QQ1Y>;(DBKZiUkwp6p7+Jr-buqS>xnX^!a1xKC z51ey5{mZVD)`pqcXUo_YZ%0%XTylJ4ue=XDniO%+S&zsb zOUXWQ<7K%dqC@MsBIUmEd;IPPRFnU0k7EK`m6NvrWEyXv=A=plAm0!%4EsdU_>|SI zNt4D;bwmV5j41TYZf}kot5$EDg4YMqyt549oM1XE;ct8%Lf3I&k{$mm4gx^Hg6m}V zfAKHes59=gCe7qfNhsWfl`LPqh*gsTJJMB6dFu8m96hy2Sc5p6ECVn@MP(z8b>B!FeEXo^g`~|QWzP#?OVJy=*VDow8 zuvy8h)VysEt(A#OKQwh#VL)zX4d!mp(_JI#I*F!th)AD-+kCT()&yxiik<|GU+pP| zWS6z-n}L*xtD4Hzsjx!&ott-J4WOk7-7&E_00gF!JQiEuf`D&RgJ+|&vCQyh- zC;=lsFtL4o|AIGbT$_5&A$K;)8u_XHc^JyUhieK#Li0QQtjtvr%xd!~`ApGPzMSHp z=Y6hj5q2VNEgw&;=P_~0Huai04^J~*%rlng$<5UGbyRT;5QR9qVANUc*Wr5IWIvK% z{1^-=BheS3n{VPhM+qM~mo2p5gf?60c|W}dbH)&46vxT3$) zN=i9+NU8Y(TQtF1f5h~LG*IBO^ZZwf+&deGgC+%_1{K>15RR97zsnPcar*u36uujk zz|(o=UfkcgqQZ%;ih16H-lPvkr~!retHpvrdU?hwi@?TqNUdB*2hq|j0gNr;z6vn)RJvA zNOn-+9S;qD^wFE7le5N^pZU92l3^?=Af|6woCVjoan}{ZJRX0D$p|8a)%E;GN|JX!0sHfj9e<`-P~A(VN|Wa5MfK!0eBbO; zxrjPzSePhLN|}sj`aP=B8btPWd8KH6;@XnhwSXzPp`#?r^B--)9JBI%R+U8^=N3y( zaE(go<-oS-SrvGZ`N_BLKEj{gm(El&?9pLNa7RD41b)4dE4RI0d_}dw=wN%v_t0__ zqTuz>tT)*pXxcNNiB*xi0A~6ME&+Pia~5!SSUxqcHK65l(n495mZb{G9Z9 zdN&B;gt?_wobYj5EGY6`#c{in^tqpWzsCV8{@$=x5JM!H2p*UStJ5Kfk9so$f6#!g z1P|##G=%&4ly0|@Cvj6q7(kmf-UIID z22v~zEK!}Gi7u$i7Dbuz&7Q6NCi|j854kSaDCzxvV-fu2GRsM;Ne}Mp*Y~U*blW&4 zfmBXqeVMc66*V_9##n)Kg=rF86;@tDU{OnfPG(mWnyxN&|4kO?kM)ezK;cCBbVQr zC*FQE7rALW$f)$u&`wBUk`|!Y3YA>yLHakYGbY;3m2^-(iZ-`mvUV7YoHS-U>E%~` z|2qlr*X6~WJkR~X5$?fN75K{AbQU`I)3+2tA2%DEvJ34Z4%_c^o;4IkMGSORF24%n z1+mmVZuGkJ!5^UWcT*-r?g{o${N)o+<+145K_nNotdKJu=j274aVT7q?YRI&cof5#LO9eE8dQ=(CH56y5!7>xhZ6lQV- zVv^#VbqZ;Fm@6lcKwFF;%qwo_PZeoE+m?)4Dw559>*#G_C3a9&$@4*N@g~zLMS;BDAz0jxY({oN#{lFh)d=Vk2Gm zYLPoZp=)Rwv3j`$lpHiK4+-iz6=<5H(fpM#jcPkI<2P?>Vonn$^_1jm#~KJt zF<7c>tsxhD0w^ynkv4W>?P?2cuip2?R*iO8PL3u;f)*O{8Lh}@_oD_x2BieuHx7jt$Xt)C@JCo;>QCa~s@;_vgf6b4v-a#G>Jxhoe_ zM^jhI5kfXYvFS#Gc@?$QpHjLsqUH%@Mv}VKO;n=mBGi_x*kxGUGsR(CV z_G#P?DY+DksT!Zs;xitp{DjDhM4!PUd)T3aAGFmhd$9&HwF`s}RDueGQgQ$F2^2dKj-RZ|yea&zk~eD!KeC9kSES<>8(l>UBph~*!3dGUi; zcq-?Bqr*ADVgXMe9dk>wn;=P9 zGg^HecEjn%+^91Dn&{KeNU&Xtk(*X&cq2r)pHoe;hN=>&@hEUu6n!9@DuFp$%Zz*G zst5QLKU93MQtwp$E;A0LEx}nHuIM#)7|3-#ODnd0xcZDLleV3FE~b2lp?b406AzDK z(?0JMWZwr zRnT!_J5G3|>Xq$%+cZ5nJSti;m8LQ8L7b3&sZ93umDudGVd^~RoTt{si~L?BxAy=X zgpK3$piB69>4k17E-}q+%WL^E7#!TRCHwV=JB{l9X=4AgxciZ}9V3un7DlH>hxR3# z%Cz|g55G`CK_YAJBre2 z!V0VIq5#!WZsH9k#-_)bF@K#$&5{U?$0NZ>occD$zIP^0&Mp4@3l@UQ-XK^}2$d+o zga!I~sFzcYFNqj0o3Uv$cM=w=))2bwNwP>h)Up~0Sn?by)Xp1#zFf7;U`GuRc`~bN#YVW}&w` SKmStr6^++`SLH9QgZ>A_9U4dg literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/Contents.json new file mode 100644 index 000000000..443111564 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "help-circle-filled.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/help-circle-filled.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/help-circle-filled.imageset/help-circle-filled.png new file mode 100644 index 0000000000000000000000000000000000000000..b1097478cab4dea6b2fc839f377c0092e7b27647 GIT binary patch literal 955 zcmV;s14R6ZP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0r*KoK~#7F?Uy}L0znXkTcOHKBO~Piin%~$A|60J zfK>#BvIhVU02fd^fW-ksbJ4^|G*>e-PGCRjU3<57nVxk^1O2LA?d&kK)6?Da1JPe+ zf&6H&cI8=NEld5BHDNunu35L%4_XaqvD#`t7q6^7>(bTGWdLS5U{xs}Fyug+-o| z$KgxzFzS&XUHV!DR{HuS?LOdh3qma6HER;a4x%gre*Hp%hVP^fjDk33JtQkj8I32R z9kSha<}sDQM`=G=uH{*$p9N(53C+2dTmP8gkO@2tDeR9)5@b6L9qgD`Df&u=L2(&@ dtH<<@kq;R~Su|J$!zlm&002ovPDHLkV1oATs(b(d literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/Contents.json new file mode 100644 index 000000000..12a42342d --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image_viewer_close_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "image_viewer_close_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/image_viewer_close_icon@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/image_viewer_close_icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..336bee1df6be7343f1f82c7b69ce11df84f30872 GIT binary patch literal 346 zcmV-g0j2(lP)h=}=ZpU9rt3li*@dVX<2I?eCKre?y;ecstSW=SU@-g(08TC_`2o~R zeF}bfLI4*R;hIJw2%eZYd2!VMLgZVQG=7kK*TM004=D@t22J?Xi>_pF07*qoM6N<$f+;tX7ytkO literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/image_viewer_close_icon@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_close_icon.imageset/image_viewer_close_icon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..65f9dc0319187d32750f998593e58cf80e4c8828 GIT binary patch literal 571 zcmV-B0>u4^P)FLwYwaK&!t`q?I;o+_4&-JoaZYK|dBwA=#tR3T>o7v25_ zP`JV)nNw(Ns1~aUs&VUT4=w0H%Y!4Pbc~8-Qj0mYhSE7`2UmL^rs{U0r4?Z3o+o3> z(Fzxla|n}com#g&5J!z^W7j5x?=3xtm`l*%<{4Hl^9-?@Njmok3}RA8bKKbZETN$WAEr%7eYcG~6)Fll|}w3u{i=`BsY>Xf`W4JPdYe6{o%w>R1E z?A_nSWVTy0qyY{w51gbu@NeD&PZT}yQo{o;Ry}aJ!vh!0JaEa?0~hT*@V1W!-Z=EY zThAVtywJ^@oarw&Fo9}Vj$-sR^X|i@}hPA7-L>Va~Ild`oWlJil7kbn=fEbQ^4JJdr6A%qY@2qDDu<_}qxkZ}HQ*cJc)002ov JPDHLkV1hB7{VD(e literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/Contents.json new file mode 100644 index 000000000..8a2f26234 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image_viewer_save_button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "image_viewer_save_button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/image_viewer_save_button@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/image_viewer_save_button.imageset/image_viewer_save_button@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a74f7c8a33c0c367a6a3582584c4fc4e5506d1ff GIT binary patch literal 2092 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 IM6N<$f`-s@`2YX_ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/Contents.json new file mode 100644 index 000000000..2af5c15b6 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "info-circle.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/info-circle.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/info-circle.imageset/info-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..19da8dd6765ff39d50b3081b318f250076b20bfc GIT binary patch literal 901 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� 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/Contents.json new file mode 100644 index 000000000..79933e758 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "input_camera.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/input_camera.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_camera.imageset/input_camera.png new file mode 100644 index 0000000000000000000000000000000000000000..18c3b539690b11ba3df1f347a684f41ddc64464b GIT binary patch literal 924 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 Na0LzdGFgj&e*ptSARYh! literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/Contents.json new file mode 100644 index 000000000..55487f71c --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "input_keyboard@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/input_keyboard@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_keyboard.imageset/input_keyboard@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b37face0755f2b45a0b842dc6cc2998b07f46761 GIT binary patch literal 2333 zcmYk7cQl*(AIC%Nl%h3mo1al!A!i-5vCd$ z8ChCd+Wc3{PJjbb0Qd0lU=l!q0`9;xfI2%n13k=Gz%XM0j>qGHB!HiEGH(SCpyvbv z;ecnt69o)VK$@wkDboYWoMI~QYCt$J3vgik{{xwW%uHrDcJJ+D;A4;pR%TZ~EaHYf zDfurOx(2ChbQ!zovVmQOm{Y+V|MhGN8~(*F#G{qq5#hL>(1#b&4jq=^9S7qz_dTq! zS{EhGaGavmhnmZ>qu$)pW{o;3{v*Dk2x0~TalFQ%FI$JOtPPS`P%ix78RTSL#Zmn# zYfz*Rl`R-&wn;xI@`{}gxt!*P`Ko5~NXByJkBkDj0N-KWp_>2g@$}T;Q9TjGi<|`q zB;_q#Z>*vJ<0f;XR850O_Y9GegW8(+h+@yHL33|P%iwP5^|=Qbl^B(%2+^tfWYlST z@6EgR5;>vKAq$Va6qRjj+9wv88ufkPX%!oB5XvjC-Znnxl7hY5?tEZgrS+Ee&T4&ccI5H#T*zgDrh{)v&w79sI%@Iw=tydNeA6j*kQQJC**}bO zhT$2iP#%s$WEpRO<6MZL*sI&7YQjBGT-}fBX%0PNm{WyWMt@(&W(gyVEmd8!gR0Bi z-g4!QL%~QV*o$!ezjSVVYAlb)lL@QNq}r$nB?Y^O{mLW^mXDNrS}1w>M(2sF3?G!) z1Yw9|j)}8`*0PxX;sSL7I`X9{i+3=s?Kz)NLy1u3q}0GZ79@KkT$xK6@q^dJ6VSk!k&!hc-WChYi7uiKJ=&by?rYQu%3r=+XES>Cg64haCbX7k2G5m2}6lHK^X=GYVg= z>{I--rq@41MfbFG=t3i<@|92H5*v$r=1DKhYMLU^`MF;_ZnH0*vHe+ET7^0qY`af? z(LFD|QgW7XZ#+MKG0;R!qm$-&bvJ6EztyQ+pjL5dH!z@2En#-aJDqmdP#`@mOXC}O z%BDP36|VgYFZK90nn3S15sn$v?LAB>u94$hT^O+yB0^ru$renuNBWkJOloZ9jNLjt zoOzgGhrV`Xk?Fb;RL!uryW>*s68W)zUyN+cxaS@xQ@hVMh;-09;<)>|x~Ih!BuFi7 z(irP)UEJlr7jfDiZmJu}voPD>5aA5P517mS6xtH-9rvu7W_`f6oZG?@X7mYJc=1_5 zi)6Usj5zNap`5clNPr;7Xz79^lMiB?%9jgQ$mIUhP4DgJWFgsC-gIAvRb9H{FvEG@ zAND5~@4K5S12a=$tCFNmhi4a%!WAx0ZQA6gwu&E>9~*r zsg_giIndB}3;hZav5)S7pi(N0tAMnD3n@mPZC|S05E4fQMB$hN~s_&TTWS;h;=kb`Q9X{Ca1eA7*2M)H|=9Oqrf9zM9E5dXtei$73;&Gq$i z{0~UgT|8Ks|D8pxgz7c9cuyfS2bM!*}OKcGzMA#26Xj;C3`4x_WD+rdXw))e97~N1J z(lCZz7|(U_Q?H1726z7@+hNqTXYZCXKMc8akJ0PJlC`jZyKSw_ovxx>MKJq} zibQyGubI3F!X#c1vEH&0?2DN0dC>CnNpQ13-MIOpF4_Qnu)FWHcJQHAH-X6zO$J_|U?Y0|; z?eK>De1n-D8Mh6cD*GNv1sGN%B_Su(?2$mTKkx4Qz2uWvXpxq*kk+D@#^<15h4|bo x-1RkYSB`tlKmA|q?T5>V?;W*-$@hd)#C`8iKdt_CtN{E?K{$*Vy3)Wo`d_NzDfR#W literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/Contents.json new file mode 100644 index 000000000..61f5799c0 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "input_more@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "input_more@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/input_more@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/input_more.imageset/input_more@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..16d5cd7f241296c58d6c908bd4644465c227d697 GIT binary patch literal 2880 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/Contents.json new file mode 100644 index 000000000..c849a89dd --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_call_audio@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_call_audio@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/message_call_audio@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/message_call_audio@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..63e0ec96ae816d6fdb4effe5811319c11d2b7541 GIT binary patch literal 575 zcmV-F0>J%=P)(OkPrwLJs4v^4q)&GM=tsR9`qITWq1JJ!g%4z4H6UZ!a>P;AsS1kbCo~9Z{Q600elDgz*pc1_yl|wk&DF66kty#A{x(0BG?g;Ec!MA&wveJ9asYv zfVMZaAcn)jb(`0=AuF!PQ<~`@|p-ylrGr4}^ApgXwlo5Uebba9_3k3poj!Mfwq-z+rf@^oe6gc;-Ism?S zN&4ya)r?pZneozSO=cNb^;+J>iJlQ|im(&btu}2;yHTyO7Txk4@Y3_MNx<@u3T+6V zib&r{#EzG(C*v293spU-k*&4eCcH(=v38RxKhU3Gye`!@;UOJLxmaft1fU8X`B>0dwudRM{!{v-eZ N002ovPDHLkV1nyM{Z;?~ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/message_call_audio@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_audio.imageset/message_call_audio@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..8ecf8ce138e13e9f87abfd9162efe1bdd17d40a6 GIT binary patch literal 828 zcmV-C1H=4@P)! z%W4!+5Qe|XjL~?B_v^-ZSt$eq61?M1T)I#Yd>LOrds4Sga1b~I><9J% zdw`w5Hed^|5$FSYfr{T=2WElqzzi@Ad;%tcao|1h7I+1`20kz1g94d|*s&u>2jqL4 zd_6VnqU;8SfI;90a1MU}Aj*-T=>m7r--M6qv~IvErg+T??Uq-uygORdp9| z1~~0UBfen~`}UUYeo4ln&-VYP-(v)+R`}+^i~x`9C?X%)acb#|s`jMnsj7wfMc@K( z)^*LhLPcBC^G&fAm=%nc? z0>O3IG^l!yFi_HiW42B^!)}OX$(t*>ZwWiL9_p*QbA1IF&wE%UXRYwl*9$ZL+k{%< ze)I$De8f7ZOsqE_%dQTSz?d(JUizx>9bjvKA3l3WM;N3{kgdR8kJQ7ypgZm*-mVvk z-|4BU{i^yg>Bg1{YtaqY&30kls`jetkgARsL9nkEo!gmhMdjfL2W zLRv}o-am}Yf#c2U#>L%xuRk1?z2~y$JO9kW%ouY>l9`$O6Ll6yi<0)W9DSCw?&n)jD|nKbRWpF|Enfi}z$Bb?w-=!b7y{pct7i_$7x0iz z*&FzUB;5jD2kc;huvwFU-cFLPNO~dZrKFo?R!4pgB1cQ*@1r6ri-sjk@$*LR+NVao zJePF2bhVidl6K4;LsAm73=HI+>MZX|8`QiT6eL{w+vANb$O6NIezNJsERq% b<}mOSYS>z1CLF(~00000NkvXXu0mjf-pu}l literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_video.imageset/message_call_video@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_call_video.imageset/message_call_video@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..1d129574bcdc997e257562e106deaf4af39ad443 GIT binary patch literal 827 zcmV-B1H}A^P)YvWLn9)R38j?$7)gqhfq{QOlpaOO$PCk$k@Dju zCPIlRc7yg2L-OGR@bu)Ks?Ev-zu8ccoHvF59mQtENW~y18%y5nE zp{_(so?&6@PG9Z82qalNkhCF<4njKFC6-9~9CNhKnP+@+IlKc_0TUq?i+&8a3S2g` zuPG4z%U!~~e8T0x8(?vPRMCPjZe&LZF(@Kn+sCm@31P*OmdkQ88T*V8WI zgscbdNg9^4&SOEm{Yl-WXqaqkvmm4yZ7=Xd(iuq$df8(0j9Q0dYN3v$z=a@Wm)|Ul zn{@NBGn8?cjle@m$75?Pyqk3U)n~EjA3@66%=JQ}Hq9VAPDwD`{{W*T$mB#r=cmKj8<|*83s)!|3B-c0N1yVZ zu9D}TDmSyEX7(Xe(8_&IG2eJ3lZ%G2l+Hg91s-#O+ie+ebs7+3{4-!nCcx$yDFlrC zIgXd)*>IQscrURajhA%MgK7aPX0n^XC2L=_x~P&VQ4Q6mv<)_*G_X}q)yua*kl8a5r+nB|?${PGT%?U0hWY%yoAPd}&kXhexF@I7oaEe2n6Swt4t-l>B7C0qjfm2rQ fe=R9h3h(O)SKi4^=vm6300000NkvXXu0mjf8eXJn literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_chevron_down.imageset/message_chevron_down@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_chevron_down.imageset/message_chevron_down@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..57de8cf5551076788fd0c9b19a0a93093902852c GIT binary patch literal 537 zcmV+!0_OdRP)7~CUC&y@Qi$p1U$!MiKcNm^usmtTec`rP-@Cjbxb`6qynpc2u} zC^DPrQvTY}D*PF858&($qPGixW3Uf<^nSt&k1p#l|Fd&*Q%|15U-P}(9>AB|C_0Z= zpMS3;`|)`deuqaT*H5a(h4}M_4QQ&ON@mgN418qVtFid-LENX=l*| zvq%50*eHSzYUZ|diKgg0V#BhNBWxXqrvdy;oiVpofMawTv3jyQIyajpS>ey5)6NRD zI2n@!Vs(zEy+4x>>=?U(Rz*wjI(2TF<>~`abeUqinj0|poh2gHaxvGSjhN5Dd;=F~ zuHCIPSQ=?(a{-9acA@n^#7H}mQENa_X}z;iOJGt-J&UE(0Hx4+RZA&>N+I7CrJ(Y+LleG1j)|k&~ykMfaQ)+Z5eXtWD89#o83zQ>@*^ zTO;p0*at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0>4Q_K~#7Fj&)2yVy|JdN)r0I0q2Y+N7b{M zrPM8kUP^E8zKDn_VD|$e+MruCk>pz=pu^^T=TzRd`trvLYcv-k&<eAp&H;sfHMWUJ)}(81O6to3RwkCM=*;s zp&~=Bw^9lrvU5b?0H2O+|1ESQP)yl@!_`o-7jzgQ6rX-zC8uoK{tinG!R5|^Igw#)Sq(k2MdJst6j&mWGW zC%in^F;Sipea6 zB9rr+pvneFd7oB=KvSC1aVY_auSl^`nGF^Qb0Qt*(hSF{VVTh$*ObyD!0vzJHP}ka z1*UGkzC7W60;Sog0aNPfC?$a6F(e1L+LaUFkmMcFn~$;^zeIjlSujD`rt_&aGdKe5 zu)~v7(06Z*&bADEdD(0l5JRtOA6Vi%f_?@Lc3B@c6KEC%y`y??1Lwo4{pIt(16CXq Uqf^ikbpQYW07*qoM6N<$f{ut1V*mgE literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_convert.imageset/message_convert@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_convert.imageset/message_convert@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..44d66379ea13c566c95930f4bdb470509c17bc62 GIT binary patch literal 328 zcmV-O0k{5%P)_8rsx&VR1_RRTLY%E`?g3jb4{x$^8m?;j8|D zKZC`%_l}nQ3_V8l#h4P{0NkT~YpC@(8u&H+67{K}5K`)|LRct#h=<~PCD8b?e)eWm zlZ`EY)n?=hvytLgSxRBQpT#j;I}eb;H=l@HVK!8+Edpw?@smTTYvDEJ68H3DvMbQx ae@@@3x>3&1sjEc*0000VDj1V-J+b7U#vd39Y+j@|yvPOygQhV& z2773JI}?u|Rvuk&;`s{Ti^nxs6N8|~4B+g=Dv*{ifDM52;^qo4JiCNFxjHHZJoSOn zI#k(Vrd%C+aR0B1TmXFXxCCn@bwHIJX3o{2$_}&T>X5R-q+ky0aj6AJIgAu;Z-F}K zVWwCK*DIHlx6wR)HX%&~l^Ru6N4Etbz;F4PJ}>YundEIP-cb1XW?qI2j!wi4E} ztrkwm#`1Yozyjdgi)*vQ^6?9}@%#?pgX1+=E6FV-pGPOqLI;|hkL~19*6ihz<3c5$ zRh;ylSjwqvuFkUsNIBI~%kd_@WSqV09)jD_8y~&n*k4Q0Q9S`Er&@D$a(=&3PGxd* zv>$iX-Hf0|k)DdQgiAS$(GqU(f$TLgjrCN60Rry4yj*K#KdyKH0000og z!cY*M|M?K(s#^n?a)WvUxPdn?E=1x+I0143SrS$r0Z$+|fE&;l6yvHZHMakc^9iNJ zgwnJ{7WO4wOlC5#Go9&wpFs?qUccQ+RBZ-mLK-kO5{z~1!HLaw#a5b&E`Yj&?BEE_ z&mxJ<*246ZnnBiJY%HSuQn?mD!8fPk_`6~EYYrs9MiGb>j|x`Ejo>9=nv_&Sf7g&{ za5xZ_tv1^;Lw**!RN;Bp$q@~N{Xpp6%MW})S`}KC4JYoL`4q&opIxFe;g7+o$e_Jk zHE73b{zJ|V>=|Z9GIinDGm6~k3~;h#7M%3IfY0hjyT-9sJOw5|FEZ4GfN%G}HE^R$%!8v8 z3MuurB;cZ_&*T)iP$m{Znib9$?1bZnk$XEG;5W*o9~%NMfq+dYdgoo>NX)%~CYS-G z`ag0wU$+wqJuk)f6?g;QGg~RWTi{AI`x-AborJ8K=*1~=uXrZmrmH{9`5kaByAK*4 zD!Aknbb*1Kj}%EDR|nPIGtgAVYw90tg85vAo`DaVq1K$I2!trm3Z6C0K&N6Wi$L`N z3H`c=_43c=wo$b1@j#%0f<2=+5THFI&j;Hgfle*~6ScaCZ`UfGQdzJG!BBdA=N_w( ztk*@Ck=6A(()Ti!wTTEqN!Hi*rq{8Pt&k00hAqKX-hHw7sDfm&gMwrlmExh$b*Ww8 z)c3+;RYke!kDWW&7|UkJ>XJRP!bn_{nqB^gw0$-dn*>becrNz-GrAFf{Fi}2x$Yf4 P00000NkvXXu0mjfmt@@t literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_copy.imageset/message_copy@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_copy.imageset/message_copy@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a4def7db1f8def89016083365447051333d850cd GIT binary patch literal 546 zcmV+-0^R+IP)#F|abg)-U2`m{|KEd{IXRRt5@`f>er;r3u z7XyVJzg{O+NU8uf+ZxpwQ2F1$YuD!J8T&fW#`qBva z@Lz60apPs1zT^8ru@^fj*vM*<_MW1WR8$ILgomWBOEqn+b8#-8&K-Tr;Odu>;t1)*`@Bw zO>A|#I@+R=<;yfqt!U(IJ!jtYMg}a}*#2!$bW!u%RzhkCF~E+%rMB&=tWwmpsc7Uv z3oT_2wdgcSP}o{(pYDv9TuX}*s%)>OVr&bo?4h_@G#UAjtm2o~_wqAu7mCCSo$QjP koBm?z5|gN(BS&!f135s%q0icl`v3p{07*qoM6N<$f}iO7)&Kwi literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/Contents.json new file mode 100644 index 000000000..6bfdce312 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_delete@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_delete@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete.png new file mode 100644 index 0000000000000000000000000000000000000000..1f88b304da706b867422a19576a82a3ee5eea71f GIT binary patch literal 706 zcmV;z0zLhSP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0RKruK~#7F?UykQf-n?@|EI*o)xps#cmt2%6}*A7 z4wl8Zc@@2ZNALzRIyktv5T(8vLI?`t@MT)Wt&exG#p?W zDY{qX^YxB2`G=NaR|>xU+AjXcL<{A&h+7Z&+ha0b^5lI+Y-E^ysZ o#1d2P9^mN4e4R{9Q@a>`1D5$wxlJ4KHvj+t07*qoM6N<$g2f~=mH+?% literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0309a2c1d41f7a3482cc519672313f9ad59099f8 GIT binary patch literal 525 zcmV+o0`mQdP)kY02lt#2&N|}kcz_Qo_ux4<02Jq>a6EY}w01PPS z%1Or*2Rs?kh3}i}PsDf0GXii*`MzdpkVLtw$U?hUPDxnbHIH0lae$ALyUh%oQ+J~F zJ1%Cy+BV5E;*E*t2|AK^k6(oDuIAL|FgJKAK^KWAI%3?Ru29DDpy~rF=UO}!l!gQP z`si`k9xzi;?+ks4C!2v3;$t*m3;hVK&))yK4Adcbe<1ofed`Vc%+O5Vx&yAEz7_?3 z8O6T?`0HCxu3(dTWc*J#&}pb|)d3ISR`hfFRvmCGr$R*o_9L|3Dn?g!z=`HrR7ib< zrFUO(z>{E>sBr5JqgVe2-_!xeD;i3tea>4ttzvGEQWrB>2c`h70UYrkztWFJ)DV~C z%sxb(x-j_+z)(fiq(uzLH6?c?i!Kwv27q;|lv* literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_delete.imageset/message_delete@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..243ed214d2e25fa9f1390fc1762d8853093ed114 GIT binary patch literal 691 zcmV;k0!;mhP)L*!F z-RH5#Gnv?vD8z%R$QB9&&)*{R;$uW}B4P}U|+VCl0R%24JDzc(utL+fL zNreRrM=%C(?BUxm$}NhNR31M)0x)HrJ25*i@M%*({|~tb%J|CaBQ7=vaL)Rl>@o|S zSp$V)pCvYb-{%cJ1&oHTv;bHuk11WjRlZ!tSC;GMTyd~2l}7|c5DlbO2)Fob?|dQN>RI9+mo<4 zzKQxcd0<<#t%Qqwh%-PINsb#miXJ*4oZb%=;t~@^j~CVx>;DkWR#@Pa+k_p@q(V5u z9P0?#kS$ws22&tpTO7J|9z=mbppiH<5{E|O&`2B_i9;iCXe17e#G#QmG!lo7D9d+( ztIHWo0Xe^pT5<+C4@d@K&1^>Akkh*YGKmFqIQDVT{=bT&w!s4P2%Or8nU!dpR_RTF z(34k)9QRuFp@5ugW9E2rOLnIUNM<4HFuAK@D_~aThk2X>=5)Gx%dR-anqw|wJ?I`= zyh?3a9-TQfvVg?sTmHVXGHP960pN*aR=TO`@Nj^_3EH)&=`M)cC`wZ47m-3*b_H9s6nu%v*1rCS^XKX=4v?j`8F;Z{=# z%iKzVb}C%Ca^@V#DtTrQxz3kG$pZx*Ts>|uV?Q+;4>!N`_(^ch0MlZPhX*Jj%{{^4 zk^62oK0QKVX#+2{0OwK=OztKOjzD*~;$DV>F&>Qs;?+|wNr*=fSgZ&DNgy?N{&CPb2Ns%% z&Y$-ioDiu_t2Rc%9cq(nHpg(*ab3fN*-3+`sVqFw`0+A0A|C~a3QWqgLgaTrs_m(% zOtExS<@#hL(X@A|5(Mo)6wPQl{2;q4hBq%L2yo-uIUlrtQO&oYWiUbfJ@Ilg}P zFS?j0U*->TdG0%xG3?9a*>y-#NfCv@+H?sn^~$;i-btH?j9+|C`W|tby-mo*8`~az z6L_!#LMEj%514AW9!ctofHOpBxdgXd_DTHt)A!;}pW2BX>$afn5||=m6q`xv6oWGl zbQg+-FnDg)hpn;4KooX%gP}r80AwzV7$3*C-jB8Zh_=7lJcEg&yV2$hl9vhuTOudk zj5C*QX?=0`KXi!3*xP6{jNn{WY@U;D7-dsJWIPS86Tr5F!1&r%kVr5H7Qv+0@O&*K z=9@T{dAB0%6d(p5%R{oP5gQ7%Ft{#6BUl8JU^7A1*b1UCBHya1(-&OI4ogW>ze6vAN87zK4G<3Bl15k?Bma?|W-@WPcs&D@rxx7#L%I|43)6!;x2cWWC3I7*g zULA=Cesc9!@a451({@S3Hy+!MH`|LS8SlK{>RB6Cl&Uze2YUt4T?bsBqK{JgtT<4Nc292zR4~A>-%=qmp zBeJ4Hbcs&UJ%~heJui-X3_>=3vN)40Vc+Q&;gi*cBJ=5*>XkS&eg`&}*W%r@3A-YN zis#-*V{DGrDv$7rjL3?Py{BJPoua$%49PLo4TDd?%J$NK#`?zM-V+D0_g_cVTe`)u z!__0}@%xEyVoT);Ef%`e$P#>F{H z6CU9e8IjGSF%X;;OoGj{Z21K+&%^iRv~}KPm5*9L2KfGo-=g-)vsw?H?OVy0*NVa; zydtC3&%pGqg)0jt!Dg_1I%c8+6nY{(aNT!7R=fxgy>bZm{rz_eL>wkwlSkgAP5OjK zc!vY02Ap6KOoB~g6 z;+lD14o!v#^|Yn?t&r@q6)z}uvg>7l+pFm=%yDXn6{Q z)6kNmk`;hE1&d%3Y@JixwBE(Yc{E#!_n;a{1)$egML=50YsP}$gi^7Y)#E*SCYEbC z!L+J6*Q<{igUP>Ad9F31w+rt!3g0diCguc}lzlA3Pc^m$Q<(iYV{%IJGG7#zM|5#r zzx^LxV49xllu>r;nh^!OSid%_$4@Z0L8Fm}lHa|3%Jk`VLeJase&H?oMr1{Y>Z;eT z&A~~UlNr?eLj5AXM8_&`^#2yWc%FfmFXV5C{{SKhD>Z=>{P)1 zYm8OZ75>&f=RW5JDip=ElG0Kwz9w3YNvnubuvo1MwP^$K@xwGR(MBl!#mtYkiZ+^< znpSBIO`GV9Cbr;f)S^vLQY(-8ZmEnB3sn&2b>C;N=3Dn(m>Fcacjlr5*5uATcb~o2 zTHjvl?7jBh>j)-Jg04jli@H2Sv|5;YYWb;1;X-(St~xhM6f*>v24E6^WTjdTz&-_b zsbIT!|1O-{j4U>0uUNHXzXVl5_hN^|-JZWDrtCzhYf*s3-BI;t{Doz+oRcfXxp|19 zZ%7cdNh%I_g_a`wwvSeH76DxOBVl0Qnk_)1s1HH#clExb(5Ng%1u6pm4JU;{*aPH6b`l_PkVFW6>p-@E zz&(u!t21-%Tvu+Rf{qSBXQw~%fV&R#aQemN3p8*)6&GAAp@U|NfcGVc2o&xhaJ7%eqE%xm(c$D%^vXZiW}ZiB9LKquwb^lZDDU6>4|)2IKse5q?nZjk20 zb;uVL*+OI%r7JcXEXjD1w}{Eg7nQQ919hQJrWKfhnsF~}X>vYo#9savO{(&;B%OY#=$m&CU=L$yIpm#6Z>4&couYBK)-@&DF_7he6yj(iuO+mmLO+<> zn*x}@TN!n=X{0rIbyB4A;Qyd-rkw*7|pu3wPT$KLe)cd3*IaddA zM?YASbD{RBKoZ zl2js$`m;-Om*4Y;(ZR_9AU1(_XyU~cUveUw#K|N?5ktP(=IR>A;z|LEzAQiH0t==Z z3tT;dK-B9#uiBo!jy;ovNaY#>Dv4}wy$G%tV2s^_X0W%$UPKH;>#Nps>NWpKf8u0h1OH0KNW z-RVETl9SIHaqm~}ug9`&Yta-Y;H%5Vh7h0Sc%Xy%mouOAeyciSJLn?DshU1v#yq6s z1WQ+Lpml+QMmRCnY?h7GbCqG=bmwcPo`u#hg_37T+(?qZu%zP`88*7ME(#nv&aiG( zhaV#h=v56KJQ>+5;x0rW&OUT;CxSr4%40%zJ_4jrVlr%`jx#jmB08~t#Z17@L#DuC zv|$5P?Rp&{5^-frm`9BHIi1@KoWNi0xKrV6zG(as@AD%eCSoH-7E}OZzAdbeC8$*r zj@LFm%;!&+dj9t5qI#a~L2<|8SR)ljY{W>cAzb`I1k!3z6~mxo(!!A<2wDMmq4N^8 z3te1=q1Q2p7>11)i8T!1X4zixIH9r`rx+3ghenUM4dE}=7{nT?$_#LmoU>J(E=@&B z>M>L**zZ&jvw5~r5fk}G!}uqI7{c-JS9LrHAlA^1KYab(%!EB(FbE9H=OF_Tw22{#D%e_9AZ8_Ia4o}SM| zMa*uRhChD&hd8tOWGml1@F8w|`w?sz*n!qCZ3~8BB5S5l0`wO%IH&bg{QmUoac;|W zD}Q}(Cw}zipYYbuF0*#cmKNJJ=XaZfYHI&kPmRPUiJeU98)?^tKd-Cj?aY5T@ z=r0bL`yFUgD_Cc3FPL=MqDegw^Kn9J-gq=_up89 zfl}T^(WnZ#e6ohtFo8#BFTs^l&%y5E(5G&K(^YpD2XWOG&cUOzms%UPB@dHTIfkSR z%A!olu6k9ih~dm5b~zE6dDOpv7N>cH35W^$0 zmtg6%^N{tWgZR*DOHV%E>acG4m{1P5F~oRN24ztuW!FU_o)`tb*F|8OfsAWW%AkV^ zJh$f^d~fT+__#P&cIjcD=}iQ3|Ez0l&NlhOX2I!+Hmp8r^{| zZOMbY$dkOs4HmXn?TC?B9eiOUqNtyHx#JCl%b=UEv9A~3*}57Z4ZEX8?` zby$~e*p@sd3>Jju0%9XZ7C4@x=I7kG4T>J5;|iU)lrrc9-p}pDf-Mi?wf+w*aQHL; zEaN@aVO_Ri+XjGT?96>>4*orcapg+q(D9Z%LgMiNO3}f2RO2Kn%w^)v!hj7v8}{`Y zFdng#H^0@cS;l*;!@6wK5U~5tcp^p^57_UHx40|bZ7bu`bOxyEg65de2W`lCFqdHrGhddCLrDh$}?fgKyHjQ3clabQVe+p~s=N2SDg(D^X) zu%2J`n6#y?)qz|?(~=l7^ISOZOE@XsY-PI(gBE!3oVcL}X{wR5Bm?csKAySk?(2pp zxr_{bR3!@B9e${=7op8l4eN3O;;N`QOxW^{A6~Pp!Ka3vx)VbbxI4_1htUDR6|?8( z+|{ea(ruCms~aN?(}n|88MxcO8Q0)_PD_EL?!=Ivb62mq@~{-9krrk?a#vTt+z1`a z{T3~$(6ZhdOoVu3P78VJo(idZIlrTehv5F$O;dgy|MnTyAhdPu6I?`7xR{ogQ?nXf~+7tod> zSOzLb>71>YqJ2Uev-U9yPy;C({5O^(vn1Q|M>*D&GGAUOMINkl zdvH`&9ml`FbMLQF?Bj%2U-|AR#Qf&cKV0T*xEmw>F`Gd!Xu!L zwK_VjwKI;k(^03y$J7#9L@Cyk%A;s&fLQt<0TKv=ge=Lv?!7 zh9P@5_wL=#{?0kS^Zgyp5-z39wFd5Mu;s_`NBA&5(<9ssJN&=RvNU#77gHsmRVfj6bND+Y)fdVF= z2u>{7&%hS8oX7w7+12|7^2wZ%Ia!|co~Yixsg6jkHVT3P`&>evv%&gzN&gwiVh96y zw+te~C_te*!<%R35o_@;*cWE)RWtJ6~R(IdpmIZ4z z2s_=c&EEfmTO5VGU{1x>^B#L!$zj`ZRvD3CfGtOY)`xdF z5s@$wR>Dl!(X4;1o#hyTH_1>TNmWMifH7FHq&RFl31z1~qZ~8v6{8>=W}6)!-k?On zN|*^d#Xt>Mn)Tq$_63&}JCZQgC}Payl&u5PhcieqlrR%^O6InDdN$H$7}rt+5@y0q zqAomQ=e9#Bms!|vwpq#tn5BGxS;_~PrF?){$_JRGe1KUhr-rGLnuE^QC5IyI4(SQe zFum@u0?1(WS_yBuBLxRWP#FkQAgebk*E?q?;mCa6yRJbB5f#9Q0q8KJzzE=rWi#~8 zf=l4N7dwrE{U`LBy&8xD%wijNluk#95r)MM3?fAB1a?P{hVjC5~VyvxP3T;Jxj9^h9#2pJ$g5~;rxwc$S^rzEr;knK|D9iyo_g-=! z1|y_QoKBs?n%1ql*$D&zx43Ku9vHs>Jyr}R4f1vf`WkhWOZ113Vg~^-@P51tn~wf9 zXZ>GJBhY3CrDg;>&mP7z9lLZBl=zS+d35q3%q^IJK0B#L)Q^%t7TEWXpN}QwGxc?q z7lEsfG~%??hoF*bEswlB&}K(>_CIg`2lhtWGz=M3ij5F9R4vOqz8pe<+vg-Pr(gnB zPF}3xyQ41Q7adJ_t@mA&8ez9f$dPgikUa<%9IS5Lf?hkW-@G)(TZ=34or$-5$1c7t zfhuqfC~T;@TYnIex|n3(jpzq>vi&uEESK=|G81u_QK*7A(0>9yJi6UmY(x6^w+qRzFr9Y1EM@9}bU(shq_Q%FGCUcj`^Nbf#Iu zvEd*{ChDi&g%TsA`?Pdw;=)T{Vew6Pcw&u)ovuM_Y}hNYe5 z=rksbpSJJB{&Pn&O?h&tOgH68!7{9^x+~+h$f*6~@z?Nr@B4Wfb%z~5_MoI38>?Fz z^_)2!iC?;54qm8!5EFtW8kD3gO=sW3(49jvpQSo9+uG z-Xt2QMaF8F0`-j5ht+M3nGtw6RI`T#z=5gFZ#(y4%b8}4NFpq42qf9Y`qo$QUaae? z?XM9HP()frM_`$4=zdVI8Y3NTa|778H<>cx}s|{ssI{9x~_3c z;lu$hi`(xVj-ARR*kjBB)K}i^j@^1eS^kxGfTZEL1*VNv%TQ#5GP9BwyZ(z6Ex$o~ z@~nr{SR+YxFE-I5g$F-+?01F3Wc(-hp{VQ#2%Lu16C+_{s5Cb-iSL>&sqD zx~|P5lfHn3B{yBtwaNMY=kvu4aapW57MFe!-@Ng2I`Xu&1=i4xCwov4D8bh5LwNq= zUfoZMkst-AO;yWLW`xgkZAKM90wL*G7#Ao){nWc%H`*IG1lG2mLwdF$fF#NO@$nsK ziJi<$^*#|9hsUPeiT**90Y)Z3B5ew+sa%4}(3p(NB9NXqz5|7Z{I3hwvX|r1Tu4>7LsZAc5a?SxNBJ?{+R7WcCy(xEkAY42CmmRNbNCNA^*+#k` z^;Aa__C#AXU>R`6n1!gXT$cHNsx*=Tl4oB@|0nLUlsVHb%f4tEo@)Q+Wi6_7K}w#h zYu$>dHzUb+YLnAfGrYpkBNJ}NZN*dF0y`<3lm4wu3hvHF7KU^|$_S#lzXOjS+u^x3 z15%sf+^04w*YIjeZpK3sZ_h-cKzQQVtJ=yEI9Iag)2_|)oqMspr^WLQz0_uE?o*qb z03@$4RBjaL*+!6zOvP$s-c~j2KM{1L^2#nfstem_E z(<9^b!XSBrT~;5~x4oj9@rT3j{CHBZMsr@Icds6 z{t622sQayoh4}jT`TAH`nfkcYbY(%%)flAdwKgxFX~qku_UovYDxdf_Kbv;1Ug?mF z)Mm)n2RdL4sMrkQ*^|57rVE8*i5@hbIha*pNX*s}h z=QT8Q-=-n=#>rcTEA!a2Br;@C5nl^82aX=e2ZEI*E`Nu*Bm20N?y0n;C7zrz3<~HQbwd%mU$KK+kwb3XF8?x>1WlkUUV!+mn zlGa9HChYWf)3pto^)|@W%LBhR%ZoDGAp85NdSYpoB-0xT{twq4 Vg0kw3QEmVL002ovPDHLkV1jy|RDJ*e literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_excel.imageset/message_file_type_excel@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_excel.imageset/message_file_type_excel@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6240a8abf04fe4e22e1cb2f11677a1aeb0455816 GIT binary patch literal 4594 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_img.imageset/message_file_type_img@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_img.imageset/message_file_type_img@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a3b468c983c8829de6b4aa9a2324bd98eabbf1f4 GIT binary patch literal 3940 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 T1nS|b00000NkvXXu0mjf%Bx~B literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_pdf.imageset/message_file_type_pdf@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_pdf.imageset/message_file_type_pdf@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b52b3fbd53153a915623ad00b34045bb05ee6b15 GIT binary patch literal 5723 zcmV-h7NqHkP) z33MFQm4^TKs;XPvlDr#RHeeRB#4!eJY=kSOG%yY0q=Jys42JO%I7~{^fw44GPmA?M zCF!4(ZD82SfpxCLssv{X+TgxAkZb_V(pcPp1~L_&cGN$)^Vkfaw8z;0rKIR`Agm|XY+Rhmh@Od17nSi zV*9j7N2W}EE5K$~TPC+%7X?8VnsfoiK<^{A+#axG^-VIyj09(}3nW43HW+VA8hlWj<3d0>05f8sy#O52~`2 zE?0_%>K7K>Q_ieQ!2T0InKrJb#;krIZJKj(u7D#&47Lw}HW@do430L(nVWH1FB`F7 zO{NlL-tNT>`U%a`CJn1GYo1P9)|`yvIF6K(ftj(@_P&S#u^=YIh8PhmVn*zUVet99 z-JFXC$Z6wh4AXcrZJA%q*p3~8?c1P<5wRj>#7;2`jLZtKI#6HF$)Rd7svn{kv=nTI z0amYmidcITF(OuqnNj@^F(j5gWNpC&x2ef(`TFFmYAthit8F`+F>}wqK2~24u_9)~ zju@(?Q+>T&EBkJC?&~KUBDk}ZGgLbof4i|fR^OHc1UX4UqLmBt*s2#_|B#lsendpt z%)TRS8nw0{hbo2}HW4#orx-H(4u4&&#_5*nQzw`li=~u?(8uC%V>l%_K!Fk05jd`P z{=yZW1p zunF86;GS$S1(&Qhh8uoyVo6MiE$4zyOL0z#gE$!!5fMvbN^Cj6juHWfD^?`LG*fmI z2a{tRK?`5X_{EBZ7-O2L9BV?N&Vh{0H$oyyb;zZXsN+;-<7J9t3yzMMo^)9{dMQ9v zTSBgd|FKk2MCq#^iOz%>EwBu*ZOD8Ma2ySkaWKmQl1ad{0MTU$Q{)s4zyy|R#IhOi z5p^&o91Fhxfq-EExh&ibA405J0r~k(uuoIf{d@buLWAhXOVQ)IX;R{)$N+3+)%Cw)yHCvbF}L{l+0;LCaTY2<$1 z=w-txG#}oNzc&hZXp(WrY#S2)@&gUGIPbEOm2wX*9d{j=-*T!+H%?pNNkQs5#mj++WrJ5WQ8 zuy}YqXBTYHV6`kN9`ofnPWCfTA>Z&rI7{9tf%l%rNAC|de80$Bakv2w9R^SkLmdk| z?$4_`cc_biEV=Yr@4J-%8?`@V47iVq($~qVfRhv;>{+C2X;zMv{Ai0luI6g*wR9Z} z9-<>#u?6=<(HfZM&o0)nQhU~N*N*n0z>cR&)na(P2AqmU)dvE}PyUuynEq!nJk6trk-h1|Za<71uy z8wVbwzN-X7FreYXZfn)4#jb|=kSm;kytM2C2D$BHRSe);dsG!_3#LQ@#hIO@i#wl(N7e!9>e6v<-Ro}J zsOIpfC;r0=I1sXQ7rv%{u9W(dmEp-v>mdvI$ghRN4Oj@UR0`tlMLJ_tsn&{>0rV~c z@xgmwluC@S^@blX!LclePqxB&;}!2%7h9~PH%C(;9(mu1WDL0POP9+!FZ|8>v5RX~ zdh_r44*>F@l?5Zx3bzH5gbS8RLo8pSJnCX0UKfDHnpKb#$5u$fK|{V z0kL%poM)aY9ji3|u34!Q6#c-1jt*cybeXJz{nY=e;ab;2%XW{G+!f0pTU*OQo6%Kt z0Ml@rOhRl}4|n0~`j{&^CLm5YS=DL5=^vZWq63&jg?KeJ;D?Ou8027FT?EDv<6xe7 zwzl$qV8zh^OvQEiEcm#izz;sORMry|@s<*a3oZuV{{SE#hblwXw?M!+{$%}g#TqQG z=jx%u)J{aH07vH!MUJQ(H)U)4W%mPBAW5MrJ_Q$IK2Lv?fl9ocMY<9DUu z*_RKVb0n_*AwUk7ZAUh%6b-;${|)2h>E6$K2w-sSaPct*A#vGNkZsZ3Wf=ZXMV9B- z;QNh$d4f0pR+?93g*2k;!kSeN^RyX|yLU%&s1841wNJPW##c@R8#1ij2D5gmw6#=) zG=b!`{|ABP(- zSztb|0vpAB!oguy>baPzC$m>v2=UJ9j_E}D9j*&w{Lx7M>rIf^T%>x!g&Q!{Mi=rh zr=O;`k(RH0g|=4b<@pe+mLvbG{{pBEtK5?5q7c+AU?vh5T@Kk2o%uN2fE^ob$WWO7 zaC+s(4f2*Hh2ic(nToz=DwmA-A-O8GjZvaFwZ&#ae>G$}@f_*W4_=cgYIB6$^+D#GY z<<)}9yiKkQhy>vCi z+SSP2aT8KMxl21}<=b8;DN$X6)Q^9G-0eSt`|cW;haU-kz!X&d7pz&ck-V{?6d)<@*5hwA&9pYYSlC z_e+QkAL#8hJph@9b=#-}E;d1~G!2T;O1aT==beh~cViC#S1=;wgx^VaKdSFhAo7d)w6 z)QWZ$s&y`<32O(!n0}gal)6kC{yit2{-$GVhm3xw?}3;13L$TrAP?UU);Y5wl{3iu ztCOX>xl>~m)qJ$ljs&kgu>Itt`e(!28cJQ@>Ex03J7^I2_@iMQGf96xe4IM=h2_oD zV&i()kN#dcV`$BkJbS|gOQ*qx?FW9?5voU~TDSZ2yH{mmOg$0o;Rn0D^ImuW8yqsz z4D0OKod-6!0eAV^aF;ApZTuy5cuyq2M%2MP<~Z<4Q(zu532da-(jh4S_XY3o5GW4l zRDNs3k)vRJ`@7(a-&F5GtXr!)2o&-Vg@XS5f1L;SomEKv^zPE{^oDSHkIG-53X^WE z*)~IEy+en=I(JTm53lddKv#d^!FvH(XCD3~n8!_3mO<9aYHHiRBpA3TLiN_el`N=I zH`}0+NmcAeUsnAEdeoq`@3xO2n{{lPa@OkNs`Pu5BiQ{58Z%VZd(QdbqbgW0iB4KR zr}{u!=Y0pploQl?wR?Bpxg5lX58mcJTS}PWsMv7S5SEzzy-tQM@1#`MAaTVtrNhE+>+jzZSq7jQ zrEWVL*TY@8RNc74UA;o}{Gce7l9Ukdy@SNnH-Hl?-`YD{R{0lz|33Y7vHw2H*V=y6 zZ8No$$aMRj<-QBpU(N`jhPuS}F466OD_L()ek|B`azr42;hlWh(xT_d<%?B+54mxJ z8X*+>Djk(ly`;Ts!hPv^Apy5}~XaOsDdOe&_X|;(HWe^YF=Yx> z(vpP*E|8$;!k`*zy8u4qMQCrSZB@xKlJcDzurH|foPDV4m`9be&ZFmWT~HR*RkkeGJxjgy$SaFqX2DO zvrO=blVP2C4veWM=~jv5@5O7^R1C5O&vIZM7ay!c?v{Up+_Fh0FS>chWA6hFg3aaB zmJ$j>nlomVy1AFNAnf4O1RbAT4$h)C;J)%AoW*ZKezH|TR8YxM{bBoyo=ATwMWIFF zH~K(T@Mj!*9E_um)d7>e2v&$~TU8-~{rDq5^Ddw&)g!U!-UUox?1GBJ^3eyVN(!r0lap{5tOBERS zBaNi+B^4c5iPF6kyZgfHRWI5P%~2a)%Yt=QJ*@9uq^|mQUTIfZ6%TGjOHuaGD6># zf(e1HRa1b-#QzKYi{%^FuW>8AFdCYN@%=%C}n`!Nm)8q>)66? zf30hv`ay@BzMt@KZAIduE0DbD$L-IP*$a|Jd(DJw4pk7mT-|zMA{NUW|-`Hx$b`c#a={xnrjwLSY zXRfwyh_&w^_20jPF>!L2OD1>mn<)JHZe5wt4;qK|ZNPp>OR`E;5LRrNK%@BZ{gAx= zW__$e-qyEVv3V2n|8WbLe-o@2a37e1y*)$7XFGq6Uy@R3tB7aG)Dk#%%XJXjKh@8V z;r0Q*zNNKhRMA=yVkNZ;PkQnkEZQ%W`_Z)!@2%B|^nDPJkFvVii`EUvX!%p|4k(>T zEu{>I?Vlib!&PvXEr~;seG;(h_F4;v3cOdZ)CmQ0yefeXrrF4$TDuzgYc7Xau`KqO z`*f&MZ4k>N-*|(n?pKigG8unop6_9M&Xe8rN93-#6k_uRAWkBLprXSIc%Y^h*7+Bz zZgMKb7*s9`=Z#lkH$4KeY_YCM@>7Vhx=pO*lqn)m``3N)wsF!;FxbI|>g^+R14$}f z+4iw6FyFL6yWZ(^*U9o&Ur2ODSwsN! z?PlW@SBi}Zu7bkxq!@k)Vo6MiEg$yQTg_6kc@|}GdU76)wNFG0i6xWl&%{;@%M9$H zoa5T2F1?Q-hgXsq5=#m88#=&cLj$j!zi@>u#1kpg;6nCb5w#tGj zGHglfmNv(2u{dM2O6}RV605sK%!r+0D6LzFDIMpmu~DGDo~K@X{R1K7O;v`$Sx>e< zjnyL}X2gyd5=&xAY>T~S)PbfZw|V;HM{5(-?3RM506+y2`Qc0gk`oA?8gSo zUv$2#uQ$-tL~vy>I;kKxG%&o>lx{O?o=#ilS2MO_Ghi?Jx1q6-z$$0C7~IeY8o?H<$+(VtF1>(WC?TCdbKF>cL^{eZ9Um?+BX){mfLDk7 zf>)|8KAASIrpBy(AZ?m+a;|_Q1vOEmw%gIU=S&EtjZL~H<7Sn?(dIaFGfwMeBNnX5 z`0RquuLvvn#MV%E|-ZlL{GkwxTz_<{QGdW{pQl`P^UD!fGk?w&H+8@QK4v{ff zT=(_JI<6E_ZUopoztA8nor66y(cS%J5QZ7Fb(6Rf{@b;hVc?OOiI*A z#?n+t&^;fse@f8OM#?tEu$2MpT!~c)&K9)6eRat5OLo(9%6W99{|D-Fb(WO&GZX*- N002ovPDHLkV1ff$8>s*Q literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/Contents.json new file mode 100644 index 000000000..9314ee1cc --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "message_file_type_ppt@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "message_file_type_ppt@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/message_file_type_ppt@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_ppt.imageset/message_file_type_ppt@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..436784cb655123579fbc36107343e3dfc81cc328 GIT binary patch literal 1389 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 E0oxxB_W%F@ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/Contents.json new file mode 100644 index 000000000..8eb94f10c --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "message_file_type_txt@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "message_file_type_txt@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/message_file_type_txt@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_txt.imageset/message_file_type_txt@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3817cab58837891c41661069dff99352303661ab GIT binary patch literal 1288 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdoh8U^(yU;uumf=k45$`7*8|?e+O- z-`~}EZU~BCx#%!cVku{U%ft+!(?w2=6J#$w)nCxmrQwjG^^dc>!`f9-Na^$vN3WO$ zO$UpGE+G+OLVk}v|0+wDtEv|NnUwsyZ{qdYpMRGe%}g)z|Mev;ExqiW?nC6^5*Z1K0pKl=^- zbr{_JawX%yxibg0zKh#qXcYC=sB%)8OUJK$zXY2kBi~N7xD|2x&z`!c|9lJGc>=2I z9_a|yF(t&XEuOf&TICjpTtoUX>!~MO<9+SkSpCzH>r9&N`F_ROGV>$77DC~VeC`Am z<$vPY{QIWVDl5+8%2o+HMjKA7cx96>bhDW4joq57i)ST&)5@@cJ~nPQM7w&WayE0z*{EqM2E4GUG9twpJnN{YS?p*#j){7O%B{)wx;-Yv&)=L z>)dUPD-Jk>Bq(SZ2#88_aPhdXvISk(C0{CMA;S7wQTPPkiAhd%Tq%svvSN)a1#GM4 zu*{XQ|Iwt~_*U5(G~+{rIDH%co+ zyO(?uzHwc@bkT+M4E;Kuxc|ZiQ}51JE#J*EpK)LBL+RGb>3=(?aU@mh+;zV8YR>-Y z2iA6r>@tgaP@8bY-SqNWck6uLClztq<{wBBH|Lgd*>T=#&+(6UPx155J;l0+i8-(l z)6+x>TTGeVeWSe0_{sm?I}KCgp{Dc$da7&HOAIR`*oV;^%JZ zM!7q6m!?Lni{(7}_M8;Q%_%b*YIl{Y^q(klOWdhsuza_@z}I=sPv5e8YY4Qy+r+owZQy9nZ!mJZCQ(PRsEM|MVxa@|IcL)GPk|xhZk$ZvS^~ z-)oV)?D(W#FB`wL{BO2-_M?&UX0)fax6Z^b?YDY+G-STy|8n~Z@(jRuU`rI@OydFcbn+hw^T)I z&Y%2c#AJNqG)G*8+9k~e=T^(>nNMeknR-1jXUit#k5)YICsw=<-+R31+22K9cgXsG h-NVF2iyIi!yo_v zupe$`<+2-b|27e!T@Up)U;zLkHgK!6Zn%==(xDIqx+0xS9v??>ALVC8027no@K-4+ zgO3IYdI$jrFlkfOuI|CA(+X-;4F3_!Ob}-v?UY!AVV6IgaxV(O6q>oH{3J!h?ffJq zmWsFiRTi8?YFg7ILz#u{%=4AD)P`laDr)8Q0pV~%SGjtQUAqa{$}4{kBc5{Jl+u3QHE;V z|9FqbLz$$U_6SG0GmwJ8^~)!Ex3(i4aWAwb)`Ic}c~=>dh}7sKl^7gUFJR!1fFw+Z z4FHxc8gHngG38L$m#NFER7b=1tp|G{y_j>yg10s%X&J@+UVAs~*-zxY6fR015!I4l zgISE1O1TOrbrSPDf-PXc)k>o9V%)%deNL&@W(m{^B9+3JDe{IhKhMqFH{Swf235kC zxn6WQ-k1?nS-pAGB|1ptg@Ld0)!whO@l6IX-2VPIUo>#>-C>yHHgwx2Qj<=qW!jNnnT$;{M%cDsVMBHY@^a&VRMuwR%3UUs2xhzE8?7%4fWxrR1l%#C}3) zq$(2BpuwNz64DwtR+H)A2-P>VOJsp4`AAIBzLoimq2m6Z_w-p+Yf;V?v|!^p1qzUI z`#)7vHUyGjQ^}zwSDWIqnxzvajb}+y`S_9}9|%rS0A=Y59Q(OrI@S=aU3O?H;VL@N^vfmuefzT4TJ zb#C%P9nFGhEIpD}QO4}u$qNjfX?$Kb*6CdH&c+#czBM_`NGh7+!a_U3AK6oN04tOqhQg!HQ7=9PIq;SzJ1`ae#z>9 zsix0xIZ;|bI3lo1oaa~eAdEU97Vt`f;DS1SqwglKVSORBCr=V+MLF@_2MpFNv1<5~ zB`iRzZYe;xUpj0%;d)Svb6O9V*fvBg$4QVTPTp3KR)A_4Vco(%Jeg%UQ*tYkz_xpB zvq9PaON<5cGu*bf-?vW}qktM3_W(1$ydDV_TeY{XiH{pOI7vWerFnPqrCL>GM$YsI z%wHe?NFH2!S5wOBz$$c+0UT2z!U|51w=`#svF33*A9F!2rWE;OkI+5tS5BJobE^6@ zHLn2#zk*eUn&srI+eV$cMhGY$is-!w1MEgkDskzGY}6y)q5vmBq<@iU$EIUT2>D%r z>n-+1{;`-v>$|n}*0q^hdMnG?vpez7xXyMvPfbh9Q!{SH#aG-b2=CBcQyp!+>JpR# zcAxD!cOXYayxx(vJv)T>Ii&B-Ej4bZ)IfS;!q?aJ!rsA}R#Tb96 zbMqy>`+Aw5wjJUW|M@4E{>$JFGwu2BUxNqi zNi#HUBW9>$>I*aTfO}ml;o{Y^%_kCys3nAxO%w3-*P7LdCg^XK^`_R)z$dYDTPrpm5ow60O#11q7Gn z`f!h$3Z^3?Ko9*ywwv|isR$LFqYiT0S$pR8`B5$O+!glsafZ`Tfo&Q{@i~M-Ix@ri zaZT!&_Q4q7@86Td3lzax$*vpw0>{V5_-VAGE6|Vj{xI9K=rA^U$mX zhEj&2F2_OPHZnpZ6eb8kwl3Xxi;6(zk$>&wB*IEEOm?Z6?3&?SUPxcU%b8uiesx+4Bz|C^7MCni!RTS>p*4P%>|Zda z+YUUPZ7%e@bD&@zC2Po@d5DoMjMYley{uFC*xP71;`bvIAhotv8p8>#=xx|W%NzJS z*}JpcM_b(dZ6nIScc5)Vvd&-FQc^2eL)d2Vn;+glIV4T$gCc*LgT3)3k()AqEn0qb z#)aJ@9lNH6PSmZvst#p+Ho~RuJBM)@+Hqtz{`UX3e`QMsn{uV%8M;3-ZJ=n!!W#!~JBs7lkCCX(`l^^=Ig~hb8 WuALo?NAErs0Jyb-Rh^}G(!T&2GBVWw literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/Contents.json new file mode 100644 index 000000000..56c16896f --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "message_file_type_unknown@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "message_file_type_unknown@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/message_file_type_unknown@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/message_file_type_unknown@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7f913d7404e1c3852fcc4a4a96175cfbf8d34c20 GIT binary patch literal 1558 zcmV+x2I={UP) z&2Af26vzMP&WFF<*o|q$0+kB2N>c^YmJMRT10YohB@r9MORxe#smh9%05*t3RfWm} zu&BDBtsrVzQ6W(zP*u{}P8@&4p1B8|NhfVXX_9*EjK}vcMVfd#_MP9Hd(S!7bI%Z- zp@oG7Yi@4Nm+fC}{eDtw^&Tm_#TwH9SBNN$1d5qE09HuRGHZQRDO`Hv#A`RVGkH?9 z?E`}UiHNT+-9BSGnU9(EyP0e*%UYw`?LuoU??VzrL{Lh>bzKlCwA;-Vk^0f=wZA$& zGj;7rK3m6x8?rOpv2^S1XHfKMHk)&r&2@q1KRQ_6^Az-?i8rl7V6alk%H{HCwVFL> zzA$s*$ocK80vI9LpYGmGO|BXTHHCxYwWT|smCKV~)ay0RfP1#2M@~$X>}vJF=VxY)ofmc_-TlUIf6O?re`aRo zd7eu8IM>hkXxp|XBHe@c=BeYaE-8SRJbaSLW*yJ#1zzHXzailgHo{0)3A2FRjhlB) z0P4DB*(nK8#@J!(R^pxE``!kiPbq?rai z%6*1;LH2v>jufD;H7uoIS&}{vR&t31on{VZi1rI)WB{2NljR~R9J`f@caZq-EidKy-%(F%bYm z2}D~UzEnoawS(?GXeA2ZV~BQOiLNtvh=~KZ1<|$x$Ku+9g_uZyAwm4-PT~MDfzhn`)jEe*L^!NZ|W~{fm5n15oXBBg)SXtsg0TL1NSvMjJl$_U%6}+Kz z;RE?}U^P)FZt#ZYL@0(Q9+gZO)29r;K4k#*DFbkvGLbksuaU__;+naPP$m-3%w=RU zk+^0qBbAAaMmtMb2{QnZnY`0oH+jfjI*|7wu17Ri1gq>PgSX8HuAN_S>qjMpjFNkZ2 zXA~R{`NBw82{U0wkINu0^V!)Czbidm2H6CN*ecE~WsvP!t-A2T;>A zh2ngpVG40$7lpV@DflQ#!9Q`Zc^H9u^e_UY&~EMgFoLHh6T%%*p<50BFfWh8Xs$Q6 z9*05Tid1ey0>xT)JgrxJ?Ju*&Ri*T$*QbtdJr0A2{^MZ(1rc@z5;Og&ivR!s07*qo IM6N<$f>osB5C8xG literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/message_file_type_unknown@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_unknown.imageset/message_file_type_unknown@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..506b402ca7adef5258cf46346019d72e78a3f949 GIT binary patch literal 2672 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 O(&_6MYL{x+5dQ^CPB1$F literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/Contents.json new file mode 100644 index 000000000..d9ec5c22f --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images": [ + { + "filename": "message_file_type_word@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "message_file_type_word@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/message_file_type_word@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/message_file_type_word@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..227fce247b43c0cdfe8f9f9f66090fc8f71e506d GIT binary patch literal 2814 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 QApigX07*qoM6N<$f)_hx=l}o! literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/message_file_type_word@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_file_type_word.imageset/message_file_type_word@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..8f2e795e6536f18cd5bd674cb96c4fc794b22904 GIT binary patch literal 4712 zcmaKwXHXMdv&ZROI!H$p9zY>dL#QGUA@trm5e%UtB~$^02Pu)zC4itodT#-dqBQBf zg`Q9p0)o;MxV-m%z4w0Dv$J#NoSmKd&+hy-(b!P?799^A2?@z9h>n)&jSu~g(NNu- zicsJr2?^^%h}L8CQ1rfoU8TJR_tk7cZeZ?;-a~ZrzjyBF_}aS`i&rIHXyG4AJag@d zOEj{j5$gN8v%ZtW6#vZ4)%7q&G~;`maNiDFf+fxUan43D1^B^jy#v+&E;#+y-vA zq2ix$+gp!l(@nBiocH#@<~KuoJBu%(v!w8_5h7= zjZh2H-iW9h{vK3~TT{*LyGN#2Pnd10y~-n^#hi zzHZ$4E>~cYeK<0~B440YuPb~i8kP@O59yT&w)!V9$)Ew8{Y@-BJ=DbFaBYX}yDJ+1 z?uxQ1o^j=Qdvz3mE;EAAD&O~zdK^E*?4}2;g&v#zhSDiWK@SA*z_pWvjj~4Qw=-8g)(g@H_GY9E$fs z@Neu^tD6z)=|~VT)2=GCJbMCT z7U9oTI93YT)kxC}fEbR%8(r(y?9G9A*!|yE$Nn}W7Gy_Bl(egsoD5^M+(1H zr@%`yAfhOb`%V%rhJbljDri%w18#l9Taqr><44?lE!6={kdkKUl@(TteRgQg*aK}3 zb-0F7JiF%CM2w7z>$$Gx_~`{Ln2 z&Yvs0?H8SewYQ0Zbvv7sUGwaI*B@U*hNB(@`tzM_N;?xs+IyFj3j!3(qgl>Iw`1f9 z*uwK(p|w{bF)^r1uPu}W2v zhd>VFzNXG1m}?gpViL{m#r)ljLiNu`S*k7fC0)U&$2a6>7-iEoWNJ57-o{8qU=Rtjm=A$g^K~ zYo;vq+Bzx2G|lUhmVF&UCO+lu-bd7JU9SYYh9^kqR>DN95@{`Zu3A$R_^ReZQ-D+j zP6Wjo0SY1x+LFjIlybaC`vcsTSj{&47?T%u`C&-PEzWPs)_Lka++?2tR0XT0*_-#e zF2u7u<#q5(gl`1>QJ5B0Ab}61B;H+Zmm~dqTxrnNWL%sZu^E;JO_XQBjq1`$oY8tCJS1?4x#N;9M2tq?dJ{aQb5j=At68Ddn)|N(21u%VBLrMVy}x zmHfrZpuhlGmOzH-)v}SN=92@Gm%63kR?F|a9G}8BzTI7xtAZf}{pL9TL=&BYkF!r> ztgbej|M-VCNKSFG2|_j+kGb@jd))>;Uq!NjCJ0BR!9F`hlntC}5i*tlaF%)E7y2f# zDD>bG^D?pR0*LDBRt@G19qNq|(Dr$!Tti)Lw}om4+q0cY5^Z$Y;gOdwXG*x=R_E?& zKy)_3c(nERsFofOnzZ*JY4jWM=n%ZBS`}R#_-QZpP7~7OJfD4A%IM|J2pm zVWv;85Ju0Sz@FaAW=7`@^0<#%*`WUsUnq+$^fY;a-KMo;Cj8spBcZF=Z|~WNAXQuR zl36UAFq!z|bnk(F9KD~nFe-?cDtWTX;^B`Af1ZB3uTsQ>UhPuokl1_iGn9eViOt5A zk2@@m{qRdE_Js=HXX1+^^xU6JE&jouS#w&2N%>rxiI4BP@Fd!26+|B zarw083vx<0rvSsDrt{C{!PZPy|J+obGgr3 zW#KqtFoz!uhy;rInZ;2INq3sPJfQ84x@NB`l>U?n8a|j?nkHe63Eftz^k``8b~3+Ru$qf-oOCWGeNQv=PB*%>@S%@x-{`J^Zbg8;|CF{NnV^19^vSbP#9hjF zym>h=v;zvH{{xd8+B6J&euVetk3o4y?6@%${l`61$*VC2oYNpqJP9-NZz?5tG^j+8oDckjZ zBXlDQ?OzX$tphiFo#7$kNR$$!8WL**m;lQP9VrX_%da_m2^{iz`j4*X#6Y&F5mv4A zZ@@?Zbu5D$D`e<1$Q@iYLj#THw_80&w3~?1{fTcgB z9!cj9)_!}?c7Pls@kRR(?}NFdA=cAkcJ(cuU%uhD9_>a0f6gsC2LLquYSJsFStJOP z>(o*R7&x$>XQ()h&~Rxoe0aHML~j1P97SV};JHyUU<6^`8U{DV4r z#s>=iTtNSer%cEiC$|l=dDJ%~Vw+^+Xlg(NudKTkUFT6~t03yRpTUwRSb6@=L?`e5 z6s#>3caO8wt?G61=*2N*VsWu~nR*;G_D8N8{j;r<@aoMkf?ip2GI?%oa_I1gODH_) zQ)u#g+IoUv&C=v`rO7aGmiGdx+|BHUYGD7mYHlOd-F;s~ZgWBjnvzx{1*z9WpfD*$ z_=N|}6t3cOh0a1n>m#y2_djH`zW1+whJO*>dL5n>nP-0oOfb1mnO>lVxKxCXe|>e33LGf`%(qn+RcpoP!X zG$svxVABjM@=14u`W8Fj2TLiCQ5cGPq@UpMfPqM!aaYL||F$2yM_; zz6c39tN+P>g}NUCI)g=d=N#~1e?%UOAqEzUYp z`-&?^8SzK|HfW2#?ksC`j%8hiVu&S?3ImiI&vP3coeF;C&2#JBUc{H!7Y4_9(Hb@H zwOHy8W=xhvh*l9y%4w@J^&%V-tN=e(?IYE>(?o6>(*kuq4XD+s#2ff&+!P#MkFjAO z4rZ~-0V`{1*;V6L-9++*VGod|2DKS_=uE{2513W5v4Khad+n@v$ZUfb(5bW{8SOP# z1~!y#)12M9YVT92L*f(@ObJOzk zBb(>sAJ>X!>jLkk8gcm{2ImeaEt%w^5`0i2dsgY9b|^^?Uf*(FOLC`$g=d%uNXbr3}<$Rz^{+l@7u&L?Nb$(ls`*23T{n9iUgpfnUFN zQPQF@T@sva=-HkYSTt#wu4QH^AW0j0IjXITte3pXRXl?7_D@%9h3742x%t@jc+lMU z_A=I~2sc#iKFs^+YJ_JwOKMl1*eU7o(W~2N4f-ZNt~!WAP=JwuHT^r>+-U!Ain?!R3`=%aF{ z<+`?%?t4Gq?y%)~OB>t!G(i3O`S5GrdRIqRIa$Y1iXcxBrw>1n*`e#+6(M@*F$1O4 zt4(2{j|z&1emjd5DS<9J|=$>SL zsN)$w)+1e$E1{ICC=YvrM2xVa-CPD}M?nFxvZ?5`R_&vJ&g*S$<6JgACHS%j`*50`+DpB`I2tOLi^NamNdNpU7xs4wemCj-0h?jE7?GW zj<1hzAn?U%y4_}${NeMbbQ~OVf1f{2#ZVhC4q~XHdT(_f)-p1xmZW~C>6B!=Gp-ae zrN@CE_2XJ^!DrwWzv{v*EY6}pDRu91`Zq*g|BL%?dc#U{ahpu6kkdGZnRoEMI&&;y zlE^=T!V_cAY03dQ{~@qUQRA|XF`@quJ@KOKD(E^uMMXK)xC}GL6)mX&e;GMsg68h` zy-EGK?SIJMCfa3WK2rAv;eYppWdw0Vi)Ga*qtz+tFa-sXspmx|;s8x?^w*^F7k?gw zkfz@=GTm82u3SvL(%oetdB1A9SsqJ$P8nrF?ou6qVP33Epoilv5AWPCss>=9)G?)$ z=8FbFPK8)<|!HSY0K zp29Nyb(@k6jLhrDgQ$uoM2}BK`yq0eQk&21D%)+ePLvuSi+;l!04+7Tliga*v6!r% z8;3j1_{>Wx5Yt1hyWXcEn1K-U;26h6R>uTUMf3<12HW}3<2;I*`uv}%AZf< zPly~v6vn?-)x9(OCs`20c=I4a63Ip5Ax45|mXPQ{!X^Y20-im3G6+gMdGss>iAZ(` zIYgHbMF}Q*(0DMo38|8^1+cY9~2CR-mg+e}S1)xZAUt2f&{ zuL*Y%&&_H&QZce$KfnJ;qwq?i)E;Z?P6jXPyI~#X1?MV$dQ1E`1ThsK0T(@TgKw0(zu?G zxtpeiu`KVy-~gWP1NeF$z`;Qz0<D7T90?=FrxD_d;1|(BG zU@eemgLa{uId`;%nay%;ltNmXO5Ux_@89vqpN-?drFzG34qjzeVQ~j%&n$dao*p}r zEj8s-YyZnOQUoNDUX^!SGKJE!9|C&Z59c-1zkS0)-hiCg7PI@0OnEv@vZcmc`MW*G zu=6@$wqjMK0wu|@w)@J7@>UOz2+797Ydi3^2P9j58%QR7PLwU3>`&VpB4Hs+gpDu~ zR>I7&V}KeLW_A(bdpY$chZ+vNme{UbR9-j(=+>fdGSt65wqdLC-JGZs$s@4L*gbaO z;x*-#UBhpu<4J0A3G5-l19_LFQ+^Zoi3{_p-^8683~j~i2qcVzl`s=_l+7R5!EAoe zib_W8IE)xU+i_DUJ@*liv~r%Qp?>8}AYb0FMdW^BfHgtNxjK=4Dgm3r<%y&$%eGr> zhbr<8Y=!Lbpb+VEOX>X#4x0~E0?%|%=-c{Hw zB<(tIvgN!mLfDIxFeBpaRGXivDMESRpD;$a4!rA3NgRkQA%p_2$ARrd#R2WhiNh=s zdL+z*T>xP_+H#+T!x6PUZL~wURR^|nVqOa%{{;zcFljo1Y11HW-~cFl+}R{5r6$m;)ay2U4s9*QQtpu9IRNxakyF4&+z|7E`PP z3o6!un@)k{K#FzX+7#=+byBPYH=P2@fgJ0=Vv2R(0P4UgK$rZmyOX(w!(5CTd?`B3+L>|+85meQ6K;rPzlgjg8h5ZJJ0r%FK(awSYye*H<0iS1YkFG z%`cyT9i+4y&MI}U$=J#eAHKr6wS(kttSV^w3FyaPgL-I}d)fYV4d&O=;H97aHmWBwvBH}>!%w!kw^~kfG_@H>(0`gB+&r)drb*8*%@BYF}yFBV(t^TT<7E+gh|5ac@qL>~2bgG~!OirV?bg znrEaD_hZK|)n(i@;_ke;)tOuNrR})_ok+)Z+N>HFwnkd)%RhP0UCGXE&Qh#N>2yM) zSu;&*`DFRv#rOJI1@bV0FtnD35v;g3#t)Ex^uwXbRRBljaTv8~KH$e;AP8BD%vyWX qS}f+)E@+E$>A{}IVG!N1!2Sz_a(5*{sas9}0000il7@rh&`WY03j_K8MhjK=acp(xvgsK~yAq0gRSBxA`= z80%POWY9;L5Ryr*+r9tYKkpy!d7s~T&-p#)oacGZIZ1Xl<~*F@oJ>qiJWvZ0htoUq z--rF|sl0Madz^`hqZw*)%_%Z(E58$YVM?;EyEpr@X)Y`<#y!}ps`I>H>6^YylR)FoBr%ne;;^-DZ!}`;PPhNg5UD} z#{S62Fym%>Gvt6iXJc%Yo3Ha#K1EZX;Y`gQ{YhnfL{1~wzcYi4nXyIAL;V}ISq0g5 zwfU}Qi5EC>+BPipiS1o_sK?LF$IYV4)}>hTx}6#AK|CJ81c>ac7P3GkWDNGGD;+`Y zPFWo~79^M6Pgd24Y6@|G-~(47rT(t4h|m}d-aBd`1vB`m?8iglOo&?=n7G~ObF86W z=*%q1iPMzzeKjcq;r`Z<=JJD$EBT=j{|jd$Ba8dM+@~|y2vhr`X!P!K;Bqjr-rN5# zWO^dZ-`9_xl#@zjIkcXv0@D4~l+JRGc+c}2ko&`|wrv#LxvYzdgq6pf9ug=zO!;4N zJM$3SObuin;a}1gVYW4MaR^`%d_t?A!Ie}Rnok_5JaR(HLzjQaIIr>vrsr_CEcd%C z1QthS4j89a8Z;Fdc@C*(`GR@7pXl=I&@A9Q@gz!rzO2?ujoSbSlKVQgtxC@u3CSN^eNsp9=8nF-6Z?N#msc*>= zj|QJLJ_wrTF&X_O&d&QZ<)eGtL8ycEA^8s}675nBM_N$|-{I~8mJnM*Q0cvO^MbI- zD5!<|OYk&RsHH81h#ru&gi3WnF=(KiYX;aLAH45ix~%}SN==9!7pE-T`Z`P{6El@z4o@${0M&Ptz?Nk^9+Fi>Uiw(-|%iP+8tzt_r zuhOf|pcLud#it>A&)fF>twh9~KY5bP>dPhKC;5z%eaz8D$&Jx5I1Ro8i6=_uzuRju z2;&(B;n&p`=GJZ}S9Fuyof^awdD{VN>glt>G+QqT1$TM5S$}F=;L`Y@&?T)P-r>hS zwfX&i-4aLBUy{URFavuUK(Cj7gmNLCi2+Dt|CYytZ#Q-<^0$rqj^%iJE_Q$^FoT`) z;8%Nf%WSKZ~ z+}weo!{i7h_%%-U1N<6Gp{agqEB|I7NtNpWk1%Irz#5lhxgK1eLv<2`J^o7)b$QP3 zHw+|g1K7crmZT!;brktALCQ* z9o4y+5{k%su;zgmH?MV#JG7HARRS`@-y!)V6@J{7c1VY5rX0`s5b0wv{LUuZ26sq8 z-4ut_{)wBBh)Us9v+c-m>k8#l@;(}yerjlh17qcv`+QSy>S!v5s+7$vTf|AWZAz2~ z>cBv%@g?qg=1IL?4cCa06I45$Lda2aiNH)JM!o1Py4$e60(YZxWG3`Io>1EWsWqSI zY5tfUTg|BYXF}XkA^=o~+UbPQIaJ@R2XU?A@2NG{{&Uaq^C+-Ukoq^A9yZ@=^X#+t z_swmdm{|zht@`{u72Q4g3iARsEu!xtZN$(!I667#IE}T%VJB4WK1CRAp)K|-48RxH z1X=Krxhchvnq(Hz14kvrVwsQRj>QlNQw?@86Q$Cw2pxaAp0RB_@*n859# z&IIv`bbkAocPhuLbse|9FO_q&L4F9}Z73fFafr2iWowlss zUna%dUo2}vhe2t)e5`Zog-(Q^lz4l}!j$=~CpBCPTmUv6zc4vVxAn&VB%7lK&fTWj zTu8P;DF;`+lHj%amMon4_9I`~am`hC{fYTCoXPyec%-7JzYWg+&N~b)Ci*Br`uK6y zXI2#zESy@vNr~TUG41;1@@2;vEqf>4oR*@%Ec>9&Yw@0oC{$B09;N@o5iJwV?^5uU z2DiFH(srY&2A`K$TZ7>WdkveUUvXSlbBO5+*sZe>4Ul!oOKSDW3Xhrrh!j__-Mbf1 zqduQVO)CFA#&S@c#@^VR0ziD*MqfYL2qw*b4q4vbHj(5Nb<3yPksuM=j;mUVKgRZX zCOA}wC`VSbXLqTspcI+YMUGDtzM4Wz$M69urCF4Cbu9k5^zk6EJpt~sh`3NXi&e-b z_YN)yYDu=SZD&`wIW7#1**?v)6bsUq1OJSXorHO_u;J|at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u10zX9K~#7FwUgs08gfvi|r$L;M+RZ&Ay4L_h^qp%OHiP+gMxY1{*-?#LPyR4E|36ii4LR8XaZ zu%sk=_e5S6S+fxkn#1IdO!**!0u83Ub1YBuEZamxJnWOcHg@vBY$P?eUC|bT22=oM zEf7AYHPjFsm4^B5=3LlCU@R)2oiDOU5glM3G%;-4Jy30#fv`$#E2Mo5)OmFx{o8Xf zFy{(dA^vJO{D_%t7+hVb1dYb$qLmC0u^M28deo#K+k{z~n%!e*--UF|Uy=!d6M`@C z*?Hv)4&uM6RU=~1-j$Z~uA05?M2VfP9wMB1JdxVdd z4gsBkwp|tk>(4jg%L=*LquS(w>{&$kPJsv4I>fju94c8jzK6V7lQE}#TaU;jq)Y9k zy9o%Sy7SSju>Fyab1yYOsX}pq%qkjk<Je76}q1=bVc&?j><-Fib?T`zngYj1}GL0Z|sT5^Fz;;WI_9BR! zJB$RrOyC%&8{Cx@QD@l(P65MS3L<{C6e0#mfB7lRk55pE=;&`xTu}i@Ba#>&jaNX# zAQ|mlZK}uYO;r%;2-ik8&s-}LB3d5;6rI~%J_uIDpa@EV;6Bw8--|4$=>LacQ*}y# zct)SHR4qU&=Hq$O%^yl#6N|j+KN`S}S2Pkc5WaaKp9kwSx*)!<*tCZJ0~zF%XTjM5 QCIA2c07*qoM6N<$g5=*XIsgCw literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7c2b103933aff1e4f080808bdc18b5d26415d289 GIT binary patch literal 679 zcmV;Y0$BZtP)rcI%p5F5cXK~RiE zu87)%fTDs=d?6A+#76OfiAGkwg<*5<&GF`XkAefsEc=-6o7tT?d*Hv+PaJ=NBt?Rk ziXf3l9|7(H8zM3dJNF@A0vH9>H@U(69b8U}e2Yq#0wL$e@m3J1_&dDiV)@W%VH8qC&F~=mIVP4}ot2+^zZfz%`BC z2im4XA~F4)Kv{-u`wTn>Rv4nqz)j8H0hVUin^FSf#lU$3=+o^K&%gjrV5+s13IHD`?GB~t9N;+6Z7?#uN8X0e@B1|Z z+mr<#GE&gCEyj)~{$As~8cVB1GL=4HE}i$}v;p`Ei~whW9^fc&40vKnv6l{WdHeE! zVc53sOIfjL1O>8Xm2etmK^oUAv?$X;NeL~((sE*Gw;CUY;k7}v=sdT>y zduue8=SlXe);B<6DpvV7jKq)9j#|Jgi7Vn+?xty^&>s_5Bsl-9`~ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_forward.imageset/message_forward@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6682e0208f1d73683542348fb4e5e6d90540b10c GIT binary patch literal 1061 zcmV+=1ls$FP)wKLsnoS#bQ4iTU^j-K7m^^N5R$2Afdnc@i8m5N(M3TN zdN4F9e?Sw15JmCGkwT71gkwMrY1^r)FdpeC&_;_TKA#*Is+Awf8_* zy3&=dG$<9I7btiDq@V-AJ_#%X9h|<5{1!lmIsu zxFPU=t33ux$@;Z5ftv#EBn|_8S>LuN5bbTX-)WC{|F(9aQ@|R%-%`6Dn3#=eiwKkw zR}b(Ma9v=}>-(x~Mf!p9|2K&ModZY_=b90ipx@VKEAbWZkmmVMlU+rYc6kJN8h8;{ zZy*O`=^*)bwi2IP$uZ_6iVS!Pi?0Kp0E1cNxuuPJ0eD#bd)02rNPH@SV@kr^WDc+$ zxNLyyJ>QFTfC~d`$w+);dP*k|N|P&W0RE@}G+Fy+MVQ^dHee$#TVn&1lh~S(*gX~` zLfJgvL=E7nOn3`;M*E-%<5IksxQ~r%rP$c`?!0A$ilwu$};v2 z>oaN+0c?2&{3@_A>l>l8S!IQh_%I{!ZYwz=qQ%l|($m0vQy!bM6)DVEN2Mn!*+;EU z!xEFdpp6;ek6K+H1j~?KwJ99`Y5G1BmZnxVmTMO9o4L&iJ6ZK){#mia=1(Jvd)2-q zV}-s$D)^e(0}kd}^ZQ?b8Ky7*Hfxl6;1>XYS+)8TFx4>C98^zHRhxF9d~g_83=Aoo zrtIpHD#|DHOjccUM}X1_UebFmJ?fW2^A`%g6=n0Z8o#Sg!qbq%a!*xu9j+4z63Ewp zoxl3Uxc&aL=)Au*BIeM(*zG!2HRKeFoU9Fgw%y*JZ99Xa=mrn5ZLVVFOiEHXW}4mPQOQ~5JlVS zR>Aw=S8elpd7Rq!4cPrIYik|q&sec`Q|&utfcFWu80cXGpQ2J;bTxaeQq5NL1NAAR zj`~;nvm$Cl=h|i!)gmx#KL2r<;A4Vu@_ASE-Y6B_>uPb2oexT}yvP68aDFx&jg{(M z?jBT9EXuwEDrA*!r08qA0x3$mUO#TdS*1kmcPX5tsxV@F(aC>s6df|>T~gNZ@PYl4 fuPa^Y3gyT@YYJ4>bE&t=00000NkvXXu0mjfW+U>F literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/Contents.json new file mode 100644 index 000000000..a04f5dd5b --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_info.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/message_info.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_info.imageset/message_info.png new file mode 100644 index 0000000000000000000000000000000000000000..7cadcab038408b06c20758ea2ba2b88e5990fb6e GIT binary patch literal 1269 zcmVat5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u12RcOK~#7F%~wlK+dvrpXJWFUi%5XP(mg@b696|T zCrEFQvTFeeBbBzhrZ-4VKyQ$6f~F^^mq?|O7iE)&r{6#EIKT586C(AKz!}f@f4@eN zC&I|o9tZR$Rdl2c)s3Q%4AtzWqC^>r@$BQ>-ENh>$B{~W=&E%G4kHtehtxDgZ*zt| zgcY?bJ9K>?sd(kNWgD#V-yvOr={wp05TvyWL6%u$p#WCv0d=8uICHwkCMHe2Q9yB$ zX=jBUQ5}1AEF0NVGKniyI$30mIROsN4S#%FV9tycT|azOJ@UZ*r8$NWY`tSC1Rkfq zKdJsw0p>JEa8U}zpH+*Vfc*=@?z?iav&a&;09lm(aLkuUTo$tyWxB|c*DfZsJIDc7 z(EJ&gGcp>d8BJmyj~v_?tOeNgqXGTOzcO^T&g^>^rh$)J$o~YjA^LNpI;+lGcNqHW z)s`=%PSKC^)e-=^dzw52_@f-UUH`Glz9Ontg*J}*wb0WzKmgVQ5*6=702h!S%4UH>Frs*rB|itSmL1Nwg<9!L zW`V=~mqI_NX?h}N8kKjOo`rT5%2Ii6LfSGo?Zq--3&d^}1T*b-3kXKjpyxE!Dy5(K zZaQtBTSIYH=xbRXZB5ud8shhz9%6vZ;{XGM9VWJfY*5K9z{9J?Sd|nipb?E}6-4kV z5l1Uw3T?Q}X>ZeTClz_jR`_7n4x(jy!NH|z$`P%SPQJ+}qP=s|!lIci6Z}4Tj)BB7 z$5e#w*%hPNH_)E6V|Qp=rAU~P58h_u)W*!2mYfUW`^fF*0&pzAEGJF~u5|e8`nY2$ zbC{hqm-8U=kR|{B8p|CHQIf}c{$j!d3pDER9gsVx(*)%;M1>1c$d0ggEE!A9XByFR z0WzYs%D~1&lQsYc9Q(Y~)l9-#1N|PFH^>*0+j0k5w-#w#Jn9K|J!kV8O|STsyq@EK fY`C_Uyq^C9t1W@HKV7HN00000NkvXXu0mjfgvmeK literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/Contents.json new file mode 100644 index 000000000..aae3f429d --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_listen@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_listen@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/message_listen@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/message_listen@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..289ca75311a1dafd0fd6f4ca4d3f8946a8e1a1ff GIT binary patch literal 495 zcmVIaly>5T*00%{60eooXK$|!gkUuR(_wZFqyPo04M=W$4VE$oP`yu0-N$(Bp?tsnpoj?cZ%LbXLx2qx9SzO&w>TG(^eXPcP_@M@dKh?hxN<(X4wD$002ovPDHLkV1n_2+;ji{ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/message_listen@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen.imageset/message_listen@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..7ecd868ed495b9092f64534e3c11cf6f5b5a460d GIT binary patch literal 649 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$>^XU<&YbaSW-L^LDmxzOW-tTfB92 z=a=+GF7pXo?CYL3xYP@=SGhiN+BtRApEaMRK5_N4P1*7!dGQ-P>yLJqGIZBZ?lhej zd-^ehva);Y7fVUc+4)Qf?^Z3z{9aniaVXcy9^u_HA^M0(gW_a(K zGA}2clihLlQa1z_nyA9&3nsh&NKY`=P^e7Fq`#5 z;QHLFIj2h_T+S#yzViI^;jozUsLi@nt?aEz5}{jK7T!vouvb2PW|ijjAMe&|yDKBy z#w^4($^4&X_5V$3_up*xvP++;YU=&-Li@C;9s3wR$Q;POf4F$MZ}~&By|#aXu5_L_ zv%dbRn&0bf@8yCH1)M+Wxlj4$$@Y_z)%?P%Rd`}18~9{zn>$fvV=l{s#qJT0L>tmo z;+cQ!e4DsRqkYR!J+qvR;=bEvERva(vvIlU?IPJcZS`;#XF(oU1CfIPco~5lDwp&3 zelPNiJGSukyX#QE$=j@zrS)j(p}VwH`1 zJ$cKf7mG4EN@HjHxt0n3^!v2u>obXURmN*p)feQuMW=hWZ?n%lEU+t1*lF4C4caZc zqF$D^XZnV1yY=;`(x>Jp-uw%X*Zscz|IPYaC2_VZ_^h^7#N5hcZ&>5^Bx#FHiJX(| zVvaT?i6}3=m`hyV%`!i-&u)v^;l;O1`c}Jrb#|ft+rv$A5trYacCai9cKXYzQ0T*X e<^)IELHT=KC373vPg?<#7=x#)pUXO@geCyERBeyOHy1(TtGxhHFVY`ANNz@XRWQiam|9U{8;E2I9m(3n*`1x;ycRg& zzr%(uko4CkI(;)Ueuem_@k4~8`&wVHS4(mR;9A?aX7)nTnb~_L`QC@*1%MlEug&Z+ zaOc#Y$u4=oy#(zOq>{_d6cezFxnsD3?rZ^E0@!$BRp%0wd!ZY}Y&%?ekTk6Y$wn1C z$u@nR=v3Ypvk1J!B9JEh6j6{o_?CCUql7r>R#qs!1Exi^B+s?l(UWIHJj>o`#e3r= z9}p3$-9@iiIBrKz9-s%#-|uNu^{ZCs##BDV$sLXXta%Gq^C4u-*N`I5 z5A(oE1P>)G9S{*>S21@Cm(n6T2haex(p1ws6@@v$`*>f)%sX61Fp^atN`LKGe!`!{ Y2amhAVCN|n`v3p{07*qoM6N<$f?ZptTmS$7 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_close.imageset/message_listen_close@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_close.imageset/message_listen_close@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9ad4f8efefb6bf45d052b606fcea4fc708d5de45 GIT binary patch literal 465 zcmV;?0WSWDP)~vG2|{90PczCRZhP%^EZHsnU7OM zEB?xH1o%<lh~70C)uOfeStXti1q>cskp)aC*489?V$=a3}Unh<{`_G!`d%;+p~f zjn)vtl;}Caj)1+)ojVmJZ5t61l?fI3x)*tEce9j` zcOa1J(N08)Z&Yc{^Q0RwanOl5I83kzSX{hCNHXF*#tKSG{75HS08cX7WW+~0(TV+% zq0cQZ{)!V`=s~PL?vfE7aH8!AKJ<=#X{8~4z=@T5V-ID(eYeuTu_BEKEQzd32sz)x z$oVmfoL^+fc`1RM7jwvYIgOk*G{}8RiJUj}814(1Qy%HWMr#ORN}c)sO-n>C%)B4l zV)T6^X5Kpv;qv{ad69Z+k#D}an|*uzCjQ_kC@3f>pa6aWnm0pIj&$XU00000NkvXX Hu0mjf#{$SF literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/Contents.json new file mode 100644 index 000000000..8f5abeff4 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_listen_from_here@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_listen_from_here@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/message_listen_from_here@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/message_listen_from_here@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..36f72b4e85c98e5d68726573076a7c238e572ce1 GIT binary patch literal 706 zcmV;z0zLhSP)ez=5QV?Jy8yBbxQN6Lh$x_el89eGgAg?+RcIhmdI}_HDJ@Y^(?>uWigZ9p5gJH` z5QsRQT>TAT+bs4K*Z`iXZD=ga zZ;N9!re1vA2yX$mf#w+drslSc{ra>{Wa?Lq*uyAioP_geJd1G)NZB7Re;Wi`(+7iUo#LuuMIj*yiI z+*1EsH9n|W(|vLFq+vCWp@{wu6sl$Ye+Qh?nBT=g!d2^Z4+PG8jW26#P(}1;0AEDz zs`-WYfmgsnMG`K^W?!7ZA4;=fY{v?q9e3nuHxlUq=Z~yLOA^iXBImo(>PY(-w_p@Q zCCX3WqITj#`bk2iA=KP2;7`Q;EX|SQD=Ngi@ps_RBw$J%2OG;R&6{k3SU7Lr*F_RH z<3A&tF#lWO^L;a3>@=kO8OEeC7utF3gD6@~RpB>~1gA@*sSq~Jm7L0Lsb7&Zug&~F#mngJ zsF>#6-$ma6B;Cfbi_Ys?r6t7z5jjCM@k5L%I(R# ok@N-}3}=O{YkvfO$H`~?7p;i1bS!~(6951J07*qoM6N<$f*}1&9{>OV literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/message_listen_from_here@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_listen_from_here.imageset/message_listen_from_here@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..c18fc26bd5d65310bd5eac1e7ce26a8da84439df GIT binary patch literal 1092 zcmV-K1iSl*P)$ z%Zn6A5XOJiGp(&okL)hvX&4`PTE+Ds>>>-Y*S*X^(7Rs5C!R!D@Zwof#DBrVUPVC< zUcC4?h^)J-s0T$De80xWv`$IF7Z8?RU0v1LZHpfyId)}!5g8d785Jdb<4F3tz^}j>V418P z@Bw%T+yE{CXMk%?htTF3AcEx#)4+LP&Ml~@&1ueb4(C9W{~0)&qhN5AN5E~hNBXVZ z*t6mq$RbR@R^W*g_^3Yk`6*zVT&StD(9qv&^z4+z`ngZU+1EuF@m>j?S0DWQIIy|| z*J{oAkm1^!kriF}?aqfo{|~USO(?Co!)eVwGi(%x=iDB95j7_)_;E{uE&vlwSeb~H zI0Yxfm5TeFGfL0*g;{P)A>v8$YN5mQpuj^G(Im(+3)LIM)|*P z&~ZhLakXZ}JU`57Iv(SEC0XeCx=y<0`IfDArOOo80{3#1Jpufsu|+nV6H?0Gl6mco;&zcBdp4#yX+g%CN(ZmgXDB+|J_f(nEol}E{SsMeuH>z0 za~iU=`MhyA9AC$Kpj)9~^+$jyT{u%pfoCH`p;>Wx?jv~?&!S9xeG9zQqUT*E*(*}u znOw@agRHJlcH0wK#`;*OXSlo8t8BKHJ!r9zR^^RW-uPOm@trLoiIf9H(aCcoJDicN*DOagWP&3jlB)|{S#khm4XbZ5IzaQAy<|ghX8l~d+x6Mu5a$&Wn z1=x#*C0XsTpk!%qUUL$q3SnonFc)oAS%4K>{{}V#%L^=hLdDynY}BkA#vTQB<&0b8 zbbH|Det~X$I1nv%!rLhoa`-A}w&7d|Tn1KXr+!n#!qN=-5v5|jk^Lb>L%ZwTHi;FZ z+qD?nqx$WPODT>Gx^i6=b@f|Z{b^2bfkT}!Bu@EXRM@i1?BuXmKj~2DHH3qD-`)1% zo5GKMNLiv|3^Q2RWs%7poRsqe=9 zKH)W$N=971|5D8-X(KKImBQByQRXU@6d&XQvZ3&s_>Uh93ZDS9^fH2yuY&#n0000< KMNUMnLSTY#2NC%I literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/Contents.json new file mode 100644 index 000000000..72cacd8f8 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_more@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_more@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/message_more@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_more.imageset/message_more@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b45c5208a8774eb3ec8f05c50f376e0736fbd65a GIT binary patch literal 333 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 No137G>kQqZ{|~_W6+Zv~ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/Contents.json new file mode 100644 index 000000000..c37f2e761 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "message_multi_forward_merge@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_multi_forward_merge@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_multi_forward_merge@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@1x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..9f10cf07a351c0cac54c07d5551ada4f0c5b2f26 GIT binary patch literal 1193 zcmV;a1XlZrP)Px#IAvH#W=%~1DgXcg2mk?xX#fNO00031000^Q000000-yo_1ONa40RR91D4+uX z1ONa40RR91C;$Ke09h_XmH+?*A4x<(R9FecnNNsKQ5?tLFb1(vGFdPd{uQHSp)9aK zmTER)@m^9W%0fyOO8#tESRlo2l(0}($%Z}6kV!1a!r$>Dn#^=wR(dgWq33)I)izdUv( z9?fM={madKeC*@XT4`pK(H{#m$vbcX&!Y;)`dUN7CFp!G@CnPK*%+^eOJIUi;9B<@ z7C|1xr+cv8$$4N<*Qmbw!3J%gxP34J7B!Ytv@7rfp2K`dmAS3KT59IMuOO4SkhYQw z?i0}d6I_M^uoX7KbWl4$n`bZwQe_SLSyNTM_(#FcR&R)T*t`MH@DNOaEFEOfxDT?7 z>c*@=A8WF<(i*U1*hUN8W?b8H3pB|~1-jsmYhshyZ7tSR{X|GD(066loZ7?q z1sD(Qs;0oyTfCX9$$PdoE3CQ2sSin;Idi%Hc}Ti(dkhOq1HB4%Q1A4XOR`llhgO0XLu`Et_#Ob9EJ$81)5#K>hh71Xl)p)*49JA zOK!!B!@&PMScA1#Q;%opGIJ5O!!FncjkKH2S&aq?^qKrf6F+NE)>K^wZ7cv6;;ap; zL9^ym-Z8@cr9OIUHmq@uxGi##-2^9K1Jr9T9i6;__3>?+dm{KYvZfl%q}9$xQ%$e} zjS4>Ag{B`(#w-V;BA=c57u7NxTYh%N;6*>&qkrC`^FR5+-Qdp4>)_?q(X;zkGIlJ^ zobj1xna@o@EYeF~Y3Y&Qz?jDNLYOP}M#Qs;x7#3oRQEx zc8&5QsmL2gAAOBsVa7CeN8}RZ+7x%yCK!feFaZ7F9?p%X_}f0mw9!@{F~%Smv*YrK zI}JtND*EVajARx4uyKR2GO(26T-z literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3f32482c0bfd5a897a0f81b13676cbb7cc56c452 GIT binary patch literal 2638 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_merge.imageset/message_multi_forward_merge@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..ce27467ff11a0fa7a007f9103645b06ed7742463 GIT binary patch literal 4281 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c9ac1142d12d6fc912af410170396cd10ea01211 GIT binary patch literal 2641 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multi_forward_separate.imageset/message_multi_forward_separate@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..aae311040313c6fd681171f5835d77b01864a7c2 GIT binary patch literal 4248 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 Yzf`GX+yX_v$^ZZW07*qoM6N<$g8jIiC;$Ke literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multiselect.imageset/message_multiselect@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_multiselect.imageset/message_multiselect@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..03b9548b99f80d815ef5182ac1e9b7be53b8a4e0 GIT binary patch literal 599 zcmV-d0;v6oP)B%#sMZ5r1k$8b9h;&I!5jqNqf>pA}M(ONPH%K zBIe^XjSRT)RiWjytzaYUE6@XGKpS``)&Zst#|GJi@C3L9_QhL`-T-tUkAbdw_OJw} zDQx5=C@%mL;diG3w*{PKR3KZ+cOZBq75d^n5D^iv+*PRUwCBL$9!`^(@jq3d3!I2I znSTIW0R#CwcQ{RB0=#!l0)+wfW#iIzE`6N>PLtMmG~OX4aI|uc3uEBSGlh`0%V`=D z8Bc3m4JdMwD!y*mU^+6FzX5yFXEw<);*UN2!U4^F z&$ja20#2UwoF(v5oo&pmo(`fG@wUun;2|)Q%PZqgox_~w{1iWO+C$;l-+m=?dFBbC zw#*~hvoDRcw@v^)j%#D#X#p?gZdC{I1lR(;R2ARU)uQi8a3SM;l=g)j51s;_fnUIZ zjAxSc8u+08e^f%at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0eneBK~#7F?N>oggFqC0ekoc^#*u@dV}7; zcmZQ~T3z7AxU2C3rYEqTphswLAj>XXu(p)%A1D0@9pK7&Zd_fdKlBRb8H;X8h(L z`T$g7?nNJH{sr;Qr#}mr&7}S@nOrH{Lhe&iYsq_`SWj(fiL*EnJNUrzZ3uf002ov JPDHLkV1n*PctQXG literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..784d7b20bce2f9db960f887ccfa3184d2f22a3b6 GIT binary patch literal 573 zcmV-D0>b@?P)-LD6hY@z4am{zwkn{S5>Nqi zU#QW94T)>Gf&IXY8k1>x&9|5@q z6;R(@s8vgyqT|2!AtD_pJhIExaq- z-z9<eAOE7Q)(vuOkAED1Dxu-&oV%(@=YiFi%Zqd9^^A&PczO1n6!Ks_#xudf9L}2$??GK_*t=RDEeC$^^tbM zPC^*a23gQs?$p&mde;xV$R2G$efqEP3D|ezOZ$#g%OXvw7@hnc*BqV)?vOES00000 LNkvXXu0mjf3U~^8 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_quote.imageset/message_quote@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..273f9ebf27996fb5206be77118776dc9d3668ccc GIT binary patch literal 772 zcmV+f1N;1mP)c;Do$Rf9w~>MGEMqpC-AS@Zmb)(uLo#n>-uKOWZzdDi$36-n$k|p0Vjtla%|mNi zD+dnS!!!dMDY^|`B%h7IIp861vH`9X1K)sKz^6147NP1BVjMTDY)Cg(SE7WE4S)+k z2l%V!UKZ;aBG8-%q;N;NJ#}>bkAOu-9T$K{z+=hnR7@b!oHu}*(y5asltk?KeOF6Y z=L&Qa&l3r_2-UF-^r;qY%{lbcfK6wM+9%4u%e))ro($qx^|p4%GF)goF?J;}(917sCAz{iA4sQyM~MP=X_^ev6I zqIzOqOJ-G*?@W1T`6RLAu2F7#j1y-RJH~3t976;urg3eGLI3Ff^p5&3ICuERf%l$< zh$oaa>1OpDVPaFFneY+Z>GOoujI#G;`Hvy;On>&i(kN^3Tle;_fir41azB78vVK@_(;Vc?WFxSFdRG=U zgMJ2nI?twl0~~b>QTJb-5d!z6Y%k$5CTCx5^3TFZ^<~G$?Vj-kIIVUY*o{M=au9f> zG}=|xS!$})v#{*^c$+KbgG6B3KRI)Uu&KVzyxmfuXL#lBlp+x@_Avi8gI&QP`?~AOsiv7m-3iWC$r@ zP+xY)#9Gk;gl1Q;d#3E_3Jko~mE7MFa{FACz1=%sHD+$xXK@jy-W= z4(#hZkBXxu4)|Iz=)TaBu+Z3`OSm%oQT0FC1txQ0TNBV^iT6QA>~mmM6)tyWHrLpy zboyG8DPVmYVUPuEvs+i8`Aa82VriP6{1k16q`=W-22)_=`p*b zx&Fk)Fy^JVS+-1#8i$b&XTQqsyxNo__PSP%2ZULO!*h0Y9Q&){X`9&P5*NylIGn3_z>w6JMPcm;+jDjGN|ye#G-X=|YcApVZ@>2Aq(S6Cjq zqigxgn%DAcnls`;w&R_WcjL!o-DP`<)%9O9N`B2&+=1mW{@c�q{GSk6(SFD|(e9 dC;2_El)rlQ$ z&ud*(5XV3F{fL@IE0ww^QgNX|+JG2oQc|o6i6V+CH$~88>84x3qM*=KLBv9!h~OfB zfLN^-bf=}Z#)Yk@n^v%`jZkQ5Qrnt$<;Zu!=TwFJ{P@%7#1vcmEPz_Y+_&Y;vM$b10IIM)jF z46vjYw~9TJ2fh>dSW6!R_m^h~cKG2Tnn^h05Fk zjqNLQQ*X$&NHknU_HqMo+70kKFeaXu8+1b!HfOqAfti#QFxNTcW?)gSgxh@mr0IU` z0b7=FMFLZ@cR=8qZZ^xJ(A|!=m1@8zZs|Uc4AyTNsyPSjoFK(@CKM`zfZuKqemJUyu&3vuJQI;1`!TKJPI%3e0v$1^P;R!HSNU0WSfYwY*{D?E&r;e{oiG zz1!nL^JeKN8esKCQ-#o{PT!=*Y(iEk*@g}DUBNg{X3$S+H)vCym;aWnz>;{{lJPqH zBw2-KdcJq>wikF-ZK8RXu!WR3Wdk1oepQ9Fqe8~|c8E8~94P22lbLi$D($zwvnw=&5Ch&*tyho(kSS-A>}E_o%wf=NlgZ?YXuO- zm8jTjQf9trGu0UYKNXQ})eJ0alOYSO5=tR|Loo>c5jX2g{(8n6a8q&FV(%9HPVwz- zYlBq&Ozjqz4=o7miXw~I%L;MG+D9Bu9hi=Dhq9#4pXkQlNYIV2nCy9B{pV~k27CE~ z6Sfvy;N@dCL?(7CvaG7?S@nloi5DHlz8Cd& z!Bt1in$>Kp-RmOPEq4o8dw%qKt>VAd_$TF3psjYw)fl<3cx<%*27R#=qn6q+jb*EU zT3D>aOrk+vEyl)b==Uy-VdGsE29If7T<)5iHC*35m-FWubE?;m9R0dpx4&&Kcp8^3 zulrIzzz^~R$B<;^+(tE>C*5^RE%?-Bx0oHOp|^qkk&S#-HyQK#gmlqWbt6iNgFinm z@Zp-=Q<(ktfH#%%mz@*q?jv7Ex^|X~wXQ$2&N^Q=;Msco(7mKQ=$umHujT7k9gb`* zU+n+4|7uX?T4qdM6aNi#Uc#45gZg0cpZ&Yd->vHR`&&@GxBvhE07*qoM6N<$g1N}Z AmjD0& literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/Contents.json new file mode 100644 index 000000000..867817b94 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_reaction_collapse@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_reaction_collapse@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/message_reaction_collapse@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/message_reaction_collapse@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4e1e560a62c01ed8e120ca6f95162af0ed99f94c GIT binary patch literal 435 zcmV;k0ZjghP)k10Dl&pb%_a=jA3*q~4qrYLF`hWbGL^uj|ZpU6omo)h;jtzLaSh*mRlWRb=FJ z2iOA^vQlOP`vhL=0{1nriy&j0-5c=XL8IuYqC|6*Z>Dmh0G5^B{H19gfER&&fNkJJ zjd7qdzUy;Wg{T_Kd7(`0NJJZKmwBoob{k}*AuqK@qi>ST+l>}#b*b|(7SMPwxAk)^ zW~*y0>?lB^XD7H~GOz2RmDI?3x-`Qp#&z?lF5N*8-SOuhxgG2xT002ovPDHLkV1n&RzTp4> literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/message_reaction_collapse@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reaction_collapse.imageset/message_reaction_collapse@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..eed808a4c07db7774d8f9d04410e4dc684e0695d GIT binary patch literal 595 zcmV-Z0<8UsP)g1$gYE$dnJpX~Yf=~W%-#{tr|3r^1livm&fp5Sd^)y%k zJ_BoGLK;K90J)BJwYLuI&~@ zbbafJI{ACu-=2}x6drR`H_ciM`GL-tq^gBD4mUavYTp&EpE&fc!FUT{UNz6&VlF{F zI%@cw%0D>n1{Ru7svT)DSjfu84HUXV;MceX(Pl3E63we7$FZ&p;O}a6i|M!O$(ce3 zy%PDV?qMltv1&oIXTZBhdj;~Hu|D(8bL{{C002ovPDHLkV1o2J5tIM` literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/Contents.json new file mode 100644 index 000000000..d1508bca2 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_recall@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_recall@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/message_recall.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_recall.imageset/message_recall.png new file mode 100644 index 0000000000000000000000000000000000000000..91ccaf65280bc15930721b2495cca112f1baaa24 GIT binary patch literal 886 zcmV-+1Bv{JP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0kcU&K~#7F?UqkY!ax+pzgLWIT|mMTPoOstZ{Q8U z6No!S5+D(9C!7FpVm$%ez%EJXf~65<{HBFEEy}{u(FMPx&CDC6bE zzokt0vZLBgJd(LT>v?c9f;r395o$m8&P($~XS7Wf_M?=5gdtGuOk z9#S&77jN2=Dvw|g2?b3ZdmI0lMIqu2KQRuJe)#?M9WePWVpAh%uZG-_N+_}nJ%6F{ zQZR9F=D;>aR>=Y->jp3zyl9L+Fd&J21Dv2Zg|Gr!H-P85_Em;+`#Xl8&znTS%1J)jQUI{&qSDH&ra z50?J{xCSm&JJzuz?_y17U4|s0ci;>71U@{d;~4u4Z2CkZoq~pwg0sLBECE}u;gcUO@84K0~@fFwz65zmubK?=@48d;2E+PdcYl)m^8~IdxtI9XfRAFiI2&{;L^tp$oC7ez0(-4DthoBt_#MRSz|2z|1l z7_j;C3Hek;3BjA}HSRXL9=Hb_1eO$it^?;Z=lmiBZtXw74{yBEc$5H*Y29LXfob43 zutxJAWtBQ2iMjv218;y=z$0K@3Mpa}WOMF!Ymr}CxX%1M2M&9RjJCiX;7A4w6k2aX zkGb2x30bH)%0UeLq}4dE^MMIq3fLIdQAyQ}J(g-qR!t7^YhqsSl2EGsrqBAKN z@UKN)m>_W}g%x}u9vCg1^yXtwi5@l8?rSq%r0fS~GnCSW=#+^}&L8%qm&S~ndFIWD$_jY_mhTntNqRKnMZ*6GlpLkEVBzs?xe UzRozlSpWb407*qoM6N<$f+VIf5dZ)H literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/Contents.json new file mode 100644 index 000000000..88f3a8d42 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_reply.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/message_reply.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_reply.imageset/message_reply.png new file mode 100644 index 0000000000000000000000000000000000000000..ac8df66a764dfc04e5fd63c83c2d37f3eeab15f2 GIT binary patch literal 1261 zcmVat5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u11d>GK~#7F#g|P|8bKVzUyp`VC0S4vta3?C5N;4T zfpP-z2D(c$RgP6rvooFmJVCsHdV+8R$wo`0 zdVm@9e#fpCcI@B67rTj!6ktjT!dTQ{k>}g1$VgLxEN$D80e)Z(N(~|iYjEZ)@lqhm zp)Jl6LRy^EKoAdiguwhDq#zB11&X?+e>C~qz#O!g7J?LQE=|P-v`iP7D@Y5$kS$*y zn1DI_p!QRT7Lxx?ksj;$@Hw3ZnGQSy4;Y8{$b0#nBvF67+Z|3|-kmo{- z((-3Iyg|rBxbENQb@gW7oD6}miXM@EGfN0xiYn7Z&G$QcW;tmYIcv2~Mf1JOWn0uV z;6fK=_1(YO#m}S@3uG_45Vagu>x~R`_1!cRF!|0sYB7-3zKaSUrgVGHsK{7?k+Whj z(2AC=52C-GPYhs~3zP$pv9wIg#wQ-kZggt=hRlv34}*t{|Lc7dD{{Sk$&kgY3ZljD zz@qWyHO`x%X=8*GO`QOj(RHVb4{ch@BpW7SsE`)xTg#cV>$U~sW#DH2Hiy$B6eIP_ z%QG%Yhl-wVa(2f)2;p`ytvHZUJ)QrT-uD9_wmb%(bQoEEZw|~Rm*r#L12yBV7Y$i;FXD+=guos+#X|~HB2FH zrs_&&y$LueJwy=hnotu#+VptBloK#hNy&`MQGu7Me92m=J|`lWSiP7*$;9a^^#_(< zhKkpWFp2aq9f&ldc+I4T9Ku*17VW7&!Xp?(3^&!sm_f3xUjT#0@1>Vhubg~b>|*Ob XV)}yvRV}iD00000NkvXXu0mjfNRC7( literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/Contents.json new file mode 100644 index 000000000..ea761a1c1 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_toast_error@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_toast_error@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/message_toast_error@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/message_toast_error@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e3595b35974f5c1124e5bbc0b92622a591999374 GIT binary patch literal 1569 zcmV++2HyFJP)U>h*?d$b`aT5T$j!+ zmv){4Q-A$k)t||l8vWnYL5&#};E{=yZHo!sB_1(oDdMW&yvxUe(=MMoo|R;r_?B4G z3TlS9Ni3&3^n3@Gie;^}gV^4m)mN9r9O_ZbVG|}8icnF31O?%L{L_vm;#3HD6?Va!XbBie+=k*;rwF^@ErnnmGiG7&g~k7pMfj= z2Nx-7yi*22%K!iX1Y=1h1h&h^EX5)R{CPTB!ap)wnb2Z zP(g?<(f<&IFZ~bwcn~Bi5Gsk#U0HjOo=m8$B#S`TG?h?MPUcbEm_h_NV~s+}5`r;Bi71TC;Q0djQ1RH<56opQ zm(XVww$Rym3Fux}v}%kUHKk1PW`kA66l`XLa~_8M!_rV_IGa}+lcJAwbd>9wb{C5` zJe=o8g;0nrm*C_|9_I>dHxYryrl#Gi7#^Na$Lu3q^7|X(oIeA&o`^&`!r`5WJs3Me zOvM?yUKI*`N`|Bm&bGIo(sg|hfO-Iu?G8LgWR$oNUofs!hQl9i2MNO8hEcgAYg6OYG7>bOi};7(WNX5Gi9Uz!fQL4ut$0W7J^|+RA@_zP*YrWf7h5fAH-) zt{_VfB2rh?7!812Z`R_FGNLglKLB{l4I_C+Ks=}*b!3l1?Lpac1KqV@?3;#GGNNw^0x+#-#j(dCW~f^Hi$}jxDdR&v7<5Xad;3B}CMT zk~YPqd|TSmQla^LHMrCF)c0};X}3&sTQSZ1XC^0Omi6~o?3HPn15RLaj3lu#a|LtU zrDK+oPm{5MvT%5W@QH~0L<7cM2C08hAIl`x&yti2U>ZTT>Hv2kQ;U&!namX;L-?m; zLBWOcp&_dQCkP1&qDT55M?8y@IyZxW30P682n83Bq@ZnFlQL2 zQPrP#8EeSpkZdyv-4UG(QJU6-xLT6*KSTEWZk+zIgsPrL4|@RRCK@w+;F)};q`W=lMx`Nr$xgEz~Cls)K5Lr{>YPWj2sXd3db7O$Fa%&veD6(SWY%0 zE=zI}k_Av~IS@E5Ow+*pGHP2bo|UA7^Cc9HDJ0u4_j-e+&COp~Uti{oBLL(NTPT=0 TB^$}-00000NkvXXu0mjfXU3=W literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/message_toast_error@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_toast_error.imageset/message_toast_error@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f865bd1b125340e09cbd7e353e122a532e483a11 GIT binary patch literal 1194 zcmV;b1XcTqP)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0_RCYK~#7F)tOyx+CUJ;XV;)qC=Y(BRJE1N2?{3& zNJ+#S^aO2B(A*&82IT}%Z@^O&iAcC7Xl_udrs}IJMNxw6-Ok^^$RybKgOBY`0vmgc z|C-sIot+g_kRQFC{o?+<5h&G_L=InSXsvBN+OsAee}rAq}7l#@T5 zP7C6M)*oY{vI6nLYPC9ed2w-_=fTWEAy5awsU~XC8sHbZ!m>WGl_we7rvn%7-QAtC zTTf^M2qC^a)M{t-;m}KMmv)n_=&%*X`mqmgl8LmecA9l%(v6#=qlOBC?^_TF9Ddgr z?@Vg2)S`HRZ;UDbU@42ohH1YUjh+p(XF|8=?j$_!2F<74V8QhmI6D>zY;*%?iCpe24`E;MxchC)ygpyk)$VJrGQ7s@U_C&RFWm|Hazic!9< z8E2h9Q)JapC=dP5WGFmy^>B<_-y=g|BYr>xkVrOY`++?VcIEcAA(c`sG90*Ha$b2r zhQmS~smyl!BU->UWJPT zKXWhclmGDG_wjK*I@h!wP{0t#PT2orG8moTjxjesIrbUqgft8#8S zAVc7+ZV)d*RN^=NAn5N|R+sy%Lj@2)(7ow&{)R77%Aun0Oyt*U{e??U+hBc<;u!5J ztHN|CWjnQ`1JD44D%wqK_1K&5mz5uvrg^BQK`bXM6WSh$l{~PzkgL?VXrIkYg1h6S zCeC>9VLEMYG8$lXPiwX2s#<=Y%?8v>&)2!xfY^<&n6Z66X65S>ey~dgwr>>j3ZAmF z3XCX~iiRnol&x9r3DFM4HpIf!11IJI=e!W-@9$r)+Wko4AK+C?VzE&x#sB~S07*qo IM6N<$f`#KO7ytkO literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/Contents.json new file mode 100644 index 000000000..ea9323d56 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "message_translate@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_translate@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate.png new file mode 100644 index 0000000000000000000000000000000000000000..09a41eceaea297abfad7fad8a3b04f552476be18 GIT binary patch literal 986 zcmV<0110>4P)at5VQ9hz=bbGKoXf z(h7EQXe$&&FjNJrQ<{DWZG0ptQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I` zBHqX4{WzR+xm>^-P#G)s0x0R0kxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#Df zyLJ%SPh6MIE|+$m0#kqeUDcn-ni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~Ua zJD!zfocNYl(h6#ZxJfLhJM?@9mx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&q zKaMSOS71;sj{+>pL`e}7vc&VypY?`La=`luFqi^{?NiPd)$u0vAa{K~#7FloP-W=n@GQSsZ zf)>l*Ga)xm8uAe!Ew@c*`JVEjmB2?AO=Lu70@X8ex#(&le{!~!P6MyDgN(>bKoRy# z^Clh(Y*xUe!C}Wk24waD-hFJ@+tm{jtS8K()6lT3C=Fi9<+2gjIH4w)3yX;~F~Q8> zpfmy`IS!+p^+xiqggBpdVO38_>cds~W^JcfHhmGs z);m`H6W{{9mv0tpPECO2ovQvxB&NjH{1FWA{6Yu>tJn`JsIN(oIS^A?UqxN!t=rn1 z79n*F_~s4gFPPWS&gd_i7vsktB)^D&8mcQt4h3W0nxi!!MdM0Co?2|PC=x+$tosl* z5(Vr5_D5AH4IG1?{fAQo#9Wxf7B^cc1F|`Vw!|po+5dO(1%R-;IQDKrRR91007*qo IM6N<$f(!MwEdT%j literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_translate.imageset/message_translate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f1e714dcfbc426bd84de97c95842d2761c6e6bf0 GIT binary patch literal 855 zcmV-d1E~CoP)T%u$Im&7^DJ?&J`dkv6Go z-2hH;?K6PAY%+hHyiFsYm{ww6aVR5p29#8|X%uT8c(8(THkLJ(6s_nTkRqSTMseB6 z1fGEyf_YTw*MZD8kvQ3s`lK~wQN`+bq9Bs~whIoXDv#<egJPNE9v`;V?k_S+Qw9TQ^2E{a@!zt zpL6|W3rbX0GEMe)Cz0`M+JrXow5tPx%GN=g=$AD5D>N>VPjWo&c)C)R4~7@8X=y1j za?><=7{D=(-ve0YcypBqJ?GmmN_v4#9lc0{ubExLtN~>1G;Nf7ao`n&j`rw)DxTs= zA2Vy0x*f(eqHv+;@o-e8(UU~1$*SiB4Ue!NV^K3rC$b_0UKj*4I{U<0(uf|rS1;DC z8K(;o2o8z73q&J1Km;WHCRinDP=VM5Djc(-_c-M>3<1D9EnG&mJBGCR0mEA-> zy(7;VCySU9k4)iFcNMO*BSvPFS9ZoKDP_u~Y(vyLPMuF&r3AYLdS71!uz?C=`)+9+ z50*InfxPisx8IT^N3s5h=qqWd;m$?Hlx9Vl6FvPgT8ulpryCHx2t=an h7P;5C8byam$b#-+O zu!c3XLT7^W%N62X;y;dVYO!rBPyjfW&qDym8_YGnvl;vc0G8UWK5RYqTLJtE;23~? zGG0XHxS-yj2jE)(M@i#oMLP^&H-H(=ZR_&Oa0C6*+JVeDF2EdslQO@q?G}YDgK4u0 zHg+jB8VYe5z~yuuBf1X2l@S^x$IVJdg7-hs5@m&E?cF)-cK~=1zzP0NZfqC$6#%C= z=_Gr9UgDT*Xei?+f^SdJP3A?59ST1eY3tcv!iAS~r#V?z(gGv)*8rGGXh>l~O(({U z&J_R-l9uuKJ!1pchR~D?xrU9|_W*tqA1rC!O%%wq{Y@~xq!wqz?G#l?aTn=hOgQ0p z#6=z=!5M1WGXqNaXD@TEJ5TccD(Ocy}*< z);2Cmo*zR-z_QH(mPkl9b9um|p0iTXJ(2vUZoZOHICh@1n?$5Qf(6S5>-F|~=O$;=Lp8vmEJH)x!+Spx}??@J{*mq)K@O z(}ueMY?jbzn|V=qj99^N+z;Rd06*juvqHhcs>A~#Oj9_$eRBEtGoXw#FCf_SGj)7d zI&pHwHv+g#>OFNO%^tsfgfvEzs!F8XDrREW>LQh8f_%>8&vGdh%qtlt)5DBd*8;ee zDYv^~maw4<~}h6H9d1W viK0T3L7Q)oaEq|=))bx9c)3}_{|09OpksC4mR0=D00000NkvXXu0mjf)>dTB literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/Contents.json new file mode 100644 index 000000000..6f49cde46 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message_video_play_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message_video_play_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/message_video_play_icon@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/message_video_play_icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9cea94e9eb608b4860a50a8c899ca6740cb88df3 GIT binary patch literal 2002 zcmV;@2QB!CP)NklaIiXS%n0W_Gu~q%*tSuj^ISt5;S1UUdT{ zN|Y#3qC`dn^Z-a2AHf}oP2GVILgRN%Dv*;(>zmX=sAiJ#0;$4<5Nht+G635EJPF`&0FMEu^^K(FAWQI|8Lg5>G-h$6<+sv89EO0 zTbxv$pRzs@9&{18BRQGRJyof~1Do(@N=)s8r^}=S5mR{!3jym?Eks|*{xY(+C9(_#_H^U;V3 zej~ZAE7M{MI3=!A9?dV>pr6;IzmQ}*$@-2Z1dMCm^3Y?5Eb&@GU^!N(6-MbwoT}7F zKCjp3rL^ddO>yH*cy&3dPjQM#Z_Ko1&5q>H&itzuZPQque_rLvcSv!m&O1ythd%cWrV{w42DZL1pk!S!5h;SEe-9cPL@B-tuiy(DKQxVU*;{zHO1y z+VL`yAlIQZa!u7qa4X59QRKZUR+GhINS=1&)r}!Aup4ueSqYBUf9fnw6?;-F0(Kmc6%L6*H@f~EJap2 z)~2z(HWbK&b7x8Jhy^ad3fH>T8=Do!y$&)FcahRIfJ}j>^jb(kuU!S2HTr8uiV8B$ zO`VEl6q%QLT_j;DM(o-)-EOSAEda5|0HCO}!)Vuad44Z|uO!IGR%HmGo@0A^a+8kAfbM*Gd}~uE zbav<8N;gE91pr=CMeY!OvRb3vCp1L1=(1Ex3}~B>#saOe)Tu0r&e}-N5n+ojdHObL(NU$li~+M2!Cikn?`|y&Ztag zsfJChK%4)6x^@m2c3`p$yKKYGNnN&pSrmB5)!dnL zwF04l?!h9ir7`aUcq)W&)Dr2GPe~z?%`CdAIPpnR-j8lZzMcmOATP7;+~x?+pr)Ziu)IUY^6ROLiofM z6S7pE+I^ZYHfjeQMD`m~xr3?vm@O8_D0|2#twDFJu1|x6DDL$T!cAKU$YEh)jk7?* zIi=flk)oYI@TLCRI+-Mv+Y4S$Z5AZKwTTA-w zD)D%^sg#UXnzifiJ0DclY=58nhtSJGq6I8C>{FSlE;SOFrJ{+E}m7%8m6l zN;^h;n-sL>MrlWfii^fczB6l!i=xpwkaKz4GFkYH`4ay4k%wX%mFN4ckTEK*Q+}4!u6^d^TM^EeC0m}aE^X- zqWtVhuYq&y!#jn6<)@!=QDT_wA0I0}e>Y1%z_>);5bz&hyrIQTbMFI;Nhm)7nj<)a kKSEogM2Qk5N+2u#2LjNs)5{lsMgRZ+07*qoM6N<$f*Z=k#sB~S literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/message_video_play_icon@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/message_video_play_icon.imageset/message_video_play_icon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..1075fd375abef26c05de67562470bd24f391e0c3 GIT binary patch literal 3686 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_file.imageset/more_file@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_file.imageset/more_file@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..1d638bd961651defb6ffeefdb112abc1bc894cd7 GIT binary patch literal 862 zcmeAS@N?(olHy`uVBq!ia0vp^At21b1|(&&1r9PWFspjHIEGZrc{|5DTf|YM_552G zWyf-^N2l2JCbd3riq+KMV7so>^vGXCMO*M_FW0V1t-CIiibbkE%QSN5%0F!3XPx%T zZ+VcVe94K$lV|-ZdVTHfymd@10!|!(YG$ukCS7H@y?mx%JL9CVAnVvf-?=^$oj-9j zoMt%3(9w5Ap22R`&7iw-ULv>DKdUsTGo1Tfx$>~!#UuN+U1zW{-E6+pW3Jpc-$}s? za!flI5==dwD0s$6G4gxp&Ajt#B_a$R*E0e@5XX|C3H0G*T9zApKyK83I&ytY7 z^Ntz5rR$KPj z|B^W%KL0PD)t5aM><@nJP`zE!-8O&bP3|ej!zZm|+sB=dz23ZY+3`KbQ?fR0k82KP zeR}EC?@xMHXUSPqzlxsy=lWCKeR?Y|N2)vD`;&IGkRklCsqU#H^IC<sW~wpYdc3G|v(|iaBJc9#bIs>( zTt7dxo_WI~ZN*E6e|J5(|E1>br7r734>td(=U6L#W%f<2ldcT?f93>*Rc=vTJK0}n z*W&%#PD*T@`C!iaPZn!6T)WPxGOR!Irugza<=)lizv>&G_$X@I-kQ*o^Y?t1^V#}` z4f(UpINdBa9^O^>D$er7ZZ|GPP%2hzanSt3q$qF7%<;3c)I$ztaD0e0syQ9 Bj6wha literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/Contents.json new file mode 100644 index 000000000..5ffa51efa --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "more_picture@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "more_picture@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/more_picture@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/more_picture@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5c6f31ecfe4c5c8b9ce10849efbb563f3ef767c9 GIT binary patch literal 1121 zcmV-n1fKheP)w`am;P-8FQbx~J~Fbzeh|9zA;W zm>yQ{xce3Toeneei3YlRqNDIJMzI(WE8*@7zyV+}{*RrFAhl*@zeOi=s2dmqjsh#d z4Ri-V=m+3kGdo{LGE8?D@SMnQ(j5eu-M}BfD`xgf70K9p52?MTf9lE=_*>iRe)a#i zAA{h{vRv%Ju3-WA7dR9hGb^eHcGiQIP-~n4%_t-XD_xz!pys~IvU{{TL3AD1>Ke&G z4Y@@T`V#nA;o~r{AK2&;$w39Vxc4jYM#PuP58Mv|PXo7$d?!ezk$#@^83oUjFEb0$ zr-9WjIi^%0%?rS{3YtZ}L^4vNZ-6E3%g#wOg@x+!IwiKNU6CghQj36p$lVu%OwQEv zUlG5hOWrh<+Bp}Etl)tllP#%sp}h5umYqyF)|UHok*FyQtmydkDR4>S?g5@`@#UG+ zveK=1?}GXpx|ik^2Mu^4ED8Jx>;(?W!fnX2kfRC`=YX$uD%hnkbRTe&7B|f+lUWn7 z>r0soGm^_Efn|B?x6JIjkul-HKL?%yVoO|{g=A`YX0q0XEH`T|T9nei&wvv_PH}da zEa$eF{ROR6)4pbAA1MSSk%{&rz)SA_jJq$D5{sEhCgz8-Bx6s#YG&W7f;Y^P-2F!21>kYu zI^bTQRg!6rzNdzBu|>zdM4@ar&Rn6OI9LQ_vIOL$@jx*aZ{2U zuBIe;TsiEdR;$C7NPHERE0HYReYbMgb7rfljvf^Nyy<}S?PsjVKNDe0O zl4mFIlKD`2EelbZAb6DgKv{4Ld3H$Ur$|rRLa$)zFDrg_5PI9@>>+Z1# z4+0Oj9Jpsew$gdvJu|z|^bx`p4=Va0fm@~Za)EZcv2JF+kNb>dh+L-c+LulfeF($^ n*r~*A(oea1^ytx}2h-s{I^yyW4NV(|00000NkvXXu0mjfBU%|p literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/more_picture@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_picture.imageset/more_picture@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..2ca0c13fc4d1233be47130a1b19e367c03a99982 GIT binary patch literal 1410 zcmV-|1%3L7P)i(eVn(FH5>aXwbo_kN7x{#EVl$4Z|l$4Z| zl$4ZIt0ewIRXbr*M3k0FRgH^mnuGlV|0hWW4a=nqGW-gvdLyt0xC98TwT)T_eg-}f zkrOeaGIi;z>V?3gz->SV6rcbHL-k9l)Q!YBT3N@ltPGkEv=d zB5zlLYHzp;xYc2{+2*~Jdjoq&RX-PzQ)N&)4)km>;wN<_-k(}bx#j$>!JG`}RSD=o z1%Oxhvz7rh+b31)PfL~h%n>UA9kGVBd$IpWfozjNH9FqLt@y~`oNp(vLjbk0*mj3{ z0yybVZFI2H!KPtXMW6=LrlsElF9BbP$huEg7l3`h1Hi>TUK3ibtOCr>fM-QyC4Nyv zmVvia^=sg1;L_Am`$u`R_XWVOz;gyO%9X|`zc~^430O+4vmEF);9U_p?cn~CfwK9m zh@3OfBJw5h1u$PWlV~eY+iw>C))TB%AbsP(Dgo^w@yNaGnUyM#{!V$+!IrmxwGdrj zDbtQL^Xd;Y_x^CVx6mp;Z}5SBtA|egfEtuF;BHmDCsp<|@z1Logrl3Nz z%6(+9;M3#K-*5MQb4_1xESq`;sMi2b*<4;#!(9GK;4%l<^t4l>KzIsx&!@+~`F{I> zuLJh__L-*6uS#;H+16~u605aBxs`{$u8t(D$P&L5T zp1unwvEFBYQ_6kxaT+)(A|Dk?h~o2d&`YZN3-E9gP=`%0k1K+!KU=?O$(SV(`O!hz z6gZE|W(t4XW#2bc)f{!8Zm836fi90M{yOl2r4T)bun}+yB}LusFw65}t@?wyp-wu` zypP2tZiNA!4ez|guEv0UtlS9HNd>9}i$8B^!pP#GC2q7*RlOg$&$nL|k$2+Pn}V86 zvBf(M$*MXQz0hsd;T%%cOhk?~4KL9wy%0fW{w)JXxVB~iSW z#rIngmyeT|3$s4;*4jAKY|1UZ;$!3D6j};Ut%bkY1k`NGE#4XMQ9-raY3$0t)r<*fK0y{A1ypm>D|Rum>U~PHZt<-`4Jkk?iYryt z5{qvMs;Tm=iHy(U`}|8ZW$}@;)VhIg#^MLPgCB>*x7VOb>m!Hcc_H#>+{*}B~yHl)aBAn+7*Hd+Yl!}seVV7#__2w$SMhn);lV06jQc_Y@~07*qoM6N<$f*n4yxBvhE literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/Contents.json new file mode 100644 index 000000000..fd1e7d423 --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "more_video@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "more_video@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/more_video@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video.imageset/more_video@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e85659122b5f59123955b7ef6b84e7f2102e474d GIT binary patch literal 794 zcmV+#1LgdQP)v^FazwNetrN8BJ#;ZZf7RNN9hi51=tH@)XygH9e5!kZ?+>c2QC6PeAJfR z{_94(Z}(L7vxv;AY9=B-M{Otv&I7A1EQQ$%th+^>1Lnsa_#Bu9Y-Cb%G?|;~^f)G} z?x6Ang$*)t|1>h^yy|x)jhEf89d%)LsOq6Af$HHx2h2Cz-$=w6{b7zS%=&i;xC*Qk z2~=O)AAnabruV#yJq}z;L7;l}70gjpPXf1qW9~Sosszr7$h(3@7l8}FqzkTvXq~C5 z9tLg#XZ+6=?)pxq@Ofau8u1^7br;0fK6`GCQ=XuhNgnW6P8{wkos2iq8>!%`jot5l zNr$3vp{l3d9FN#q^UUY3IGzuY#>MB}eQLz}au>Ml$v`m2!X%|kpgpkRqqP~@_J6QO zO}GV#I}sDNwpI33BvAa}Q>ywg5-5Is!n{+R4#tHIwRbHOxavY?O5$J;T^BP zH$+5cRn@BBw}G=hlf5lfV`ngkl;1+Ftg<8`kAX+PlGkV^8#{wB;x!|`o>NsTd0w}w zL@NXuQ8CJ9bJ@LYKVYW3$J23V)37!8)z7Kz(#=s!SpQ;&GRoQ%5w#hd1@3#%v@~G; zW5jDe)}Oc0y^;Ht1FwODULm%CnbIrlXa07#|NdcAr0pLe{8yimNNYa?>ged`=;)x3 YU$aL(y3WgB!T&RK@bE%5ClOG z1VIo?5>kGms`lI-Dk4e-sA|uCipXH2PrORp9MKK9#+MyB54;b&2F%zYCYu3pAGj$Z z*9uX)j6U&i&BEey;6q>wuuMK|^6R+{To94_U5Yw%sH&a-jsOocaAh5@0>1)hfhTsL z3sL*3deEKuz@3_FE`0#(2abx!<*xFn33%9DnX8cH&^Kp(psKT7g}e#68(5kGLB$&> z3;oyaKo>4GL0`!@Z?1LCMsh*>ssG_LQXUY6pb17|J_;K~c^g5Sqy=G0@FA^rXa}mg z6?h9+=(iAalRxTM_D9yD77bZ3H=wGAfKPxofm=-lwf8*+?$w3Bim$ZfzEz#d>RC#M!ddU%_0j(Y!n#)T{zrKKi< z+8-^5$X{!m(8k99s_HFZAMh*(K`X-hu!r|97xJlh!wRI|auTI({A;h1gNQZZ{f>wC zyB>Z2xsYWsY1PV`kZX-B{B<%5@15?*L#1}4n4PO6`k^o3LiT*Z$a3Y*n((e91TEo0 z+Kkk3;O$Z2T}24Ws#*NKz)A0Kj|}hi2x|FpI9FPeD0_ZbnM{V`aA33l{J*V`O$9AZ zG&ka|VbL1SPOzwyKKt0cl_rAPw{<{OKL!4%IGOlJ;SJ!RC+}>!Pi<)$@;y(~OKZ5e z?@8R;8iI~MOGB(W7RSvyInkz-A0?Ko^Rr~Z)H^CQA!uSk(8PqGi3vdy6M`lt1Wil` znwStYF(GJTLeRv7pw$yWW6$Y^psP_Yb+lU`V=co8L0Pqi>386g7i!G8sIA-@f|d}G zMG^TLI0gI!%=#2(W4DH&)rAgsMC2Usl`nSK+BCPuZRF}ghobE0J5{|2eCE|7>gk}? zh~dx*>>j&4RNpt)PUZ09%NG;q;fI_EC!N65FJsEsYvj$T#OU%Y0)O2fYoLC2s} zh-<=HLv`Lt#cic?gnYV2%hu&bqm(cW42A7^G1xR!eGL4XNm(ga2y24=>HX13E<2i0R*u-Un zwi()$NnRcJ^xKV%+Gc2l*a#~Pu~l0T1VIo4K@bE%5Clv!&jI11oVW!^P*eZ_002ov JPDHLkV1ilB22KC~ literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/Contents.json b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/Contents.json new file mode 100644 index 000000000..493a6513b --- /dev/null +++ b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "more_video_call@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "more_video_call@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/more_video_call@2x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_video_call.imageset/more_video_call@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..76f32863193e8b779e750b7a92ddeddd825d33e6 GIT binary patch literal 808 zcmV+@1K0eCP)RG1@Nz+prWOqfFi#jqCuidK@d^|4Mjv@ zBn(7}J!tGXvP9?eMXo_}K2N>ZXXSt7;}9{b@VOfh)k43yZ;f1=}7`mw@$I4W9#xfLSKCM3Z@_F3u97 z>Io{3P}p8%7GdHB$*X?rRupd+e>C+x_*ZB!PgT`cgnpI4+~;N(z9w`bXh$VXMEa_F z3b+Ma1b&qXG(m@fHQ*Jn6MScL!YuQc2+-YdG-0OYDsTfh>C4P~nLzdIwIL#3OFCWi zCFQ6KmI%V}0&o{N=TX=7=`~PYdcB3HTj)?Aq!3?wVsfvY2JQe$KBlg}|5@%TlZ<5| z!`}y)Y0#*NV!2S&1>c}71IIn0C|k$#abaK=xu~Ja2)qY8@+8vrxi)fxQYp}5{I6v} zRk!OB>E07hBIdQ;xZPKoz|1G=lB#|WvS={}?bLCk=H1UuutqbjK)cAibOv}hLWmil zP9`>NUeSSBsVp30+mu&QH3vtPFJeeC6O++$GFnbX%gJat87(KHnP}Su&KK2h+9WV2 zK7DeLbK^=VjS^TOA}@jGK*tZ>BfVL9nuv|CbSNV4RQ02u8_Tv{UL<^ m>M;^&?SnvxL?V$$P|9y&(m1N6_&YZM0000U>aCchOAG{q=d>Tc<7rK@bE%5ClOG1VONr zkn$TlRh%Ukydo6S`unYJYDC`nT8wI9-+ahvf_JW%8 z)#rWUznZzlN#G#x0Wd>znDFu31bz^asX9fSwNO<(1snzbX@FG@Vij;7_zrkw7wQnT zP}MzdnbU5mO^v0~z*gW(5xG)VJT(FLxm7k*$g=C3Wqzir%j97RyA5E?qP1e?;P>8z@5 z0k#8wR29^OTn0=I`OFOPhL#9vhpMgxjsaf)tAWppiej3O4}gaQtk8a`kANLbO>iam z?)vKkR?`COyc~NDtSKsr`OIb*A~MthRrR$0S}&XA{ypx!4fq=P1o$6#;bWbsl9bdx z_?L;9mcsflAtLt2pEhJsrJsA72ObCZx+Z2cDZ3}6Y7Fi8fG(pTB_bz;D zZfhND-Mwuh)YrHwXZea#HKkeZZJGN7U|IZ4l~5LSXb*0c>w&L;Pu;Cw^l|ULgrK}% zbXHX>4G#f_En}x;zEKiv?o2E!P165ucPzdEJ}f1jk;YWjLQ_oX3`ttu2CVd(c&~lD zV;Ngf;R{Tpb*eh)rO`0KH~8zNYN?E4m7@x3B5nlEsOm$n!F0e(X}8Cn#uW9VQpM$y zs5V2`IiNI3mi(HQQ#FQ)*J1Ms)}iGTixpeiLhCImHz8QxK2>k6yi(X-)6{iBFCR=;`yQ<#v z-lzR;t*PAVgoTb)0jOK+zanxO_!fBN)@s#2Sm*+*)sog-Rkhf9#G5>R4VNE3wkB(} z=G&kAh@_OO#7gIgp&6?IJ9j)owW%YdTguAWTi8v|Crx?SU>?-RYwg{C+J!nE@wPAc zCGCVmvzhVI<~I>}Ro4atg%_&VTChdN8o&zjpX=NmCpK}(Ol&h$7qJmm9%8GuAP9mW i2!bF8f*=TJGw%TH*@xbjKMAw|0000xZ zq#gb*0N7)+Dk7`hcyp2rLaC~Xh^VSg0C*C>EP(9*CdqOMzN1uC86((lMC5R8ki`JNS=9Cc zxJXc`i--fhLMQA~RhyefbAn8n!Oarf5_MO}5O=~7LC#W_Y#D%Z)u0go`}tEH@4L)? z)3s$HsH(2RqBjFzxu)Jk*%|7Jx^Hd-Nsnck(*<>9q104?EztDzG^=Klxg*puoon$= z$V2A!I{@V9M%vVsrNXBs!lBZcp3$1cv%qpAl*WUc8OG$-5#z&v+5fCqeBY##Yo zGqhox+q0sVU=@Jop?|{xQPE*8wD~Sa5fbX!NWU;v!FS-H)>=-0BOG+9Llv`4>ff1` zokU$5C*MXK^Df#jLYvmADCI(S2@USfyZ~=3-oZ4)K%(O2qy(jHs;;5!E9Nq za{AWG<1TE*gC4sTz(b|?Mn9FkUXxa-t8BxA0B#9UJKMpAz5#kPegkls{N}yK{e39a zcF%Acz(mcXi#BCXQ|02?3weU!X&@m?YYk-qc;umsZX-ruB-Wk%HQ_>KekUPLaH!1%NNQ#r*{b&|#8`Jl@p zXFMN^$f-K_71A%j639mZyul8CiQbNt?gv}1Vmg-eSlU{|b^_SVdAh=^Fp&QM_(Vj0 zu6r3HbpVz?+Se1W@XD)21INO0vdBgGFGd=S%$)>hb9x))T9g}OyY_{MoNsc|By|B2 z858D%>qi)qB7Vy6HP6X-qsKSt1Y0~ZL)8t1dYQQ5wH5@aN=mSy7@ z+4o8d{reuNz_-3^Y{~CrjA)pa*12v?w?-b59mMx)2?HX6y>5Z;m!~Nu3^^qXITbmS z+~DBHy7OLgU3qe(ZqZah4@11*bSfS`_w@e|!3p2)bYZbBrA*syaOB`yCvWX)lQa>g zfv<8?dZmkg2VMrb65e%+;wT&T&zmeCMIE8gLXC09?tNr8hsCoPkZ+;~{|I$|X{}t{ zwynVCP^S-LH|6rCs3R0S*)%6Rz_Kj%8>yOO#URkB!fwa8?r97X0ohc4~sd++&$ P00000NkvXXu0mjf$^7P9 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_voice_call.imageset/more_voice_call@3x.png b/chat/uikit/resources/assets/chat/chat.xcassets/icon/more_voice_call.imageset/more_voice_call@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..32cee503825853432257d55334a8a6986a7991be GIT binary patch literal 2442 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/Contents.json new file mode 100644 index 000000000..8e26c0aa5 --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "natural.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/natural.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/natural.imageset/natural.png new file mode 100644 index 0000000000000000000000000000000000000000..6c641fc0d88650158fff058eb27c9d9cbc640c8b GIT binary patch literal 9842 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 Y59?_@TMzby(*OVf07*qoM6N<$f_t@iWdHyG literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/Contents.json new file mode 100644 index 000000000..bb4ff1275 --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "none.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/none.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/none.imageset/none.png new file mode 100644 index 0000000000000000000000000000000000000000..1993195729dc60df670a48cc531ecb4617853200 GIT binary patch literal 1711 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 JPDHLkV1iKy&SwAs literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/Contents.json new file mode 100644 index 000000000..eeeda903f --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ruddy.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/ruddy.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/ruddy.imageset/ruddy.png new file mode 100644 index 0000000000000000000000000000000000000000..3a7f526edd227ac46d658c01c5a707f53e835cf1 GIT binary patch literal 1166 zcmV;91abR`P);oA%$B{L$_>AZ zv2si2f>pK;N=w6F?xVpfcnA5+Z#2NWoB{1HpwMW@z6=eD4D@>4&ShNJ{|Z+p8@rr* z-g6%(<6dE0%BJ#c<>T&sn!+^-()_)foY(O4#RW~kmcO#{4QUv(Ixi$PzH9i^)J8v0 zpJhUwOmm%((D>OJeox&l!oB9(3Glm3E<+9F5N{+-r|bqY8N@9$zfxroHyZ9pGVnf@ z%aViu|2q_yE0PG}hV+tz5n()TzLMvP;Fpa}HvULNC)|@H;MeOMRwNwo-BC|c0MGSQ zpK!Ujx-#?`NCMyuTZ0ysZ~^$ef??>@Q*D~5Xe8jd)w-jAS;;ju3V6RE^;8S+Rz4jy z3jEUg*i*YYqaal77|mM$2zWc?^79*blW#|j!!)!TDh-Z5;d-E%z|gg<8)908%->8B z0B^C8$kHZ8DeiV zwR~>G>xcbYa`ug;md}l7{cwCc?rwCMt9)+6>j&QJ<$bR5xe=`&CO)2EJ~z$i7jBe%PIhYf+{|4+MNt$*QIweh z-f0<`0WRJE{J;b%3cLq}DGw|aEiJK4LgNbukl4NWJ@0p5-iP0R<`0_(9fbWVN*Hjr> zhtJHq8nhC60en~FC?98GThHr=diK;3h>OQr+#P}Mb{s2e5yS<}F4hTXEVyhk@bf`T z7-NHseinY6A5)(;!WAC^X@R^?J}}i|Yyrb<}e?zZRnLkKQWv(cS gq9}@@D9ZWr2kQA~6nQ?f^#A|>07*qoM6N<$g0$E!@c;k- literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/Contents.json new file mode 100644 index 000000000..8563125f1 --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "smooth.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/smooth.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/smooth.imageset/smooth.png new file mode 100644 index 0000000000000000000000000000000000000000..e85f1a0523235d6aac0e4384d9205c4ba3c71b95 GIT binary patch literal 1845 zcmV-52g>+~P)I7vch%_QM**SWay>9c{F^V+NF1N?r_uFYR;G1 zrIOAKcTvWr9nS|p9~n9a+=bk5Ugk!MKj)H;%+cIXZd^)lS8r(=mKqoGq5b{|$iJn= zmAz~~4NYU?+}6KnPHHzlyX@x5$8 z0iXjPI5&j${=~}~$_3W-FmJVV!$dQUp*X1M+BemIGyOSkBOnL~JIMC}!+6~N8Hg#lvLr8T9;fP6dfzfcAE##c|sDh0F}E(M~0X0q`8 z8_2f<|GT7cZnUF7I|3w6D%`oow=@riI*Flhf<}QD#@>O?H*`cm{ryQ8-2)7{pi>u* z9yQk^9s%6MZVV9nm|5>f=HM})%-rK{%hD}!&@e>vz z1+Z-Lt!8BVL;2P^7axG!iiMN0bOaOv88kHK0BW>-&)*1+Q;-0u0^Wf9Tzfw<6|Z}L zixkj~f(pnMBtWVFbaV2N@&qZsDCN5f`V3@Ir+oq{fS;dO9;2c>`3my;Jzk@Le}Jr! z%mb1UfEwRsJo#!zfSC50Ef6a}zDNND$bX=Gu^_)6s?aLzU#0v{NEBc|{ujy@3-W&$ z-RJ&K1<4 zeRmX@t*1p7;rhWSF-E{^%AY%s|3kb>7-m?JJbZnS$s>T`Z6|OvgyCbY8S>tf0Wuck zixhy|OhfZPYuAw(T9F`73Gzh>m>loO1d(0u{EfUPZ+_4^IlM=TVS);p0j*uHfY?<> zUgA9&kPqWz5(QKs-^|1l@}M|Rzei6EwNF<-+Y>AhdrR}60{MM2gyhE%EmTR}&?xJl zscFLu7Kjy{?^8nUl|)FY098CCb5Ljc9%c}RvqS57LaG2sJmr?=fyBM+5~_eoA|z6P z@J8llCZhnlm&#?wA?~D)=W9X{=yAN`KAIf~p;OUacKna@h|G^itN>x0d@iE^xtE5* z@!k(IbKlVh<0PYC4tuXzkbj|&?W1}|R;z#w2_|C>+y^34j6&9u^4>wE0O6zAo^9r) zDAcn-sDGdmvL4_^fdw*#@-{`9&LIu6*pQ#)suj?V0*RgTrYKjhNTUF$>}h`=!SZb= zuPGv^GT||H;4oz?cg-?RyH!DZa+=LL3W@}(NO+7IoiEGxnM<=-M|&ZfZC#>_1XPKj zyksv3&0eQz%`EG7nVLK?qY4D&(MBVh-wAIhHQa3VrP;D9Xj6+$eWM7G+klnWfT zX`i70(18W#8H1Y7VCO{7R(m1nfS~M_r&DuojlYM(lu&asnTyO4Iv6;&snmR?yXWpN zZi_Au9b}|j&C{qk_r~ADaW?NRIo{^onyod5INIjjM}3PDH*VSuO~X>ND)rEQO17^z z)I2a&(DWzweDHBS+cz(@J$bG#aG1+F_c+{`0qHiEIk4k%e6tcSFY(C(ZuV!cIUiK- zFgOeN`PCvdr{m={{1DviaC^t6!8iJjGg-IJ@nxO`HIIq=M+6PU!BYDoPs;3No>?iN z4?pi!R8X@vYY6`yp`xb2_piOm`t7M%D_{t3L&ox5W6$cEUcwKxvu061VipA?W>G+5 j76l|`Q9xoA1tjKwiIMRW?tw8w00000NkvXXu0mjfsnl1o literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/Contents.json new file mode 100644 index 000000000..66a00aef9 --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "whitness.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/whitness.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty/whitness.imageset/whitness.png new file mode 100644 index 0000000000000000000000000000000000000000..2a552c00fac220210a7f34e0d7ca84c52505d647 GIT binary patch literal 1998 zcmV;<2Qm1GP)O9xykFsT5p0+|Yq4!$Ryl_5xBBCEom!V-@u1Ux=2HvgiH^KtTDnfxI(|ziHRd71F?(dm$Ke z^cHroKBXDJjysn+-67n}eVkj4tjwtV4tA_aS++%B*%WrcN(!v}E_KC^lruySb@zmT z$~P+EiVC9dzl0x;R4cB9Na;{Xmxh1CSy64k`f{W!sWM;(>wAx9DB5%10IN@>+)#z+ za-GB9N;;>~AWZL6GCyv z))pZsY;lpmy2v?NN(uPsy4v!)bs<#aRooDQ!i0ayUCVJxser5-E+>9(gwWMe`2%?M z12uwc@0jQRN~wTs@NH<1fnBBJXekBYErI&nvS>U2)y1~ zc;saUvav~!LSi-MT5q@I-V2X>xH&=G@en*Bhnl|blOV4o%YBf!MnutY;8n7sSh#{u zA=3Yl>CW%@A%Qsyx%-^$g^(E~qWO*_Hnd^Bm+m-`##)+!N9_f$>ZK80{)xN5g+=zv zWeo|C2!K;BUr0tva{i#W_~h3LTQAq}L~1!k59mooLvnnsmEi0CXav_hN614Y0M4fE zNyZDw@wqldev)ymXTBHB1tbFCT95SMq+^A_dtSjHRg(rYFW_3YAxa~OApoh#^$*F3 z?N2fJNh?fR&Yt*ySt~mG>ym=lcke4eld4laiNFFT>JAqPL z?r&zdK8=Z+*N8L&yG4nsWUVi~w>ja}7h_DdU&!j{yY+2HDS@3Atl#^y@1{KW3{#bC z#**J?E{NO%_JvqIAo(uIJtoT1szd;jY5^X;?>&PPKu-c@3*g8+VhG?P9VVQwrzZgt z0dTX(dUp<{rD498%%-FCfFdF06~K*sBGoMs0O!Lwqr-%2cs{GD`GwK zy(!G0siBtnzL(DGSpm3=xbD=_VZp7iiQV7QnK0*{kFtd5ruPjc0`cNDTju)*iU|af zalq`OWrjV+3Xua`UEYs~uqO?&=#IE5Svcq{Xa;y6thK_--hlBVG1Z)W!S5N17ZbE` z z1?ZRp1P#Wp0lNx$2wM7tZ17FHNd>YkI?FA;o!lel9#FCqaSk1r0T{NYo9oUu{=X9B zKbdzFz(+~E>jm)GB?&8tJH(}fU?9iaU6VgdR@Aaj*cHYrv;=|sgxy0g5Pr_Ia%Gi< z&X4UI{w@T_TK|Fq2#IJg6=w#UsR}gKiHQhk$XX3!*BVRaLSi+!d$we)n=_Z+=~}+M zyXUjZ_rWL1!R6o4xb^fxg5}335fD-!>jf)Rcvi&7vq;X4;8;*jT%lu~xAU0_g84`3 zr!_q%3Le9LS17!>8}kouT2KjKdx1hYs&wTK%n(AE*1uM8$>}LoSN=et5E?2C{B@|R zqTnNV3Zagp8DQ;opEKARsual2L9OdKAUjvhkpF~1As&@)xTM;EToqM!stWw(0!yk9 z_*q5O2*I=R%UV4g@EgR;DOIIs!NXdysPeHRZ_!vljw%5fvo$4-TT~4G+NPBuo3>%>0|E7FN#wax!xm>T2xL|AWaOA6tBZM_8M$I z&oc!b_Wk|wp}<-=DSE{4j^f?%=adBVqvD22?_kH8YE?~EGc*LekBVztTCdceMqYoO g>-?c3M~;N@4eu2hg11HzUH||907*qoM6N<$g4zbpegFUf literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/Contents.json new file mode 100644 index 000000000..2cf53111e --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "beauty_record.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/beauty_record.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/beauty_record.imageset/beauty_record.png new file mode 100644 index 0000000000000000000000000000000000000000..90632deeff32b0e8292e40123aa24a600e1c35d6 GIT binary patch literal 1487 zcmV;=1u*)FP)XH{Iz ze!A>cd9k0m{I5S3i^X?}zm9ai`l9OAG)I)Ol`4<+Ne)F?&$tgs^P@Ilj99t#tFB%gV zSN?{0TI~TCp$~v*-W#NOaSKmV9jeeBjLV163fCB^V~jM+*MN=<5}I6$Qg?`ZQcq_# z4D503={3SMgAHXb7E(%LB-cfHQ>vYMGO|vSWhiT$&_%}dnpJ{fTJ$_5D?>)gX!JdQ zid0VDbW5l2OD_6dXRXQ$jYq8Py)AFpdsvbg70ZI~1aL(Yk>~|$TPLP#IG{Uqk*d%v zjMJl+4CwayYJs>{pBAZoC&K+sO=kq}Ba~0a2Bi(-DBu(RL^C1MP~{BcT$Dp%hH+k) zIa&-24F!Jh5j0bjZrcb02^vDcdxl$oL=X;lG)%qt7ICTGb0FYFqUZeAK8#`k-`UOD zdk%p2BF=jnRi0yo-y^~L5%TmrWDPUQR5TEn1pqB;<#r=kc28pb#+ zC8Fv);B*GtB4Y%!hHiUq`oVL)d+nMz&y^XTx`1z9D@p+V>3vO!fPdX#1Mzqdb4Y}} z=T%ln3Ew;->8Zp2iW1k}kL5{^yfstj;6*iayzP;-bkFI&%?s*hm+_V^JjZe-nIcu5 z)%kB#w%ubeEu!kF_NV(#*Fb$0G(RYvUFNZ(@^rAjyC7h>W2zi|-!;&Z5*6T3O6@%L zzEC<`ot?c_D%={sU~7P1rKl(a+Xp;3{YQ%dLp@BV+8+8b3|J5V-`{@oRgz4lC4Df! zPbQDn)fSyf=o6+~c=9xvDTuk>sF?Kc%+){vt_BLwHPF8Aq>tK3Gl%x>KV1WHv*kbN z;~|*uk8BX-LB!8GFBxwB^wa(4lkcEI5d)!6Jc!CrG2idqf4TOKc=hS!OURfW^{a2m@!kP~tZEnN>qA}>zfGV)G>`h63{e;K() zPkAOJr`q6sD?}gvQKXQPvE)O@qcmB`THqHYVzOi3gw%|X>`Zl}<@NZG$d6BzSuqX> zdZYnZL#3n-A80W!7YQX-jNxCtt!N-H4?!;|ezgX3o3;L1+8bH}!eS%eP%NG;?4kn;a4aa)o@!44Ma;DKNPKK2}r$gy%7{ zGg5?c%H>$XG_}ysJe%eQf3wm^lpbUd_g4I7w8EfVD5Dg**=5J9M**hE zZE=1^+x1ukoX=1VE?^hH8;+qQ#^aD>Cg_rZdEqT(ENv9sj4GOdbNs-P9*qQ<5ot+r pKd_{i#Pz7RV=`8+T)A?Y`~q~hxBLm27v=x}002ovPDHLkV1iutw($S} literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/Contents.json new file mode 100644 index 000000000..bd0d5f53d --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "camera_switch.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/camera_switch.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/camera_switch.imageset/camera_switch.png new file mode 100644 index 0000000000000000000000000000000000000000..a8d1cc71c423bea6127cc961c8adf0dbe2125a90 GIT binary patch literal 2836 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

?e%sQWvWSIUSL5E@h^rQ)~%;o&msD;&*^5aEwh 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 IVoG2yX4tbtFKG!Tz}dZqVH9 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%0DHXy0=0oStNj1K%!Dwo~S_yIBG( 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%0A~C201l}pv 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

MTsv!x0F-HSN&}Hp8C~a%rV?(J`$z0% zRrwdoy(-*?P#p(-cICQQh*SRz?u(T<4PVmwS)Bw*{jBk1V!H?y<1`!%(n+6Rub=t@ ze*F`!CutP-eZb<~^sR6Iar)Np{bA}Izkk|b)t!^^aL`Lr(p=lvWHN0fCu;p$Ot!XX z$-934NJ1C%Xgn#0VWE~GSt%^d0#JaK3@W*Tpa1Gt(|`Zp{kQ4$S6@x-Rwt!aD|I?+ z{5t9Q_&D9Wf1fs-0v4NTU65Gw-5OyD*X{rkauzL~GiT9+!>GH-Z1C27^L_J@oJdLg zvgtfpvQwY4^6*{ByM#N9`O&z8>09c`U0>eMn!Gcl8L1^0!TkNw-$7;v$MtcZ^MO*f>wIH_Q&`13#d zqx3s3JfFV#jbDVDIDmT)7~mv2?X;2_r1`ucimhZxcXQ(7^28ni--fz)7B zdLM)Hbn?pmIGrDmPpxxJXi3^JNz^h~MKHA>XWjyX%nU1nkpJnsXIRe_*iM*>n!zbM*2Mt11I3jLBHM^6W5J{X zkK@CB>h9_yIZ>RU(emnJ!?!l3_ zQ-?{p1&6LUqK>0X^ver!zVD>~W5!j0f`M=pT%fp$;_~Z%@0;oM*I!AuUV1TIzj`%o zZ2}PJCU&;B(>mNmn@V)rYg7hwRe{NGR+&ZguYjUnoyCq^)`5#*>hV|IFQ&69jf;j` zmCxh&FXm<3i9}_YXdD3^57X^Cx6@m1y_LTAgCC|p`;$LSZ~pLy>FB|2XbrA?ts9F6av4P` zn6=4c+&mFr)VT^*KPJdrYHAPQD8BahzMfuv^;Ww1;!7cVD0l5_@20C)t_4TYk(+>n z$O;+t5ikw`7I_Act0<^iIEu6CQwH9$0aMt(F_KaKpKMH=<;UN-$z^*t+(WpNhU)N-v#V_E#m*5lmxA_f4M=>sk#bRT)L zet2}09^8MB?mf7d?mxJn-g@)R^n)M$ZTdIg`lIyjTR%xBa3bpcI&14eZ%@i?Jho6% zz(R$lN*W8ly?-rz^Y4E%efm?cr&nHjDedmdFCLuJB@tG{459}6%FSjbVRt7yO}eD;f9Oke%lSJJ0me?48lel=}x?xyXnt#okp z8d_b%5SKz(?P#&W;VMVCY8DQnruI*>(+f~M%`{6|CG88g?K2Hl%2dx}nP2_=&*J^l z2FcRLwq?0Lx7~t}&kgl-10c4m*leIQMM_ECH5LXykF~Wn6ZBSkuzepJj(cgdyO~}_ zEpY4Qm(q9t{NJT-|G^)o{>fqLu5X8k^0=UJVZbP0A!i|2v{(*(?bDx0|M1s;HNANA z1vrU=w7avD_V=!)D+gE71}N7>ZfbYrCa733CaJ)p-g+1An0lmz`^7YTewGBZnBPKi zK6$KK;DczVC&}ZyIFI}OS&p?c2U7tKmX}~UQUgvSfCK%YtB&`e(jg6i3O>eyVYH$|&=q#?^crL(V3$RcGaT!Oj0BYj? ztUQ*zJCFYCu=6;<^U_$B?^%B5<#$%x%e;FU^(bZc`ZUrzuRb3&f;z9fi|@~Cqtc!N z%XwiJ(|&5tHQh-rk77zD3<+N&$5@SA!|)MKLM5qY4BO~Aw)S?@ZA6w6mSdv=q@~Ir z;-68#GdYYRh6olKV{M?H_~l>!mGs%qd@6nJGoL2o?X-(1a^=cZxQv|;K{UitsVSe6 zQJMFli12tIpXI?(4&+~c=Ey*(d4_TP;d^){;CFV_C7cZ*@4>2qb6w$#!UI@BO+ z4Gu$+6gXn>K@iFImSIKu`~UCnrQ7ek8O0P-r2SQ*$qX=-1yaE|=wqMGzb}3L7t=2S z814nx-`h)95kUluP0YI2(P;!bqcMU;S5Jw!FAESQ{?q8(lGZcr{1u6>%6nzKYOLyt zBrol~s!a8SE#F1L)#LnKxnFFTm3_IQL1uP8&}O*mf-q3IQ-A4K(PV>2>N=W;Dq4@e zK#(Ee(1Xr@|9AdRjJ)m;cRM(dRjVg6z*t0-Y>GOJrI%Z;zmdN7)i0-4Z{1?4beC0- ztLfn20Ilm5YrrU23TFXWEH*?b{zv&*FY~gFtH1v!)ni$?qWn%L9}#DHmX-4(QoIkX z7|X`lg$om0Md@Q2I9z#No{F4C!xa%$PId`0e*U>w$awLEn`t~8rvZCYzV!!xfXVq0 zG@m(=Whu=7qr5Dlh`Z%@Ie_Si_ z0hRj`;=f!&m-c&Nz$hZftQcnl#c41AgE6l1bBaXZW>1x%Q_Rep7qS~DfBoIlxL<6qv(hYOd7LrQAEAPCMzPp@|(YwKJ)3<(+g}SyUI$)4X(TBFS=|&SCbk_PD-F!zh-V8fVF}y!7hJ>GPlcY)**d^L7vR;Vd?0O3o*qqMo#(mxy;1~S0g7@4!_qXX4rjq_?)63MJs0UQA6AyR zN@uYQi_ZotX_RZd2*qz%yn48&c|X&gos(!;^{9uJNve(kJty{~uT95YtM;`V23yxy z7a_gpP=ey$!i4+_U;I+~?!W!h;5w$U6P=}2!pLbv?-vAdkBA(`?*5JR3M(QSW^He> zKLq0}PsJ+7`8 zES%kz&2Y>7N_v;NO8E63iN(?zeeQMf*9h%=)$s*1xSmy~dim<@anU&S_-EZOroHU> ztoY?!y<9<%^%aS#&a?imw_Uj}30>eS=in@V7FTC7tKMf0;WVRCu2t95kf(Ri-$Ymo zg=aVRgtghUcCfpjzWmj%K>OENNny1#=rSWzQ|>|$#3dGwJ6H7hxi5S!J$L;^>^}7@ z3sFZlrmp-r&GzZtIWt~nc6@pzdAcdp2ja4n>jQk*`={<#4b(DVXM<9(Uh(sgBVCCT zU}M+|xby0Nfh0cAAXQZZG-nx8*WzE=tF%RVj*0i8;9m2xc$ zSNv9miFC-r<<@ofnYa?Foz)w!zLsubN}($phFB3g3@h43L#q3){_FoTeF4C@cI{dy zLDn((ZgW6~D^6lIz~D)vMVP0`@4MR;5u97 zr)hn&lm5SN|3TVDA$r95p~OyY!NOzq_V=!(hj-skU-^YEr3193au(WK#F2VW;k02Z zQwEs##eGybie+UhW%T;kL6t1kgpjDLvPVx*#$R-_?{le@|pS zW}RpYA?=zgKz*qAa0=B z;1J=p?exM+H`8Bz_uV+&uc^(29~t`8XFroRcdo=N;`;0Ul>@B3@`kcVbiUptpP!lQ zV*|o7u6Y5DC(cHixHw&$n~elHBT7aqyhd=GOhjQpcy0n02q3`)(N)*-?A3`aegYN$ z1dB05Tz(@bK7Juu`!0^?&+jd_ab}b%#a+-v-oRt7duh~K!%Dg3w=~UqoO!S?+LJb( zRR}Jp#<7&_OS_fli(UnvHFT9*=q%lAdcq;YpZn4;q<`=$zl`AdHG>o9h6I|a_O=L zXyi&sq%qS51w2MYYlQ0{F9eh&`GTzp7t?m2Qe`;Ycdr5%>( zK*~D-U66Ym@{O`+p8;sN``nZA{?Tv#=jj)}{^j&P{f{yo>gl^olg4@hahv-yxPF z$19pIbrnIyj86cMj4N)mP)OfN0G=W12Yn`f;0MlOCJw>p1jrLc_FNYnk9k9!F6y=T zqdCBoXHTM13BxqVvt`X-BB3l~9jw27mA`vRS_Ce2TLcR8ojC*IY##x~SoQGKCO{~{ zRAIe;92e!K&h@nB;tQGqM!DY~2Q*B_Gd0%N*V4}Jc6#}hm(mTs2B4epy`h=mmZ$Js z#iDZyuvq7trY+DV_KBSKXGHp0{o}YT#9BCw$EmAvYh!<9)F(-MWnv%Ji#$Eg=k5$I zg;q|&odb;uD103}LDg7_6d61qdaaQpe&pv6Ie7LP979j2$8113p&iN=~2oM!^@-?Wr&n4is>81zxi4x{&;c5AGs5C zCv}(WUU^pBLIK?pu5Lx0K_|a4y~2@{IB_MNnX4&d^%E=t467yL5`Pwc@rE{$GjnCs zZwT#j^k$b&6X-nua|Rf#Ci~?9i)}s)(B&Xexr>lf7ZW_~qs2GpK7W*nrGUk`l{h27 zlcezo$W-I94B%zi8RxvbBv@S`TEoxsk`PwRZY5rh{peo@pycC_cksm=YMD(^(9p%8 zbNu+_J~;&a2}Hg~@rDS351?Q)OQf>9@m*;JB;y_i!)3?j- zGRHz%_2i=dm6auFPAY|HeZ|Gp1PSxA=*cOsK{l@a$yqRY#Xfg%TXJwB6tE_Q$n(N1GBK;aT} z4(|yA)|v2B{D6z#%jG&h{Wsv}b02-0A0O_Y^!a{L9 zq99Vnm?qDC0V95Z2kdos$U#pG{`K{$!?Q~b?Tj1wToaDTt(F{v;3mePxzyc2XOQk^qdEMYdhoA)hwECPu2c5~@Ex=cYs@X&2- zUrFul-7D$t`)`q(a?5%g5?a2HKA-&Mo<7Rl#pP=D6k=C{@+qV=OTwke3?`?8th~%C z4&`b7y0j<~Wz4MAzy$yz)yRN%l{c4}YvAc1mhdu%A%~;b!K}$lIfRo?%n&e6`h#@B zm&ki@?FC2-xF4dW9{?hLj94H!A?zVv_)^z4#saa7DFLDjH{tm8BR(Fl-(|3`G4ZVd z7X2aL#_4e;EuZQfZ1RP4KJv3Q*-VW!xCXe7(a3Ww=i8|c7FKGJsAd(t#7zN>NL!I1 zP@t5f)|yK@#f%Mey7H3y=+OcN*H8eK*XA1lUS+umHi9Ek8+yzxpE%u2J-*}GS=&jk zzVT{m|NJk0DgDv!-={+cC_BsMGUXT`xK`#zi}8ui(`W(5X(sGPQ(NV4$oji7*6V(= z)SQ7TC-LgStL9(5j!T(!>V74CNI#E>lB9zimpO+r&>6xE9rX-4_<|54)IW^iI>rNj z+{;Cr@Rji)oJ5cNKKq!4a1?@qU=du09EO0wcSJ`X7fG4B)WMJSG#Yv9wOqxR0)|e4 zcLPqr9xKYg(s!_2+!%CIA24y5a6o>+y^z-g5SpU5TPMvn;35RJgQ!mH@hm{XHVIsy z>AV)u@F57hYFqC!4`z%roc(A*y6V1$4cXsURe#uC0B5m z#Emf3NGSRQ+4b;ZfMmePBV~>#~4wM0CfVTLlCMk95{eojXb*;gbhd(Iu#7n;V3R z4`J-2(lT=qA$q_u$V~_cC)oc;z{5Tt#sU1p(v${mPw_Z21sii#E@+!LBwnis$S<5snG^`49UC4?_iH38x9hm_epIIk5Q!% zCsJjsJI!ku1xBtH|5K7sQY-CUu8|6P7JXAmHFPGGo}>5$G4rqrOiDf_yw}oZ-WRUS zd#XLV*zYs&%HFiEoEz;zAO6xW|5EzOm%otOufO(cy8D&;!8XduxdvMf@~z@M(WS_f zA4kymSd`fwUUan)SO$r+zk@3VO7-4LnQ`U@P?esm2mTwuLCC7JnhA>dl6knwSK9~& zoFHT#Gw?vap%PP(L$DAW1c75jj?hmC2EGF*1cg4IuNVO&au}nY97YC>iCltU0${kD z_u!-#rE9`zNHk6K6LJ;iJ(>VKq+uE4MD*|QzKfEswYAQ!>)mw1DfjC;oAF`r4L;#H z*&uCBm1}^|)5Pi^b@c;XBP!}r?4nyFrp5P<^K28=y6Tir^|Wra>|OCGlStivHb$%d z@RC0<@MuYj_%Zi}tWKSsYd?ZoWyQsE;O&_iDv@8g^-{Wd^JZ#a#SHwp=We79@?Qs3 z&O@bE;?q&}5VtJGt~{BQ#of_PS?U`)@P9dZ#@!qwx>#jA}z2X^Qvs!BW@fs zlglLpHvvFoy#yct*C2N>BHUnj!XdPB8|;k4e>xeEMxSXE^JJeX?`}0DJ!!T@K4au+ zIf)@)YFb&4@L4SsH} zY_+KzOD7(fD=ma0|6&A|Js$6G>A#3&8)jSxpwLeMl%vzdDRx|I!(NR^XTi#JF3syPHq#n$+kz3p2GpKtkv9?tNZEgC|MF zpGJDmI*rokA5^&u0L%@jC6z4U=3brlK7)pT5$Tg7GPypa}Bv4c$hPnv>V;o9M zu06N`bQwdqiSdaueLqbHy-MHtiC*W#E&>1ILe{Uz$1uJKZ$$7FE|6iC1xN}jKM3BX!OM4D(qBVgC+I57LE@=#Mw7; zChQo(G+$RUTn6nSdQ1R@o_dK&9}!AS?$t?5<2^(=^G(L%Y9>Or_WO$6; zgdC{kx|9^`4fRvpLHD87egNRK$fw1usC|ZDFPG5Zi^R^9JCwP>hrrEmt=l0Eb)~GI zzQw8cuYC&f1chjav?s2_kS660i_mCXm=FMEitYJ}Ozb0_9HjHSoca7YnSLupF9# z$7~W6t|#@${bgI=X@NzcK810R*};XI2(xKdAe`+hAn<6l&>+0@O~OqPM+Ok5zd0VVu_#92~rcl?pS3h@fBYN(cOKF|B+t+rYJ}=z5nGRmKfyw7a>TID9W!cpOWHd3eo%*CL zNJu*q>b=HIEDJAiR~ESRhaHmp#8z}U8^Wi3fHH07lySb^rVQ#vDhiU6100&DR z6Ug|Ui2{$VSmLz+h9D*PQk0uRX8%3_QI;Vd!5IrM z6a36nhbXm%E=^sqrq@Z01Fz7@QQk#Bk~Anl)C1q(cr{-G+cRqf$K7`D8N+DKS(?mwMsQV7*YTfDAj+|BIQo2AfKMC@zS|Db*YG8TEvN{8h!8%|z44{Bh z5KIJz;2;7Nob44ovJHdFmQ!>C&hW;Sz>QN@Hk8-(=dV&g><%rP5*-gMW)=rc+(V!m z_3&W8G-QCOygWHRLAeQ0AF8i_#b>4-k$(e@L0v=}hE~O-A^@sX2%CFnHj%+DoVXTKitViTHmwH}Bww;vv-y}j)K4LOnENQm1;FA+e3$T5YpaWBap zC&+o~{n{4h&FJj5cR1AA4~Q#@Gg(e2iXIX<83Mqr-V8@@R^v{@pYWk#fuqGoN##HU z3Y~w8NH{}T2p`f5(BL+RP|!Hn*$)JgxR(+CS@8=nJQAiFKha;lJcGRF!NP!Wl9XF8 z^*cmOMN$(`@fmEVgTMy+#4T-s50v?WfT}SWx`x=|P3L!BV0u*u&*=ka5BHKBH zp%fqz01>P{A`90bI+TAIFyt~A++@@U5CKex<4hm3jqE;SqQ%VFaP^>aZAUdgKyvKy zfSX{(?*&-&)BOi`89{I~lwTHKuA&2xS&n9`&kB5qA)VMNjr{}MKvv5HVX1>?lV{`$ zA-hV`M}hkA!6Et!bR!|=2!7~IDEDBD%>@7<3os#U6ER|abDcZ@CH$xO?Y($2-TeF) zu_W!K9X42QqV3KxDT;}xqo_t3Q&vT-(6_MCBS0vQsr*y7A+6{a)JJ6F z&m0d{Czhjy6S)rSW1C&j+4hEaPe+Wo)26>*3Qr!)NhG5RSMvuT)Lm`FhE&_Z24?*^ z^cPXOC(rCMF=IBFYx&Eh_A!oIwSl4m{T0Mq&Vx|QaOb8g1Fk$|{ede5fCrpLtV9I2 zA%Ljk$XtXR#Z+Q|xLp-7o_c`Bz}a77W`^gQL4@HBJ%^jJObO9e>kq+cOiA~cbejN% zdW)Wb5n@6wY%9it!_*riHju{%&Y~eFL7Ckps<@ysQ`MVRgswt#*0Ss@chFUoAp0(} zzqB?WAGw#~Bdj-1D6@nE%b}un%&g}oNp2ByXe(zyB@=*6{R_x}n!;SsQE=-0{`1eL z#>0mwK)W0{x6226;Hp^}X*Aj~tG8?eSf^IY6&UiR9kUXbdXc^g;Mj{oJip};J7A%m z00RNRnYVHRuzjXsnPQHJGKUvc9|spwix<&n?@aMDoxrD(!LqA*6WwVs@^kUePZ#(r zNS!7!wGNB{x4XAdBBQ$Sm~kHPR3w=dOrZJ1eJCvu>?Q5*W-HmF-S4u`xrygJ7)Q`M?Fud4_O9RA>=qbN6dd~a!4vl!5Y(1Q7M%M zr^3DxxsxVJ{U$aTlb*Z}VaZNigyTK*9(Yh~Wee^k7L3*?*YWfu9rAJFKKoDvDYs7S zjrK7qA6GIx^g>>;>`ml*6^ho^rVGWVFaaP13sM2WtTE5KnFAU7zydCJSsbz4mvf3A zS^(?H>JD83^CoA(4qw_9oKX7>s!;LOMY>!;E)sjD@VZFgk5L$DVU@EpRymgtl_`Vh zT_zPQT;V|R=Zx;oPPcyx9>HnAg3D1j=miPmWuSoDaQ_B?q1B|;lw;Bgf;T)aBn63E zu7rnaFZ;)_cf9w>CD_RnfFS<~B84jxaxvk^OAy#yZ>F8Kt<>K@W8n;abP}(75WcSC z@+id3P7a6{UB;-9j`5G>&|WKTX;=jSfM@6<@~Q~d7+FWcstD2ob*6r{sgoOwL>JCP z<*2jyfHHLXNkH-~0|>oGydHHPkf`k<0NsChgzeU5+LS;5hnD&h!b=OT1a$ay*T!%X z?A8QyTrC=cRsxd1+XHw6Zf9b-8M%!$xb%+Ozt!>JCjiOLD;m}}wq-yfv&zY3EOQY~ zkP(2Qbjw23{pbtIUB=XmORyXw*ackFhqlZBbQ&Q*AF(7vGn1-YvC-b)&Kl^aNqus6 znHI2(frYBQb4SH01p_6PEJLyBV9mfFK%77XaYY`NvycenD2ml(C^Q9;N=zA_pd^G7 zaKMjge_kWR}eEM@AQRTr=kK^ZVu+oA*gs#1TfFa;% zZlDMv_n^XWKpn=Gh4P)KS7BG|A)Jc+qH`5tgC*L1K32ZXNmYJLtcjAeC{=^(a?vBa zM7YcqZXzpHX#(;KumENp69v$g#sw)txw<^*h*HMcviQMaEqf7uj2p@lfUy|Hw*R=e zQx@#mm$>9rayqM>7Q@!#_zvBlW1BxUu;7I{3kgjk3w?#Q2g>jeo`=eu@XI2HK%wCh zX*ip!P*iLHV8-ew7|7!Q7Zjb$*h9Dl9VEcYg7@kz#;N!4Fb1T1I!+$mMR(xRAz;AB zTSsnPTW_aLHgI*>?dht5Tg%&A*C7TKhR&WYx3%C7RPDK(Ndm!2Tey)X;fOoOhxbzt zPUZ0a1I)&c((U{A(%t)qumnRs!>Qc_j%c=vOQG;V)FrbceZj4AVJ}1*!6cwZ@Q@sJ zZ?0Tqz%-bO(($C7ZohYz_8O%70OrH*{5AF$?ex57RNj0(z45tEr)$?=isj>H%Kx z8*m7-FZ@v(K_i=t3yy&m>4@dyW_p<3191A(17c;O7bgpTv1RHDoWa6Fw2 z8nGBvB7c0ZXA$nRtW^;He49UUc15$UVO0hTtt4F{t+)#80yKznb;Q{zW*Z9No{-?z z;WVazOn`=9K|J?vOqtcEtadbIof$#y2|?;-6$9}@f&@r<_aCOW-}@=@JzNGrf-aKn z;2Z|cA+T3kd(4!`?5>;uBGZwBU!4WGu*RsA2;dlsS+T`Y37KbV-ZSVM#FH2$?oAG4 zadz&yi*)BO{pg2pg;G@BsaaQ zDHA}hNjZ+;X4a@j$2gSv;o(X8_y6ucq`&^lzezv-@jL0on>W)}zxmA!FtpuOM3gv| zE*1f@b*tOEW7S1MqzqIS9fn{5fNP=-kiYxmH`Dt+X{6U)ejd zY;X&4jk#GM=HF6EK5OKdsjdP8xr8TmS+Pk)3YwI(BJDEqQ(b`T`qK$BGKKHRyCSx4?s7^Mzn5Nr z5gC6C1E=fp0jo8vB4w))iG%nd*(k4h#*e|X=tBPip#-2&&M6?KHKtb$!N#1c+1 zv&`2?-(%?zN1ARzGcSHF=gI-eeGxK%TRW5S;c{ zTjjJXTifjP+<-%KrNLIC$3h&l{m@fVU(&6$r{tAaD|op;YlYKTZ2M3t(7zV-EEnPA z0O`&d@GQ{xVtn;X#a#$6O5l&f)yCH?5_Io>+&~LK3vh{KdE>8wmfhgg&aE+nL zNmLSwfWja8nkxk0z*7VX{RM+v3rN9Y2-pY~Q@D}_%0|=>tUjnnM0Y^iE~R>4l?2k= zfSq?YBHO>$IVYk6o6qQtcM_uD(ttBiDe0Kjs7lvF98r8IPPtzvI9OJRenLJSfI`qv zEQ#gRHf2P_a&w#ZKOGc<8=Okrh4VQ=WRcVA9kX0a;%)0&ITfaAUxJfCM1eHS4e&x= zq4EN;gq7ON?xUO0qSN+jDk||>R(p$|r*^%7U53X2KIFK+zxj*5O4pvdHsb;|Lfqn9 z4D2kMByzz$PJcR;y38KMWXrR;9};0&Ch zV&fQu9UKG$HfC!;!`W2@rW?m%;}A$&p}#au2D_~sVu%?j?1jJX$z3*vW$AXy_xzC9;8FwJv{7TAch!1`^b&iV!7_1 z0ZLJtB=%^i2o;FjkJ93Y&Ji5TaGY83CrY+dkHyT>$aU-Mi`g z-~B7TBYd6TRm7zpA0Y?aWWOk@GrfCRTo{OxzYyg-1XU}YSEFeHBU|M-WQ0VYGDY|7u8!s0BP=`4uScIPt zamj01(Dw@MVz%st`mN6tcWiEE5es=GQ-bh>e4wbI72T^+gP5?S$S! zdAtKLwwcj4wmC3Kut4FcxwDE)Z6DlQly{}NObVa?7=neM;p~n;6D=kYhl0|XihSgD z0xaYNcn-xZGqB|wVL0Io@6SCV5>NwKM=(+c5r9N`?ker^TWkB_UkCkBy6dV6RX;lJ z19n(;N`Pc3=y2hR{RMkgx|GkAoOVZLZKasSU~7!%wFandvMS?YzitpCN^mA!IVsY3 z=O;f+Z+`!K>E_EX2N1htsxh1Oi0*=08j(gAqrn|%(-IsN=^Q@19o*E((Ze*_qkU83(a|CwbV} zcUAh_2DfHZ8v(GeUCwit28<6IDCpBiS-SIT{eMJ00MUgPDVs^?~&>=;_N+@Aw%|K@4#cVxc*6Kq@BS2<|_Wqa?W7c7%2mK?C zoNK2c?0ETPX-G9C z?+HJNuBXLT7XlWPSn03{o24)eEl16H-~APC0CEDG@C-q!7Y%7;HZ^q8o?z2yeeyL zAMIQO#chD&!O`6`{0ZVAeRF__HGV;n(hY-_lN11rbww1*2Mk-gx`9``c+5Z1K6Hdv zAt$3*%V^^Z!!Qf{U2g}T307lGwu9j51Mui)>1t_C^t^UH*8f_Rxh$2I;JS)o& z0FNn47|-oZLvoLjcaNFY#R{c{d>o0%Wd;!7I+Eqq3G3~Lm<)? z#UuG98efoxZP(|wLH##PgTl7}rV)tu=6j>GL)rp`pw}Va9f0!?{mEbc*`IS7*Fn1Z z`WvxdL^}_^(<4Deoy;F#ayfTnYb#Ayg1&a`TDo@g8sOAVciwqF4Bu|DOLmhw3+e4=zCx}i5!l13>{4&SphPDpM7uQcD@@U_Sj;DG!>qG1e7G_ZchSDf4fDA3GLF|qFTAEYaL zTi87G(z?LFfZf>#BsO=_?(;WT1=&p9-5mxdiadxT0|meUAao&60YtrpgT~EU9cEzY z8QSwNbpSLJMO;pm%b0TjNg10BDs6wOXhfSCtayu+1_5D&=GFZP0z<#SAvFv`4aAyL z6pVy#cF9}7qKtkLV10cnZ6TWNP>201N9h>7K@TNmk1g$o_YvD@lP0{p3e{|j0RfV? zV61DZ7eU-|;*uGV18P&g`b4Iomgjt|6ZZIckly|2Pt(8ulRuMz3j*l_a-Mm+fxe>y zXh@85D;jwn?C)cPaX0M(%rAcCGwCP)=|2Ga1Hc-wi*{8MbF&)w4zS-&Z~cINrw(#% zwxKN>#{jaDWBXE)kPKY*eqvWT;z9|~Ah?J^Tf!PW8cVPsfaBGE_1`lCi~IOmt(@*#q=h*?mx;=BTKq`rcCjj=qHO3or=fAtgmM%(EF_kbV3^0f0UPR-1CS zrsN4b-olt_)Pil7KJ$ zwoT!BHnH8%Xzu9F`|0iP{Sc9DGeE|E+uz@V_)!QW?x~!#9XDxT&&*hd)7Zy; zxD=%po&Bk}GIIeI`RSPfM#)UzSOg893!s#6tFDhr_>UWYRlb+?pW`efb_(Vo_0((! zYAl-uSioun1hEGMB#(1B0So2b&~(F9cs6EOd8*XBeJ}JCM|bY9k|_F*A&|o`W8PTX z#E#;*w0#w;Lk77PdT>D}o4vHT&&m6*zL5_9@Q>3z`V`I487>rJgPA?U&SV;K4bfL~g*P-W@;y{SQ{1M}X{D zaf_BbnFM(`C;=;dgsSKLhq5qH&-$|Wn5+O zn%_DhZ@CSZNRN&;01URglg?06dBC^Fo*c7p0Ae23x6ThU7|M#6c6=`#-J>5^%2vUA z#7~8@b+WyJAh~yYyiG2#7WXW`!|E@087%7WEX#)i7#HO(EUXh*ooiV3Y`C++_`dGS zW@oN~2FFSl4Le)Rao5pc||*}y4-ge-aJDkK#38O(xxX1o#dwaa$> z!w2d9`|lx}xA{`t!w@ajw;8y|)O$Df)84f!Y3G2ARB#r`-P&3R4$hoy6m*Ir>kM|e z3dM!sFlY?MDT9@^#;>7(?Tn`f4k)Rfd4M3?W8&PGoni`pj5^u=*)p9YouL)}&Ps4N zR4DRjfdujZrrv?;Xtp47%LCDdftMTw8c|c2pd3}45k&nIK`hHcxJ_0U8YmSd=xk5I z6~n2A_psr(b06ZPzO=vn+yH#7x4^Q&3d;^0!Y1J1;X&-Ek9t~8&Dp2eyN_9hegOA& zNS>a-p~Y)Mpk)W@I!11uKpr9-MD2N?8=HuTEVFKHy_6;p{~=(pv4*Y*;5pdArUlSw z!J%yI&{mYo&nFCecT?{Uo81H-+D5}UkMI`+ZNoOeIR(fm9luj2!Cn?LOhb2?j(rVa z){w)#uRWd?fV&R`Fmi#)pqnet(uX=oT!g^S3R4G*rI|fN&#*^;vrues16iNtzOdc| zFyc#H0!f&4dxfh|xx_$~vk)YD9Nr>u903*sz~YFLr%;#1M}L&54^ay4cX!i1U-jB! z2j(_2onWz!xpfeYfB{%2f~>*ryYBDo=vWji94H|#3lKh6U=iE|?NDJx+;|^9+4pMD zSwFcA2cLavJSN5}9-M?-%#Yz1#DcOQ1|oEdA5uJV)|z?7j<$mHkV8-$8=#MHPmN;P z1VrqWbxR$SB*U^J} z>Gn_GVSqK#RTOy7p%A>v?+RPAuUxy%EXJM%IEyy3ucC(vO||<1h9_$)YIxX;&x}I= z0ejFJA6tL2?j=wXAaN>2`D#q4b0*b zRyR5>fyxCiP^T>Ix_qoyHb&MT@vZ|$rlQmHCgRj^mL%J7Ga68}FfJQ6*)_^6uc4Xe zS@h8-Z_rQ7?*Y5zK60=f|FxS}3@5pCi#)pd0f{s+B=8})s5pFesH^F)N|3s_X} zM?d^=x`vo{aIl+3*ALJmd0Zc$hZdp>VYjfa-(p3l=Rv}>Qygap(ceMoxz9=EZpl{% z(`PUY_Gm)@X^nCqYcY8or3O2A+lPGq0P&{z@P5T@$aTrd!I8ij09`<$zZC}qcG|Y2 zH*4q0Kl+NmSpd#A0?q8XN~r2>>X3n6@6269hvrZY-WBcx0gM%SKQ-?Lb1Ru5*%tqQ^gt@RBg+#(YmcwvM`Vh-T zIg37k(R;vgcgGLYkfp@lh*=Y`k)iMNQJ#%!`{~O5PP%fy*$!8)X0VX6fVCAY)+^A+ zVu##A)>pWcC?)4JHS@iqh)%^@zQ#qLxQVkSe4@_&QxWQ_NPJGPs;_nh;YzzWtMg8g zODA^|+INx+4ij-htIG}@p?Q1&kGo(go197z>GjmVgXa|I043&<@r0OSqCxqW$I$VEfeN(P&4lV*zFBy0xch zjVk3C0vZFj$My!w(+wsB`dnKX?Nc`FlL~}vr_#K(WocsXS2@Ar!mjGWv%_Z)`I!KW zYMe)XFAW5x)e2`(p3m#0TBD2JvZAhd$VmiP00MEcwxGbkgIY3Z03d=zoO8iIR{R)p zwB3Eqjp$>!IeCEb52sv>NWY1e-PM+L41KP$f$A!=pWMYZs|9WwkFy&XylVj#?$v+oMUXfEN8JQ77t>eFSRNEpz(UUA1d&Cs=%LW; zVX=99_dZ<717=7Ra14BjpLr|oGN7+upmpU2V4;mbbpo~o<4Q=ZjD*fYB^s=)@d7-8 zULi*Ooxvc?usEb{<c}btQjgar5$L z-oQ$L&D510`T>LnBmkC{apeZM=bTdYBm)W(%gCgoE+c@M_xl*VLEDkOOTYGG=NOTJ zd-Ju7P7bc_taCiT7J3N*NN4)mgV-{D5XrB*68APhvVw3ieu*=Q4k?*b;bqnPfivxP^$HHj+M4v!ELqzQ%b( z-gSWD-e6g)uLUN`EjS3RGe^uY%IS;}uFPt16|S840U-B*IJ>B;=wZS6@c#Sh&bzmH zhg?gRJq)^zm@T(qbKiLR1$I+zr5E0KBW)jCVR;Wb3s(%7F;(!%9?#f$2b@H~l}nT@ z1}kYgyE_nNb*1M@--&L*A;uun6L&h)1QOohZIz3E2AUi;=Md2#KH(t`)e_b>4V533 zr;6g{u&|!WFDVDq3C9u3ssU^e90p%)+AFL@6;B#KfkX`5t5FcZWc~eEkl!Wsl&e{Z z!XJ@AO)u^3hX1z99nN_<1eg!O!F-AJ%_xAg5$A+>) z_aMOPIx8PCGwp6&N!M?_ke++~#k7ZYVGC};)2fx*yJ+k~!O0+#d+>l>XWH0l%HWY+ z1dAAKR7f$!djUh|y{>Qt`Tlg!i8$JlNc;gbwE=0^QvbN;k0E2GFCGyjgN{ni0vHj_ zv?Nx_M=spvvWp#I(1utdfHgh*hXR{!P9TR<>4-zo{iF4A$IjCu{- z4%W6bR2Y)ErM-c!#0`3V{M-$ELf@|eTx~^BfWZFsBzAQpahA!t2R-uzxf2w)_A4Bb z=d7w_cKj&<#c_YNu8EAF!k+>*CPhw5Q+#HCF(a&{*`9iqfphBf_;;oegNUfBIE{;z zr{rEhqCC%C86;WOu6!MQ4q~p}x*X`rh(K||&x2ya-hx5|(Fq#NRBj$~C1ijh)(MKT zhn&su&in6T{>&Z`M=f%Q3Ud!p$1j<$DhS&SHzn z<}@O#_teENu17fHwg5O;dYzlWthS2UakUpE4(g#E6F3W%qtq~(I_nSsAky#`Wkm_< zic2UZX`6=1PQam|!j<}u0e~jl3IYg~Q}u@7>z7Xhknh=tRN>@sCQCvD`>dXY;k#tHl}pp=6k5}Ca~d7^^aV7XFU6RY379@+)aZdzVJ%s@y9e#Eiqt;p&KH-7dxHuWamK_BYfP zgPG*ygq08!orkQ;Ke+ck$JDW$$~&z+6=^(#MKFBv#cKEu7>7AA5vZU-l9pi7_nLdNzGEOd{AnPohs@(20tT&IH?|>C7 z6i3{7>V`5uh@1xo?*IW6p?)gB4;Kp-BNUuka30;}`~(ia>8sgipm^ScR+;x%R=t5i z)-QbV^Xd85-UyDuFV$&#;Aj6lz1#f=qJ*2nY!0`mOS0N)$iCV)B*pas2_1 z;5>L%30bBibsJ2Jv-Xv!oz0!GbkRQCLJAQlh{UF+AZ5nNeM%sRHsa%v6`lB1z){*V zbSDsBte}vj0HGcy4|o8Cyfrb$C%`Je4Kb!WK^%dw<122)=NF;`6?cVY%Cov7opqAK zQjcVL#wadAXRzkSqe+Xi`Su9f!M4M%v3bOyX7u*BpH^t{&QCt5%Jk5n_#O+J&R&1z z)%5O#6o;40*V;!}%SvQ}f29sSYABabcgP-V8pYp6F~XGLeQ$vc)@(Pa#< zD^W)^=EE_eH=*vcuAxtzpr~57>XIM$TSjN;gV~Eq-kb>-7LGDq$Stz3=SI3DF<&Zu z8lIj!Gl`;`sDp<0@DLEfvhKm64;_VUvnJk}3%fr;011O62CJ(gS_Qg!s?VV<1Ao1T z>E!NhRz>au4y=;En&uUN4)K}ANb@rqhzg={0u z9Fjat%jPSrf`CSTDs3G@IcF2)Il>wfbfeu{YnB^HjoIBJ%Hwdwnf)hzaC?+8-dR_F5M&ryRkM6>6V3=wFMpYFPl+`+ z5(|PWM4M-2hv;%gbkbnt3N~fhgN^smce;p8LI`IRR@BJOJapN}s|u_~0Gc`u+LsF2 z{yI4hnw9zjG-yVr=v?G11QEp#>n4a|jLji8a9eC&m)P7L8+w|&7943QRfItnTtfjC zSCuwxU-q7i-~^AciE-I`8$-FYfzlHYdH-+uJObx=9_(L_k5eC`e7=ACo%Ef5{m1D5 zMW?zEKOCjf*`cJRsUXBGz=xU;bpQ#S+^5{Cz~#4O2k0}J16E{sFBc=H+GTHw7O;dy zDN8-C^RR4!x-^nh`4cd z?@kif<01U6X zyb9v%10Yc0Y;RFrc~&GS=@>qfqIbDo4o2e@(ZhT*;nOBL82Oz#^U+_<1&HJdkWrRM zH;?QbpV4Po;_GOYe@RHiY&rTbKyUV5&V-rVXOO~=B~4vI!MU+Z|29O`9s{IWQvwxo z4YBM=8^*p4xeiLBDR`-q@r`@;C-0?+CupI22!Pa9!`eu+bji`fJL!ji_7{9rem5PX zq?_|sv81+51tE{e(9(s`A^#tbP3VQJDc(|xLogOY4gK)O< zs^AqJJo>tnsXoFU4fWCUq1=rBnXBK=JZm?K5qY%dNRaAmrVL78KuJJ>C;UFpFlWGZ&uO>Znau(GrEuuVZv;^PL8Kl5pb~Kcp}TXn4=4%xP;%%s zJU-wqeX{9Mg&dCT>0J_Ye07bcn!qjgd-v0u-}^2c2Va9{m+SlQ-%dZ}91acS`0{G> zL7PNxaK5M8=*fs$0E);=ttMiq6HN>_d?}mGJvd4J&h)NT=l-y#GjznCp=Y5V}0 z5^~`h>EyILqA!v$vnqWr!5J7$f5M#dC>elaw&RuW3=#@d%fSB(8iWw6NK8-+1v3N> zz0ziKfdYrimzk^3exeT*x+DqpF?(~5h!G+J;)@x$Mcw1b6?Np&=rF_%6pdljmEQ{* zYi0*v`%h3^wW2suaLL`RHK?JTDu2tUzgFZ9d%a=7*U1i>?bm zA)O%~bz0+{;);fD>bP8P?`&w11}w6w;`dJmfZZCOFi@G@W~nH|v)oy>YK%K=Tg0MT z&&9jNKhf5Uo{#ao5-=9iup@&&Dq1Vgi{YNu^I2omJZ3as26r{sYV89torn|9Xs()5TldH|6cbp}l8GUGuIYbx595eFl7P68U6 zn1`!yUFXmoR~R~;Q$d>2A!BsrVh}s9Dz?`5k1UNwouCJ~6RH=w8&V2IsS&62G>SXRx^XJ7+aM?EIU`i?z0 zqflr@JeD;9RM~sQ58KIoCsS4zrc;+C?FZTe&~O#R+1?ckzlQ?Lz(K>Vr~pK?e=M0= zZ|Yc_#$(!X^dRPA>?&-O9tRl7VH_JMS0!zDDmw2t=M3=?95Yn=fb%kS0XqqysFhi!g2**qpAZ&EnbL7kSnh*28_iN^UQkcYbSo#P6>~$m@9_B6P2#{&cI@JQ+rw6E;g`1W9EaH z1PT>Z*=gyA%tg}N(*%V@zarAA_`h?cdfb#;-DEa) zLswcomH|%?P28!9I5LKH9S*P-M5Nha@HH?}_7r;e%0N=7AB0%MI zsu%*6Ge89z6AiRnB7$YGp7FgAiPVBv3kni#VDt(+Bw9s=2?iC$*#TgW;f~}W#`q68 zb*n5(cZnlOyAjOq!MYhvb8-*)dFT=gOe3+v^cysYjb{TgZvhIXF`yhxcjm$gsnYgC z(>kl+IUJmM4v-MmHbWsgWuM7#%#K*b6q&^M(!_kkc=0y?_(WLNgK|1Kxwsej718`> zcj)S4+UGK^bN}c=S!!uypVPlVUbXj+1sJEveE4@Z$Rg)ZFI52x{bc7tAxMN6+*Lpu zwz$QH)xX}50TR_PFL9h)brP1dU$BtQ@(^}_fgA?2n&!Wg$x&QA=fab}-K9F9T*t`H zKVb{|F`fOj7jC52Kl5{lCF~PXwr0k3u(k>3jz$;@1_Y-&Ifubn#HeH7jxZyh08H%> zoQ10fa2XT;LgNF(l+jfI(+XTTt#XvT{>Q;lpVWgybH!)D0(7gVY+R*ipa%n9#R*OV z%|kP%XZ{ks{w2B6_F3ubFeXlU844J@D1W^`rk+9Dusq-qX?km00kA3+W&eE+6LSWN zqDqKQWNX8X6=%CRj$D3+bqtVz?Zg%%FUcdP@F*t= zM?#qRm{mY5`HDIVgETn0=tSPFa3xB)t_b#sw`tD+!^%0sEIMbTvm9+X=gRYW@#Z2u z;X{Ci);lX|Zk?2ex#SL^egO=6RIUJpgQ)IGbDx5e1d&5{ao{TF%7WV0cBslrtvt2V z)BtCykq-m0!OS&6Z!tK*qVwJN(?dR*^AJC+Fx^zf&`AHyFMlQd%0K(9bnS(kOg$*O zOcd94wq#&WMlAa=BW-X*T%7yKtRo<}vY@zQXxH%LBS)~a7D^V^8Z$>T<7GMBwmMUZ zNjlMP5iWOb)Z4*{(s|=53RcGTqp>dL6MzwhH00v^`?ziq7#7O+xXC?!cp63LAkr1iBAzS1cwyb< zp0n>+e+3JPvKqCs=rOeI6y7;*9Uu-IS4ZMRCa$sB2<~F+hmkoXWz4>j9yS=Rgh+cQ zltqQ8?$_VAmA?7wzsdJ6uEbz4-8g>UGBfaG+dao&%u0xR7F>N9OyO88A1fucKzvu! z0n9u5aX=oP?~u2)lLE_za#@~#S!VmleDROXXj4HUshaZj3&e*~gs zNgBF9p-ZDf|Y_8etEC_=4~&VVZe zA;Jh461o<+HjuzyXhmR@peKNZdap>zcvzBZQm+1MGGW3|Bz5@tj-Ome0Ku|GSTeS$ zd8GX`|I9dyrEzaki|kQY0!F*#qn()l{8NkcX`N~6!SWr%fMq4 zSPqxU+$g4K5{fWp9Z!5hseo+iVZI)YgXqCBt4^_domuLae&GvYYjNf34F&`Q6PDNk zEWyS>y+H)Bz{uPT@GLdMC+nZek0M^2{|P35avnZiI|_hwnZ8t{@DLvreB;oK$U*XB8FXyp;i9dpGJ5h@mz`#cm+KgfS>iQC3sN_DEOav_ zFx`=dP7Mb0hYh9xvPyScvsM--(0N_OyZ<0@(RGST+!dw8IFOt9jq^h*mIPx z!WCsHjUQz&rW1-{p(m4D(p+}lSZSO(wm2N9s#7S^Ri=}Maod6h?GWN8&vKw~_>Q1p zO-RP5RdP|b#{mn=U|*L$@tXVIH}meofZ-Ush`VUGi==g47#lMWS_hPaH>1D)vkFJK zbH_w_wRbTV@r8akHH>n`3f{pPF%j~`p1_-mE15U0%ok?&F3~)bN zkhg=Q5usuZC7rWT91n=%bu{E#C1~OEhlGT?3`AZ{)3&U;7(i7$ZJqK~HlEQ-Z5*T- zGz1D=7Eru%6!Vu~2{%i?w^cktF1oORApJVvSd0u7&fRbcc!UC!$fgx#XI2ZbOAxTF zLfNPvKibh%E8A1mUH-7OOozG;bPI%66mfaA$=(y7Ah-fBHi*`TG@Lj zJJ;Alu+L`zo_p!J^ulw`r~QK)92~n%891Q{{f2D0V4+4{UBv`&2zvo`rF!)Jgr(H= zjvv5bIP*@$+YrMoHz*C;ti*6Qtq0YPS$<>MVi-ccavlzJEg3O$?qIW_#VFc|AmpW$ z$J4UJEVbA`g$j)5Xte#fp0<%MipzZ%;82a1Svb*XJ-kk_?ub)1A$FP zKnX6dDr4q;EO#c8Fn*2EdTyIvk(&6qd{LFlc&A(a}fY7rHC_<3w`G8$ba7 zv?Y9j06#^O24@Jy*z#zEEtca8=V*V*aWHtZXkz+gK-mkIF+(WIX_M5uDhj{wr}RbL zyWS^t|8jpmU{noIB9yD>K0`}9o$;2rAmBowRe+eGs(Tw(XSm{upRC#{S8tHIj=}KD z8bW`IF_W)Pv-8y>F4u;^#Ty&3)Gq2n=CQm*8g^c6V}~F;N?s82sI|c{I~+Ofn}a#o z_R-_(WDq-Tba3lNI^cM{-K)=Yp1CY$_?Q#3E$tt#GskUyuK86j>toD3%d>hXRiWIjnY*q7gv`|{CmmFnIu~TFPv+j7K2(75 zXh)SGg?P)e*B}}@%R*J7qY$^9Q{oiAdR*@%sys;Tl*LZRZ!yA>XB&t1uKt_`SXODX zSOyfcG)|+(z^wyLp3$r*14TmPXU58ragr9Zx1xc+{81w8D>!=3ov5Dt-Fx@FI4oxi z0>6I!dfK~rlW(D3O*?!dzh0UcxU?y z5;=Rz>iARP7a`)0Wl`a3px`cuQ^uEL(fbff48(o>EG>19@roi@9Yh0gXmaR~29oGTz{{BrfQ8S? z!p$NqxdB$QulYoR6#y&=JM+q-fE`)9P77yWTPa=atSmtmuFK*s1X=xf8ksOTZ-!JA z{NGWP54xCtF)i0e74F(Vg22 zK9r4YEb~l->zn&&=i0Tj_uO-7XZH&0{u~sG8!}UA=jh%C&&0U%{xO4qfx?%3w)0@GgWI4?au#8eQ4OA%4F)kJH0vpt zD43ke9;TOTKCYsjF{oyZzNAay+RT9f_Xb6@=0*QdBz*r`*x(I7IeOG|RYkYDMeUbJWtUb{*aurRM zz_G*NywDStJzD@kVG-mI!cj-_ScB6O2ntyE&XpBmKH8ix9ues2S-Exin+IGFz2OCQ ztM0PukdwGJ9Biau8wH2qxT{aHybn%?SR_w4CpIQ+*wH!|*atqJ_Jz5uc7>W2LsT3_ z&6~=LS{HWFW~JdyAj6PCvF(8a-{)$oaI$sa*Z~v@~#)5#H%GW zi?z%zYuX&lbOfEDvr}}=1TLp~cuSlH4(sR{kPqI!!@;sxUZN}5>TdIqWH^c|e4PwC ziyaPJaaM!1O2ck1t#ii09w0Efe}{xoSR&dCTvhRh4#a`Ixx3HI#%3pV6Uwy2RkId@ zTp1Q)BnPazjV7C|!bzxXt(K7EgTG5e#%MR*%MOD!Ia;j|tfg>KZ}kXt!9UUNr;#6t z6|WpR4mtne0;_moz5&TI;+O}E(gzW#`csCj3Qs?PB-jN(3J%mO+Pw@0pZO_HL|WdP z4p%t_olBc-mp_}XmB4B6rL?0SAnh(#+>F=>u7Q?2iZvlGP9k?1bdA*(S3KgG*K*b7 z7H51ip*XgzxXa<{tmG_&P-YFrmE%s}0vHJA(z`9?7!WwJS-&}!e0`tW(yOhI-`2q* zyypItICGv04~jW+fKiaxJgmcGJ1RC8t#r;}LaY22!p(t&?}%3_mh*{btzzYdDdlz$ zlx#{I1C|D?BF$Y4ADueq>jN(sNYM`wi1zUj?~pBFCk2x!3Qu>X-oE#4+Jlun;82#U z*RKS@@9@!~4j)bKLfqC%qpi;NHe3W|;+%=Gt_30+&F*o==O!yE*01Y_e*u?=e0pGy zPa^Kgw&D;@6ps0W+zg@1lB(ATb{x>~+ys2k{6h=?3;`J2RDnT>G#rXF7ZWKo?@TPW zop-id9tZJ4uc&rVa#Wg<2}V$-GjxfXOB2E4kKC99nO)Ubr?0s??UiU+%R;(22IDAq zMlD7rn7rw4Zj?nB%U!xCJRth1b`%!T7mW6)w)KJs^&q!cF+tUdI7geoMffR0_h?9* zDm(=k+jo77cy0H6&?g+t=TVGJ6$ymYX2w(6L78Yfn_H)8zrfJsJFy<}W8E_2mbtNx zx(mJr?^y8rvQ8ku38J(P#i+}wfXyUMC?_w%8B80{nOIC5`(7Zi{pB!inW}acBkCD9 z@h7*g=@hZVy4IaMnLSf_-}H18KO9^Yck^-bn+yDn4qA+Bde6w64zWiwSQI}V4Pir; z;W%H$JT|M2SQZgK***uQo#j9%zij7^{(?cFB1(IRE(36PAFzO{*gfEj>U_#@gM)mV zDlXwVY;(2a4Gzgk!<`sVqy8aM1%r$C9h9MdR5(^$DDw#?iQo6o9SNRzx=lq8hPTJ` z=T8Ty<%heVilOlHuoc3~rWd7TbPZLHnaL)|%@OI9Kc_@qd`_>ibV~h0)Ql!1ebT5V zD+)33smiYc0DdogXQ zW&|h<7h&?f`O75+fyyBS=U0hpDna2s+_@UcNr=-dJR!UR_hKJf9>4#h=%dbPDtEwt zSIr1OT_8N(hqY$~B<5e@`c?T(rJYn~;g{)Efa-v|Xa~c_PZ*=`y{|ii17`uSg~)v` zD7YeI9T!~zi#dNQw~+SS`|5$}*@%xOx-77MMOO7ba{##$qBSp&q46&3a>v_{mdDcj z=d=#q+Tc~mX4#d-*S|jIBET&AVyv9L137Pj1T5@0L4z4UI|o4|PDVl*rcU6wS6)od z9UP<`PLkf*+ha>KCpqzRHi)lTK_G32TT!NkKBG$ycDMKzmLiAU&urD*xx&on{)Tb- z&hPzS>F_Qhi$y^376hgo%q%$LfU6sopy~kpo@X|Xo?{|QgUz*9BwYSDaBMlQ&WNQo zO7qBMDBJrUQYSBxqWo^HHgL?Mx7Cigfbfb@H^u>P4 zU}avVM7iL>noE>Y4f(4V3IHIbfCtL!bKdM$#UV3^CycjfPQf+I%H8;7y#xaLiGFoA za0fuwDHg;5!?nV#u?ps%A?+#r0=y9Dz7ao_X#bu7A_B~e;28rRQxF^z`T4z91`%bq z{}5MQ86mC{ldD@cxm>KKNKtC#N{6Y}T{RHuUXOFXSgEt(s{F~>SG6SYMY6ZEf}LBDc$8tWURBr_ zK*Sd;98Gc*u^)hrcd4=r?e+DI^xUmm>3}bCJBa-5=^9EjIg1#Kq_4=pbVSO41b`s8 zN!%lr(j+{%RhHtk>tG0z*IPe%2hnH5cRP>s;Bf}S{L?Rm*->#m1cMvO&4=FF!Njvo ztaH#7>Cc>Xbj%TDl?%dOco`{3#10J7mk{MJT%L>`q3mIH9!j@76l0)S4)PD6u^f#4 znLll30hxz`C`>6#?oBa($wCmm)McSevqu61Xb>(~bBZW|DyDG>7aU3qY{~+7H!86y z4zmFSLZ3li7}P?%)Q>i`sob?1`V9M##9WD$kV7uzeVfzFU2gS@@NLR0=P)3?9Ag%t zBKt@F*yq~q#6dpP*A8()wp`x3`&Mh=*lg&SW2`vlW}ri>%{kk^&ZcpN34+eJzj`=A z)gR-2ncrd{jpeBNqmfI%AcO)f#nY?37jB`zmG4C_XBE&)K1nR7O3N3a_6|^V}dDD*f;FRD5V3*-MVk6YLxK=G?=2gI;1|n64}iR4x$FkhvV^3Rd#3^fbFzya5B@XwX~{|n zs!4U>tgNkw^ZNuUm3v!RHtmp(Fr;xCd z@d{`-#$vkxlSPP5fNPxoMc7rKk>>!~n7)X%W+01^i*C`YkXZ3A&x`R`0it0SI>116 z(Zj|oE;@9fGs?Tixth*-&TRdjLp}d`oLaH!-n09dCI5fo&a_FABuUGWk&$Orch9vu zI}6APg21nU|NjjU5I!Kl2V~h@=IFDkGAlFV;(1;*_3(&{s_vc{5v(fG-OcS#)63M< z4ySo@WpNJvgR-cqM2VrQZa9n8Cq(kn^1GAvyhR)f8SmaQZobwR!Osn5?u6{M5DakP zHJC}oBT2CmE|&;7!;ni()mox3G=nv7?Oi+(>Q|iZ^xY3ADH!a59k3r+NaR=5oU+UQ z2mtm&JeFa|_dr8d>YzUNQBXR9=$PI-T**_k7lsmGpG)9U%Y^N(lrr?IIHq5M6Kj@Z%aKYM*3UnCO$(Fpi}_Os5&1u>oA}T1iuupH2aL33-X7x| zN?Kxq5d@WmmVn0=y5u_hc$8igfNe65Tjbp~FAl|}>nnOK#kI-?h2e0JFNpYEv*6kN zK9|g_xQg@wGi=UhDg*m#%jQMk=sy{TSP#l&?~Do7iptBS?bfUENIo(&4S~F~Pgjc_ zO`PI>?oDtuGHLONmY)^o1o&LWb42`H`uV%yFq~6}Ji+HY>2%+gL`4h&fJ6DRlzJgF zj6da)RuF8CYwpdhl8FF;G3(d#jbuj_nqkD zc=s<4i{1bH-;4kBA7K@ajl1QG?pJ&pql2?6e(ORXgUg#Q@)01YN4%!CW!us?1-Vm^ zG$Q8Ar&^Pi&7d;6O2Tn{Ok|ijN&_aY{3ry43k%EX+-sM&V`X{lf0%nR-N36FgTFz) zH~@x0IpXKR6zAVWA|Q~>si92gQ%F@_!jl?`o+?huk`V}6LE=Cw!SyGZRRTY%-VLJ!5$46SGhrj-f%Avzi{lFvQ%CVZ;2^7;E6c=fdwoB=&<}rnft79CF z1QlsHNA;R~q))jsWD83VYh-Sj#W{PuzqqZ^$O=GuS!WTl$H27{_%1eb z;NDGS2pd0@gLMh7AMjmz7AK73hN<7c5qJ1QR5XA1r{6FB>p%Vhi)Fd^(+8{%+U5tG z@9y6&Hh=SXjE7$`{K%KQ(g+(b4s4~4KnEAasFGINqzHn% zk4H5C3C`=#gvuN8($0A(B|>W`!grRGdTF<;fjla0$>L0gf|)CId+4vr#f2$ne1e;- zyKwY1Li`xqm(!Gv1BKxTst72rit zSXOW&EGlMy@{Opne)90A9?tx=t)f*PIOX*-$v+!a3PVg9!n8hgW$ zk`xE=sU3J~`t6c2>ZRYlz<02s=k@y(`ZAm@Q(?#qkpXf=Jn88@HbKU+7qbU+oa$-V zwE@AkpR5KP=U3d^|8DV%-~4v*C7acL_xnFA{^5`Rg4OW@N6WFE0|nuD88HSoa0MZJ z!=Dx&rwnsI3_~NAkh_Ij^Vu=9!Wkh5=M|%>ic6S5pPU9Bz1s7t+_GbnVbKQR@}OEj zHvPi0pH7~yw4DzRtMoj_!*hO@HL`ZJ2)`*Eivjx{9ND(3+gbP|Xx- z9R`mAU^_}IxttUd;+@1Lxk*RyMc|J8^c8rf@;^~z${grBDyY*>g56CUjq3QImz~{F8b75h>%l^xYwd zCyT975Go75G_HeIWun3mUoRH&akY44VcO06+bE7(ETQ{19KwIg6eWs7UrV}wkxB9l zQOKuNBV4h-D%KRQgD^ptf^P^Xb?2x6-Z_j+tjdVrk|7|7QQJ&y);bVho^3(S-18WC ztWc}WHf4rqK+sFV+rk*pz)T$|3s>u^G@fvdremaJ(QKzficTJdf}1PTuv(C(!R_sA zFNQzZgeG25W)dfOp$+bUg-ggL^Ur`jEy!o`C8Tk6`{(>E3uEN$GP`gYX08N$o-lc) zwt9c&#fXwl1{-W$!k%8YqVu=N4xwfUFs%wrWzd4JfEs7W^c3$Zh&V$%mtqyk)s}{F z-mau3`C*(+i~<9k5+Y70^7~%rSg*tI5EB{DvRrWGR%8ru1YLnGg&~5qBJ4D5uP0qm z0z&7MB+6#xAv=`8$+1fr46|Ow>ghO7u~tyC`094K_{HD;cJUP(*F0R- zN8hK{)bR(bwegO0SW249Ie><1z8wF^ZcTRGK5+Q))%Cl@9h>R2I^LlCZpRnsFEO`j zO4BK3a8XsKewA6V%{;PR`Pbw2To2Q429)?)a4Q}R(!n8&A05K9&s=+G(qE?s zsN{DlcnZvrkd#8PFi^~Uzt>1L)^R+YUo^2-EXQ^8{#25g4iB`|e+HVKFv(CoPVdKK zF5FRVoxOV5l4-TRWV2Lf;&?pAr!>?s7JwzKs5RW@-tN6 zw5CfGNB8Pch01?8ICNbrdazIJwqmUk4S}?WXRGD6}FAxqF9HIEk z%hcl~f|Hexmz+Iu{j&JTVM5oeTK&jY{QJANi??sT%vaMLrexg%d&3o-EkcuW5i}JT zRY4{>9<53d7Q6qGGJBtB@_7<^c8E zb1=2C5HTIvvXGXi_=x)E+M=^zviPh zt82!<9CD*G@e0O>RVA~?O$HsL;bIw#yjnO>Bq%NF^*+fE&=tx>WuY>OvXI$f=vEl} zHrL2tTjPNBUSDZW(g0R_1}W zxK!NrxgE5sJeCV|q|69EwBK;Xs=n-O4W!)KYCwH6nXrt=Meq&27`E+jc-DbPDq)=3 z`@3qNZ3HGmL20}4IQ{WCvL*b3{VKJ16D#F?W^n`0pXhab6jw>DIV7T&)TL3gdOJPyICP z${(<)7(6t^U5WM#5n`(bN%2Eu`NX0M0z8EAsY9H( z5GP?M1n*^Jf5O^Vv29O%ql1&5euC^)MG&2J(d4GnHdP5n5}Bd4mr zJbkfvbBEGkA=_qdRVb&@O9kqNyOW+N6<#$Xd*J0cW+^?ARxqi#eRh+#mPEWh#dx+J0LksXLt-Ml5 z9GQ1mcrMf5vx!V3wF1 z^tG78x3kVMW%rodq}DYrx9ct?P$kENJ1SbnN@d6ONstcQ+fEr#OT%^jQ4~Bm#c8i% zuP;;AiQhik+bCF+15lcrhPoiMQUGP3Q#2ptRiFg~T&Sd^U!F~N0Fdb7kRF#OJ;Laa zMN30Q&sqo-hW-^4l|PjQT7y4cMHcOb302S$Hrk}35{V8avYk3J3B<`cfdS?xOyC@xvp=F}dp^z}W4%E;RU(5>&)gG}jzQh-^+1@YS zdSDQ3T(K*cT)-#b=5>8Z!B_MoN&@rWKZygm@w1>L^dAUktjJ~rlM+-tv7C}11<(fQ z023;pATrOUZOcuC>wXc}-uTVjS_vv#qyaYVGV2z#9kQseAI9~HGM-r}dWC<IaKmbWZK~zn(D5wV<>eThV6{Ebb+?W7aK&HRG)ah|$4v#y8 z{MFP%^=)&x?+yJNlpV68G-(+q4MJ_O;Qcj&WzU03sKRmlF)dBg=}cW|j|9F7DPA!% z$@89;+NSnvB*73;`MpDs>$8lX>)~Z1%NLfF^Dbm|(%S24o?;Y;JQxRMLAsowKm{5Q zIyP-*WwjJ!pSkzk5%9m5)n0mF5M0EM zfx^gcO|Dy%(=Ah{IznH_XAhe1%hG$w2RnOi3!rYdh{tNf!qD5#aaOd6AHv{gCev5% zU5Lg4Ff?(jBI^zvEJs=htC2bv%}YE;bPtAfaxac`s9+SXc3#_-SUsaLStRES6~Q*C z@EsOquTEmC{SM`lhPFP_rYQ9BUWMXp)CPFgrW-uxP*8q)R|`JxX&2rX7348 z%qk^*2DcB$In)L(h%tf+T-dQ#>f|j;JG3cjO!qUwDEVjPI3%5>RcRry{{Hen`w{|} z4WG3tW3)3>^ibp!4##?6ZCD;4@&v>P3xaU$hd5Md@1#;N1dhNXQ+zH|ewj~Ew&%B= zSQao=;%KD43Q65UEx`?OcELdMO)b|R!b7(?@!0#a@j`}iFf^c=3$;Ut) zj%}02o{-SXy)$L1RRLq_!|(%0DS<+Svyi};S|LpkL;RyXVkK6LO3OAQaGJnc8ZxcI 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 B`-&JQxCb;3Otep*6ft&cc1eMcPWv1RD{ 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

PU8cQ3(k`$uUGR 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 literal 0 HcmV?d00001 diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/Contents.json b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/Contents.json new file mode 100644 index 000000000..18b147ff5 --- /dev/null +++ b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "landiao.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/landiao.png b/chat/uikit/resources/assets/chat/videorecorder.xcassets/icon/filter/icon/icon_landiao.imageset/landiao.png new file mode 100644 index 0000000000000000000000000000000000000000..a394eb6a85858bbf2c7732931d81ff2819778c64 GIT binary patch literal 106591 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