Compare commits

..

2 Commits

Author SHA1 Message Date
linshujie 9d370f4273 - FLEX
- PrivacyInfo
2026-07-25 09:15:53 +08:00
linshujie 38946b59af - IM消息引用、撤回 2026-07-16 15:10:47 +08:00
24 changed files with 16082 additions and 13155 deletions

26
.gitignore vendored
View File

@ -112,3 +112,29 @@ screenshot/
.opencode/skills/writing-great-skills/GLOSSARY.md
.opencode/skills/writing-great-skills/SKILL.md
.opencode/skills/writing-great-skills/agents/openai.yaml
.cursor/commands/opsx-apply.md
.cursor/commands/opsx-archive.md
.cursor/commands/opsx-explore.md
.cursor/commands/opsx-propose.md
.cursor/commands/opsx-sync.md
.cursor/commands/opsx-update.md
.cursor/skills/openspec-apply-change/SKILL.md
.cursor/skills/openspec-archive-change/SKILL.md
.cursor/skills/openspec-explore/SKILL.md
.cursor/skills/openspec-propose/SKILL.md
.cursor/skills/openspec-sync-specs/SKILL.md
.cursor/skills/openspec-update-change/SKILL.md
.opencode/commands/opsx-apply.md
.opencode/commands/opsx-archive.md
.opencode/commands/opsx-explore.md
.opencode/commands/opsx-propose.md
.opencode/commands/opsx-sync.md
.opencode/commands/opsx-update.md
.opencode/skills/openspec-apply-change/SKILL.md
.opencode/skills/openspec-archive-change/SKILL.md
.opencode/skills/openspec-explore/SKILL.md
.opencode/skills/openspec-propose/SKILL.md
.opencode/skills/openspec-sync-specs/SKILL.md
.opencode/skills/openspec-update-change/SKILL.md
openspec/config.yaml
openspec/CONVENTIONS.md

View File

@ -25,7 +25,7 @@ target 'QuickLocation' do
#工具
#pod 'FLEX', :configurations => ['Debug', 'AdHoc']
pod 'FLEX', :git => 'https://github.com/FLEXTool/FLEX.git', :configurations => ['Debug']
pod 'HXPHPicker'
pod 'SwiftKeychainWrapper'
pod 'SGQRCode', '~> 4.1.0' #扫描

View File

@ -26,6 +26,7 @@ PODS:
- CocoaMQTT/Core (2.2.5):
- MqttCocoaAsyncSocket (~> 1.0.8)
- Differentiator (5.0.0)
- FLEX (5.22.10)
- GTCommonSDK (3.2.3.0):
- ZXSDK
- GYSDK (3.1.3.0):
@ -199,6 +200,7 @@ DEPENDENCIES:
- BRPickerView
- CocoaLumberjack/Swift
- CocoaMQTT
- FLEX (from `https://github.com/FLEXTool/FLEX.git`)
- GYSDK
- HXPHPicker
- IQKeyboardManagerSwift
@ -297,10 +299,15 @@ SPEC REPOS:
- ZXSDK
EXTERNAL SOURCES:
FLEX:
:git: https://github.com/FLEXTool/FLEX.git
MBProgressHUD:
:git: https://github.com/jdg/MBProgressHUD.git
CHECKOUT OPTIONS:
FLEX:
:commit: 63a6f588841e94e4c3adaa045ff16eb8163f0bb4
:git: https://github.com/FLEXTool/FLEX.git
MBProgressHUD:
:commit: 4a7c5f3e53cdea77c5dcb8578c2ee5acacdf6781
:git: https://github.com/jdg/MBProgressHUD.git
@ -316,6 +323,7 @@ SPEC CHECKSUMS:
CocoaLumberjack: e4ba3b414dfca8c1916c6303d37f63b3a95134c6
CocoaMQTT: 1e2fa493d57045d66fb1eba2bb5ffb8d77039c60
Differentiator: e8497ceab83c1b10ca233716d547b9af21b9344d
FLEX: 6ec7816b9f392fb4c635cf50381e6bd4fb15541d
GTCommonSDK: 238c6735add91e654f8564638854686ee3dcb4ac
GYSDK: db5f4b3aae8df06201be5e786076211ec11791be
HXPHPicker: 451f6af62845d60d5dd2235f4665d81f2688a51b
@ -366,6 +374,6 @@ SPEC CHECKSUMS:
YYImage: 1e1b62a9997399593e4b9c4ecfbbabbf1d3f3b54
ZXSDK: 786338c0a18e98e03eda00699c3bfd2700b97117
PODFILE CHECKSUM: 439991fe5523a65e07f0df7135995adfc31f6cb3
PODFILE CHECKSUM: f676321d820ce55c18e69318732ac59e39a57e43
COCOAPODS: 1.16.2

10
Pods/Manifest.lock generated
View File

@ -26,6 +26,7 @@ PODS:
- CocoaMQTT/Core (2.2.5):
- MqttCocoaAsyncSocket (~> 1.0.8)
- Differentiator (5.0.0)
- FLEX (5.22.10)
- GTCommonSDK (3.2.3.0):
- ZXSDK
- GYSDK (3.1.3.0):
@ -199,6 +200,7 @@ DEPENDENCIES:
- BRPickerView
- CocoaLumberjack/Swift
- CocoaMQTT
- FLEX (from `https://github.com/FLEXTool/FLEX.git`)
- GYSDK
- HXPHPicker
- IQKeyboardManagerSwift
@ -297,10 +299,15 @@ SPEC REPOS:
- ZXSDK
EXTERNAL SOURCES:
FLEX:
:git: https://github.com/FLEXTool/FLEX.git
MBProgressHUD:
:git: https://github.com/jdg/MBProgressHUD.git
CHECKOUT OPTIONS:
FLEX:
:commit: 63a6f588841e94e4c3adaa045ff16eb8163f0bb4
:git: https://github.com/FLEXTool/FLEX.git
MBProgressHUD:
:commit: 4a7c5f3e53cdea77c5dcb8578c2ee5acacdf6781
:git: https://github.com/jdg/MBProgressHUD.git
@ -316,6 +323,7 @@ SPEC CHECKSUMS:
CocoaLumberjack: e4ba3b414dfca8c1916c6303d37f63b3a95134c6
CocoaMQTT: 1e2fa493d57045d66fb1eba2bb5ffb8d77039c60
Differentiator: e8497ceab83c1b10ca233716d547b9af21b9344d
FLEX: 6ec7816b9f392fb4c635cf50381e6bd4fb15541d
GTCommonSDK: 238c6735add91e654f8564638854686ee3dcb4ac
GYSDK: db5f4b3aae8df06201be5e786076211ec11791be
HXPHPicker: 451f6af62845d60d5dd2235f4665d81f2688a51b
@ -366,6 +374,6 @@ SPEC CHECKSUMS:
YYImage: 1e1b62a9997399593e4b9c4ecfbbabbf1d3f3b54
ZXSDK: 786338c0a18e98e03eda00699c3bfd2700b97117
PODFILE CHECKSUM: 439991fe5523a65e07f0df7135995adfc31f6cb3
PODFILE CHECKSUM: f676321d820ce55c18e69318732ac59e39a57e43
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@ -143,6 +143,40 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## FLEX
Copyright (c) 2014-2016, Flipboard
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
* Neither the name of Flipboard nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
* You must NOT include this project in an application to be submitted
to the App Store™, as this project uses too many private APIs.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## GTCommonSDK
MIT License

View File

@ -214,6 +214,46 @@ SOFTWARE.
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>Copyright (c) 2014-2016, Flipboard
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
* Neither the name of Flipboard nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
* You must NOT include this project in an application to be submitted
to the App Store™, as this project uses too many private APIs.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</string>
<key>License</key>
<string>BSD</string>
<key>Title</key>
<string>FLEX</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>MIT License

View File

@ -4,6 +4,7 @@ ${BUILT_PRODUCTS_DIR}/BRPickerView/BRPickerView.framework
${BUILT_PRODUCTS_DIR}/CocoaLumberjack/CocoaLumberjack.framework
${BUILT_PRODUCTS_DIR}/CocoaMQTT/CocoaMQTT.framework
${BUILT_PRODUCTS_DIR}/Differentiator/Differentiator.framework
${BUILT_PRODUCTS_DIR}/FLEX/FLEX.framework
${BUILT_PRODUCTS_DIR}/HXPHPicker/HXPHPicker.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework

View File

@ -3,6 +3,7 @@ ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/BRPickerView.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaLumberjack.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaMQTT.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Differentiator.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FLEX.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/HXPHPicker.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardCore.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.framework

View File

@ -181,6 +181,7 @@ if [[ "$CONFIGURATION" == "Debug" ]]; then
install_framework "${BUILT_PRODUCTS_DIR}/CocoaLumberjack/CocoaLumberjack.framework"
install_framework "${BUILT_PRODUCTS_DIR}/CocoaMQTT/CocoaMQTT.framework"
install_framework "${BUILT_PRODUCTS_DIR}/Differentiator/Differentiator.framework"
install_framework "${BUILT_PRODUCTS_DIR}/FLEX/FLEX.framework"
install_framework "${BUILT_PRODUCTS_DIR}/HXPHPicker/HXPHPicker.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework"

View File

@ -2,13 +2,13 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire" "${PODS_CONFIGURATION_BUILD_DIR}/BRPickerView" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaMQTT" "${PODS_CONFIGURATION_BUILD_DIR}/Differentiator" "${PODS_CONFIGURATION_BUILD_DIR}/HXPHPicker" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher" "${PODS_CONFIGURATION_BUILD_DIR}/KingfisherWebP" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/MJRefresh" "${PODS_CONFIGURATION_BUILD_DIR}/MarqueeLabel" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/Moya" "${PODS_CONFIGURATION_BUILD_DIR}/MqttCocoaAsyncSocket" "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper" "${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDK" "${PODS_CONFIGURATION_BUILD_DIR}/Popover" "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa" "${PODS_CONFIGURATION_BUILD_DIR}/RxDataSources" "${PODS_CONFIGURATION_BUILD_DIR}/RxGesture" "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwiftExt" "${PODS_CONFIGURATION_BUILD_DIR}/SDCycleScrollView" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "${PODS_CONFIGURATION_BUILD_DIR}/SGQRCode" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftDate" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftKeychainWrapper" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyUserDefaults" "${PODS_CONFIGURATION_BUILD_DIR}/TagListView" "${PODS_CONFIGURATION_BUILD_DIR}/URLNavigator" "${PODS_CONFIGURATION_BUILD_DIR}/YBImageBrowser" "${PODS_CONFIGURATION_BUILD_DIR}/YYImage" "${PODS_CONFIGURATION_BUILD_DIR}/libwebp" "${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/AlipaySDK-iOS" "${PODS_ROOT}/OpenIMSDKCore/Framework" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_ROOT}/YYImage/Vendor" "${PODS_XCFRAMEWORKS_BUILD_DIR}/AlipaySDK-iOS" "${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenIMSDKCore" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire" "${PODS_CONFIGURATION_BUILD_DIR}/BRPickerView" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaMQTT" "${PODS_CONFIGURATION_BUILD_DIR}/Differentiator" "${PODS_CONFIGURATION_BUILD_DIR}/FLEX" "${PODS_CONFIGURATION_BUILD_DIR}/HXPHPicker" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher" "${PODS_CONFIGURATION_BUILD_DIR}/KingfisherWebP" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/MJRefresh" "${PODS_CONFIGURATION_BUILD_DIR}/MarqueeLabel" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/Moya" "${PODS_CONFIGURATION_BUILD_DIR}/MqttCocoaAsyncSocket" "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper" "${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDK" "${PODS_CONFIGURATION_BUILD_DIR}/Popover" "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa" "${PODS_CONFIGURATION_BUILD_DIR}/RxDataSources" "${PODS_CONFIGURATION_BUILD_DIR}/RxGesture" "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwiftExt" "${PODS_CONFIGURATION_BUILD_DIR}/SDCycleScrollView" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "${PODS_CONFIGURATION_BUILD_DIR}/SGQRCode" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftDate" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftKeychainWrapper" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyUserDefaults" "${PODS_CONFIGURATION_BUILD_DIR}/TagListView" "${PODS_CONFIGURATION_BUILD_DIR}/URLNavigator" "${PODS_CONFIGURATION_BUILD_DIR}/YBImageBrowser" "${PODS_CONFIGURATION_BUILD_DIR}/YYImage" "${PODS_CONFIGURATION_BUILD_DIR}/libwebp" "${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/AlipaySDK-iOS" "${PODS_ROOT}/OpenIMSDKCore/Framework" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_ROOT}/YYImage/Vendor" "${PODS_XCFRAMEWORKS_BUILD_DIR}/AlipaySDK-iOS" "${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenIMSDKCore" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire/Alamofire.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/BRPickerView/BRPickerView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack/CocoaLumberjack.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaMQTT/CocoaMQTT.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Differentiator/Differentiator.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/HXPHPicker/HXPHPicker.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher/Kingfisher.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/KingfisherWebP/KingfisherWebP.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJRefresh/MJRefresh.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MarqueeLabel/MarqueeLabel.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Moya/Moya.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MqttCocoaAsyncSocket/MqttCocoaAsyncSocket.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper/ObjectMapper.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDK/OpenIMSDK.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Popover/Popover.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa/RxCocoa.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxDataSources/RxDataSources.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxGesture/RxGesture.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay/RxRelay.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift/RxSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwiftExt/RxSwiftExt.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDCycleScrollView/SDCycleScrollView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SGQRCode/SGQRCode.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftDate/SwiftDate.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftKeychainWrapper/SwiftKeychainWrapper.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON/SwiftyJSON.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyUserDefaults/SwiftyUserDefaults.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/TagListView/TagListView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/URLNavigator/URLNavigator.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/YBImageBrowser/YBImageBrowser.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/YYImage/YYImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/Lottie.framework/Headers"
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/Alamofire/Alamofire.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/BRPickerView/BRPickerView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack/CocoaLumberjack.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/CocoaMQTT/CocoaMQTT.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Differentiator/Differentiator.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/FLEX/FLEX.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/HXPHPicker/HXPHPicker.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher/Kingfisher.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/KingfisherWebP/KingfisherWebP.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJRefresh/MJRefresh.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MarqueeLabel/MarqueeLabel.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Moya/Moya.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MqttCocoaAsyncSocket/MqttCocoaAsyncSocket.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper/ObjectMapper.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDK/OpenIMSDK.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Popover/Popover.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa/RxCocoa.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxDataSources/RxDataSources.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxGesture/RxGesture.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxRelay/RxRelay.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwift/RxSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RxSwiftExt/RxSwiftExt.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDCycleScrollView/SDCycleScrollView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SGQRCode/SGQRCode.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftDate/SwiftDate.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftKeychainWrapper/SwiftKeychainWrapper.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON/SwiftyJSON.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SwiftyUserDefaults/SwiftyUserDefaults.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/TagListView/TagListView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/URLNavigator/URLNavigator.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/YBImageBrowser/YBImageBrowser.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/YYImage/YYImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/Lottie.framework/Headers"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -ObjC -l"c++" -l"iconv" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"swiftCoreGraphics" -l"z" -framework "AMapSearchKit" -framework "AVFoundation" -framework "Accelerate" -framework "AdSupport" -framework "Alamofire" -framework "AlipaySDK" -framework "AssetsLibrary" -framework "AudioToolbox" -framework "BRPickerView" -framework "CFNetwork" -framework "CallKit" -framework "CocoaLumberjack" -framework "CocoaMQTT" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreMotion" -framework "CoreServices" -framework "CoreTelephony" -framework "CoreText" -framework "Differentiator" -framework "ExternalAccessory" -framework "Foundation" -framework "GLKit" -framework "HXPHPicker" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "ImageIO" -framework "Kingfisher" -framework "KingfisherWebP" -framework "Lottie" -framework "MBProgressHUD" -framework "MJExtension" -framework "MJRefresh" -framework "MarqueeLabel" -framework "Masonry" -framework "MobileCoreServices" -framework "Moya" -framework "MqttCocoaAsyncSocket" -framework "ObjectMapper" -framework "OpenGLES" -framework "OpenIMCore" -framework "OpenIMSDK" -framework "Photos" -framework "PhotosUI" -framework "Popover" -framework "QuartzCore" -framework "RxCocoa" -framework "RxDataSources" -framework "RxGesture" -framework "RxRelay" -framework "RxSwift" -framework "RxSwiftExt" -framework "SDCycleScrollView" -framework "SDWebImage" -framework "SGQRCode" -framework "Security" -framework "SnapKit" -framework "SwiftDate" -framework "SwiftKeychainWrapper" -framework "SwiftyJSON" -framework "SwiftyUserDefaults" -framework "SystemConfiguration" -framework "TagListView" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "URLNavigator" -framework "WebKit" -framework "WechatOpenSDK" -framework "YBImageBrowser" -framework "YYImage" -framework "libwebp" -weak_framework "AppTrackingTransparency" -weak_framework "Combine" -weak_framework "Network" -weak_framework "SwiftUI"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapFoundation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapLocation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapNavi-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapSearch-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/Alamofire" "-F${PODS_CONFIGURATION_BUILD_DIR}/AlipaySDK-iOS" "-F${PODS_CONFIGURATION_BUILD_DIR}/BRPickerView" "-F${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack" "-F${PODS_CONFIGURATION_BUILD_DIR}/CocoaMQTT" "-F${PODS_CONFIGURATION_BUILD_DIR}/Differentiator" "-F${PODS_CONFIGURATION_BUILD_DIR}/GTCommonSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/GYSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/HXPHPicker" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher" "-F${PODS_CONFIGURATION_BUILD_DIR}/KingfisherWebP" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJRefresh" "-F${PODS_CONFIGURATION_BUILD_DIR}/MarqueeLabel" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/Moya" "-F${PODS_CONFIGURATION_BUILD_DIR}/MqttCocoaAsyncSocket" "-F${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper" "-F${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDKCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/Popover" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxDataSources" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxGesture" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxRelay" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxSwiftExt" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDCycleScrollView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/SGQRCode" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftDate" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftKeychainWrapper" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftyUserDefaults" "-F${PODS_CONFIGURATION_BUILD_DIR}/TagListView" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/URLNavigator" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework" "-F${PODS_CONFIGURATION_BUILD_DIR}/YBImageBrowser" "-F${PODS_CONFIGURATION_BUILD_DIR}/YYImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZXSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/libwebp" "-F${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios"
OTHER_LDFLAGS = $(inherited) -ObjC -l"c++" -l"iconv" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"stdc++" -l"swiftCoreGraphics" -l"z" -framework "AMapSearchKit" -framework "AVFoundation" -framework "Accelerate" -framework "AdSupport" -framework "Alamofire" -framework "AlipaySDK" -framework "AssetsLibrary" -framework "AudioToolbox" -framework "BRPickerView" -framework "CFNetwork" -framework "CallKit" -framework "CocoaLumberjack" -framework "CocoaMQTT" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreMotion" -framework "CoreServices" -framework "CoreTelephony" -framework "CoreText" -framework "Differentiator" -framework "ExternalAccessory" -framework "FLEX" -framework "Foundation" -framework "GLKit" -framework "HXPHPicker" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "ImageIO" -framework "Kingfisher" -framework "KingfisherWebP" -framework "Lottie" -framework "MBProgressHUD" -framework "MJExtension" -framework "MJRefresh" -framework "MarqueeLabel" -framework "Masonry" -framework "MobileCoreServices" -framework "Moya" -framework "MqttCocoaAsyncSocket" -framework "ObjectMapper" -framework "OpenGLES" -framework "OpenIMCore" -framework "OpenIMSDK" -framework "Photos" -framework "PhotosUI" -framework "Popover" -framework "QuartzCore" -framework "RxCocoa" -framework "RxDataSources" -framework "RxGesture" -framework "RxRelay" -framework "RxSwift" -framework "RxSwiftExt" -framework "SDCycleScrollView" -framework "SDWebImage" -framework "SGQRCode" -framework "SceneKit" -framework "Security" -framework "SnapKit" -framework "SwiftDate" -framework "SwiftKeychainWrapper" -framework "SwiftyJSON" -framework "SwiftyUserDefaults" -framework "SystemConfiguration" -framework "TagListView" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "URLNavigator" -framework "WebKit" -framework "WechatOpenSDK" -framework "YBImageBrowser" -framework "YYImage" -framework "libwebp" -weak_framework "AppTrackingTransparency" -weak_framework "Combine" -weak_framework "Network" -weak_framework "SwiftUI"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapFoundation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapLocation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapNavi-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapSearch-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/Alamofire" "-F${PODS_CONFIGURATION_BUILD_DIR}/AlipaySDK-iOS" "-F${PODS_CONFIGURATION_BUILD_DIR}/BRPickerView" "-F${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack" "-F${PODS_CONFIGURATION_BUILD_DIR}/CocoaMQTT" "-F${PODS_CONFIGURATION_BUILD_DIR}/Differentiator" "-F${PODS_CONFIGURATION_BUILD_DIR}/FLEX" "-F${PODS_CONFIGURATION_BUILD_DIR}/GTCommonSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/GYSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/HXPHPicker" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/Kingfisher" "-F${PODS_CONFIGURATION_BUILD_DIR}/KingfisherWebP" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJRefresh" "-F${PODS_CONFIGURATION_BUILD_DIR}/MarqueeLabel" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/Moya" "-F${PODS_CONFIGURATION_BUILD_DIR}/MqttCocoaAsyncSocket" "-F${PODS_CONFIGURATION_BUILD_DIR}/ObjectMapper" "-F${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/OpenIMSDKCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/Popover" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxCocoa" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxDataSources" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxGesture" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxRelay" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/RxSwiftExt" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDCycleScrollView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/SGQRCode" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftDate" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftKeychainWrapper" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftyJSON" "-F${PODS_CONFIGURATION_BUILD_DIR}/SwiftyUserDefaults" "-F${PODS_CONFIGURATION_BUILD_DIR}/TagListView" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/URLNavigator" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework" "-F${PODS_CONFIGURATION_BUILD_DIR}/YBImageBrowser" "-F${PODS_CONFIGURATION_BUILD_DIR}/YYImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZXSDK" "-F${PODS_CONFIGURATION_BUILD_DIR}/libwebp" "-F${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -298,6 +298,7 @@
30EFF3E82FCA8C7000227D26 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30EFF3E92FCA8C7000227D26 /* AuthenticationServices.framework */; };
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; };
A1B2C3D42FDABC8C009215C1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */; };
D698E9C56D6F2C152772131D /* Pods_QuickLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475D33CAFA1E1911EB1F8D9F /* Pods_QuickLocation.framework */; };
/* End PBXBuildFile section */
@ -606,6 +607,7 @@
475D33CAFA1E1911EB1F8D9F /* Pods_QuickLocation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuickLocation.framework; sourceTree = BUILT_PRODUCTS_DIR; };
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = "<group>"; };
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
DA16D49AA46D4F6838340B55 /* Pods-QuickLocation.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuickLocation.debug.xcconfig"; path = "Target Support Files/Pods-QuickLocation/Pods-QuickLocation.debug.xcconfig"; sourceTree = "<group>"; };
E483B9929C03809ADEDE8341 /* Pods-QuickLocation.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuickLocation.release.xcconfig"; path = "Target Support Files/Pods-QuickLocation/Pods-QuickLocation.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -1253,6 +1255,7 @@
305A767E2FCA8C7000227D26 /* AppDelegate.swift */,
305A767F2FCA8C7000227D26 /* Assets.xcassets */,
305A76802FCA8C7000227D26 /* Info.plist */,
A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */,
305A76822FCA8C7000227D26 /* LaunchScreen.storyboard */,
305A76842FCA8C7000227D26 /* Main.storyboard */,
305A76852FCA8C7000227D26 /* QuickLocation-Bridging-Header.h */,
@ -1755,6 +1758,7 @@
305A77222FCA8C7000227D26 /* YouSheBiaoTiHei.ttf in Resources */,
305A77232FCA8C7000227D26 /* zihunbiantaoti-r.ttf in Resources */,
305A77F92FCA8C7000227D26 /* Assets.xcassets in Resources */,
A1B2C3D42FDABC8C009215C1 /* PrivacyInfo.xcprivacy in Resources */,
305A77FB2FCA8C7000227D26 /* LaunchScreen.storyboard in Resources */,
305A77FC2FCA8C7000227D26 /* Main.storyboard in Resources */,
);
@ -2183,7 +2187,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@ -2236,7 +2240,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};

View File

@ -60,9 +60,7 @@ public let kScreenHeight = UIScreen.main.bounds.size.height
///
public var kStatusBarHeight: CGFloat {
if #available(iOS 13.0, *) {
let statusBarHeight = UIApplication.shared.windows
.filter { $0.isKeyWindow }
.first?
let statusBarHeight = kKeyWindow?
.windowScene?
.statusBarManager?
.statusBarFrame

View File

@ -10,7 +10,15 @@ import UIKit
public extension UIApplication {
static var keyWindow: UIWindow? {
shared.windows.filter { $0.isKeyWindow }.first
if #available(iOS 15.0, *) {
return UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }
.compactMap { $0 as? UIWindowScene }
.first?.windows
.first { $0.isKeyWindow }
} else {
return UIApplication.shared.windows.first { $0.isKeyWindow }
}
}
static func delegate<T: UIApplicationDelegate>() -> T? {

View File

@ -7,9 +7,11 @@
import Foundation
public class DL {}
nonisolated public class DL {}
public class DLWrapper<Base> {
// nonisolated: 退 MainActor
// Release(-O) EarlyPerfInliner deinit
nonisolated public class DLWrapper<Base> {
///
public private(set) var base: Base

View File

@ -29,7 +29,8 @@ enum PagingRequestType {
typealias RefreshResult = (status: RefreshStatus, isEmpty: Bool)
/// ListType: : ListService<ListModel<PaginationModel>>
class ListService<ListType>: ListServiceType where ListType: ListModelType, ListType.Item: Mappable {
// nonisolated: MainActor Release(-O) EarlyPerfInliner
nonisolated class ListService<ListType>: ListServiceType where ListType: ListModelType, ListType.Item: Mappable {
typealias BaseListModel = ListType
typealias BaseItem = ListType.Item

View File

@ -10,8 +10,8 @@ import RxCocoa
import RxSwift
import YBImageBrowser
import UMCommon
#if DEBUG || AdHoc
//import FLEX
#if DEBUG
import FLEX
#endif
class BaseViewController: UIViewController {
@ -187,8 +187,8 @@ extension BaseViewController {
// MARK: - FLEX
extension BaseViewController {
private func setupFlex() {
#if DEBUG || AdHoc
// FLEXManager.shared.showExplorer()
#if DEBUG
FLEXManager.shared.showExplorer()
#endif
}

View File

@ -49,16 +49,16 @@ extension DefaultsKeys {
func setupNetworkMode() {
#if DEBUG
setupApiEnv(1)
openIM_API = "https://imapi.zuom8.cn"
openIM_WS = "ws://imws.zuom8.cn"
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
#elseif AdHoc
setupApiEnv(-1)
openIM_API = "http://38.207.176.65:10002"
openIM_WS = "ws://38.207.176.65:10010"
openIM_API = "https://imapi.zuom8.cn"
openIM_WS = "ws://imws.zuom8.cn"
#else
setupApiEnv(1)
openIM_API = "http://38.207.176.65:10002"
openIM_WS = "ws://38.207.176.65:10010"
openIM_API = "https://imapi.zuom8.cn"
openIM_WS = "ws://imws.zuom8.cn"
#endif
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@ -19,7 +19,8 @@ and produces an Observable<Element> as its output.
When this excuted via execute() or inputs subject, it passes its parameter to this closure and subscribes to the work.
*/
public final class Action<Input, Element> {
// nonisolated: MainActor Release(-O) EarlyPerfInliner
nonisolated public final class Action<Input, Element> {
public typealias WorkFactory = (Input) -> Observable<Element>
public let _enabledIf: Observable<Bool>

View File

@ -2,7 +2,8 @@ import Foundation
import RxSwift
/// A special subject for Action.inputs. It only emits `.next` event.
public class InputSubject<Element>: ObservableType, Cancelable, SubjectType, ObserverType {
// nonisolated: MainActor Release(-O) EarlyPerfInliner
nonisolated public class InputSubject<Element>: ObservableType, Cancelable, SubjectType, ObserverType {
public typealias E = Element
typealias Key = UInt

View File

@ -14,6 +14,7 @@ import AVFoundation
import AudioToolbox
import HXPHPicker
import IQKeyboardManagerSwift
import MJRefresh
final class GroupChatVC: BaseViewController {
@ -22,6 +23,11 @@ final class GroupChatVC: BaseViewController {
fileprivate var rootView: GroupChatView!
private let viewModel = GroupChatViewModel()
private var msgListener: MessageListenerProxy?
/// mention @ +
private var mentionRows: [(userId: String?, nickname: String)] = []
private var isMentionPickerVisible = false
///
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
// MARK: - Init
init(groupId: String) {
@ -46,6 +52,9 @@ final class GroupChatVC: BaseViewController {
setupMessageListener()
setupVoiceRecording()
setupPanelDismiss()
setupChatLongPress()
setupMentionInput()
setupHistoryRefreshHeader()
// + IM SDK
requestGroupInfoByKey()
@ -139,7 +148,33 @@ final class GroupChatVC: BaseViewController {
.skip(1)
.observe(on: MainScheduler.asyncInstance)
.subscribe(onNext: { [weak self] _ in
self?.scrollToBottom()
guard let self = self else { return }
if self.viewModel.suppressAutoScroll {
self.viewModel.suppressAutoScroll = false
self.restoreOffsetAfterPrependIfNeeded()
return
}
self.rootView.tableView.mj_header?.isHidden = !self.viewModel.hasMoreHistory
self.scrollToBottom()
})
.disposed(by: disposeBag)
viewModel.output.pendingQuoteSummary
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] summary in
guard let self = self else { return }
if let summary {
self.rootView.setQuoteBarVisible(true, summary: summary)
self.bringInputOverlaysToFront()
} else {
self.rootView.setQuoteBarVisible(false)
}
})
.disposed(by: disposeBag)
rootView.quoteBarCloseBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.viewModel.clearPendingQuote()
})
.disposed(by: disposeBag)
@ -149,7 +184,58 @@ final class GroupChatVC: BaseViewController {
.disposed(by: disposeBag)
}
private var hasScrolledToBottom = false
private func bringInputOverlaysToFront() {
rootView.bringSubviewToFront(rootView.mentionPickerView)
rootView.bringSubviewToFront(rootView.quoteBar)
rootView.bringSubviewToFront(rootView.bottomBar)
rootView.bringSubviewToFront(rootView.disableIMView)
}
// MARK: -
private func setupHistoryRefreshHeader() {
let header = MJRefreshNormalHeader { [weak self] in
self?.loadOlderHistoryFromHeader()
}
header.addFeedbackGenerator()
header.lastUpdatedTimeLabel?.isHidden = true
header.setTitle("下拉加载更早消息", for: .idle)
header.setTitle("松开加载", for: .pulling)
header.setTitle("加载中...", for: .refreshing)
rootView.tableView.mj_header = header
}
private func loadOlderHistoryFromHeader() {
guard viewModel.hasMoreHistory else {
rootView.tableView.mj_header?.endRefreshing()
rootView.tableView.mj_header?.isHidden = true
return
}
let tableView = rootView.tableView
pendingOffsetRestore = (tableView.contentSize.height, tableView.contentOffset.y)
viewModel.loadOlderHistory { [weak self] result in
guard let self = self else { return }
self.rootView.tableView.mj_header?.endRefreshing()
if !result.hasMore {
self.rootView.tableView.mj_header?.isHidden = true
}
// suppressAutoScroll restoreOffset prepend pending
if result.prependedCount == 0 {
self.pendingOffsetRestore = nil
}
}
}
private func restoreOffsetAfterPrependIfNeeded() {
guard let pending = pendingOffsetRestore else { return }
pendingOffsetRestore = nil
let tableView = rootView.tableView
tableView.layoutIfNeeded()
let delta = tableView.contentSize.height - pending.oldHeight
guard delta > 0 else { return }
var offset = tableView.contentOffset
offset.y = max(0, pending.oldOffset + delta)
tableView.setContentOffset(offset, animated: false)
}
private func scrollToBottom() {
let count = dataSource.sectionModels.first?.items.count ?? 0
@ -161,6 +247,31 @@ final class GroupChatVC: BaseViewController {
}
}
private func scrollToMessage(clientMsgID: String) {
viewModel.ensureMessageVisible(clientMsgID: clientMsgID) { [weak self] found in
guard let self = self else { return }
guard found, let indexPath = self.viewModel.indexPath(forClientMsgID: clientMsgID) else {
DLToast.showError(text: "无法定位到原消息")
return
}
self.rootView.tableView.layoutIfNeeded()
self.rootView.tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextSendMsgCell {
cell.flashHighlight()
} else if let cell = self.rootView.tableView.cellForRow(at: indexPath) as? TextReceivedMsgCell {
cell.flashHighlight()
} else if let cell = self.rootView.tableView.cellForRow(at: indexPath) {
let old = cell.contentView.backgroundColor
cell.contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
cell.contentView.backgroundColor = old
}
}
}
}
}
private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
@ -171,13 +282,157 @@ final class GroupChatVC: BaseViewController {
// MARK: - Message Listener
private func setupMessageListener() {
msgListener = MessageListenerProxy { [weak self] msg in
msgListener = MessageListenerProxy(
onMessage: { [weak self] msg in
guard let self = self, msg.groupID == self.viewModel.groupId else { return }
self.viewModel.onReceiveMessage(msg)
},
onRevoked: { [weak self] info in
self?.viewModel.onMessageRevoked(info)
}
)
OIMManager.callbacker.addAdvancedMsgListener(listener: msgListener!)
}
// MARK: - Long press (quote / revoke / avatar @)
private func setupChatLongPress() {
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleChatLongPress(_:)))
longPress.minimumPressDuration = 0.45
rootView.tableView.addGestureRecognizer(longPress)
}
@objc private func handleChatLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
let point = gesture.location(in: rootView.tableView)
guard let indexPath = rootView.tableView.indexPathForRow(at: point),
let cell = rootView.tableView.cellForRow(at: indexPath) else { return }
let items = viewModel.currentMessages
guard indexPath.row < items.count,
let msg = items[indexPath.row].chatMessage else { return }
// @
if !msg.isSelf, let avatar = avatarView(in: cell) {
let p = gesture.location(in: avatar)
if avatar.bounds.insetBy(dx: -6, dy: -6).contains(p) {
insertMention(userId: msg.senderId, nickname: msg.senderName, replaceTriggerAt: false)
return
}
}
showBubbleActionMenu(for: msg, from: cell)
}
private func avatarView(in cell: UITableViewCell) -> UIImageView? {
if let c = cell as? TextReceivedMsgCell { return c.avatarView }
if let c = cell as? EmojiReceivedMsgCell { return c.avatarView }
if let c = cell as? VoiceReceivedMsgCell { return c.avatarView }
if let c = cell as? ImageReceivedMsgCell { return c.avatarView }
return nil
}
private func menuAnchorView(in cell: UITableViewCell) -> UIView {
if let c = cell as? TextSendMsgCell { return c.menuAnchorView }
if let c = cell as? TextReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? EmojiSendMsgCell { return c.menuAnchorView }
if let c = cell as? EmojiReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? VoiceSendMsgCell { return c.menuAnchorView }
if let c = cell as? VoiceReceivedMsgCell { return c.menuAnchorView }
if let c = cell as? ImageSendMsgCell { return c.menuAnchorView }
if let c = cell as? ImageReceivedMsgCell { return c.menuAnchorView }
return cell.contentView
}
private weak var bubbleMenu: ChatBubbleActionMenu?
private func showBubbleActionMenu(for msg: ChatMessage, from cell: UITableViewCell) {
bubbleMenu?.dismiss()
rootView.dismissAllPanels()
let menu = ChatBubbleActionMenu()
bubbleMenu = menu
menu.onQuote = { [weak self] in
guard let self = self,
let oim = self.viewModel.oimMessage(forClientMsgID: msg.id) else {
DLToast.showError(text: "无法引用该消息")
return
}
self.viewModel.setPendingQuote(oim)
self.rootView.textField.becomeFirstResponder()
}
menu.onRevoke = { [weak self] in
self?.viewModel.revokeMessage(clientMsgID: msg.id)
}
let anchor = menuAnchorView(in: cell)
// menu.show(in: rootView, anchor: anchor, showRevoke: msg.isSelf)
}
// MARK: - Mention
private func setupMentionInput() {
rootView.textField.delegate = self
rootView.mentionTableView.delegate = self
rootView.mentionTableView.dataSource = self
rootView.textField.rx.controlEvent(.editingChanged)
.subscribe(onNext: { [weak self] in
self?.handleMentionTrigger()
})
.disposed(by: disposeBag)
}
private func handleMentionTrigger() {
guard let text = rootView.textField.text,
let selected = rootView.textField.selectedTextRange else {
hideMentionPicker()
return
}
let cursor = rootView.textField.offset(from: rootView.textField.beginningOfDocument, to: selected.start)
let ns = text as NSString
let prefix = ns.substring(to: min(cursor, ns.length))
if prefix.hasSuffix("@") {
showMentionPicker()
} else if isMentionPickerVisible {
//
if prefix.last == " " || !prefix.contains("@") {
hideMentionPicker()
}
}
}
private func showMentionPicker() {
mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.nick_name) }
rootView.mentionTableView.reloadData()
rootView.mentionPickerView.isHidden = false
isMentionPickerVisible = true
bringInputOverlaysToFront()
rootView.layoutIfNeeded()
let bounds = rootView.mentionPickerView.bounds
rootView.mentionPickerView.layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
}
private func hideMentionPicker() {
rootView.mentionPickerView.isHidden = true
isMentionPickerVisible = false
}
private func insertMention(userId: String?, nickname: String, replaceTriggerAt: Bool = true) {
hideMentionPicker()
let insert: String
if let userId {
insert = viewModel.mentionUser(userId: userId, nickname: nickname)
} else {
viewModel.setPendingAtAll(true)
insert = "@所有人 "
}
var current = rootView.textField.text ?? ""
if replaceTriggerAt, let atIdx = current.lastIndex(of: "@") {
let after = current[current.index(after: atIdx)...]
if after.isEmpty || !after.contains(where: { $0.isWhitespace }) {
current = String(current[..<atIdx])
}
}
rootView.textField.text = current + insert
rootView.textField.becomeFirstResponder()
}
// MARK: - Actions
private func reactiveAction() {
rootView.backBtn.rx.tap
@ -267,16 +522,15 @@ final class GroupChatVC: BaseViewController {
})
.disposed(by: disposeBag)
let sendText = Observable.merge(
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" },
rootView.textField.rx.controlEvent(.editingDidEndOnExit)
.map { [weak self] _ in self?.rootView.textField.text ?? "" }
)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.do(onNext: { [weak self] _ in self?.rootView.textField.text = "" })
sendText
.bind(to: viewModel.input.sendMessage)
rootView.sendBtn.rx.tap
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
let text = self.rootView.textField.text ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
self.viewModel.input.sendMessage.onNext(text)
self.rootView.textField.text = ""
self.hideMentionPicker()
})
.disposed(by: disposeBag)
//
@ -429,6 +683,7 @@ final class GroupChatVC: BaseViewController {
// Send voice message via OpenIM
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
viewModel.cacheMessage(msg)
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: viewModel.groupId,
@ -456,15 +711,23 @@ final class GroupChatVC: BaseViewController {
// MARK: - dataSource
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in
RxTableViewSectionedReloadDataSource<ChatSectionModel> { [weak self] _, tableView, indexPath, item in
switch item {
case let .send(msg):
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell
case let .received(msg):
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell
case let .emojiSend(msg):
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
@ -496,7 +759,8 @@ final class GroupChatVC: BaseViewController {
self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
}
return cell
case let .notification(text, showTime, timestamp):
case let .notification(text, showTime, timestamp),
let .revoked(text, showTime, timestamp):
let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text, showTime: showTime, timestamp: timestamp)
return cell
@ -505,6 +769,33 @@ final class GroupChatVC: BaseViewController {
}()
}
// MARK: - UITextFieldDelegate / Mention table
extension GroupChatVC: UITextFieldDelegate, UITableViewDataSource {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
let text = textField.text ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true }
viewModel.input.sendMessage.onNext(text)
textField.text = ""
hideMentionPicker()
return true
}
func numberOfSections(in tableView: UITableView) -> Int { 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tableView === rootView.mentionTableView ? mentionRows.count : 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MentionCell", for: indexPath)
let row = mentionRows[indexPath.row]
cell.textLabel?.font = .systemFont(ofSize: 14)
cell.textLabel?.text = row.userId == nil ? "@所有人" : "@\(row.nickname)"
cell.selectionStyle = .default
return cell
}
}
// MARK: - UITableViewDelegate
extension GroupChatVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
@ -516,18 +807,32 @@ extension GroupChatVC: UITableViewDelegate {
(cell as? EmojiSendMsgCell)?.stopAnimation()
(cell as? EmojiReceivedMsgCell)?.stopAnimation()
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard tableView === rootView.mentionTableView else { return }
tableView.deselectRow(at: indexPath, animated: true)
let row = mentionRows[indexPath.row]
insertMention(userId: row.userId, nickname: row.nickname)
}
}
// MARK: - MessageListenerProxy
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener {
private let handler: (OIMMessageInfo) -> Void
private let onMessage: (OIMMessageInfo) -> Void
private let onRevoked: (OIMMessageRevokedInfo) -> Void
init(handler: @escaping (OIMMessageInfo) -> Void) {
self.handler = handler
init(onMessage: @escaping (OIMMessageInfo) -> Void,
onRevoked: @escaping (OIMMessageRevokedInfo) -> Void) {
self.onMessage = onMessage
self.onRevoked = onRevoked
}
func onRecvNewMessage(_ msg: OIMMessageInfo) {
handler(msg)
onMessage(msg)
}
func onRecvMessageRevoked(_ messageRevoked: OIMMessageRevokedInfo) {
onRevoked(messageRevoked)
}
}
@ -562,6 +867,7 @@ extension GroupChatVC: PhotoPickerControllerDelegate {
let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
// 使 SDK clientMsgID ID便
let localId = msg.clientMsgID ?? UUID().uuidString
viewModel.cacheMessage(msg)
// loading
let localMsg = ChatMessage(

View File

@ -32,6 +32,63 @@ struct ChatMessage {
let timestamp: TimeInterval
var showTime: Bool = false
var isUploading: Bool = false
var quotePreview: QuotePreview? = nil
var atUserIDs: [String] = []
var atNicknames: [String] = []
var isAtAll: Bool = false
func with(avatar: UIImage? = nil,
showTime: Bool? = nil,
isUploading: Bool? = nil,
imageUrl: String? = nil,
quotePreview: QuotePreview? = nil) -> ChatMessage {
ChatMessage(
id: id,
isSelf: isSelf,
senderId: senderId,
avatar: avatar ?? self.avatar,
senderName: senderName,
content: content,
voiceUrl: voiceUrl,
imageUrl: imageUrl ?? self.imageUrl,
imageWidth: imageWidth,
imageHeight: imageHeight,
timestamp: timestamp,
showTime: showTime ?? self.showTime,
isUploading: isUploading ?? self.isUploading,
quotePreview: quotePreview ?? self.quotePreview,
atUserIDs: atUserIDs,
atNicknames: atNicknames,
isAtAll: isAtAll
)
}
/// attributed @
func attributedContent(isOutgoing: Bool) -> NSAttributedString {
let baseColor: UIColor = isOutgoing ? .white : UIColor(hexStr: "#333333")
let atColor: UIColor = isOutgoing ? UIColor(hexStr: "#E8F8FF") : UIColor(hexStr: "#16B3FF")
let result = NSMutableAttributedString(
string: content,
attributes: [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: baseColor
]
)
var tokens = atNicknames.map { "@\($0)" }
if isAtAll { tokens.append("@所有人") }
for token in Set(tokens) where !token.isEmpty && token != "@" {
var searchRange = NSRange(location: 0, length: result.length)
while searchRange.location < result.length {
let found = (result.string as NSString).range(of: token, options: [], range: searchRange)
if found.location == NSNotFound { break }
result.addAttribute(.foregroundColor, value: atColor, range: found)
result.addAttribute(.font, value: UIFont.systemFont(ofSize: 14, weight: .semibold), range: found)
let next = found.location + found.length
searchRange = NSRange(location: next, length: result.length - next)
}
}
return result
}
}
class GroupChatView: UIView {
@ -45,6 +102,14 @@ class GroupChatView: UIView {
///
var onVoiceRecordState: ((VoiceRecordState) -> Void)?
private var emojiPlaybackGeneration = 0
private var quoteBarHeightConstraint: NSLayoutConstraint?
func setQuoteBarVisible(_ visible: Bool, summary: String? = nil) {
quoteBar.isHidden = !visible
quoteBarHeightConstraint?.constant = visible ? 36 : 0
if let summary { quoteBarLabel.text = summary }
UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() }
}
private func setupRx() {
@ -55,11 +120,13 @@ class GroupChatView: UIView {
let needsReset = !emojiPanelView.isHidden
|| !voiceRecordView.isHidden
|| textField.isFirstResponder
|| !mentionPickerView.isHidden
guard needsReset else { return }
stopVisibleEmojiAnimations()
emojiPanelView.isHidden = true
voiceRecordView.isHidden = true
mentionPickerView.isHidden = true
if !excludeTextField { textField.resignFirstResponder() }
UIView.animate(withDuration: 0.25) {
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
@ -123,6 +190,8 @@ class GroupChatView: UIView {
addSubview(tableView)
addSubview(chatWarningView)
addSubview(quoteBar)
addSubview(mentionPickerView)
addSubview(bottomBar)
addSubview(disableIMView)
bottomBar.addSubview(bottomBarCornerView)
@ -136,6 +205,11 @@ class GroupChatView: UIView {
bottomBar.addSubview(addBtn)
bottomBar.addSubview(sendBtn)
quoteBar.addSubview(quoteBarLabel)
quoteBar.addSubview(quoteBarCloseBtn)
mentionPickerView.addSubview(mentionPickerContentView)
mentionPickerContentView.addSubview(mentionTableView)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
@ -197,6 +271,28 @@ class GroupChatView: UIView {
.topToBottomOfView(navBarView)
.edgesHorzontal()
quoteBar.layoutChain
.edgesHorzontal(15)
.bottomToTopOfView(bottomBar, offset: -6)
quoteBarHeightConstraint = quoteBar.heightAnchor.constraint(equalToConstant: 0)
quoteBarHeightConstraint?.isActive = true
quoteBarLabel.layoutChain
.left(12).rightToLeftOfView(quoteBarCloseBtn, offset: -8)
.centerY()
quoteBarCloseBtn.layoutChain
.right(8).centerY()
.width(24).height(24)
mentionPickerView.layoutChain
.edgesHorzontal(15)
.height(180)
.bottomToTopOfView(quoteBar, offset: -6)
mentionPickerContentView.layoutChain.edges()
mentionTableView.layoutChain.edges()
bottomBar.layoutChain
.edgesHorzontal(15)
.height(50)
@ -395,6 +491,63 @@ class GroupChatView: UIView {
return tv
}()
// MARK: - Quote / Mention
lazy var quoteBar: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#EEF6FB")
view.cornerRadius = 10
view.isHidden = true
return view
}()
lazy var quoteBarLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12)
label.textColor = UIColor(hexStr: "#666666")
label.lineBreakMode = .byTruncatingTail
return label
}()
lazy var quoteBarCloseBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#999999"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14)
return btn
}()
lazy var mentionPickerView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
// clipsToBounds
view.layer.shadowColor = UIColor(hexStr: "#0F2846").cgColor
view.layer.shadowOpacity = 0.18
view.layer.shadowRadius = 12
view.layer.shadowOffset = CGSize(width: 0, height: 4)
view.layer.masksToBounds = false
return view
}()
lazy var mentionPickerContentView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 12
view.layer.borderWidth = 0.5
view.layer.borderColor = UIColor(hexStr: "#E5E5E5").cgColor
view.clipsToBounds = true
return view
}()
lazy var mentionTableView: UITableView = {
let tv = UITableView(frame: .zero, style: .plain)
tv.separatorStyle = .singleLine
tv.rowHeight = 44
tv.backgroundColor = .white
tv.register(UITableViewCell.self, forCellReuseIdentifier: "MentionCell")
return tv
}()
// MARK: - Bottom Bar
lazy var bottomBar: UIView = {
let view = UIView()
@ -537,33 +690,234 @@ class GroupChatView: UIView {
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
if !mentionPickerView.isHidden {
mentionPickerView.layer.shadowPath = UIBezierPath(
roundedRect: mentionPickerView.bounds,
cornerRadius: 12
).cgPath
}
}
}
// MARK: - Quote block
final class ChatQuoteBlockView: UIControl {
private let lineView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55)
return v
}()
private let label: UILabel = {
let lab = UILabel()
lab.font = .systemFont(ofSize: 11)
lab.textColor = UIColor(hexStr: "#666666")
lab.numberOfLines = 2
return lab
}()
override init(frame: CGRect) {
super.init(frame: frame)
cornerRadius = 4
backgroundColor = UIColor(hexStr: "#EEEEF0")
isHidden = true
// addSubview(lineView)
addSubview(label)
// lineView.layoutChain.left().top(2).bottom(2).width(2)
label.layoutChain.edgesHorzontal(10).edgesVertical()
// label.layoutChain.leftToRightOfView(lineView, offset: 6).right().top().bottom()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(_ preview: QuotePreview?) {
guard let preview else {
isHidden = true
return
}
isHidden = false
let name = preview.senderName.isEmpty ? "" : "\(preview.senderName): "
label.text = name + preview.summary
label.textColor = UIColor(hexStr: "#666666")
lineView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.55)
}
}
// MARK: -
final class ChatBubbleActionMenu: UIView {
var onQuote: (() -> Void)?
var onRevoke: (() -> Void)?
var onDismiss: (() -> Void)?
private let panel = UIView()
private let arrow = UIView()
private let stack = UIStackView()
private let dimView = UIControl()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
dimView.backgroundColor = .clear
dimView.addTarget(self, action: #selector(handleDismiss), for: .touchUpInside)
addSubview(dimView)
dimView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimView.topAnchor.constraint(equalTo: topAnchor),
dimView.leadingAnchor.constraint(equalTo: leadingAnchor),
dimView.trailingAnchor.constraint(equalTo: trailingAnchor),
dimView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
panel.backgroundColor = UIColor(hexStr: "#333333")
panel.layer.cornerRadius = 10
panel.clipsToBounds = true
addSubview(panel)
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fill
stack.spacing = 0
panel.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: panel.topAnchor),
stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
panel.heightAnchor.constraint(equalToConstant: 44)
])
arrow.backgroundColor = UIColor(hexStr: "#333333")
addSubview(arrow)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show(in host: UIView, anchor: UIView, showRevoke: Bool) {
removeFromSuperview()
host.addSubview(self)
frame = host.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
stack.arrangedSubviews.forEach {
stack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let quoteBtn = makeButton(title: "引用", action: #selector(handleQuote))
quoteBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true
stack.addArrangedSubview(quoteBtn)
if showRevoke {
let sep = UIView()
sep.backgroundColor = UIColor.white.withAlphaComponent(0.2)
sep.translatesAutoresizingMaskIntoConstraints = false
sep.widthAnchor.constraint(equalToConstant: 1).isActive = true
stack.addArrangedSubview(sep)
let revokeBtn = makeButton(title: "撤回", action: #selector(handleRevoke))
revokeBtn.widthAnchor.constraint(equalToConstant: 72).isActive = true
stack.addArrangedSubview(revokeBtn)
}
let panelW: CGFloat = showRevoke ? 145 : 72
let panelH: CGFloat = 44
let arrowSize: CGFloat = 8
let gap: CGFloat = 6
layoutIfNeeded()
let anchorRect = anchor.convert(anchor.bounds, to: host)
var panelX = anchorRect.midX - panelW / 2
panelX = max(12, min(panelX, host.bounds.width - panelW - 12))
var panelY = anchorRect.minY - gap - panelH - arrowSize
var arrowAbove = true
if panelY < host.safeAreaInsets.top + 8 {
panelY = anchorRect.maxY + gap + arrowSize
arrowAbove = false
}
panel.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
let arrowX = min(max(anchorRect.midX - arrowSize / 2, panelX + 10), panelX + panelW - arrowSize - 10)
if arrowAbove {
arrow.frame = CGRect(x: arrowX, y: panel.frame.maxY - 1, width: arrowSize, height: arrowSize)
} else {
arrow.frame = CGRect(x: arrowX, y: panel.frame.minY - arrowSize + 1, width: arrowSize, height: arrowSize)
}
arrow.transform = CGAffineTransform(rotationAngle: .pi / 4)
alpha = 0
UIView.animate(withDuration: 0.15) { self.alpha = 1 }
}
private func makeButton(title: String, action: Selector) -> UIButton {
let btn = UIButton(type: .system)
btn.setTitle(title, for: .normal)
btn.setTitleColor(.white, for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
btn.addTarget(self, action: action, for: .touchUpInside)
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}
@objc private func handleQuote() {
onQuote?()
dismiss()
}
@objc private func handleRevoke() {
onRevoke?()
dismiss()
}
@objc private func handleDismiss() { dismiss() }
func dismiss() {
UIView.animate(withDuration: 0.12, animations: {
self.alpha = 0
}, completion: { _ in
self.onDismiss?()
self.removeFromSuperview()
})
}
}
private func chatFormatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) { f.dateFormat = "HH:mm" }
else if calendar.isDateInYesterday(date) { f.dateFormat = "'昨天' HH:mm" }
else if calendar.isDate(date, equalTo: now, toGranularity: .year) { f.dateFormat = "M-d HH:mm" }
else { f.dateFormat = "yyyy-M-d HH:mm" }
return f.string(from: date)
}
// MARK: - cell
class TextSendMsgCell: UITableViewCell {
var onQuoteTap: (() -> Void)?
private(set) var messageId: String = ""
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
messageId = msg.id
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
contentLabel.text = msg.content
contentLabel.attributedText = msg.attributedContent(isOutgoing: true)
let hasQuote = msg.quotePreview != nil
quoteBlock.configure(msg.quotePreview)
quoteHeightConstraint?.constant = hasQuote ? 28 : 0
quoteTopConstraint?.constant = hasQuote ? 4 : 0
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) {
f.dateFormat = "HH:mm"
} else if calendar.isDateInYesterday(date) {
f.dateFormat = "'昨天' HH:mm"
} else if calendar.isDate(date, equalTo: now, toGranularity: .year) {
f.dateFormat = "M-d HH:mm"
} else {
f.dateFormat = "yyyy-M-d HH:mm"
func flashHighlight() {
let old = contentView.backgroundColor
contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
self.contentView.backgroundColor = old
}
return f.string(from: date)
}
private let timeLabel: UILabel = {
@ -574,7 +928,7 @@ class TextSendMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -598,41 +952,59 @@ class TextSendMsgCell: UITableViewCell {
return label
}()
private let quoteBlock = ChatQuoteBlockView()
private var quoteHeightConstraint: NSLayoutConstraint?
private var quoteTopConstraint: NSLayoutConstraint?
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(bubbleView)
bubbleView.addSubview(contentLabel)
contentView.addSubview(timeLabel)
contentView.addSubview(quoteBlock)
contentView.addSubview(avatarView)
timeLabel.layoutChain
.top()
.centerX()
timeLabel.layoutChain.top().centerX()
avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.right(12)
.width(30).height(30)
.right(12).width(30).height(30)
bubbleView.layoutChain
.topToBottomOfView(avatarView, offset: -15)
.rightToView(avatarView, offset: -13)
.left(60, relation: .greaterThanOrEqual)
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
.bottom(10)
contentLabel.layoutChain
.edgesVertical(10)
.edgesHorzontal(20)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10),
contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20),
contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20),
contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10)
])
let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4)
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
quoteTopConstraint = qTop
quoteHeightConstraint = qH
NSLayoutConstraint.activate([
qTop,
quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor),
quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60),
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
qH
])
quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func handleQuoteTap() { onQuoteTap?() }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
@ -646,29 +1018,33 @@ class TextSendMsgCell: UITableViewCell {
// MARK: - cell
class TextReceivedMsgCell: UITableViewCell {
var onQuoteTap: (() -> Void)?
private(set) var messageId: String = ""
private(set) var senderId: String = ""
private(set) var senderName: String = ""
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
messageId = msg.id
senderId = msg.senderId
senderName = msg.senderName
timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil
timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar
nameLabel.text = msg.senderName
contentLabel.text = msg.content
contentLabel.attributedText = msg.attributedContent(isOutgoing: false)
let hasQuote = msg.quotePreview != nil
quoteBlock.configure(msg.quotePreview)
quoteHeightConstraint?.constant = hasQuote ? 28 : 0
quoteTopConstraint?.constant = hasQuote ? 4 : 0
}
private func formatTime(_ t: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: t)
let now = Date()
let calendar = Calendar.current
let f = DateFormatter()
if calendar.isDateInToday(date) {
f.dateFormat = "HH:mm"
} else if calendar.isDateInYesterday(date) {
f.dateFormat = "'昨天' HH:mm"
} else if calendar.isDate(date, equalTo: now, toGranularity: .year) {
f.dateFormat = "M-d HH:mm"
} else {
f.dateFormat = "yyyy-M-d HH:mm"
func flashHighlight() {
let old = contentView.backgroundColor
contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
self.contentView.backgroundColor = old
}
return f.string(from: date)
}
private let timeLabel: UILabel = {
@ -686,7 +1062,7 @@ class TextReceivedMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -694,6 +1070,7 @@ class TextReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
@ -710,6 +1087,10 @@ class TextReceivedMsgCell: UITableViewCell {
return label
}()
private let quoteBlock = ChatQuoteBlockView()
private var quoteHeightConstraint: NSLayoutConstraint?
private var quoteTopConstraint: NSLayoutConstraint?
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
@ -718,13 +1099,11 @@ class TextReceivedMsgCell: UITableViewCell {
contentView.addSubview(timeLabel)
contentView.addSubview(bubbleView)
bubbleView.addSubview(contentLabel)
contentView.addSubview(quoteBlock)
contentView.addSubview(avatarView)
contentView.addSubview(nameLabel)
timeLabel.layoutChain
.top()
.centerX()
timeLabel.layoutChain.top().centerX()
bubbleView.layoutChain
.topToBottomOfView(timeLabel, offset: 14)
.left(30)
@ -732,24 +1111,43 @@ class TextReceivedMsgCell: UITableViewCell {
.width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual)
avatarView.layoutChain
.topToBottomOfView(bubbleView, offset: -15)
.left(12)
.width(30).height(30)
.bottom(10)
contentLabel.translatesAutoresizingMaskIntoConstraints = false
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
avatarView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentLabel.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 10),
contentLabel.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 20),
contentLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -20),
contentLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -10),
// +
avatarView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -15),
avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
avatarView.widthAnchor.constraint(equalToConstant: 30),
avatarView.heightAnchor.constraint(equalToConstant: 30),
nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5),
nameLabel.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor)
])
nameLabel.layoutChain
.leftToRightOfView(avatarView, offset: 5)
.bottomToView(avatarView)
contentLabel.layoutChain
.edgesVertical(10)
.edgesHorzontal(20)
// senderName
let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4)
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
quoteTopConstraint = qTop
quoteHeightConstraint = qH
NSLayoutConstraint.activate([
qTop,
quoteBlock.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60),
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
qH
])
quoteBlock.addTarget(self, action: #selector(handleQuoteTap), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func handleQuoteTap() { onQuoteTap?() }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
@ -823,6 +1221,7 @@ final class NotificationMsgCell: UITableViewCell {
// MARK: -
final class EmojiSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { lottieView }
private let timeLabel: UILabel = {
let label = UILabel()
@ -938,6 +1337,7 @@ final class EmojiSendMsgCell: UITableViewCell {
// MARK: -
final class EmojiReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { lottieView }
private let timeLabel: UILabel = {
let label = UILabel()
@ -947,7 +1347,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -955,6 +1355,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
@ -1101,6 +1502,7 @@ extension GroupChatView: UICollectionViewDelegate {
// MARK: -
final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
private let timeLabel: UILabel = {
let label = UILabel()
@ -1238,6 +1640,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: -
final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime
@ -1289,7 +1692,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -1297,6 +1700,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()
@ -1394,7 +1798,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: -
final class ImageSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
@ -1504,7 +1908,7 @@ final class ImageSendMsgCell: UITableViewCell {
// MARK: -
final class ImageReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) {
@ -1549,7 +1953,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
return label
}()
private let avatarView: UIImageView = {
let avatarView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15
@ -1557,6 +1961,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2
iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv
}()

View File

@ -11,6 +11,17 @@ import RxCocoa
import OpenIMSDK
import Differentiator
enum QuoteKind {
case text, image, voice, emoji, unknown
}
struct QuotePreview {
let senderName: String
let kind: QuoteKind
let summary: String
let quotedClientMsgID: String?
}
enum ChatSectionItem {
case send(ChatMessage)
case received(ChatMessage)
@ -21,6 +32,7 @@ enum ChatSectionItem {
case imageSend(ChatMessage)
case imageReceived(ChatMessage)
case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
}
typealias ChatSectionModel = SectionModel<String, ChatSectionItem>
@ -33,6 +45,7 @@ final class GroupChatViewModel {
struct Output {
let messages: Observable<[ChatSectionItem]>
let pendingQuoteSummary: Observable<String?>
}
let input: Input
@ -40,22 +53,42 @@ final class GroupChatViewModel {
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
private let sendMessageSubject = PublishSubject<String>()
private let pendingQuoteSummarySubject = BehaviorRelay<String?>(value: nil)
private var lastTimeGap: TimeInterval = 0
///
private let timeGapThreshold: TimeInterval = 300 // 5 minutes
private let timeGapThreshold: TimeInterval = 300
var groupModel: GroupInfoModel?
var memberList: [GroupMemberModel] = [] {
didSet {
buildAvatarCache()
}
didSet { buildAvatarCache() }
}
var groupId: String = ""
///
private(set) var pendingQuote: OIMMessageInfo?
/// @
private(set) var pendingMentions: [(userId: String, nickname: String)] = []
private(set) var pendingAtAll: Bool = false
/// clientMsgID /
private(set) var messageCache: [String: OIMMessageInfo] = [:]
/// /
var suppressAutoScroll = false
///
private(set) var hasMoreHistory = true
private var isLoadingOlder = false
private let disposeBag = DisposeBag()
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: [])
private let historyPageSize = 30
// MARK: - Init
init() {
input = Input(sendMessage: sendMessageSubject.asObserver())
output = Output(messages: messagesSubject.asObservable())
output = Output(
messages: messagesSubject.asObservable(),
pendingQuoteSummary: pendingQuoteSummarySubject.asObservable()
)
sendMessageSubject
.subscribe(onNext: { [weak self] text in
@ -64,54 +97,36 @@ final class GroupChatViewModel {
.disposed(by: disposeBag)
}
private let disposeBag = DisposeBag()
var currentMessages: [ChatSectionItem] { messagesSubject.value }
var conversationID: String { "sg_\(groupId)" }
// MARK: - Avatar
private var avatarCache: [String: UIImage] = [:]
/// memberList userID UIImage 30
func buildAvatarCache() {
var cache: [String: UIImage] = [:]
for member in memberList {
cache[member.user_id] = member.userIcon
}
avatarCache = cache
// loadMessages memberList
refreshMessageAvatars()
}
/// avatarCache loadMessages memberList
private func refreshMessageAvatars() {
var items = messagesSubject.value
var didChange = false
items = items.map { item in
switch item {
case var .send(m): if updateAvatar(&m) { didChange = true }; return .send(m)
case var .received(m): if updateAvatar(&m) { didChange = true }; return .received(m)
case var .emojiSend(m): if updateAvatar(&m) { didChange = true }; return .emojiSend(m)
case var .emojiReceived(m): if updateAvatar(&m) { didChange = true }; return .emojiReceived(m)
case var .voiceSend(m): if updateAvatar(&m) { didChange = true }; return .voiceSend(m)
case var .voiceReceived(m): if updateAvatar(&m) { didChange = true }; return .voiceReceived(m)
case var .imageSend(m): if updateAvatar(&m) { didChange = true }; return .imageSend(m)
case var .imageReceived(m): if updateAvatar(&m) { didChange = true }; return .imageReceived(m)
default: return item
}
}
if didChange {
messagesSubject.accept(items)
guard var m = item.chatMessage, updateAvatar(&m) else { return item }
didChange = true
return ChatSectionItem.with(m)
}
if didChange { messagesSubject.accept(items) }
}
///
private func updateAvatar(_ msg: inout ChatMessage) -> Bool {
guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false }
msg = ChatMessage(
id: msg.id, isSelf: msg.isSelf, senderId: msg.senderId,
avatar: cached, senderName: msg.senderName,
content: msg.content, voiceUrl: msg.voiceUrl, imageUrl: msg.imageUrl,
imageWidth: msg.imageWidth, imageHeight: msg.imageHeight,
timestamp: msg.timestamp, showTime: msg.showTime, isUploading: msg.isUploading
)
msg = msg.with(avatar: cached)
return true
}
@ -129,29 +144,359 @@ final class GroupChatViewModel {
memberList.first { id == $0.user_id }?.nick_name ?? ""
}
// MARK: - Quote / Mention state
func setPendingQuote(_ msg: OIMMessageInfo?) {
pendingQuote = msg
if let msg {
let preview = makeQuotePreview(from: msg)
let name = preview.senderName.isEmpty ? "消息" : preview.senderName
pendingQuoteSummarySubject.accept("\(name): \(preview.summary)")
} else {
pendingQuoteSummarySubject.accept(nil)
}
}
func clearPendingQuote() {
setPendingQuote(nil)
}
func mentionUser(userId: String, nickname: String) -> String {
let name = nickname.isEmpty ? getUserNickName(id: userId) : nickname
let display = name.isEmpty ? userId : name
if !pendingMentions.contains(where: { $0.userId == userId }) {
pendingMentions.append((userId, display))
}
return "@\(display) "
}
func setPendingAtAll(_ value: Bool) {
pendingAtAll = value
}
func clearPendingMentions() {
pendingMentions = []
pendingAtAll = false
}
func mentionCandidates(excludingSelf: Bool = true) -> [GroupMemberModel] {
let selfId = AppContextManager.shared.userId
return memberList.filter { !excludingSelf || $0.user_id != selfId }
}
func oimMessage(forClientMsgID id: String) -> OIMMessageInfo? {
messageCache[id]
}
// MARK: - Send
private func onSendMessage(_ text: String) {
let msg = OIMMessageInfo.createTextMessage(text)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let quote = pendingQuote
let atAll = pendingAtAll
let mentions = pendingMentions
let msg: OIMMessageInfo
if atAll {
msg = OIMMessageInfo.createText(atAllMessage: trimmed,
displayText: "@所有人",
message: quote)
} else if !mentions.isEmpty {
let ids = mentions.map(\.userId)
let infos: [OIMAtInfo] = mentions.map { m in
let info = OIMAtInfo()
info.atUserID = m.userId
info.groupNickname = m.nickname
return info
}
msg = OIMMessageInfo.createText(atMessage: trimmed,
atUsersID: ids,
atUsersInfo: infos,
message: quote)
} else if let quote {
msg = OIMMessageInfo.createQuoteMessage(trimmed, message: quote)
} else {
msg = OIMMessageInfo.createTextMessage(trimmed)
}
clearPendingQuote()
clearPendingMentions()
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: groupId,
offlinePushInfo: nil,
onSuccess: { [weak self] _ in
self?.cacheMessage(msg)
self?.appendMessage(msg)
},
onProgress: nil as OIMNumberCallback?,
onFailure: { code, errMsg in
print("send failed: \(code) \(errMsg ?? "")")
DispatchQueue.main.async {
DLToast.showError(text: errMsg ?? "发送失败")
}
})
}
// MARK: - Revoke
func revokeMessage(clientMsgID: String) {
OIMManager.manager.revokeMessage(conversationID,
clientMsgID: clientMsgID,
onSuccess: { [weak self] _ in
DispatchQueue.main.async {
self?.applyRevoke(clientMsgID: clientMsgID,
revokerID: AppContextManager.shared.userId,
revokerNickname: AppContextManager.shared.name)
}
}, onFailure: { code, errMsg in
DispatchQueue.main.async {
DLToast.showError(text: errMsg ?? "撤回失败(\(code))")
}
})
}
func onMessageRevoked(_ info: OIMMessageRevokedInfo) {
applyRevoke(clientMsgID: info.clientMsgID,
revokerID: info.revokerID,
revokerNickname: info.revokerNickname)
}
private func applyRevoke(clientMsgID: String, revokerID: String, revokerNickname: String) {
if pendingQuote?.clientMsgID == clientMsgID {
clearPendingQuote()
}
messageCache.removeValue(forKey: clientMsgID)
var items = messagesSubject.value
guard let idx = items.firstIndex(where: { $0.chatMessage?.id == clientMsgID || $0.revokedClientMsgID == clientMsgID }) else {
//
refreshQuotePreviewsAfterRevoke(clientMsgID)
return
}
let ts = timestampFrom(item: items[idx])
let showTime: Bool = {
switch items[idx] {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m.showTime
case let .notification(_, s, _), let .revoked(_, s, _):
return s
}
}()
let tip = revokeTip(revokerID: revokerID, revokerNickname: revokerNickname)
items[idx] = .revoked(tip, showTime: showTime, timestamp: ts)
messagesSubject.accept(items)
refreshQuotePreviewsAfterRevoke(clientMsgID)
}
private func revokeTip(revokerID: String, revokerNickname: String) -> NSAttributedString {
let isSelf = revokerID == AppContextManager.shared.userId
let text = isSelf ? "你撤回了一条消息" : "\(revokerNickname.isEmpty ? "对方" : revokerNickname) 撤回了一条消息"
return NSAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor(hexStr: "#999999")
])
}
private func refreshQuotePreviewsAfterRevoke(_ revokedId: String) {
var items = messagesSubject.value
var changed = false
for i in items.indices {
guard var m = items[i].chatMessage,
m.quotePreview?.quotedClientMsgID == revokedId else { continue }
m.quotePreview = QuotePreview(
senderName: m.quotePreview?.senderName ?? "",
kind: .unknown,
summary: "引用内容已撤回",
quotedClientMsgID: revokedId
)
items[i] = ChatSectionItem.with(m)
changed = true
}
if changed { messagesSubject.accept(items) }
}
// MARK: - Locate quoted message
func indexPath(forClientMsgID id: String) -> IndexPath? {
guard let idx = messagesSubject.value.firstIndex(where: { $0.chatMessage?.id == id }) else { return nil }
return IndexPath(row: idx, section: 0)
}
///
func ensureMessageVisible(clientMsgID: String, maxPages: Int = 5, completion: @escaping (Bool) -> Void) {
if indexPath(forClientMsgID: clientMsgID) != nil {
completion(true)
return
}
//
let param = OIMFindMessageListParam()
param.conversationID = conversationID
param.clientMsgIDList = [clientMsgID]
OIMManager.manager.findMessageList([param], onSuccess: { [weak self] result in
guard let self = self else { return }
let found = (result?.findResultItems ?? []).contains { item in
(item.messageList).contains { $0.clientMsgID == clientMsgID }
}
//
if !found {
if let cached = self.messageCache[clientMsgID],
cached.status.rawValue == OIMMessageStatus.revoke.rawValue || cached.contentType.rawValue == 2101 {
DispatchQueue.main.async { completion(false) }
return
}
// find SDK
}
self.loadOlderPages(targeting: clientMsgID, remaining: maxPages, completion: completion)
}, onFailure: { [weak self] _, _ in
self?.loadOlderPages(targeting: clientMsgID, remaining: maxPages, completion: completion)
})
}
///
struct LoadOlderResult {
let prependedCount: Int
let hasMore: Bool
}
/// UI
func loadOlderHistory(completion: ((LoadOlderResult) -> Void)? = nil) {
guard !isLoadingOlder else {
completion?(LoadOlderResult(prependedCount: 0, hasMore: hasMoreHistory))
return
}
guard hasMoreHistory else {
completion?(LoadOlderResult(prependedCount: 0, hasMore: false))
return
}
guard let oldest = oldestClientMsgID() else {
hasMoreHistory = false
completion?(LoadOlderResult(prependedCount: 0, hasMore: false))
return
}
isLoadingOlder = true
fetchOlderHistory(startClientMsgID: oldest) { [weak self] list, isEnd, success in
guard let self = self else { return }
self.isLoadingOlder = false
guard success else {
DispatchQueue.main.async {
completion?(LoadOlderResult(prependedCount: 0, hasMore: self.hasMoreHistory))
}
return
}
if list.isEmpty {
self.hasMoreHistory = false
DispatchQueue.main.async {
completion?(LoadOlderResult(prependedCount: 0, hasMore: false))
}
return
}
let count = self.prependHistory(list)
if isEnd || count == 0 {
self.hasMoreHistory = false
}
DispatchQueue.main.async {
completion?(LoadOlderResult(prependedCount: count, hasMore: self.hasMoreHistory))
}
}
}
private func loadOlderPages(targeting clientMsgID: String, remaining: Int, completion: @escaping (Bool) -> Void) {
if indexPath(forClientMsgID: clientMsgID) != nil {
DispatchQueue.main.async { completion(true) }
return
}
guard remaining > 0, hasMoreHistory else {
DispatchQueue.main.async { completion(false) }
return
}
loadOlderHistory { [weak self] result in
guard let self = self else { return }
if self.indexPath(forClientMsgID: clientMsgID) != nil {
completion(true)
} else if result.prependedCount > 0, result.hasMore {
self.loadOlderPages(targeting: clientMsgID, remaining: remaining - 1, completion: completion)
} else {
completion(false)
}
}
}
private func oldestClientMsgID() -> String? {
for item in messagesSubject.value {
if let id = item.chatMessage?.id { return id }
}
return nil
}
private func fetchOlderHistory(startClientMsgID: String,
completion: @escaping ([OIMMessageInfo], Bool, Bool) -> Void) {
let param = OIMGetAdvancedHistoryMessageListParam()
param.conversationID = conversationID
param.count = historyPageSize
param.startClientMsgID = startClientMsgID
OIMManager.manager.getAdvancedHistoryMessageList(
param,
onSuccess: { [weak self] result in
let pageSize = self?.historyPageSize ?? 30
let list = result?.messageList ?? []
let isEnd = result?.isEnd ?? (list.isEmpty || list.count < pageSize)
completion(list, isEnd, true)
},
onFailure: { code, msg in
print("loadOlderHistory failed: \(code) \(msg ?? "")")
completion([], false, false)
})
}
///
@discardableResult
private func prependHistory(_ list: [OIMMessageInfo]) -> Int {
var newItems: [ChatSectionItem] = []
for msg in list {
cacheMessage(msg)
if let item = toSectionItem(msg) {
newItems.append(item)
}
}
newItems.sort { timestampFrom(item: $0) < timestampFrom(item: $1) }
let items = messagesSubject.value
let existingIDs = Set(items.compactMap { $0.chatMessage?.id })
newItems = newItems.filter { item in
guard let id = item.chatMessage?.id else { return true }
return !existingIDs.contains(id)
}
guard !newItems.isEmpty else { return 0 }
var result = newItems + items
for i in result.indices {
let ts = timestampFrom(item: result[i])
let show = i == 0 || ts - timestampFrom(item: result[i - 1]) >= timeGapThreshold
result[i] = setShowTime(result[i], show)
}
let count = newItems.count
// 便 contentSize offset
suppressAutoScroll = true
if Thread.isMainThread {
messagesSubject.accept(result)
} else {
DispatchQueue.main.sync {
self.messagesSubject.accept(result)
}
}
return count
}
// MARK: - Load
func loadMessages() {
guard !groupId.isEmpty else { return }
let param = OIMGetAdvancedHistoryMessageListParam()
param.conversationID = "sg_\(groupId)"
param.count = 30
param.conversationID = conversationID
param.count = historyPageSize
OIMManager.manager.getAdvancedHistoryMessageList(
param,
@ -160,17 +505,14 @@ final class GroupChatViewModel {
let list = result?.messageList else { return }
var items: [ChatSectionItem] = []
for msg in list {
self.cacheMessage(msg)
if let item = self.toSectionItem(msg) {
items.append(item)
}
}
// messages come newest-first from API, sort by time ascending
items.sort {
let t1 = self.timestampFrom(item: $0)
let t2 = self.timestampFrom(item: $1)
return t1 < t2
self.timestampFrom(item: $0) < self.timestampFrom(item: $1)
}
// set showTime flag
for i in items.indices {
let ts = self.timestampFrom(item: items[i])
if i == 0 || ts - self.timestampFrom(item: items[i - 1]) >= self.timeGapThreshold {
@ -180,9 +522,10 @@ final class GroupChatViewModel {
if let last = items.last {
self.lastTimeGap = self.timestampFrom(item: last)
}
let isEnd = result?.isEnd ?? (list.count < self.historyPageSize)
DispatchQueue.main.async {
self.hasMoreHistory = !isEnd && !list.isEmpty
self.messagesSubject.accept(items)
//
self.markAsRead()
}
},
@ -191,35 +534,22 @@ final class GroupChatViewModel {
})
}
///
private func markAsRead() {
let conversationID = "sg_\(groupId)"
OIMManager.manager.markConversationMessage(asRead: conversationID,
onSuccess: nil,
onFailure: nil)
}
///
func appendLocalMessage(_ item: ChatSectionItem) {
var items = messagesSubject.value
items.append(item)
messagesSubject.accept(items)
}
/// id /
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
var items = messagesSubject.value
guard let idx = items.firstIndex(where: { item in
switch item {
case let .imageSend(m): return m.id == id
case let .voiceSend(m): return m.id == id
case let .send(m): return m.id == id
case let .emojiSend(m): return m.id == id
default: return false
}
}),
var chatMsg = items[idx].chatMessage
else { return }
guard let idx = items.firstIndex(where: { $0.chatMessage?.id == id }),
var chatMsg = items[idx].chatMessage else { return }
update(&chatMsg)
items[idx] = ChatSectionItem.with(chatMsg)
messagesSubject.accept(items)
@ -227,11 +557,11 @@ final class GroupChatViewModel {
// MARK: - Receive
func onReceiveMessage(_ msg: OIMMessageInfo) {
cacheMessage(msg)
guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item)
var items = messagesSubject.value
// clientMsgID
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
return
@ -245,11 +575,15 @@ final class GroupChatViewModel {
}
}
// MARK: - Append sent message
private func appendMessage(_ msg: OIMMessageInfo) {
cacheMessage(msg)
guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item)
var items = messagesSubject.value
//
if let id = msg.clientMsgID, items.contains(where: { $0.chatMessage?.id == id }) {
return
}
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
lastTimeGap = ts
items.append(showTime ? setShowTime(item, true) : item)
@ -258,12 +592,20 @@ final class GroupChatViewModel {
}
}
func cacheMessage(_ msg: OIMMessageInfo) {
if let id = msg.clientMsgID, !id.isEmpty {
messageCache[id] = msg
}
}
// MARK: - Helpers
private func timestampFrom(item: ChatSectionItem) -> TimeInterval {
switch item {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m.timestamp
case let .notification(_, _, ts): return ts
case let .notification(_, _, ts), let .revoked(_, _, ts):
return ts
}
}
@ -278,19 +620,23 @@ final class GroupChatViewModel {
case var .imageSend(m): m.showTime = show; return .imageSend(m)
case var .imageReceived(m): m.showTime = show; return .imageReceived(m)
case let .notification(text, _, ts): return .notification(text, showTime: show, timestamp: ts)
case let .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts)
}
}
private func convert(_ msg: OIMMessageInfo) -> ChatMessage {
let isSelf = msg.isSelf()
let ts = TimeInterval(msg.sendTime) / 1000.0
let content: String
let voiceUrl: String
let imageUrl: String
let imageW: CGFloat
let imageH: CGFloat
var content = ""
var voiceUrl = ""
var imageUrl = ""
var imageW: CGFloat = 0
var imageH: CGFloat = 0
var quotePreview: QuotePreview?
var atUserIDs: [String] = []
var atNicknames: [String] = []
var isAtAll = false
// :
let maxWH: CGFloat = 200
var msgImageW: CGFloat = 0
var msgImageH: CGFloat = 0
@ -305,22 +651,36 @@ final class GroupChatViewModel {
}
}
if let sound = msg.soundElem {
let type = msg.contentType.rawValue
if type == 103, let sound = msg.soundElem {
content = "\(sound.duration)"
voiceUrl = sound.sourceUrl ?? ""
} else if type == 102, let pic = msg.pictureElem {
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
imageW = msgImageW
imageH = msgImageH
} else if type == 106, let at = msg.atTextElem {
content = at.text ?? ""
atUserIDs = at.atUserList ?? []
atNicknames = (at.atUsersInfo ?? []).compactMap { $0.groupNickname }
isAtAll = at.isAtAll
if let q = at.quoteMessage {
quotePreview = makeQuotePreview(from: q)
}
} else if type == 114, let q = msg.quoteElem {
content = q.text ?? ""
if let quoted = q.quoteMessage {
quotePreview = makeQuotePreview(from: quoted)
}
} else if let sound = msg.soundElem {
content = "\(sound.duration)"
voiceUrl = sound.sourceUrl ?? ""
imageUrl = ""
imageW = 0; imageH = 0
} else if let pic = msg.pictureElem {
content = ""
voiceUrl = ""
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
imageW = msgImageW
imageH = msgImageH
} else {
content = msg.textElem?.content ?? ""
voiceUrl = ""
imageUrl = ""
imageW = 0; imageH = 0
}
let sendID = msg.sendID ?? ""
@ -336,37 +696,81 @@ final class GroupChatViewModel {
imageWidth: imageW,
imageHeight: imageH,
timestamp: ts,
showTime: false
showTime: false,
quotePreview: quotePreview,
atUserIDs: atUserIDs,
atNicknames: atNicknames,
isAtAll: isAtAll
)
}
// emoji pattern: js_emoji:
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: [])
private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview {
let id = msg.clientMsgID
let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "")
if msg.status.rawValue == OIMMessageStatus.revoke.rawValue || msg.contentType.rawValue == 2101 {
return QuotePreview(senderName: name, kind: .unknown, summary: "引用内容已撤回", quotedClientMsgID: id)
}
let type = msg.contentType.rawValue
if type == 102 {
return QuotePreview(senderName: name, kind: .image, summary: "[图片]", quotedClientMsgID: id)
}
if type == 103 {
let dur = (msg.soundElem?.duration ?? 0) / 1000
let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]"
return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id)
}
var text = ""
if type == 106 {
text = msg.atTextElem?.text ?? ""
} else if type == 114 {
text = msg.quoteElem?.text ?? ""
} else {
text = msg.textElem?.content ?? ""
}
if let pattern = emojiPattern,
pattern.firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) != nil {
return QuotePreview(senderName: name, kind: .emoji, summary: "[表情]", quotedClientMsgID: id)
}
if text.isEmpty {
return QuotePreview(senderName: name, kind: .unknown, summary: "[消息]", quotedClientMsgID: id)
}
let truncated = text.count > 40 ? String(text.prefix(40)) + "" : text
return QuotePreview(senderName: name, kind: .text, summary: truncated, quotedClientMsgID: id)
}
private func toSectionItem(_ msg: OIMMessageInfo) -> ChatSectionItem? {
//
if msg.contentType.rawValue == 2101 || msg.status.rawValue == OIMMessageStatus.revoke.rawValue {
let ts = TimeInterval(msg.sendTime) / 1000.0
let tip = revokeTip(revokerID: msg.sendID ?? "",
revokerNickname: msg.senderNickname ?? "")
return .revoked(tip, showTime: false, timestamp: ts)
}
if (msg.contentType.rawValue == 1501 || msg.contentType.rawValue == 1510 || msg.contentType.rawValue == 1520),
let noti = msg.notificationElem,
let text = parseNotification(noti, contentType: msg.contentType.rawValue) {
let ts = TimeInterval(msg.sendTime) / 1000.0
return .notification(text, showTime: false, timestamp: ts)
}
//
// 104 video 沿
if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 {
let chatMsg = convert(msg)
return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg)
}
//
if msg.contentType.rawValue == 102 {
let chatMsg = convert(msg)
return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg)
}
//
// / @ /
let chatMsg = convert(msg)
if chatMsg.content.isEmpty { return nil }
// emoji
if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil }
if let pattern = emojiPattern,
pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != nil {
pattern.firstMatch(in: chatMsg.content, options: [], range: NSRange(location: 0, length: chatMsg.content.utf16.count)) != nil,
chatMsg.quotePreview == nil {
return chatMsg.isSelf ? .emojiSend(chatMsg) : .emojiReceived(chatMsg)
}
return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg)
@ -379,35 +783,27 @@ final class GroupChatViewModel {
switch contentType {
case 1501:
// admin
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
let groupID = opUser["groupID"] as? String ?? ""
let isOwner = groupID.contains(AppContextManager.shared.userId)
let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建"
return NSAttributedString(string: text)
case 1510:
//
guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil }
let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? ""
let text = "\(nickName) 加入了圈子"
return NSAttributedString(string: text)
return NSAttributedString(string: "\(nickName) 加入了圈子")
case 1520:
//
guard let opUser = json["opUser"] as? [String: Any] else { return nil }
let opUserID = opUser["userID"] as? String ?? ""
let opNickName = getUserNickName(id: opUserID)
let newName = group["groupName"] as? String ?? ""
guard !newName.isEmpty else { return nil }
let tip = "\(opNickName) 将群名称修改为 "
let result = NSMutableAttributedString(string: tip + newName)
result.addAttribute(.font, value: UIFont.systemFont(ofSize: 12), range: NSRange(location: 0, length: result.length))
let nameRange = NSRange(location: tip.count, length: newName.utf16.count)
result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange)
return result
default:
return nil
}
@ -416,22 +812,24 @@ final class GroupChatViewModel {
// MARK: - ChatSectionItem Helpers
extension ChatSectionItem {
/// ChatMessage case
var chatMessage: ChatMessage? {
switch self {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m
case .notification: return nil
case .notification, .revoked:
return nil
}
}
/// ChatMessage case
/// id revoked id chatMessage
var revokedClientMsgID: String? { nil }
static func with(_ msg: ChatMessage) -> ChatSectionItem {
if !msg.imageUrl.isEmpty {
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
}
if msg.content.hasPrefix("js_emoji:") {
if msg.content.hasPrefix("js_emoji:"), msg.quotePreview == nil {
return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg)
}
if !msg.voiceUrl.isEmpty {