Jelajahi Sumber

Merge remote-tracking branch 'origin/dev' into feat/room

* origin/dev:
  feat: 用户搜索增加去重逻辑
  fix: 修复语音欢迎语播放异常的问题
  fix: 修复欢迎语解析异常的问题
  feat: adjust 的 deviceId 与应用的 deviceId 保持一致?
  feat: 调整 UIScrollView 嵌套滚动逻辑处理
  feat: 修改陪玩师过滤价格文案
  fix: 修复潜在用户没展示在线状态的问题,调整潜在用户的协议逻辑

# Conflicts:
#	Lanu/Views/Profile/Profile/LNProfilePhotoWall.swift
陈文艺 4 minggu lalu
induk
melakukan
9cce055f8c

+ 1 - 0
Lanu.xcodeproj/project.pbxproj

@@ -82,6 +82,7 @@
 				Common/Views/LNCircleProgressView.swift,
 				Common/Views/LNCountrySelectPanel.swift,
 				Common/Views/LNGenderView.swift,
+				Common/Views/LNNestedScrollView.swift,
 				Common/Views/LNOnlineView.swift,
 				Common/Views/LNPopupView.swift,
 				Common/Views/LNSortedEditView.swift,

+ 1 - 0
Lanu/AppDelegate.swift

@@ -121,6 +121,7 @@ extension AppDelegate {
         }
         let config = ADJConfig(appToken: token, environment: env)
         config?.logLevel = logLevel
+        config?.externalDeviceId = curDeviceId
         config?.enableCostDataInAttribution()
 //        config?.delegate = self
         Adjust.initSdk(config)

+ 0 - 108
Lanu/Common/Extension/UIScrollView+Extension.swift

@@ -60,111 +60,3 @@ extension UIScrollView: LNKeyboardNotify {
         contentInset = originContentInset
     }
 }
-
-extension UIScrollView: UIGestureRecognizerDelegate {
-    func toBeNested() {
-        panGestureRecognizer.delegate = self
-        bounces = false
-    }
-    
-    // MARK: - UIGestureRecognizerDelegate
-    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
-        return true
-    }
-    
-    open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
-        guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer,
-              let currentScrollView = panGesture.view as? UIScrollView else {
-            return true
-        }
-        
-        guard currentScrollView.isGestureWithinSelfBounds(panGesture) else {
-            return false // 不在范围内,直接忽略手势
-        }
-        
-        // 获取滑动方向(向上:y 负,向下:y 正)
-        let translation = panGesture.translation(in: currentScrollView)
-        let isScrollingUp = translation.y < 0 // 向上滑
-        let isScrollingDown = translation.y > 0 // 向下滑
-        
-        // 根据滑动方向判断当前 ScrollView 是否可以响应手势
-        if isScrollingUp {
-            return currentScrollView.checkOuterScrollViewsAtBottom(panGesture: panGesture)
-        } else if isScrollingDown {
-            return currentScrollView.checkInnerScrollViewsAtTop(panGesture: panGesture)
-        }
-        
-        return true
-    }
-    
-    // MARK: - UI 树遍历核心逻辑
-    /// 上滑:检查所有外层 ScrollView 是否都滚到底部
-    private func checkOuterScrollViewsAtBottom(panGesture: UIPanGestureRecognizer) -> Bool {
-        var currentSuperview = self.superview
-        // 向上遍历父视图,找所有外层 ScrollView
-        while let superview = currentSuperview {
-            if let outerScrollView = superview as? UIScrollView {
-                // 只要有一个外层没滚到底,当前 ScrollView 就不能响应
-                if !outerScrollView.isAtBottom() {
-                    return false
-                }
-            }
-            currentSuperview = superview.superview
-        }
-        // 所有外层都滚到底,当前可以响应
-        return !isAtBottom()
-    }
-    
-    /// 下滑:检查所有内层 ScrollView 是否都滚到顶部
-    private func checkInnerScrollViewsAtTop(panGesture: UIPanGestureRecognizer) -> Bool {
-        // 向下遍历子视图,找所有内层 ScrollView
-        // 递归遍历子视图的子视图(深层嵌套)
-        if findFirstNotAtTopScrollView(in: self, panGesture: panGesture) != nil {
-            return false
-        }
-        // 所有内层都滚到顶,当前可以响应
-        return !isAtTop()
-    }
-    
-    /// 递归查找子视图中的第一个 ScrollView(处理深层嵌套)
-    private func findFirstNotAtTopScrollView(
-        in view: UIView,
-        panGesture: UIPanGestureRecognizer) -> UIScrollView? {
-        for subview in view.subviews {
-            if let scrollView = subview as? UIScrollView,
-                scrollView.isGestureWithinSelfBounds(panGesture),
-                !scrollView.isAtTop() {
-                return scrollView
-            }
-            if let found = findFirstNotAtTopScrollView(in: subview, panGesture: panGesture) {
-                return found
-            }
-        }
-        return nil
-    }
-    
-    // MARK: - 滚动边界判断
-    private func isAtTop() -> Bool {
-        let topOffset = contentOffset.y + adjustedContentInset.top
-        return topOffset <= 1 // 允许1pt误差
-    }
-    
-    private func isAtBottom() -> Bool {
-        let contentHeight = contentSize.height + adjustedContentInset.bottom
-        let bottomOffset = contentOffset.y + bounds.height
-        return bottomOffset >= contentHeight - 1
-    }
-    
-    /// 判断手势触摸点是否在当前 ScrollView 的可视范围内
-    private func isGestureWithinSelfBounds(_ panGesture: UIPanGestureRecognizer) -> Bool {
-        // 1. 获取手势的触摸点(相对于当前 ScrollView)
-        let touchLocation = panGesture.location(in: self)
-        // 2. 转换为 ScrollView 的 bounds 坐标系(处理 contentOffset 和 contentInset)
-        let convertedPoint = CGPoint(
-            x: touchLocation.x - adjustedContentInset.left,
-            y: touchLocation.y - adjustedContentInset.top
-        )
-        // 3. 校验点是否在 ScrollView 的可视区域内(排除超出 bounds 的部分)
-        return bounds.contains(convertedPoint)
-    }
-}

+ 199 - 0
Lanu/Common/Views/LNNestedScrollView.swift

@@ -0,0 +1,199 @@
+//
+//  LNNestedScrollView.swift
+//  Gami
+//
+//  Created by OneeChan on 2026/3/12.
+//
+
+import Foundation
+import UIKit
+
+
+private class ScrollViewWeakWrapper {
+    weak var delegate: LNNestedScrollViewDelegate?
+    weak var childListView: UIScrollView?
+}
+
+private var scrollViewWeakWrapperKey: UInt8 = 0
+private extension UIScrollView {
+    var minTopOffset: CGFloat {
+        contentSize.height - bounds.height - contentInset.top + contentInset.bottom
+    }
+    var weakWrapper: ScrollViewWeakWrapper {
+        get {
+            var wrapper = objc_getAssociatedObject(self, &scrollViewWeakWrapperKey)
+                as? ScrollViewWeakWrapper
+            if wrapper == nil {
+                wrapper = ScrollViewWeakWrapper()
+                objc_setAssociatedObject(
+                    self,
+                    &scrollViewWeakWrapperKey,
+                    wrapper,
+                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+                )
+            }
+            return wrapper!
+        }
+        set {
+            objc_setAssociatedObject(
+                self,
+                &scrollViewWeakWrapperKey,
+                newValue,
+                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+            )
+        }
+    }
+    
+    weak var curChildListView: UIScrollView? {
+        get { weakWrapper.childListView }
+        set { weakWrapper.childListView = newValue }
+    }
+}
+
+extension UIScrollView: LNNestedScrollViewDelegate {
+    func listViewDidScroll(_ scrollView: UIScrollView) -> Bool {
+        curChildListView = scrollView
+        
+        if contentOffset.y < minTopOffset - 0.001 {
+            return true
+        }
+        
+        contentOffset.y = minTopOffset
+        return false
+    }
+}
+
+extension UIScrollView {
+    weak var observerDelegate: LNNestedScrollViewDelegate? {
+        get { weakWrapper.delegate }
+        set { weakWrapper.delegate = newValue }
+    }
+}
+
+
+protocol LNNestedScrollViewDelegate: NSObject {
+    func listViewDidScroll(_ scrollView: UIScrollView) -> Bool
+}
+
+
+private class ScrollViewDelegateProxy: NSObject, UIScrollViewDelegate {
+    weak var originalDelegate: UIScrollViewDelegate?
+    weak var observerDelegate: UIScrollViewDelegate?
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        originalDelegate?.scrollViewDidScroll?(scrollView)
+        observerDelegate?.scrollViewDidScroll?(scrollView)
+    }
+    
+    override func forwardingTarget(for aSelector: Selector!) -> Any? {
+        if originalDelegate?.responds(to: aSelector) == true {
+            return originalDelegate
+        }
+        return super.forwardingTarget(for: aSelector)
+    }
+    
+    override func responds(to aSelector: Selector!) -> Bool {
+        return super.responds(to: aSelector) || originalDelegate?.responds(to: aSelector) == true
+    }
+}
+
+
+class LNNestedScrollView: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
+    weak var scrollDelegate: UIScrollViewDelegate? {
+        didSet {
+            proxy.originalDelegate = scrollDelegate
+            delegate = nil
+            delegate = proxy
+        }
+    }
+    private var proxy = ScrollViewDelegateProxy()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        
+        proxy.originalDelegate = scrollDelegate
+        proxy.observerDelegate = self
+        delegate = proxy
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        if observerDelegate?.listViewDidScroll(self) == true {
+            scrollView.contentOffset.y = -contentInset.top
+        }
+        
+        if let curChildListView,
+            curChildListView.contentOffset.y > -curChildListView.contentInset.top {
+            scrollView.contentOffset.y = minTopOffset
+        }
+    }
+    
+    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+        gestureRecognizer.isKind(of: UIPanGestureRecognizer.self) && otherGestureRecognizer.isKind(of: UIPanGestureRecognizer.self)
+    }
+}
+
+private class TableViewDelegateProxy: NSObject, UITableViewDelegate {
+    weak var originalDelegate: UITableViewDelegate?
+    weak var observerDelegate: UITableViewDelegate?
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        originalDelegate?.scrollViewDidScroll?(scrollView)
+        observerDelegate?.scrollViewDidScroll?(scrollView)
+    }
+    
+    override func forwardingTarget(for aSelector: Selector!) -> Any? {
+        if originalDelegate?.responds(to: aSelector) == true {
+            return originalDelegate
+        }
+        return super.forwardingTarget(for: aSelector)
+    }
+    
+    override func responds(to aSelector: Selector!) -> Bool {
+        super.responds(to: aSelector)
+            || originalDelegate?.responds(to: aSelector) == true
+    }
+}
+
+
+class LNNestedTableView: UITableView, UITableViewDelegate, UIGestureRecognizerDelegate {
+    weak var tableDelegate: UITableViewDelegate? {
+        didSet {
+            proxy.originalDelegate = tableDelegate
+            delegate = nil
+            delegate = proxy
+        }
+    }
+    private var proxy = TableViewDelegateProxy()
+    
+    override init(frame: CGRect, style: UITableView.Style) {
+        super.init(frame: frame, style: style)
+        
+        proxy.originalDelegate = tableDelegate
+        proxy.observerDelegate = self
+        delegate = proxy
+    }
+    
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        if observerDelegate?.listViewDidScroll(self) == true {
+            scrollView.contentOffset.y = -contentInset.top
+        }
+        
+        
+        if let curChildListView,
+            curChildListView.contentOffset.y > -curChildListView.contentInset.top {
+            scrollView.contentOffset.y = minTopOffset
+        }
+    }
+    
+    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
+        gestureRecognizer.isKind(of: UIPanGestureRecognizer.self) && otherGestureRecognizer.isKind(of: UIPanGestureRecognizer.self)
+    }
+}

+ 29 - 5
Lanu/Common/Views/LNOnlineView.swift

@@ -11,10 +11,26 @@ import SnapKit
 
 
 class LNOnlineView: UIView {
-    private let borderColor: UIColor = .primary_3
-    private let borderWidth: CGFloat = 1
-    private let duration: Double = 1.2
-    private let scale = 1.1
+    var borderColor: UIColor = .primary_3 {
+        didSet {
+            reset()
+        }
+    }
+    var borderWidth: CGFloat = 1 {
+        didSet {
+            reset()
+        }
+    }
+    var duration: Double = 1.2 {
+        didSet {
+            reset()
+        }
+    }
+    var offset = 5.0 {
+        didSet {
+            reset()
+        }
+    }
     private var animator: UIViewPropertyAnimator?
     
     private let borderLayer = CAShapeLayer()
@@ -36,7 +52,7 @@ class LNOnlineView: UIView {
     private func startAnimate() {
         let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
         scaleAnim.fromValue = 1.0
-        scaleAnim.toValue = scale
+        scaleAnim.toValue = 1.0 + offset / CGFloat(bounds.width / 2)
         scaleAnim.duration = duration
         
         let opacityAnim = CABasicAnimation(keyPath: "opacity")
@@ -58,8 +74,16 @@ class LNOnlineView: UIView {
         super.layoutSubviews()
         
         layer.cornerRadius = bounds.height * 0.5
+        if borderLayer.frame.height != bounds.height {
+            reset()
+        }
+    }
+    
+    private func reset() {
         borderLayer.frame = bounds
         borderLayer.cornerRadius = bounds.height * 0.5
+        borderLayer.removeAllAnimations()
+        startAnimate()
     }
     
     required init?(coder: NSCoder) {

+ 23 - 0
Lanu/Localizable.xcstrings

@@ -10466,6 +10466,29 @@
         }
       }
     },
+    "B00128" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Welcome Message"
+          }
+        },
+        "id" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Pesan Sambutan"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "欢迎语"
+          }
+        }
+      }
+    },
     "C00001" : {
       "extractionState" : "manual",
       "localizations" : {

+ 1 - 1
Lanu/Manager/GameMate/LNGameMateManager.swift

@@ -458,7 +458,7 @@ extension LNGameMateManager {
 // MARK: 潜在用户
 extension LNGameMateManager {
    func getPotentialUsers(queue: DispatchQueue = .main, handler: @escaping (LNPotentialUsersResponse?) -> Void) {
-       LNHttpManager.shared.getPotentialUsers(size: 5) { res, err in
+       LNHttpManager.shared.getPotentialUsers { res, err in
            queue.asyncIfNotGlobal {
                handler(res)
            }

+ 12 - 14
Lanu/Manager/GameMate/Network/LNHttpManager+GameMate.swift

@@ -65,19 +65,19 @@ enum LNGameMateAgeRange: Int, CaseIterable {
 
 enum LNGameMatePriceRange: Int, CaseIterable {
     case all = -1
-    case `0-1000` = 0
-    case `1000-1500` = 1
-    case `1500-2500` = 2
-    case `2500-5000` = 3
-    case `>5000` = 4
+    case `0-10` = 0
+    case `10-15` = 1
+    case `15-25` = 2
+    case `25-50` = 3
+    case `>50` = 4
     
     var text: String {
         switch self {
-        case .`0-1000`: "0-1000"
-        case .`1000-1500`: "1000-1500"
-        case .`1500-2500`: "1500-2500"
-        case .`2500-5000`: "2500-5000"
-        case .`>5000`: "> 5000"
+        case .`0-10`: "0-10"
+        case .`10-15`: "10-15"
+        case .`15-25`: "15-25"
+        case .`25-50`: "25-50"
+        case .`>50`: "> 50"
         case .all: .init(key: "A00008")
         }
     }
@@ -394,9 +394,7 @@ extension LNHttpManager {
 
 // MARK: 潜在用户
 extension LNHttpManager {
-    func getPotentialUsers(size: Int, completion: @escaping (LNPotentialUsersResponse?, LNHttpError?) -> Void) {
-        post(path: kNetPath_GameMate_PotentialUsers_List, params: [
-            "size": size,
-        ], completion: completion)
+    func getPotentialUsers(completion: @escaping (LNPotentialUsersResponse?, LNHttpError?) -> Void) {
+        post(path: kNetPath_GameMate_PotentialUsers_List, completion: completion)
     }
 }

+ 9 - 8
Lanu/Manager/IM/LNIMMessageData.swift

@@ -18,6 +18,7 @@ enum LNIMMessageDataType {
     case voice
     case order
     case call(String)
+    case autoReply(LNAutoReplyType)
 }
 
 enum LNIMMessageCustomType: String, Decodable {
@@ -136,13 +137,13 @@ class LNIMOrderMessage: Decodable, LNOrderProtocol {
 
 @AutoCodable
 class LNAutoReplyTextMessage: Decodable {
-    var text: String = ""
+    var textContent: String = ""
 }
 
 @AutoCodable
 class LNAutoReplyVoiceMessage: Decodable {
-    var voice: String = ""
-    var duration: Int = 0
+    var voiceUrl: String = ""
+    var voiceDuration: Int = 0
 }
 
 private extension V2TIMElemType {
@@ -256,15 +257,15 @@ extension LNIMMessageData {
             case .official_image_text:
                 type = .official // 官方消息
             case .playmate_welcome_text:
-                type = .text
+                type = .autoReply(.text)
                 if let message: LNAutoReplyTextMessage = decodeCustomMessage() {
-                    content = message.text
+                    textContent = NSMutableAttributedString(string: message.textContent)
                 }
             case .playmate_welcome_voice:
-                type = .voice
+                type = .autoReply(.voice)
                 if let message: LNAutoReplyVoiceMessage = decodeCustomMessage() {
-                    content = message.voice
-                    voiceDuration = message.duration
+                    content = message.voiceUrl
+                    voiceDuration = message.voiceDuration
                 }
             case .none: type = .none
             }

+ 2 - 0
Lanu/Manager/IM/TUIUtils/V2TIMConversation+Extension.swift

@@ -46,6 +46,8 @@ extension V2TIMConversation {
                     attrString.append(.init(string: .init(key: "A00020")))
                 } else if case .call = imMessage.type {
                     attrString.append(.init(string: .init(key: "C00012")))
+                } else if case .autoReply = imMessage.type {
+                    attrString.append(.init(string: .init(key: "B00128")))
                 } else {
                     let lastMsgStr = lastMessage.displayString
                     guard let lastMsgStr else { return nil }

+ 1 - 0
Lanu/Views/Game/MateList/LNGameMateListCell.swift

@@ -229,6 +229,7 @@ extension LNGameMateListCell {
             make.horizontalEdges.equalToSuperview()
         }
         
+        onlineView.offset = 5
         container.addSubview(onlineView)
         onlineView.snp.makeConstraints { make in
             make.edges.equalTo(avatar).inset(-2)

+ 15 - 10
Lanu/Views/Game/OrderCenter/LNPotentialUserViewController.swift

@@ -31,7 +31,7 @@ class LNPotentialUserViewController: LNViewController {
     }
 }
 
-extension LNPotentialUserViewController: UITableViewDataSource, UITableViewDelegate {
+extension LNPotentialUserViewController: UITableViewDataSource {
     func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
         users.count
     }
@@ -52,6 +52,13 @@ extension LNPotentialUserViewController {
     private func setupViews() {
         title = .init(key: "B00116")
         
+        emptyView.isHidden = true
+        view.addSubview(emptyView)
+        emptyView.snp.makeConstraints { make in
+            make.centerX.equalToSuperview()
+            make.centerY.equalToSuperview().multipliedBy(0.6)
+        }
+        
         let header = MJRefreshNormalHeader { [weak self] in
             guard let self else { return }
             self.loadList()
@@ -60,7 +67,6 @@ extension LNPotentialUserViewController {
         header.stateLabel?.isHidden = true
         tableView.mj_header = header
         
-        tableView.delegate = self
         tableView.dataSource = self
         tableView.separatorStyle = .none
         tableView.showsVerticalScrollIndicator = false
@@ -71,13 +77,6 @@ extension LNPotentialUserViewController {
         tableView.snp.makeConstraints { make in
             make.edges.equalToSuperview()
         }
-        
-        emptyView.isHidden = true
-        tableView.addSubview(emptyView)
-        emptyView.snp.makeConstraints { make in
-            make.centerX.equalToSuperview()
-            make.centerY.equalToSuperview().multipliedBy(0.6)
-        }
     }
     
     private func loadList() {
@@ -102,7 +101,7 @@ extension LNPotentialUserViewController {
                 }
             }
             
-            self.tableView.mj_header?.endRefreshing()
+            tableView.mj_header?.endRefreshing()
         }
     }
 }
@@ -172,6 +171,12 @@ private extension LNPotentialUserItemCell {
             make.width.height.equalTo(40)
         }
         
+        onlineView.offset = 5
+        container.addSubview(onlineView)
+        onlineView.snp.makeConstraints { make in
+            make.edges.equalTo(avatar).inset(-1)
+        }
+        
         let infoView = UIStackView()
         infoView.axis = .vertical
         infoView.spacing = 4

+ 1 - 0
Lanu/Views/Game/OrderCenter/Visitors/LNVisitorItemCell.swift

@@ -59,6 +59,7 @@ extension LNVisitorItemCell {
             make.bottom.equalToSuperview().offset(-25)
         }
         
+        onlineView.offset = 5
         contentView.addSubview(onlineView)
         onlineView.snp.makeConstraints { make in
             make.edges.equalTo(avatar).inset(-2)

+ 14 - 0
Lanu/Views/IM/Chat/Cells/LNIMChatVoiceMessageCell.swift

@@ -26,6 +26,11 @@ class LNIMChatVoiceMessageCell: LNIMChatBaseMessageCell {
     private var curItem: LNIMMessageData?
     
     private var isPlaying: Bool {
+        if let content = curItem?.content,
+           content.lowercased().starts(with: "http") {
+            return LNVoicePlayer.shared.playingUrl == content
+        }
+        
         guard let playingUrl = LNVoicePlayer.shared.playingUrl else {
             return false 
         }
@@ -177,6 +182,15 @@ class LNIMChatVoiceMessageCell: LNIMChatBaseMessageCell {
         container.onTap { [weak self] in
             guard let self else { return }
             guard let curItem else { return }
+            if let content = curItem.content,
+               content.lowercased().starts(with: "http") {
+                if LNVoicePlayer.shared.playingUrl == content {
+                    LNVoicePlayer.shared.stop()
+                } else {
+                    LNVoicePlayer.shared.play(content)
+                }
+                return
+            }
             if LNVoicePlayer.shared.playingUrl == curItem.imMessage.soundElem?.uuid {
                 LNVoicePlayer.shared.stop()
                 return

+ 11 - 0
Lanu/Views/IM/Chat/LNIMChatViewController.swift

@@ -210,6 +210,17 @@ extension LNIMChatViewController: UITableViewDataSource, UITableViewDelegate {
         }
         
         switch data.type {
+        case .autoReply(let type):
+            switch type {
+            case .text:
+                let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatTextMessageCell.className, for: indexPath) as! LNIMChatTextMessageCell
+                cell.update(data, viewModel: viewModel)
+                return cell
+            case .voice:
+                let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatVoiceMessageCell.className, for: indexPath) as! LNIMChatVoiceMessageCell
+                cell.update(data, viewModel: viewModel)
+                return cell
+            }
         case .system:
             let cell = tableView.dequeueReusableCell(withIdentifier: LNIMChatSystemMessageCell.className, for: indexPath) as! LNIMChatSystemMessageCell
             cell.update(data)

+ 6 - 1
Lanu/Views/Profile/Profile/LNProfileInfosView.swift

@@ -34,7 +34,12 @@ class LNProfileInfosView: UIView {
         }
     }
     
-    weak var outerScrollView: UIScrollView?
+    weak var outerScrollView: LNNestedScrollViewDelegate? {
+        didSet {
+            detailView.outerScrollView = outerScrollView
+            photoWall.outerScrollView = outerScrollView
+        }
+    }
     
     override init(frame: CGRect) {
         super.init(frame: frame)

+ 10 - 6
Lanu/Views/Profile/Profile/LNProfilePhotoWall.swift

@@ -26,7 +26,7 @@ class LNProfilePhotoWall: UIView {
     private let columnCount = 2
     private var allPhotos: [LNProfilePhotoWallMode] = []
     private var columns: [[LNProfilePhotoWallMode]] = []
-    private var tableViews: [UITableView] = []
+    private var tableViews: [LNNestedTableView] = []
     
     private(set) var contentHeight: CGFloat = 0.0 {
         didSet {
@@ -35,6 +35,11 @@ class LNProfilePhotoWall: UIView {
     }
     
     weak var delegate: LNProfilePhotoWallDelegate?
+    weak var outerScrollView: LNNestedScrollViewDelegate? {
+        didSet {
+            tableViews.forEach { $0.observerDelegate = outerScrollView }
+        }
+    }
     
     override init(frame: CGRect) {
         super.init(frame: frame)
@@ -151,7 +156,7 @@ extension LNProfilePhotoWall: UITableViewDataSource {
         }
         
         let mode = columns[index][indexPath.row]
-        cell.update(mode, isFirst: indexPath.row == 0)
+        cell.update(mode)
         cell.handler = { [weak self] mode in
             guard let self else { return }
             let urls = allPhotos.map { $0.url }
@@ -183,13 +188,13 @@ extension LNProfilePhotoWall {
         }
         
         for _ in 0..<columnCount {
-            let tableView = UITableView()
+            let tableView = LNNestedTableView()
             tableView.separatorStyle = .none
             tableView.showsHorizontalScrollIndicator = false
             tableView.showsVerticalScrollIndicator = false
             tableView.register(LNProfilePhotoWallCell.self, forCellReuseIdentifier: LNProfilePhotoWallCell.className)
             tableView.dataSource = self
-            tableView.delegate = self
+            tableView.tableDelegate = self
             tableView.allowsSelection = false
             tableView.contentInset = .init(top: 6, left: 0, bottom: 0, right: 0)
             tableView.backgroundColor = .clear
@@ -202,7 +207,6 @@ extension LNProfilePhotoWall {
                 })
                 contentHeight = (maxHeightItem?.contentInset.top ?? 0) + (maxHeightItem?.contentSize.height ?? 0)
             }.store(in: &cancellables)
-            tableView.toBeNested()
             stackView.addArrangedSubview(tableView)
             tableViews.append(tableView)
         }
@@ -230,7 +234,7 @@ private class LNProfilePhotoWallCell: UITableViewCell {
         }
     }
     
-    func update(_ mode: LNProfilePhotoWallMode, isFirst: Bool) {
+    func update(_ mode: LNProfilePhotoWallMode) {
         photo.snp.remakeConstraints { make in
             make.horizontalEdges.equalToSuperview()
             make.top.equalToSuperview().offset(10)

+ 8 - 3
Lanu/Views/Profile/Profile/LNProfileUserDetailView.swift

@@ -23,7 +23,7 @@ class LNProfileUserDetailView: UIView {
         }
     }
     
-    private let tableView = UITableView(frame: .zero, style: .grouped)
+    private let tableView = LNNestedTableView(frame: .zero, style: .grouped)
     private let providers: [LNProfileBaseProvider] = [
         LNProfileUserInfoProvider(),
         LNProfileSkillListProvider(),
@@ -33,6 +33,11 @@ class LNProfileUserDetailView: UIView {
     
     private var curDetail: LNUserProfileVO?
     weak var delegate: LNProfileUserDetailViewDelegate?
+    weak var outerScrollView: LNNestedScrollViewDelegate? {
+        didSet {
+            tableView.observerDelegate = outerScrollView
+        }
+    }
     
     override init(frame: CGRect) {
         super.init(frame: frame)
@@ -118,21 +123,21 @@ extension LNProfileUserDetailView: UITableViewDataSource, UITableViewDelegate {
 
 extension LNProfileUserDetailView {
     private func setupViews() {
+        tableView.tableDelegate = self
         tableView.dataSource = self
-        tableView.delegate = self
         tableView.allowsSelection = false
         tableView.backgroundColor = .white
         tableView.showsVerticalScrollIndicator = false
         tableView.showsHorizontalScrollIndicator = false
         tableView.separatorStyle = .none
         tableView.contentInset = .init(top: 16, left: 0, bottom: 0, right: 0)
+        tableView.bounces = false
         tableView.publisher(for: \.contentSize).removeDuplicates().sink
         { [weak self] newValue in
             guard let self else { return }
             contentHeight = newValue.height + tableView.contentInset.top + tableView.contentInset.bottom
         }.store(in: &cancellables)
         addSubview(tableView)
-        tableView.toBeNested()
         tableView.snp.makeConstraints { make in
             make.edges.equalToSuperview()
         }

+ 4 - 3
Lanu/Views/Profile/Profile/LNProfileViewController.swift

@@ -22,7 +22,6 @@ extension UIView {
 class LNProfileViewController: LNViewController {
     private let uid: String
     
-    
     private let avatar = UIImageView()
     private let titleLabel = UILabel()
     
@@ -35,7 +34,7 @@ class LNProfileViewController: LNViewController {
     
     private var hasFollow = false
     
-    private let scrollView = UIScrollView()
+    private let scrollView = LNNestedScrollView()
     private let cover = UIImageView()
     private let voiceBar = LNProfileVoiceBarView()
     private let userInfoView = LNProfileUserInfoView()
@@ -216,7 +215,7 @@ extension LNProfileViewController {
         scrollView.showsVerticalScrollIndicator = false
         scrollView.showsHorizontalScrollIndicator = false
         scrollView.backgroundColor = .fill
-        scrollView.delegate = self
+        scrollView.scrollDelegate = self
         scrollView.contentInset = .init(top: 0, left: 0, bottom: uid.isMyUid ? 0 : 80, right: 0)
         scrollView.clipsToBounds = false
         scrollView.bounces = false
@@ -277,6 +276,8 @@ extension LNProfileViewController {
             make.centerY.equalToSuperview()
             make.trailing.equalToSuperview().offset(-8)
         }
+        
+        infoView.outerScrollView = scrollView
     }
     
     private func buildCover() -> UIView {

+ 3 - 1
Lanu/Views/Search/LNUserSearchViewController.swift

@@ -66,7 +66,9 @@ extension LNUserSearchViewController {
             if nextTag?.isEmpty != false {
                 curList = list
             } else {
-                curList.append(contentsOf: list)
+                curList.append(contentsOf: list.filter({ item in
+                    !self.curList.contains { $0.userNo == item.userNo }
+                }))
             }
             nextTag = next