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/GLOSSARY.md
.opencode/skills/writing-great-skills/SKILL.md .opencode/skills/writing-great-skills/SKILL.md
.opencode/skills/writing-great-skills/agents/openai.yaml .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 'HXPHPicker'
pod 'SwiftKeychainWrapper' pod 'SwiftKeychainWrapper'
pod 'SGQRCode', '~> 4.1.0' #扫描 pod 'SGQRCode', '~> 4.1.0' #扫描

View File

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

10
Pods/Manifest.lock generated
View File

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

View File

@ -214,6 +214,46 @@ SOFTWARE.
<key>Type</key> <key>Type</key>
<string>PSGroupSpecifier</string> <string>PSGroupSpecifier</string>
</dict> </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> <dict>
<key>FooterText</key> <key>FooterText</key>
<string>MIT License <string>MIT License

View File

@ -4,6 +4,7 @@ ${BUILT_PRODUCTS_DIR}/BRPickerView/BRPickerView.framework
${BUILT_PRODUCTS_DIR}/CocoaLumberjack/CocoaLumberjack.framework ${BUILT_PRODUCTS_DIR}/CocoaLumberjack/CocoaLumberjack.framework
${BUILT_PRODUCTS_DIR}/CocoaMQTT/CocoaMQTT.framework ${BUILT_PRODUCTS_DIR}/CocoaMQTT/CocoaMQTT.framework
${BUILT_PRODUCTS_DIR}/Differentiator/Differentiator.framework ${BUILT_PRODUCTS_DIR}/Differentiator/Differentiator.framework
${BUILT_PRODUCTS_DIR}/FLEX/FLEX.framework
${BUILT_PRODUCTS_DIR}/HXPHPicker/HXPHPicker.framework ${BUILT_PRODUCTS_DIR}/HXPHPicker/HXPHPicker.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework ${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework
${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.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}/CocoaLumberjack.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaMQTT.framework ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaMQTT.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Differentiator.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}/HXPHPicker.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardCore.framework ${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardCore.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.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}/CocoaLumberjack/CocoaLumberjack.framework"
install_framework "${BUILT_PRODUCTS_DIR}/CocoaMQTT/CocoaMQTT.framework" install_framework "${BUILT_PRODUCTS_DIR}/CocoaMQTT/CocoaMQTT.framework"
install_framework "${BUILT_PRODUCTS_DIR}/Differentiator/Differentiator.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}/HXPHPicker/HXPHPicker.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework"

View File

@ -2,13 +2,13 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
ARCHS = $(ARCHS_STANDARD) ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = 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 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' 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 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_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}/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_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 OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) 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 */; }; 30EFF3E82FCA8C7000227D26 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30EFF3E92FCA8C7000227D26 /* AuthenticationServices.framework */; };
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; }; 55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.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 */; }; D698E9C56D6F2C152772131D /* Pods_QuickLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475D33CAFA1E1911EB1F8D9F /* Pods_QuickLocation.framework */; };
/* End PBXBuildFile section */ /* 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
@ -1253,6 +1255,7 @@
305A767E2FCA8C7000227D26 /* AppDelegate.swift */, 305A767E2FCA8C7000227D26 /* AppDelegate.swift */,
305A767F2FCA8C7000227D26 /* Assets.xcassets */, 305A767F2FCA8C7000227D26 /* Assets.xcassets */,
305A76802FCA8C7000227D26 /* Info.plist */, 305A76802FCA8C7000227D26 /* Info.plist */,
A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */,
305A76822FCA8C7000227D26 /* LaunchScreen.storyboard */, 305A76822FCA8C7000227D26 /* LaunchScreen.storyboard */,
305A76842FCA8C7000227D26 /* Main.storyboard */, 305A76842FCA8C7000227D26 /* Main.storyboard */,
305A76852FCA8C7000227D26 /* QuickLocation-Bridging-Header.h */, 305A76852FCA8C7000227D26 /* QuickLocation-Bridging-Header.h */,
@ -1755,6 +1758,7 @@
305A77222FCA8C7000227D26 /* YouSheBiaoTiHei.ttf in Resources */, 305A77222FCA8C7000227D26 /* YouSheBiaoTiHei.ttf in Resources */,
305A77232FCA8C7000227D26 /* zihunbiantaoti-r.ttf in Resources */, 305A77232FCA8C7000227D26 /* zihunbiantaoti-r.ttf in Resources */,
305A77F92FCA8C7000227D26 /* Assets.xcassets in Resources */, 305A77F92FCA8C7000227D26 /* Assets.xcassets in Resources */,
A1B2C3D42FDABC8C009215C1 /* PrivacyInfo.xcprivacy in Resources */,
305A77FB2FCA8C7000227D26 /* LaunchScreen.storyboard in Resources */, 305A77FB2FCA8C7000227D26 /* LaunchScreen.storyboard in Resources */,
305A77FC2FCA8C7000227D26 /* Main.storyboard in Resources */, 305A77FC2FCA8C7000227D26 /* Main.storyboard in Resources */,
); );
@ -2183,7 +2187,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Debug; name = Debug;
}; };
@ -2236,7 +2240,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Release; name = Release;
}; };

View File

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

View File

@ -10,7 +10,15 @@ import UIKit
public extension UIApplication { public extension UIApplication {
static var keyWindow: UIWindow? { 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? { static func delegate<T: UIApplicationDelegate>() -> T? {

View File

@ -7,9 +7,11 @@
import Foundation 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 public private(set) var base: Base

View File

@ -29,7 +29,8 @@ enum PagingRequestType {
typealias RefreshResult = (status: RefreshStatus, isEmpty: Bool) typealias RefreshResult = (status: RefreshStatus, isEmpty: Bool)
/// ListType: : ListService<ListModel<PaginationModel>> /// 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 BaseListModel = ListType
typealias BaseItem = ListType.Item typealias BaseItem = ListType.Item

View File

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

View File

@ -49,16 +49,16 @@ extension DefaultsKeys {
func setupNetworkMode() { func setupNetworkMode() {
#if DEBUG #if DEBUG
setupApiEnv(1) setupApiEnv(1)
openIM_API = "https://imapi.zuom8.cn" openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
openIM_WS = "ws://imws.zuom8.cn" openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
#elseif AdHoc #elseif AdHoc
setupApiEnv(-1) setupApiEnv(-1)
openIM_API = "http://38.207.176.65:10002" openIM_API = "https://imapi.zuom8.cn"
openIM_WS = "ws://38.207.176.65:10010" openIM_WS = "ws://imws.zuom8.cn"
#else #else
setupApiEnv(1) setupApiEnv(1)
openIM_API = "http://38.207.176.65:10002" openIM_API = "https://imapi.zuom8.cn"
openIM_WS = "ws://38.207.176.65:10010" openIM_WS = "ws://imws.zuom8.cn"
#endif #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. 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 typealias WorkFactory = (Input) -> Observable<Element>
public let _enabledIf: Observable<Bool> public let _enabledIf: Observable<Bool>

View File

@ -2,7 +2,8 @@ import Foundation
import RxSwift import RxSwift
/// A special subject for Action.inputs. It only emits `.next` event. /// 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 public typealias E = Element
typealias Key = UInt typealias Key = UInt

View File

@ -14,6 +14,7 @@ import AVFoundation
import AudioToolbox import AudioToolbox
import HXPHPicker import HXPHPicker
import IQKeyboardManagerSwift import IQKeyboardManagerSwift
import MJRefresh
final class GroupChatVC: BaseViewController { final class GroupChatVC: BaseViewController {
@ -22,6 +23,11 @@ final class GroupChatVC: BaseViewController {
fileprivate var rootView: GroupChatView! fileprivate var rootView: GroupChatView!
private let viewModel = GroupChatViewModel() private let viewModel = GroupChatViewModel()
private var msgListener: MessageListenerProxy? 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 // MARK: - Init
init(groupId: String) { init(groupId: String) {
@ -46,6 +52,9 @@ final class GroupChatVC: BaseViewController {
setupMessageListener() setupMessageListener()
setupVoiceRecording() setupVoiceRecording()
setupPanelDismiss() setupPanelDismiss()
setupChatLongPress()
setupMentionInput()
setupHistoryRefreshHeader()
// + IM SDK // + IM SDK
requestGroupInfoByKey() requestGroupInfoByKey()
@ -139,7 +148,33 @@ final class GroupChatVC: BaseViewController {
.skip(1) .skip(1)
.observe(on: MainScheduler.asyncInstance) .observe(on: MainScheduler.asyncInstance)
.subscribe(onNext: { [weak self] _ in .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) .disposed(by: disposeBag)
@ -149,7 +184,58 @@ final class GroupChatVC: BaseViewController {
.disposed(by: disposeBag) .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() { private func scrollToBottom() {
let count = dataSource.sectionModels.first?.items.count ?? 0 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>> = { private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> = {
RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in RxCollectionViewSectionedReloadDataSource<SectionModel<String, String>> { _, collectionView, indexPath, name in
let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath) let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath)
@ -171,13 +282,157 @@ final class GroupChatVC: BaseViewController {
// MARK: - Message Listener // MARK: - Message Listener
private func setupMessageListener() { 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 } guard let self = self, msg.groupID == self.viewModel.groupId else { return }
self.viewModel.onReceiveMessage(msg) self.viewModel.onReceiveMessage(msg)
},
onRevoked: { [weak self] info in
self?.viewModel.onMessageRevoked(info)
} }
)
OIMManager.callbacker.addAdvancedMsgListener(listener: msgListener!) 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 // MARK: - Actions
private func reactiveAction() { private func reactiveAction() {
rootView.backBtn.rx.tap rootView.backBtn.rx.tap
@ -267,16 +522,15 @@ final class GroupChatVC: BaseViewController {
}) })
.disposed(by: disposeBag) .disposed(by: disposeBag)
let sendText = Observable.merge( rootView.sendBtn.rx.tap
rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" }, .subscribe(onNext: { [weak self] in
rootView.textField.rx.controlEvent(.editingDidEndOnExit) guard let self = self else { return }
.map { [weak self] _ in self?.rootView.textField.text ?? "" } let text = self.rootView.textField.text ?? ""
) guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } self.viewModel.input.sendMessage.onNext(text)
.do(onNext: { [weak self] _ in self?.rootView.textField.text = "" }) self.rootView.textField.text = ""
self.hideMentionPicker()
sendText })
.bind(to: viewModel.input.sendMessage)
.disposed(by: disposeBag) .disposed(by: disposeBag)
// //
@ -429,6 +683,7 @@ final class GroupChatVC: BaseViewController {
// Send voice message via OpenIM // Send voice message via OpenIM
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000) let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
viewModel.cacheMessage(msg)
OIMManager.manager.sendMessage(msg, OIMManager.manager.sendMessage(msg,
recvID: "", recvID: "",
groupID: viewModel.groupId, groupID: viewModel.groupId,
@ -456,15 +711,23 @@ final class GroupChatVC: BaseViewController {
// MARK: - dataSource // MARK: - dataSource
private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = { private lazy var dataSource: RxTableViewSectionedReloadDataSource<ChatSectionModel> = {
RxTableViewSectionedReloadDataSource<ChatSectionModel> { _, tableView, indexPath, item in RxTableViewSectionedReloadDataSource<ChatSectionModel> { [weak self] _, tableView, indexPath, item in
switch item { switch item {
case let .send(msg): case let .send(msg):
let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath) let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg) cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell return cell
case let .received(msg): case let .received(msg):
let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(msg) cell.configure(msg)
cell.onQuoteTap = { [weak self] in
guard let id = msg.quotePreview?.quotedClientMsgID else { return }
self?.scrollToMessage(clientMsgID: id)
}
return cell return cell
case let .emojiSend(msg): case let .emojiSend(msg):
let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath) 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) self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView)
} }
return cell 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) let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(text, showTime: showTime, timestamp: timestamp) cell.configure(text, showTime: showTime, timestamp: timestamp)
return cell 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 // MARK: - UITableViewDelegate
extension GroupChatVC: UITableViewDelegate { extension GroupChatVC: UITableViewDelegate {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
@ -516,18 +807,32 @@ extension GroupChatVC: UITableViewDelegate {
(cell as? EmojiSendMsgCell)?.stopAnimation() (cell as? EmojiSendMsgCell)?.stopAnimation()
(cell as? EmojiReceivedMsgCell)?.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 // MARK: - MessageListenerProxy
private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener { 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) { init(onMessage: @escaping (OIMMessageInfo) -> Void,
self.handler = handler onRevoked: @escaping (OIMMessageRevokedInfo) -> Void) {
self.onMessage = onMessage
self.onRevoked = onRevoked
} }
func onRecvNewMessage(_ msg: OIMMessageInfo) { 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) let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path)
// 使 SDK clientMsgID ID便 // 使 SDK clientMsgID ID便
let localId = msg.clientMsgID ?? UUID().uuidString let localId = msg.clientMsgID ?? UUID().uuidString
viewModel.cacheMessage(msg)
// loading // loading
let localMsg = ChatMessage( let localMsg = ChatMessage(

View File

@ -32,6 +32,63 @@ struct ChatMessage {
let timestamp: TimeInterval let timestamp: TimeInterval
var showTime: Bool = false var showTime: Bool = false
var isUploading: 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 { class GroupChatView: UIView {
@ -45,6 +102,14 @@ class GroupChatView: UIView {
/// ///
var onVoiceRecordState: ((VoiceRecordState) -> Void)? var onVoiceRecordState: ((VoiceRecordState) -> Void)?
private var emojiPlaybackGeneration = 0 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() { private func setupRx() {
@ -55,11 +120,13 @@ class GroupChatView: UIView {
let needsReset = !emojiPanelView.isHidden let needsReset = !emojiPanelView.isHidden
|| !voiceRecordView.isHidden || !voiceRecordView.isHidden
|| textField.isFirstResponder || textField.isFirstResponder
|| !mentionPickerView.isHidden
guard needsReset else { return } guard needsReset else { return }
stopVisibleEmojiAnimations() stopVisibleEmojiAnimations()
emojiPanelView.isHidden = true emojiPanelView.isHidden = true
voiceRecordView.isHidden = true voiceRecordView.isHidden = true
mentionPickerView.isHidden = true
if !excludeTextField { textField.resignFirstResponder() } if !excludeTextField { textField.resignFirstResponder() }
UIView.animate(withDuration: 0.25) { UIView.animate(withDuration: 0.25) {
self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20) self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20)
@ -123,6 +190,8 @@ class GroupChatView: UIView {
addSubview(tableView) addSubview(tableView)
addSubview(chatWarningView) addSubview(chatWarningView)
addSubview(quoteBar)
addSubview(mentionPickerView)
addSubview(bottomBar) addSubview(bottomBar)
addSubview(disableIMView) addSubview(disableIMView)
bottomBar.addSubview(bottomBarCornerView) bottomBar.addSubview(bottomBarCornerView)
@ -136,6 +205,11 @@ class GroupChatView: UIView {
bottomBar.addSubview(addBtn) bottomBar.addSubview(addBtn)
bottomBar.addSubview(sendBtn) bottomBar.addSubview(sendBtn)
quoteBar.addSubview(quoteBarLabel)
quoteBar.addSubview(quoteBarCloseBtn)
mentionPickerView.addSubview(mentionPickerContentView)
mentionPickerContentView.addSubview(mentionTableView)
navBgView.layoutChain navBgView.layoutChain
.edges(excludingEdge: .bottom) .edges(excludingEdge: .bottom)
.height(kNaviHeight) .height(kNaviHeight)
@ -197,6 +271,28 @@ class GroupChatView: UIView {
.topToBottomOfView(navBarView) .topToBottomOfView(navBarView)
.edgesHorzontal() .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 bottomBar.layoutChain
.edgesHorzontal(15) .edgesHorzontal(15)
.height(50) .height(50)
@ -395,6 +491,63 @@ class GroupChatView: UIView {
return tv 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 // MARK: - Bottom Bar
lazy var bottomBar: UIView = { lazy var bottomBar: UIView = {
let view = UIView() let view = UIView()
@ -537,33 +690,234 @@ class GroupChatView: UIView {
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") 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 // MARK: - cell
class TextSendMsgCell: UITableViewCell { class TextSendMsgCell: UITableViewCell {
var onQuoteTap: (() -> Void)?
private(set) var messageId: String = ""
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) { func configure(_ msg: ChatMessage) {
messageId = msg.id
timeLabel.isHidden = !msg.showTime timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar 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 { func flashHighlight() {
let date = Date(timeIntervalSince1970: t) let old = contentView.backgroundColor
let now = Date() contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
let calendar = Calendar.current UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
let f = DateFormatter() self.contentView.backgroundColor = old
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)
} }
private let timeLabel: UILabel = { private let timeLabel: UILabel = {
@ -574,7 +928,7 @@ class TextSendMsgCell: UITableViewCell {
return label return label
}() }()
private let avatarView: UIImageView = { let avatarView: UIImageView = {
let iv = UIImageView() let iv = UIImageView()
iv.contentMode = .scaleAspectFill iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15 iv.cornerRadius = 15
@ -598,77 +952,99 @@ class TextSendMsgCell: UITableViewCell {
return label return label
}() }()
private let quoteBlock = ChatQuoteBlockView()
private var quoteHeightConstraint: NSLayoutConstraint?
private var quoteTopConstraint: NSLayoutConstraint?
override init(style: CellStyle, reuseIdentifier: String?) { override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none selectionStyle = .none
backgroundColor = .clear backgroundColor = .clear
contentView.addSubview(timeLabel)
contentView.addSubview(bubbleView) contentView.addSubview(bubbleView)
bubbleView.addSubview(contentLabel) bubbleView.addSubview(contentLabel)
contentView.addSubview(timeLabel) contentView.addSubview(quoteBlock)
contentView.addSubview(avatarView) contentView.addSubview(avatarView)
timeLabel.layoutChain timeLabel.layoutChain.top().centerX()
.top()
.centerX()
avatarView.layoutChain avatarView.layoutChain
.topToBottomOfView(timeLabel, offset: 14) .topToBottomOfView(timeLabel, offset: 14)
.right(12) .right(12).width(30).height(30)
.width(30).height(30)
bubbleView.layoutChain bubbleView.layoutChain
.topToBottomOfView(avatarView, offset: -15) .topToBottomOfView(avatarView, offset: -15)
.rightToView(avatarView, offset: -13) .rightToView(avatarView, offset: -13)
.left(60, relation: .greaterThanOrEqual) .left(60, relation: .greaterThanOrEqual)
.width(100, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual)
.bottom(10)
contentLabel.layoutChain contentLabel.translatesAutoresizingMaskIntoConstraints = false
.edgesVertical(10) quoteBlock.translatesAutoresizingMaskIntoConstraints = false
.edgesHorzontal(20) 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) { @objc private func handleQuoteTap() { onQuoteTap?() }
fatalError("init(coder:) has not been implemented")
} required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
bubbleView.setNeedsLayout() bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded() bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft ,.bottomLeft, .bottomRight], bubbleView.setCornerRadius(corners: [.topLeft, .bottomLeft, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
} }
} }
//MARK: - cell // MARK: - cell
class TextReceivedMsgCell: UITableViewCell { 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) { func configure(_ msg: ChatMessage) {
messageId = msg.id
senderId = msg.senderId
senderName = msg.senderName
timeLabel.isHidden = !msg.showTime timeLabel.isHidden = !msg.showTime
timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil timeLabel.text = msg.showTime ? chatFormatTime(msg.timestamp) : nil
avatarView.image = msg.avatar avatarView.image = msg.avatar
nameLabel.text = msg.senderName 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 { func flashHighlight() {
let date = Date(timeIntervalSince1970: t) let old = contentView.backgroundColor
let now = Date() contentView.backgroundColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.12)
let calendar = Calendar.current UIView.animate(withDuration: 0.8, delay: 0.3, options: []) {
let f = DateFormatter() self.contentView.backgroundColor = old
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)
} }
private let timeLabel: UILabel = { private let timeLabel: UILabel = {
@ -686,7 +1062,7 @@ class TextReceivedMsgCell: UITableViewCell {
return label return label
}() }()
private let avatarView: UIImageView = { let avatarView: UIImageView = {
let iv = UIImageView() let iv = UIImageView()
iv.contentMode = .scaleAspectFill iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15 iv.cornerRadius = 15
@ -694,6 +1070,7 @@ class TextReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2 iv.borderWidth = 2
iv.borderColor = .white iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv return iv
}() }()
@ -710,6 +1087,10 @@ class TextReceivedMsgCell: UITableViewCell {
return label return label
}() }()
private let quoteBlock = ChatQuoteBlockView()
private var quoteHeightConstraint: NSLayoutConstraint?
private var quoteTopConstraint: NSLayoutConstraint?
override init(style: CellStyle, reuseIdentifier: String?) { override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none selectionStyle = .none
@ -718,13 +1099,11 @@ class TextReceivedMsgCell: UITableViewCell {
contentView.addSubview(timeLabel) contentView.addSubview(timeLabel)
contentView.addSubview(bubbleView) contentView.addSubview(bubbleView)
bubbleView.addSubview(contentLabel) bubbleView.addSubview(contentLabel)
contentView.addSubview(quoteBlock)
contentView.addSubview(avatarView) contentView.addSubview(avatarView)
contentView.addSubview(nameLabel) contentView.addSubview(nameLabel)
timeLabel.layoutChain timeLabel.layoutChain.top().centerX()
.top()
.centerX()
bubbleView.layoutChain bubbleView.layoutChain
.topToBottomOfView(timeLabel, offset: 14) .topToBottomOfView(timeLabel, offset: 14)
.left(30) .left(30)
@ -732,30 +1111,49 @@ class TextReceivedMsgCell: UITableViewCell {
.width(100, relation: .greaterThanOrEqual) .width(100, relation: .greaterThanOrEqual)
.height(30, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual)
avatarView.layoutChain contentLabel.translatesAutoresizingMaskIntoConstraints = false
.topToBottomOfView(bubbleView, offset: -15) quoteBlock.translatesAutoresizingMaskIntoConstraints = false
.left(12) avatarView.translatesAutoresizingMaskIntoConstraints = false
.width(30).height(30) nameLabel.translatesAutoresizingMaskIntoConstraints = false
.bottom(10) 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 // senderName
.leftToRightOfView(avatarView, offset: 5) let qTop = quoteBlock.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4)
.bottomToView(avatarView) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
quoteTopConstraint = qTop
contentLabel.layoutChain quoteHeightConstraint = qH
.edgesVertical(10) NSLayoutConstraint.activate([
.edgesHorzontal(20) 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) { @objc private func handleQuoteTap() { onQuoteTap?() }
fatalError("init(coder:) has not been implemented")
} required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
bubbleView.setNeedsLayout() bubbleView.setNeedsLayout()
bubbleView.layoutIfNeeded() bubbleView.layoutIfNeeded()
bubbleView.setCornerRadius(corners: [.topLeft , .topRight, .bottomRight], bubbleView.setCornerRadius(corners: [.topLeft, .topRight, .bottomRight],
withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2)) withCornerRadii: CGSize(width: bubbleView.dl.height / 2, height: bubbleView.dl.height / 2))
} }
} }
@ -823,6 +1221,7 @@ final class NotificationMsgCell: UITableViewCell {
// MARK: - // MARK: -
final class EmojiSendMsgCell: UITableViewCell { final class EmojiSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { lottieView }
private let timeLabel: UILabel = { private let timeLabel: UILabel = {
let label = UILabel() let label = UILabel()
@ -938,6 +1337,7 @@ final class EmojiSendMsgCell: UITableViewCell {
// MARK: - // MARK: -
final class EmojiReceivedMsgCell: UITableViewCell { final class EmojiReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { lottieView }
private let timeLabel: UILabel = { private let timeLabel: UILabel = {
let label = UILabel() let label = UILabel()
@ -947,7 +1347,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
return label return label
}() }()
private let avatarView: UIImageView = { let avatarView: UIImageView = {
let iv = UIImageView() let iv = UIImageView()
iv.contentMode = .scaleAspectFill iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15 iv.cornerRadius = 15
@ -955,6 +1355,7 @@ final class EmojiReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2 iv.borderWidth = 2
iv.borderColor = .white iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv return iv
}() }()
@ -1101,6 +1502,7 @@ extension GroupChatView: UICollectionViewDelegate {
// MARK: - // MARK: -
final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView { final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
private let timeLabel: UILabel = { private let timeLabel: UILabel = {
let label = UILabel() let label = UILabel()
@ -1238,6 +1640,7 @@ final class VoiceSendMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: - // MARK: -
final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
var menuAnchorView: UIView { bubbleView }
func configure(_ msg: ChatMessage) { func configure(_ msg: ChatMessage) {
timeLabel.isHidden = !msg.showTime timeLabel.isHidden = !msg.showTime
@ -1289,7 +1692,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
return label return label
}() }()
private let avatarView: UIImageView = { let avatarView: UIImageView = {
let iv = UIImageView() let iv = UIImageView()
iv.contentMode = .scaleAspectFill iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15 iv.cornerRadius = 15
@ -1297,6 +1700,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2 iv.borderWidth = 2
iv.borderColor = .white iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv return iv
}() }()
@ -1394,7 +1798,7 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView {
// MARK: - // MARK: -
final class ImageSendMsgCell: UITableViewCell { final class ImageSendMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)? var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) { func configure(_ msg: ChatMessage) {
@ -1504,7 +1908,7 @@ final class ImageSendMsgCell: UITableViewCell {
// MARK: - // MARK: -
final class ImageReceivedMsgCell: UITableViewCell { final class ImageReceivedMsgCell: UITableViewCell {
var menuAnchorView: UIView { photoView }
var onImageTap: (() -> Void)? var onImageTap: (() -> Void)?
func configure(_ msg: ChatMessage) { func configure(_ msg: ChatMessage) {
@ -1549,7 +1953,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
return label return label
}() }()
private let avatarView: UIImageView = { let avatarView: UIImageView = {
let iv = UIImageView() let iv = UIImageView()
iv.contentMode = .scaleAspectFill iv.contentMode = .scaleAspectFill
iv.cornerRadius = 15 iv.cornerRadius = 15
@ -1557,6 +1961,7 @@ final class ImageReceivedMsgCell: UITableViewCell {
iv.backgroundColor = UIColor(hexStr: "#E0E0E0") iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
iv.borderWidth = 2 iv.borderWidth = 2
iv.borderColor = .white iv.borderColor = .white
iv.isUserInteractionEnabled = true
return iv return iv
}() }()

View File

@ -11,6 +11,17 @@ import RxCocoa
import OpenIMSDK import OpenIMSDK
import Differentiator 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 { enum ChatSectionItem {
case send(ChatMessage) case send(ChatMessage)
case received(ChatMessage) case received(ChatMessage)
@ -21,6 +32,7 @@ enum ChatSectionItem {
case imageSend(ChatMessage) case imageSend(ChatMessage)
case imageReceived(ChatMessage) case imageReceived(ChatMessage)
case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
} }
typealias ChatSectionModel = SectionModel<String, ChatSectionItem> typealias ChatSectionModel = SectionModel<String, ChatSectionItem>
@ -33,6 +45,7 @@ final class GroupChatViewModel {
struct Output { struct Output {
let messages: Observable<[ChatSectionItem]> let messages: Observable<[ChatSectionItem]>
let pendingQuoteSummary: Observable<String?>
} }
let input: Input let input: Input
@ -40,22 +53,42 @@ final class GroupChatViewModel {
private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: []) private let messagesSubject = BehaviorRelay<[ChatSectionItem]>(value: [])
private let sendMessageSubject = PublishSubject<String>() private let sendMessageSubject = PublishSubject<String>()
private let pendingQuoteSummarySubject = BehaviorRelay<String?>(value: nil)
private var lastTimeGap: TimeInterval = 0 private var lastTimeGap: TimeInterval = 0
/// private let timeGapThreshold: TimeInterval = 300
private let timeGapThreshold: TimeInterval = 300 // 5 minutes
var groupModel: GroupInfoModel? var groupModel: GroupInfoModel?
var memberList: [GroupMemberModel] = [] { var memberList: [GroupMemberModel] = [] {
didSet { didSet { buildAvatarCache() }
buildAvatarCache()
}
} }
var groupId: String = "" 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 // MARK: - Init
init() { init() {
input = Input(sendMessage: sendMessageSubject.asObserver()) input = Input(sendMessage: sendMessageSubject.asObserver())
output = Output(messages: messagesSubject.asObservable()) output = Output(
messages: messagesSubject.asObservable(),
pendingQuoteSummary: pendingQuoteSummarySubject.asObservable()
)
sendMessageSubject sendMessageSubject
.subscribe(onNext: { [weak self] text in .subscribe(onNext: { [weak self] text in
@ -64,54 +97,36 @@ final class GroupChatViewModel {
.disposed(by: disposeBag) .disposed(by: disposeBag)
} }
private let disposeBag = DisposeBag() var currentMessages: [ChatSectionItem] { messagesSubject.value }
var conversationID: String { "sg_\(groupId)" }
// MARK: - Avatar // MARK: - Avatar
private var avatarCache: [String: UIImage] = [:] private var avatarCache: [String: UIImage] = [:]
/// memberList userID UIImage 30
func buildAvatarCache() { func buildAvatarCache() {
var cache: [String: UIImage] = [:] var cache: [String: UIImage] = [:]
for member in memberList { for member in memberList {
cache[member.user_id] = member.userIcon cache[member.user_id] = member.userIcon
} }
avatarCache = cache avatarCache = cache
// loadMessages memberList
refreshMessageAvatars() refreshMessageAvatars()
} }
/// avatarCache loadMessages memberList
private func refreshMessageAvatars() { private func refreshMessageAvatars() {
var items = messagesSubject.value var items = messagesSubject.value
var didChange = false var didChange = false
items = items.map { item in items = items.map { item in
switch item { guard var m = item.chatMessage, updateAvatar(&m) else { return item }
case var .send(m): if updateAvatar(&m) { didChange = true }; return .send(m) didChange = true
case var .received(m): if updateAvatar(&m) { didChange = true }; return .received(m) return ChatSectionItem.with(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)
} }
if didChange { messagesSubject.accept(items) }
} }
///
private func updateAvatar(_ msg: inout ChatMessage) -> Bool { private func updateAvatar(_ msg: inout ChatMessage) -> Bool {
guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false } guard let cached = avatarCache[msg.senderId], cached != msg.avatar else { return false }
msg = ChatMessage( msg = msg.with(avatar: cached)
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
)
return true return true
} }
@ -129,29 +144,359 @@ final class GroupChatViewModel {
memberList.first { id == $0.user_id }?.nick_name ?? "" 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 // MARK: - Send
private func onSendMessage(_ text: String) { 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, OIMManager.manager.sendMessage(msg,
recvID: "", recvID: "",
groupID: groupId, groupID: groupId,
offlinePushInfo: nil, offlinePushInfo: nil,
onSuccess: { [weak self] _ in onSuccess: { [weak self] _ in
self?.cacheMessage(msg)
self?.appendMessage(msg) self?.appendMessage(msg)
}, },
onProgress: nil as OIMNumberCallback?, onProgress: nil as OIMNumberCallback?,
onFailure: { code, errMsg in onFailure: { code, errMsg in
print("send failed: \(code) \(errMsg ?? "")") 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 // MARK: - Load
func loadMessages() { func loadMessages() {
guard !groupId.isEmpty else { return } guard !groupId.isEmpty else { return }
let param = OIMGetAdvancedHistoryMessageListParam() let param = OIMGetAdvancedHistoryMessageListParam()
param.conversationID = "sg_\(groupId)" param.conversationID = conversationID
param.count = 30 param.count = historyPageSize
OIMManager.manager.getAdvancedHistoryMessageList( OIMManager.manager.getAdvancedHistoryMessageList(
param, param,
@ -160,29 +505,27 @@ final class GroupChatViewModel {
let list = result?.messageList else { return } let list = result?.messageList else { return }
var items: [ChatSectionItem] = [] var items: [ChatSectionItem] = []
for msg in list { for msg in list {
self.cacheMessage(msg)
if let item = self.toSectionItem(msg) { if let item = self.toSectionItem(msg) {
items.append(item) items.append(item)
} }
} }
// messages come newest-first from API, sort by time ascending
items.sort { items.sort {
let t1 = self.timestampFrom(item: $0) self.timestampFrom(item: $0) < self.timestampFrom(item: $1)
let t2 = self.timestampFrom(item: $1)
return t1 < t2
} }
// set showTime flag
for i in items.indices { for i in items.indices {
let ts = self.timestampFrom(item: items[i]) let ts = self.timestampFrom(item: items[i])
if i == 0 || ts - self.timestampFrom(item: items[i-1]) >= self.timeGapThreshold { if i == 0 || ts - self.timestampFrom(item: items[i - 1]) >= self.timeGapThreshold {
items[i] = self.setShowTime(items[i], true) items[i] = self.setShowTime(items[i], true)
} }
} }
if let last = items.last { if let last = items.last {
self.lastTimeGap = self.timestampFrom(item: last) self.lastTimeGap = self.timestampFrom(item: last)
} }
let isEnd = result?.isEnd ?? (list.count < self.historyPageSize)
DispatchQueue.main.async { DispatchQueue.main.async {
self.hasMoreHistory = !isEnd && !list.isEmpty
self.messagesSubject.accept(items) self.messagesSubject.accept(items)
//
self.markAsRead() self.markAsRead()
} }
}, },
@ -191,35 +534,22 @@ final class GroupChatViewModel {
}) })
} }
///
private func markAsRead() { private func markAsRead() {
let conversationID = "sg_\(groupId)"
OIMManager.manager.markConversationMessage(asRead: conversationID, OIMManager.manager.markConversationMessage(asRead: conversationID,
onSuccess: nil, onSuccess: nil,
onFailure: nil) onFailure: nil)
} }
///
func appendLocalMessage(_ item: ChatSectionItem) { func appendLocalMessage(_ item: ChatSectionItem) {
var items = messagesSubject.value var items = messagesSubject.value
items.append(item) items.append(item)
messagesSubject.accept(items) messagesSubject.accept(items)
} }
/// id /
func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) { func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) {
var items = messagesSubject.value var items = messagesSubject.value
guard let idx = items.firstIndex(where: { item in guard let idx = items.firstIndex(where: { $0.chatMessage?.id == id }),
switch item { var chatMsg = items[idx].chatMessage else { return }
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 }
update(&chatMsg) update(&chatMsg)
items[idx] = ChatSectionItem.with(chatMsg) items[idx] = ChatSectionItem.with(chatMsg)
messagesSubject.accept(items) messagesSubject.accept(items)
@ -227,11 +557,11 @@ final class GroupChatViewModel {
// MARK: - Receive // MARK: - Receive
func onReceiveMessage(_ msg: OIMMessageInfo) { func onReceiveMessage(_ msg: OIMMessageInfo) {
cacheMessage(msg)
guard let item = toSectionItem(msg) else { return } guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item) let ts = timestampFrom(item: item)
var items = messagesSubject.value var items = messagesSubject.value
// clientMsgID
if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty, if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty,
items.contains(where: { $0.chatMessage?.id == clientMsgID }) { items.contains(where: { $0.chatMessage?.id == clientMsgID }) {
return return
@ -245,11 +575,15 @@ final class GroupChatViewModel {
} }
} }
// MARK: - Append sent message
private func appendMessage(_ msg: OIMMessageInfo) { private func appendMessage(_ msg: OIMMessageInfo) {
cacheMessage(msg)
guard let item = toSectionItem(msg) else { return } guard let item = toSectionItem(msg) else { return }
let ts = timestampFrom(item: item) let ts = timestampFrom(item: item)
var items = messagesSubject.value var items = messagesSubject.value
//
if let id = msg.clientMsgID, items.contains(where: { $0.chatMessage?.id == id }) {
return
}
let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold
lastTimeGap = ts lastTimeGap = ts
items.append(showTime ? setShowTime(item, true) : item) 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 { private func timestampFrom(item: ChatSectionItem) -> TimeInterval {
switch item { switch item {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), 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): let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return m.timestamp return m.timestamp
case let .notification(_, _, ts): return ts case let .notification(_, _, ts), let .revoked(_, _, ts):
return ts
} }
} }
@ -272,25 +614,29 @@ final class GroupChatViewModel {
case var .send(m): m.showTime = show; return .send(m) case var .send(m): m.showTime = show; return .send(m)
case var .received(m): m.showTime = show; return .received(m) case var .received(m): m.showTime = show; return .received(m)
case var .emojiSend(m): m.showTime = show; return .emojiSend(m) case var .emojiSend(m): m.showTime = show; return .emojiSend(m)
case var .emojiReceived(m):m.showTime = show; return .emojiReceived(m) case var .emojiReceived(m): m.showTime = show; return .emojiReceived(m)
case var .voiceSend(m): m.showTime = show; return .voiceSend(m) case var .voiceSend(m): m.showTime = show; return .voiceSend(m)
case var .voiceReceived(m):m.showTime = show; return .voiceReceived(m) case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m)
case var .imageSend(m): m.showTime = show; return .imageSend(m) case var .imageSend(m): m.showTime = show; return .imageSend(m)
case var .imageReceived(m):m.showTime = show; return .imageReceived(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 .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 { private func convert(_ msg: OIMMessageInfo) -> ChatMessage {
let isSelf = msg.isSelf() let isSelf = msg.isSelf()
let ts = TimeInterval(msg.sendTime) / 1000.0 let ts = TimeInterval(msg.sendTime) / 1000.0
let content: String var content = ""
let voiceUrl: String var voiceUrl = ""
let imageUrl: String var imageUrl = ""
let imageW: CGFloat var imageW: CGFloat = 0
let imageH: CGFloat var imageH: CGFloat = 0
var quotePreview: QuotePreview?
var atUserIDs: [String] = []
var atNicknames: [String] = []
var isAtAll = false
// :
let maxWH: CGFloat = 200 let maxWH: CGFloat = 200
var msgImageW: CGFloat = 0 var msgImageW: CGFloat = 0
var msgImageH: 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)" content = "\(sound.duration)"
voiceUrl = sound.sourceUrl ?? "" voiceUrl = sound.sourceUrl ?? ""
imageUrl = ""
imageW = 0; imageH = 0
} else if let pic = msg.pictureElem { } else if let pic = msg.pictureElem {
content = ""
voiceUrl = ""
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
imageW = msgImageW imageW = msgImageW
imageH = msgImageH imageH = msgImageH
} else { } else {
content = msg.textElem?.content ?? "" content = msg.textElem?.content ?? ""
voiceUrl = ""
imageUrl = ""
imageW = 0; imageH = 0
} }
let sendID = msg.sendID ?? "" let sendID = msg.sendID ?? ""
@ -336,37 +696,81 @@ final class GroupChatViewModel {
imageWidth: imageW, imageWidth: imageW,
imageHeight: imageH, imageHeight: imageH,
timestamp: ts, timestamp: ts,
showTime: false showTime: false,
quotePreview: quotePreview,
atUserIDs: atUserIDs,
atNicknames: atNicknames,
isAtAll: isAtAll
) )
} }
// emoji pattern: js_emoji: private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview {
private let emojiPattern = try? NSRegularExpression(pattern: "^js_emoji:(\\d+)$", options: []) 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? { 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), if (msg.contentType.rawValue == 1501 || msg.contentType.rawValue == 1510 || msg.contentType.rawValue == 1520),
let noti = msg.notificationElem, let noti = msg.notificationElem,
let text = parseNotification(noti, contentType: msg.contentType.rawValue) { let text = parseNotification(noti, contentType: msg.contentType.rawValue) {
let ts = TimeInterval(msg.sendTime) / 1000.0 let ts = TimeInterval(msg.sendTime) / 1000.0
return .notification(text, showTime: false, timestamp: ts) return .notification(text, showTime: false, timestamp: ts)
} }
//
// 104 video 沿
if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 { if msg.contentType.rawValue == 103 || msg.contentType.rawValue == 104 {
let chatMsg = convert(msg) let chatMsg = convert(msg)
return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg) return chatMsg.isSelf ? .voiceSend(chatMsg) : .voiceReceived(chatMsg)
} }
//
if msg.contentType.rawValue == 102 { if msg.contentType.rawValue == 102 {
let chatMsg = convert(msg) let chatMsg = convert(msg)
return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg) return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg)
} }
//
// / @ /
let chatMsg = convert(msg) let chatMsg = convert(msg)
if chatMsg.content.isEmpty { return nil } if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil }
// emoji
if let pattern = emojiPattern, 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 ? .emojiSend(chatMsg) : .emojiReceived(chatMsg)
} }
return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg) return chatMsg.isSelf ? .send(chatMsg) : .received(chatMsg)
@ -379,35 +783,27 @@ final class GroupChatViewModel {
switch contentType { switch contentType {
case 1501: case 1501:
// admin
guard let opUser = json["opUser"] as? [String: Any] else { return nil } guard let opUser = json["opUser"] as? [String: Any] else { return nil }
let groupID = opUser["groupID"] as? String ?? "" let groupID = opUser["groupID"] as? String ?? ""
let isOwner = groupID.contains(AppContextManager.shared.userId) let isOwner = groupID.contains(AppContextManager.shared.userId)
let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建" let text = isOwner ? "\(AppContextManager.shared.name) 创建了圈子" : "圈子已经创建"
return NSAttributedString(string: text) return NSAttributedString(string: text)
case 1510: case 1510:
//
guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil } guard let entrantUser = json["entrantUser"] as? [String: Any] else { return nil }
let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? "" let nickName = entrantUser["nickname"] as? String ?? entrantUser["userID"] as? String ?? ""
let text = "\(nickName) 加入了圈子" return NSAttributedString(string: "\(nickName) 加入了圈子")
return NSAttributedString(string: text)
case 1520: case 1520:
//
guard let opUser = json["opUser"] as? [String: Any] else { return nil } guard let opUser = json["opUser"] as? [String: Any] else { return nil }
let opUserID = opUser["userID"] as? String ?? "" let opUserID = opUser["userID"] as? String ?? ""
let opNickName = getUserNickName(id: opUserID) let opNickName = getUserNickName(id: opUserID)
let newName = group["groupName"] as? String ?? "" let newName = group["groupName"] as? String ?? ""
guard !newName.isEmpty else { return nil } guard !newName.isEmpty else { return nil }
let tip = "\(opNickName) 将群名称修改为 " let tip = "\(opNickName) 将群名称修改为 "
let result = NSMutableAttributedString(string: tip + newName) let result = NSMutableAttributedString(string: tip + newName)
result.addAttribute(.font, value: UIFont.systemFont(ofSize: 12), range: NSRange(location: 0, length: result.length)) 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) let nameRange = NSRange(location: tip.count, length: newName.utf16.count)
result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange) result.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange)
return result return result
default: default:
return nil return nil
} }
@ -416,22 +812,24 @@ final class GroupChatViewModel {
// MARK: - ChatSectionItem Helpers // MARK: - ChatSectionItem Helpers
extension ChatSectionItem { extension ChatSectionItem {
/// ChatMessage case
var chatMessage: ChatMessage? { var chatMessage: ChatMessage? {
switch self { switch self {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), 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): let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m):
return 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 { static func with(_ msg: ChatMessage) -> ChatSectionItem {
if !msg.imageUrl.isEmpty { if !msg.imageUrl.isEmpty {
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg) 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) return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg)
} }
if !msg.voiceUrl.isEmpty { if !msg.voiceUrl.isEmpty {