This commit is contained in:
parent
36a99221a8
commit
5674603e94
|
|
@ -70,6 +70,9 @@ enum UserAPI {
|
|||
/// 查询本机离线期间是否允许解锁
|
||||
case phoneUnlockAllow(os: String)
|
||||
|
||||
/// 确认本机已执行解锁
|
||||
case phoneUnlockAllowConfirm(os: String, groupKey: String, userId: String, tokens: [String])
|
||||
|
||||
/// 用户IM Token
|
||||
case imToken
|
||||
|
||||
|
|
@ -172,7 +175,7 @@ extension UserAPI: MultiTargetProtocol {
|
|||
return "mapi/phone/unlock/request"
|
||||
case .phoneUnlock:
|
||||
return "mapi/phone/unlock"
|
||||
case .phoneUnlockAllow:
|
||||
case .phoneUnlockAllow, .phoneUnlockAllowConfirm:
|
||||
return "mapi/phone/unlock/allow"
|
||||
case .imToken:
|
||||
return "mapi/openim/user/token/get"
|
||||
|
|
@ -358,6 +361,17 @@ extension UserAPI: MultiTargetProtocol {
|
|||
parameters: ["os": os],
|
||||
encoding: URLEncoding.queryString
|
||||
)
|
||||
|
||||
case let .phoneUnlockAllowConfirm(os, groupKey, userId, tokens):
|
||||
return .requestParameters(
|
||||
parameters: [
|
||||
"os": os,
|
||||
"group_key": groupKey,
|
||||
"user_id": userId,
|
||||
"token": tokens
|
||||
],
|
||||
encoding: JSONEncoding()
|
||||
)
|
||||
|
||||
case .imToken:
|
||||
var params = Parameters()
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ extension Notification.Name {
|
|||
static let lockDistractAppsDidChange = Notification.Name("lockDistractAppsDidChange")
|
||||
}
|
||||
|
||||
enum GroupDataChangeUserInfoKey {
|
||||
static let groupKey = "group_key"
|
||||
static let userId = "user_id"
|
||||
static let operation = "operation"
|
||||
static let source = "source"
|
||||
}
|
||||
|
||||
enum GroupDataChangeSource {
|
||||
static let api = "api"
|
||||
static let mqtt = "mqtt"
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 普通常量
|
||||
/// KeyWindow
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension UITextInput {
|
||||
var isComposingText: Bool {
|
||||
markedTextRange != nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UITextField {
|
||||
func placeholderColor(placeholder: String, color: UIColor, font: UIFont = UIFont.systemFont(ofSize:15) ) {
|
||||
// 字体大小
|
||||
|
|
@ -23,5 +29,52 @@ extension UITextField {
|
|||
self.leftView = leftView
|
||||
self.leftViewMode = UITextField.ViewMode.always
|
||||
}
|
||||
|
||||
|
||||
@discardableResult
|
||||
func limitCommittedText(to maximumLength: Int) -> String? {
|
||||
guard !isComposingText else { return nil }
|
||||
let currentText = text ?? ""
|
||||
guard maximumLength > 0, currentText.count > maximumLength else {
|
||||
return currentText
|
||||
}
|
||||
|
||||
let cursorOffset = selectedTextRange.map {
|
||||
offset(from: beginningOfDocument, to: $0.start)
|
||||
} ?? (currentText as NSString).length
|
||||
let limitedText = String(currentText.prefix(maximumLength))
|
||||
text = limitedText
|
||||
restoreCursor(to: cursorOffset, text: limitedText)
|
||||
return limitedText
|
||||
}
|
||||
|
||||
private func restoreCursor(to offset: Int, text: String) {
|
||||
let validOffset = min(max(0, offset), (text as NSString).length)
|
||||
guard let cursorPosition = position(from: beginningOfDocument, offset: validOffset) else { return }
|
||||
selectedTextRange = textRange(from: cursorPosition, to: cursorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
extension UITextView {
|
||||
@discardableResult
|
||||
func limitCommittedText(to maximumLength: Int) -> String? {
|
||||
guard !isComposingText else { return nil }
|
||||
let currentText = text ?? ""
|
||||
guard maximumLength > 0, currentText.count > maximumLength else {
|
||||
return currentText
|
||||
}
|
||||
|
||||
let cursorOffset = selectedTextRange.map {
|
||||
offset(from: beginningOfDocument, to: $0.start)
|
||||
} ?? (currentText as NSString).length
|
||||
let limitedText = String(currentText.prefix(maximumLength))
|
||||
text = limitedText
|
||||
restoreCursor(to: cursorOffset, text: limitedText)
|
||||
return limitedText
|
||||
}
|
||||
|
||||
private func restoreCursor(to offset: Int, text: String) {
|
||||
let validOffset = min(max(0, offset), (text as NSString).length)
|
||||
guard let cursorPosition = position(from: beginningOfDocument, offset: validOffset) else { return }
|
||||
selectedTextRange = textRange(from: cursorPosition, to: cursorPosition)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ extension PhoneLockRecord {
|
|||
}
|
||||
}
|
||||
|
||||
private struct MqttAppUnlockReceiptBody: Codable {
|
||||
private struct LegacyMqttAppUnlockReceiptBody: Codable {
|
||||
let os: String
|
||||
let group_key: String
|
||||
let group_name: String
|
||||
|
|
@ -145,27 +145,21 @@ private struct MqttAppUnlockReceiptBody: Codable {
|
|||
let token: [String]
|
||||
}
|
||||
|
||||
private struct MqttAppUnlockReceiptData: Codable {
|
||||
let app_unlock: MqttAppUnlockReceiptBody
|
||||
private struct LegacyMqttAppUnlockReceiptData: Codable {
|
||||
let app_unlock: LegacyMqttAppUnlockReceiptBody
|
||||
}
|
||||
|
||||
private struct MqttAppUnlockReceiptPayload: Codable {
|
||||
private struct LegacyMqttAppUnlockReceiptPayload: Codable {
|
||||
let type: String
|
||||
let data: MqttAppUnlockReceiptData
|
||||
let data: LegacyMqttAppUnlockReceiptData
|
||||
let extra: String
|
||||
}
|
||||
|
||||
private struct PendingAppUnlockReceipt: Codable, Equatable {
|
||||
private struct LegacyPendingAppUnlockReceipt: Codable {
|
||||
let identifier: String
|
||||
let userId: String
|
||||
let payload: String
|
||||
|
||||
init(userId: String, payload: String) {
|
||||
identifier = UUID().uuidString
|
||||
self.userId = userId
|
||||
self.payload = payload
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case identifier
|
||||
case userId
|
||||
|
|
@ -180,18 +174,35 @@ private struct PendingAppUnlockReceipt: Codable, Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ExpectedAppUnlockEcho {
|
||||
var count: Int
|
||||
var expiresAt: Date
|
||||
private struct PendingAppUnlockConfirmation: Codable, Equatable {
|
||||
let identifier: String
|
||||
let userId: String
|
||||
let os: String
|
||||
let groupKey: String
|
||||
let tokens: [String]
|
||||
|
||||
init(
|
||||
identifier: String = UUID().uuidString,
|
||||
userId: String,
|
||||
os: String,
|
||||
groupKey: String,
|
||||
tokens: [String]
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.userId = userId
|
||||
self.os = os
|
||||
self.groupKey = groupKey
|
||||
self.tokens = tokens
|
||||
}
|
||||
}
|
||||
|
||||
final class AppUnlockCoordinator {
|
||||
static let shared = AppUnlockCoordinator()
|
||||
|
||||
private let pendingReceiptsKey = "AppUnlockPendingReceipts"
|
||||
private var pendingReceipts: [PendingAppUnlockReceipt]
|
||||
private var inFlightReceipts: [UInt16: PendingAppUnlockReceipt] = [:]
|
||||
private var expectedOutgoingEchoes: [String: ExpectedAppUnlockEcho] = [:]
|
||||
private let pendingConfirmationsKey = "AppUnlockPendingConfirmations"
|
||||
private let legacyPendingReceiptsKey = "AppUnlockPendingReceipts"
|
||||
private var pendingConfirmations: [PendingAppUnlockConfirmation]
|
||||
private var inFlightConfirmation: PendingAppUnlockConfirmation?
|
||||
private var allowRequestUserId = ""
|
||||
private var completedAllowUserIds: Set<String> = []
|
||||
private var observedUserId = ""
|
||||
|
|
@ -200,18 +211,28 @@ final class AppUnlockCoordinator {
|
|||
private var locallyUnlockedAllUserIds: Set<String> = []
|
||||
private var localUnlockSuppressionExpirations: [String: Date] = [:]
|
||||
private var allowDisposable: Disposable?
|
||||
private var confirmationDisposable: Disposable?
|
||||
private let confirmationNetworkMonitor = NWPathMonitor()
|
||||
private let confirmationNetworkQueue = DispatchQueue(label: "com.quicklocation.app-unlock-confirmation")
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var isStarted = false
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: pendingReceiptsKey),
|
||||
let receipts = try? JSONDecoder().decode([PendingAppUnlockReceipt].self, from: data) {
|
||||
pendingReceipts = receipts
|
||||
if let data = UserDefaults.standard.data(forKey: pendingConfirmationsKey),
|
||||
let confirmations = try? JSONDecoder().decode([PendingAppUnlockConfirmation].self, from: data) {
|
||||
pendingConfirmations = confirmations
|
||||
} else if let data = UserDefaults.standard.data(forKey: legacyPendingReceiptsKey),
|
||||
let receipts = try? JSONDecoder().decode([LegacyPendingAppUnlockReceipt].self, from: data) {
|
||||
pendingConfirmations = receipts.compactMap(Self.makeConfirmation(from:))
|
||||
if let migratedData = try? JSONEncoder().encode(pendingConfirmations) {
|
||||
UserDefaults.standard.set(migratedData, forKey: pendingConfirmationsKey)
|
||||
UserDefaults.standard.removeObject(forKey: legacyPendingReceiptsKey)
|
||||
}
|
||||
} else {
|
||||
pendingReceipts = []
|
||||
pendingConfirmations = []
|
||||
}
|
||||
for receipt in pendingReceipts {
|
||||
restoreLocalUnlockSuppression(from: receipt)
|
||||
for confirmation in pendingConfirmations {
|
||||
rememberLocalUnlock(tokens: confirmation.tokens, userId: confirmation.userId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +240,13 @@ final class AppUnlockCoordinator {
|
|||
performOnMain { [weak self] in
|
||||
guard let self, !self.isStarted else { return }
|
||||
self.isStarted = true
|
||||
self.confirmationNetworkMonitor.pathUpdateHandler = { [weak self] path in
|
||||
guard path.status == .satisfied else { return }
|
||||
self?.performOnMain { [weak self] in
|
||||
self?.retryPendingConfirmations()
|
||||
}
|
||||
}
|
||||
self.confirmationNetworkMonitor.start(queue: self.confirmationNetworkQueue)
|
||||
self.observers.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
|
|
@ -226,6 +254,17 @@ final class AppUnlockCoordinator {
|
|||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.checkOfflineUnlockForCurrentUser()
|
||||
self?.retryPendingConfirmations()
|
||||
}
|
||||
)
|
||||
self.observers.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
let userId = AppContextManager.shared.userId.trimmed
|
||||
self?.completedAllowUserIds.remove(userId)
|
||||
}
|
||||
)
|
||||
self.observers.append(
|
||||
|
|
@ -235,10 +274,11 @@ final class AppUnlockCoordinator {
|
|||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.checkOfflineUnlockForCurrentUser()
|
||||
self?.retryPendingConfirmations()
|
||||
}
|
||||
)
|
||||
self.checkOfflineUnlockForCurrentUser()
|
||||
self.flushPendingReceipts()
|
||||
self.retryPendingConfirmations()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,8 +289,10 @@ final class AppUnlockCoordinator {
|
|||
guard !userId.isEmpty else { return }
|
||||
|
||||
if self.observedUserId != userId {
|
||||
self.cancelConfirmationForDifferentUser(userId)
|
||||
self.observedUserId = userId
|
||||
self.completedAllowUserIds.remove(userId)
|
||||
self.retryPendingConfirmations()
|
||||
}
|
||||
guard !self.completedAllowUserIds.contains(userId) else { return }
|
||||
|
||||
|
|
@ -277,7 +319,7 @@ final class AppUnlockCoordinator {
|
|||
}
|
||||
self.completedAllowUserIds.insert(userId)
|
||||
for record in response.model?.locks ?? [] {
|
||||
self.applyUnlockAndEnqueueReceipt(record: record, userId: userId)
|
||||
self.applyUnlockAndEnqueueConfirmation(record: record, userId: userId)
|
||||
}
|
||||
}, onError: { [weak self] error in
|
||||
self?.finishAllowRequest(for: userId)
|
||||
|
|
@ -299,6 +341,11 @@ final class AppUnlockCoordinator {
|
|||
if self.observedUserId == userId {
|
||||
self.observedUserId = ""
|
||||
}
|
||||
if self.inFlightConfirmation?.userId == userId {
|
||||
self.confirmationDisposable?.dispose()
|
||||
self.confirmationDisposable = nil
|
||||
self.inFlightConfirmation = nil
|
||||
}
|
||||
self.locallyUnlockedTokensByUserId.removeValue(forKey: userId)
|
||||
self.locallyUnlockedAllUserIds.remove(userId)
|
||||
self.localUnlockSuppressionExpirations.removeValue(forKey: userId)
|
||||
|
|
@ -317,7 +364,6 @@ final class AppUnlockCoordinator {
|
|||
guard let self else { return }
|
||||
guard let body = message.data?.app_unlock else { return }
|
||||
NotificationCenter.default.post(name: .lockDistractAppsDidChange, object: nil)
|
||||
guard !self.consumeExpectedOutgoingEcho(payload) else { return }
|
||||
|
||||
let currentUserId = AppContextManager.shared.userId.trimmed
|
||||
let topicUserId = topic.replacingOccurrences(of: "smartdrive/", with: "").trimmed
|
||||
|
|
@ -327,7 +373,7 @@ final class AppUnlockCoordinator {
|
|||
|
||||
var record = PhoneLockRecord(mqtt: body)
|
||||
record.userId = currentUserId
|
||||
self.applyUnlockAndEnqueueReceipt(record: record, userId: currentUserId)
|
||||
self.applyUnlockAndEnqueueConfirmation(record: record, userId: currentUserId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -420,86 +466,69 @@ final class AppUnlockCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
func mqttDidConnect() {
|
||||
performOnMain { [weak self] in
|
||||
self?.checkOfflineUnlockForCurrentUser()
|
||||
self?.flushPendingReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
func mqttDidDisconnect() {
|
||||
performOnMain { [weak self] in
|
||||
self?.inFlightReceipts.removeAll()
|
||||
self?.expectedOutgoingEchoes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func mqttDidAcknowledgePublish(id: UInt16) {
|
||||
performOnMain { [weak self] in
|
||||
guard let self, let receipt = self.inFlightReceipts.removeValue(forKey: id) else { return }
|
||||
self.pendingReceipts.removeAll { $0 == receipt }
|
||||
self.savePendingReceipts()
|
||||
self.flushPendingReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyUnlockAndEnqueueReceipt(record: PhoneLockRecord, userId: String) {
|
||||
private func applyUnlockAndEnqueueConfirmation(record: PhoneLockRecord, userId: String) {
|
||||
applyUnlock(tokens: record.tokens)
|
||||
guard let payload = makeReceiptPayload(record: record, userId: userId) else {
|
||||
print("[AppUnlock] failed to encode unlock receipt")
|
||||
return
|
||||
}
|
||||
let receipt = PendingAppUnlockReceipt(userId: userId, payload: payload)
|
||||
pendingReceipts.append(receipt)
|
||||
savePendingReceipts()
|
||||
flushPendingReceipts()
|
||||
}
|
||||
|
||||
private func makeReceiptPayload(record: PhoneLockRecord, userId: String) -> String? {
|
||||
let os = record.os.trimmed.isEmpty ? "ios" : record.os.trimmed
|
||||
let payload = MqttAppUnlockReceiptPayload(
|
||||
type: MqttType.appUnlock.rawValue,
|
||||
data: MqttAppUnlockReceiptData(
|
||||
app_unlock: MqttAppUnlockReceiptBody(
|
||||
os: os,
|
||||
group_key: record.groupKey,
|
||||
group_name: record.groupName,
|
||||
user_id: userId,
|
||||
token: record.tokens
|
||||
)
|
||||
),
|
||||
extra: ""
|
||||
let confirmation = PendingAppUnlockConfirmation(
|
||||
userId: userId,
|
||||
os: record.os.trimmed.isEmpty ? "ios" : record.os.trimmed,
|
||||
groupKey: record.groupKey,
|
||||
tokens: record.tokens
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
guard let data = try? encoder.encode(payload) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
let isPending = pendingConfirmations.contains {
|
||||
$0.userId == confirmation.userId
|
||||
&& $0.os == confirmation.os
|
||||
&& $0.groupKey == confirmation.groupKey
|
||||
&& $0.tokens == confirmation.tokens
|
||||
}
|
||||
if !isPending {
|
||||
pendingConfirmations.append(confirmation)
|
||||
savePendingConfirmations()
|
||||
}
|
||||
retryPendingConfirmations()
|
||||
}
|
||||
|
||||
private func flushPendingReceipts() {
|
||||
private func retryPendingConfirmations() {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.flushPendingReceipts()
|
||||
self?.retryPendingConfirmations()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let userId = AppContextManager.shared.userId.trimmed
|
||||
guard MQTTService.shared.isConnected, !userId.isEmpty else { return }
|
||||
let inFlight = Set(inFlightReceipts.values.map(\.identifier))
|
||||
for receipt in pendingReceipts where receipt.userId == userId && !inFlight.contains(receipt.identifier) {
|
||||
registerExpectedOutgoingEcho(receipt.payload)
|
||||
let messageId = MQTTService.shared.publish(
|
||||
topic: "smartdrive/\(userId)",
|
||||
message: receipt.payload,
|
||||
qos: .qos1
|
||||
)
|
||||
guard messageId >= 0, messageId <= Int(UInt16.max) else {
|
||||
cancelExpectedOutgoingEcho(receipt.payload)
|
||||
break
|
||||
guard !userId.isEmpty else { return }
|
||||
cancelConfirmationForDifferentUser(userId)
|
||||
guard inFlightConfirmation == nil,
|
||||
let confirmation = pendingConfirmations.first(where: { $0.userId == userId }) else { return }
|
||||
|
||||
inFlightConfirmation = confirmation
|
||||
confirmationDisposable = UserService.phoneUnlockAllowConfirm(
|
||||
os: confirmation.os,
|
||||
groupKey: confirmation.groupKey,
|
||||
userId: confirmation.userId,
|
||||
tokens: confirmation.tokens
|
||||
)
|
||||
.observe(on: MainScheduler.instance)
|
||||
.subscribe(onNext: { [weak self] response in
|
||||
guard let self,
|
||||
self.inFlightConfirmation?.identifier == confirmation.identifier else { return }
|
||||
self.inFlightConfirmation = nil
|
||||
self.confirmationDisposable = nil
|
||||
guard response.code == "0" else {
|
||||
print("[AppUnlock] unlock confirmation failed: \(response.message ?? "unknown error")")
|
||||
return
|
||||
}
|
||||
inFlightReceipts[UInt16(messageId)] = receipt
|
||||
}
|
||||
self.pendingConfirmations.removeAll { $0.identifier == confirmation.identifier }
|
||||
self.savePendingConfirmations()
|
||||
self.retryPendingConfirmations()
|
||||
NotificationCenter.default.post(name: .lockDistractAppsDidChange, object: nil)
|
||||
}, onError: { [weak self] error in
|
||||
guard let self,
|
||||
self.inFlightConfirmation?.identifier == confirmation.identifier else { return }
|
||||
self.inFlightConfirmation = nil
|
||||
self.confirmationDisposable = nil
|
||||
print("[AppUnlock] unlock confirmation request failed: \(error.gatewayMessage ?? error.localizedDescription)")
|
||||
})
|
||||
}
|
||||
|
||||
private func finishAllowRequest(for userId: String) {
|
||||
|
|
@ -508,9 +537,17 @@ final class AppUnlockCoordinator {
|
|||
allowDisposable = nil
|
||||
}
|
||||
|
||||
private func savePendingReceipts() {
|
||||
guard let data = try? JSONEncoder().encode(pendingReceipts) else { return }
|
||||
UserDefaults.standard.set(data, forKey: pendingReceiptsKey)
|
||||
private func cancelConfirmationForDifferentUser(_ userId: String) {
|
||||
guard let confirmation = inFlightConfirmation,
|
||||
confirmation.userId != userId else { return }
|
||||
confirmationDisposable?.dispose()
|
||||
confirmationDisposable = nil
|
||||
inFlightConfirmation = nil
|
||||
}
|
||||
|
||||
private func savePendingConfirmations() {
|
||||
guard let data = try? JSONEncoder().encode(pendingConfirmations) else { return }
|
||||
UserDefaults.standard.set(data, forKey: pendingConfirmationsKey)
|
||||
}
|
||||
|
||||
private func rememberLocalUnlock(tokens: [String], userId: String) {
|
||||
|
|
@ -535,57 +572,33 @@ final class AppUnlockCoordinator {
|
|||
private func pendingUnlockScope(for userId: String) -> (unlockAll: Bool, tokens: Set<String>) {
|
||||
var unlockAll = false
|
||||
var tokens: Set<String> = []
|
||||
for receipt in pendingReceipts where receipt.userId == userId {
|
||||
guard let data = receipt.payload.data(using: .utf8),
|
||||
let payload = try? JSONDecoder().decode(MqttAppUnlockReceiptPayload.self, from: data) else { continue }
|
||||
let receiptTokens = payload.data.app_unlock.token
|
||||
if receiptTokens.isEmpty {
|
||||
for confirmation in pendingConfirmations where confirmation.userId == userId {
|
||||
if confirmation.tokens.isEmpty {
|
||||
unlockAll = true
|
||||
} else {
|
||||
tokens.formUnion(receiptTokens)
|
||||
tokens.formUnion(confirmation.tokens)
|
||||
}
|
||||
}
|
||||
return (unlockAll, tokens)
|
||||
}
|
||||
|
||||
private func restoreLocalUnlockSuppression(from receipt: PendingAppUnlockReceipt) {
|
||||
private static func makeConfirmation(
|
||||
from receipt: LegacyPendingAppUnlockReceipt
|
||||
) -> PendingAppUnlockConfirmation? {
|
||||
guard let data = receipt.payload.data(using: .utf8),
|
||||
let payload = try? JSONDecoder().decode(MqttAppUnlockReceiptPayload.self, from: data) else { return }
|
||||
rememberLocalUnlock(
|
||||
tokens: payload.data.app_unlock.token,
|
||||
userId: receipt.userId
|
||||
)
|
||||
}
|
||||
|
||||
private func registerExpectedOutgoingEcho(_ payload: String) {
|
||||
removeExpiredOutgoingEchoes()
|
||||
var expected = expectedOutgoingEchoes[payload]
|
||||
?? ExpectedAppUnlockEcho(count: 0, expiresAt: .distantPast)
|
||||
expected.count += 1
|
||||
expected.expiresAt = Date().addingTimeInterval(60)
|
||||
expectedOutgoingEchoes[payload] = expected
|
||||
}
|
||||
|
||||
private func cancelExpectedOutgoingEcho(_ payload: String) {
|
||||
guard var expected = expectedOutgoingEchoes[payload] else { return }
|
||||
expected.count -= 1
|
||||
if expected.count > 0 {
|
||||
expectedOutgoingEchoes[payload] = expected
|
||||
} else {
|
||||
expectedOutgoingEchoes.removeValue(forKey: payload)
|
||||
let payload = try? JSONDecoder().decode(LegacyMqttAppUnlockReceiptPayload.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeExpectedOutgoingEcho(_ payload: String) -> Bool {
|
||||
removeExpiredOutgoingEchoes()
|
||||
guard expectedOutgoingEchoes[payload] != nil else { return false }
|
||||
cancelExpectedOutgoingEcho(payload)
|
||||
return true
|
||||
}
|
||||
|
||||
private func removeExpiredOutgoingEchoes() {
|
||||
let now = Date()
|
||||
expectedOutgoingEchoes = expectedOutgoingEchoes.filter { $0.value.expiresAt > now }
|
||||
let body = payload.data.app_unlock
|
||||
let userId = receipt.userId.trimmed.isEmpty ? body.user_id.trimmed : receipt.userId.trimmed
|
||||
guard !userId.isEmpty else { return nil }
|
||||
return PendingAppUnlockConfirmation(
|
||||
identifier: receipt.identifier,
|
||||
userId: userId,
|
||||
os: body.os.trimmed.isEmpty ? "ios" : body.os.trimmed,
|
||||
groupKey: body.group_key,
|
||||
tokens: body.token
|
||||
)
|
||||
}
|
||||
|
||||
private func performOnMain(_ work: @escaping () -> Void) {
|
||||
|
|
@ -696,6 +709,38 @@ final class MQTTService: NSObject {
|
|||
private var password = "Batiao12B"
|
||||
private var topic = "smartdrive/"
|
||||
|
||||
private func postGroupDataChangeIfNeeded(payload: String?) {
|
||||
guard let payload,
|
||||
let data = payload.data(using: .utf8),
|
||||
let message = try? JSONDecoder().decode(MqttIncomingMessage.self, from: data),
|
||||
let operation = message.type,
|
||||
["join", "leave", "dismiss", "kick"].contains(operation) else { return }
|
||||
|
||||
var userInfo: [AnyHashable: Any] = [
|
||||
GroupDataChangeUserInfoKey.operation: operation,
|
||||
GroupDataChangeUserInfoKey.source: GroupDataChangeSource.mqtt
|
||||
]
|
||||
if let groupKey = message.data?.group_key, !groupKey.isEmpty {
|
||||
userInfo[GroupDataChangeUserInfoKey.groupKey] = groupKey
|
||||
}
|
||||
if let userId = message.data?.user_id, !userId.isEmpty {
|
||||
userInfo[GroupDataChangeUserInfoKey.userId] = userId
|
||||
}
|
||||
|
||||
let post = {
|
||||
NotificationCenter.default.post(
|
||||
name: .RefreshGroupInfoNotification,
|
||||
object: nil,
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
post()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: post)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 手机信息上报
|
||||
private let phoneReportInterval: TimeInterval = 30 * 60
|
||||
private let phoneReportRetryInterval: TimeInterval = 60
|
||||
|
|
@ -770,7 +815,6 @@ final class MQTTService: NSObject {
|
|||
mqtt?.disconnect()
|
||||
isConnected = false
|
||||
invalidatePhoneReportTimer()
|
||||
AppUnlockCoordinator.shared.mqttDidDisconnect()
|
||||
}
|
||||
|
||||
// MARK: - 切换用户
|
||||
|
|
@ -1139,7 +1183,6 @@ extension MQTTService: CocoaMQTT5Delegate {
|
|||
subscribe(topic: "\(topic)\(userId)")
|
||||
}
|
||||
onConnected?()
|
||||
AppUnlockCoordinator.shared.mqttDidConnect()
|
||||
reportPhoneUsageIfNeeded()
|
||||
reportPhoneIfNeeded()
|
||||
}
|
||||
|
|
@ -1150,12 +1193,12 @@ extension MQTTService: CocoaMQTT5Delegate {
|
|||
|
||||
func mqtt5(_ mqtt5: CocoaMQTT5, didPublishAck id: UInt16, pubAckData: MqttDecodePubAck?) {
|
||||
print("MQTT5 publish ack: \(id)")
|
||||
AppUnlockCoordinator.shared.mqttDidAcknowledgePublish(id: id)
|
||||
}
|
||||
|
||||
func mqtt5(_ mqtt5: CocoaMQTT5, didPublishRec id: UInt16, pubRecData: MqttDecodePubRec?) {}
|
||||
|
||||
func mqtt5(_ mqtt5: CocoaMQTT5, didReceiveMessage message: CocoaMQTT5Message, id: UInt16, publishData: MqttDecodePublish?) {
|
||||
postGroupDataChangeIfNeeded(payload: message.string)
|
||||
if AppUnlockCoordinator.shared.handleIncoming(topic: message.topic, payload: message.string) {
|
||||
return
|
||||
}
|
||||
|
|
@ -1191,7 +1234,6 @@ extension MQTTService: CocoaMQTT5Delegate {
|
|||
isConnected = false
|
||||
invalidatePhoneReportTimer()
|
||||
print("MQTT5 disconnected: \(err?.localizedDescription ?? "")")
|
||||
AppUnlockCoordinator.shared.mqttDidDisconnect()
|
||||
onDisconnected?()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ extension DefaultsKeys {
|
|||
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
|
||||
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
|
||||
#elseif AdHoc
|
||||
setupApiEnv(1)
|
||||
setupApiEnv(0)
|
||||
openIM_API = "https://imapi.zuom8.cn"
|
||||
openIM_WS = "ws://imws.zuom8.cn"
|
||||
#else
|
||||
|
|
@ -86,12 +86,12 @@ extension DefaultsKeys {
|
|||
case -1: // UAT
|
||||
return "https://jsapi.zuom8.cn/"
|
||||
default: // SIT
|
||||
return "http://172.16.10.20:9243/"
|
||||
return "http://172.16.10.21:9243/"
|
||||
}
|
||||
}
|
||||
|
||||
static let presetServers: [(title: String, url: String, env: Int)] = [
|
||||
("开发", "http://172.16.10.20:9243/", 0),
|
||||
("开发", "http://172.16.10.21:9243/", 0),
|
||||
("正式", "https://jsapi.zuom8.cn/", 1)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ final class SelectActivityVC: BaseViewController {
|
|||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private var items: [AppCatalogItem] = AppCatalogStore.sharedItems
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var searchRevision = 0
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
|
@ -83,8 +84,15 @@ final class SelectActivityVC: BaseViewController {
|
|||
|
||||
@objc private func searchChanged() {
|
||||
let query = searchField.text ?? ""
|
||||
searchRevision += 1
|
||||
let revision = searchRevision
|
||||
searchTask?.cancel()
|
||||
|
||||
guard searchField.markedTextRange == nil else {
|
||||
activityIndicator.stopAnimating()
|
||||
return
|
||||
}
|
||||
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
activityIndicator.stopAnimating()
|
||||
|
|
@ -105,22 +113,29 @@ final class SelectActivityVC: BaseViewController {
|
|||
let remote = try await ITunesSearchService.search(term: trimmed)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard self.searchRevision == revision else { return }
|
||||
self.activityIndicator.stopAnimating()
|
||||
self.items = ITunesSearchService.mergedResults(local: localHits, remote: remote)
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled, !Self.isCancellation(error) else { return }
|
||||
await MainActor.run {
|
||||
guard self.searchRevision == revision else { return }
|
||||
self.activityIndicator.stopAnimating()
|
||||
if self.items.isEmpty {
|
||||
self.items = localHits
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
self.items = localHits
|
||||
self.tableView.reloadData()
|
||||
DLToast.show(text: "搜索失败,请检查网络")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func isCancellation(_ error: Error) -> Bool {
|
||||
if error is CancellationError { return true }
|
||||
let error = error as NSError
|
||||
return error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled
|
||||
}
|
||||
}
|
||||
|
||||
extension SelectActivityVC: UITableViewDataSource, UITableViewDelegate {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,18 @@ class CreateGroupVC: BaseViewController {
|
|||
|
||||
private func bindViewModel() {
|
||||
rootView.groupNameTF.rx.text.orEmpty
|
||||
.compactMap { [weak rootView] text in
|
||||
guard rootView?.groupNameTF.isComposingText == false else { return nil }
|
||||
return String(text.prefix(10))
|
||||
}
|
||||
.bind(to: viewModel.groupName)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
rootView.groupContentTV.rx.text.orEmpty
|
||||
.compactMap { [weak rootView] text in
|
||||
guard rootView?.groupContentTV.isComposingText == false else { return nil }
|
||||
return String(text.prefix(50))
|
||||
}
|
||||
.bind(to: viewModel.groupDesc)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
|
|
@ -74,6 +82,12 @@ class CreateGroupVC: BaseViewController {
|
|||
self?.rootView.configureGroupTemplates(templates)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
viewModel.output.createSucceeded
|
||||
.emit(onNext: { [weak self] in
|
||||
self?.handleCreateSucceeded()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
viewModel.output.sectionedItems
|
||||
.observe(on: MainScheduler.instance)
|
||||
|
|
@ -100,6 +114,20 @@ class CreateGroupVC: BaseViewController {
|
|||
rootView.tagView.rx.setDelegate(self)
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func handleCreateSucceeded() {
|
||||
DLToast.showSuccess(text: "创建成功")
|
||||
if let navigationController {
|
||||
if navigationController.viewControllers.first === self,
|
||||
navigationController.presentingViewController != nil {
|
||||
navigationController.dismiss(animated: true)
|
||||
} else {
|
||||
navigationController.popViewController(animated: true)
|
||||
}
|
||||
} else {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - dataSource
|
||||
private lazy var dataSource: RxCollectionViewSectionedReloadDataSource<GroupTagListSectionModel> = {
|
||||
|
|
|
|||
|
|
@ -28,11 +28,9 @@ class CreateGroupView: UIView {
|
|||
|
||||
private func setupRx() {
|
||||
groupNameTF.rx.text.orEmpty
|
||||
.subscribe(onNext: { [weak self] text in
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if text.count > 10 {
|
||||
self.groupNameTF.text = String(text.prefix(10))
|
||||
}
|
||||
self.groupNameTF.limitCommittedText(to: 10)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
|
|
@ -44,25 +42,17 @@ class CreateGroupView: UIView {
|
|||
.throttle(.milliseconds(100), scheduler: MainScheduler.instance)
|
||||
.subscribe(onNext: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let count = self.groupContentTV.text.count
|
||||
self.placeholderLab.isHidden = count != 0
|
||||
|
||||
if count > self.limitCount {
|
||||
self.groupContentTV.text = String(self.groupContentTV.text.prefix(self.limitCount))
|
||||
self.groupContentTV.selectedRange = NSRange(location: self.limitCount, length: 0)
|
||||
return
|
||||
}
|
||||
self.placeholderLab.isHidden = !self.groupContentTV.text.isEmpty
|
||||
guard let text = self.groupContentTV.limitCommittedText(to: self.limitCount) else { return }
|
||||
self.placeholderLab.isHidden = !text.isEmpty
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
addTagTF.rx.text.orEmpty
|
||||
.subscribe(onNext: { [weak self] text in
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
if text.count > self.tagMaxLength {
|
||||
self.addTagTF.text = String(text.prefix(self.tagMaxLength))
|
||||
}
|
||||
let count = self.addTagTF.text?.count ?? 0
|
||||
self.addTagCountLab.text = "\(count)/\(self.tagMaxLength)"
|
||||
guard let text = self.addTagTF.limitCommittedText(to: self.tagMaxLength) else { return }
|
||||
self.addTagCountLab.text = "\(text.count)/\(self.tagMaxLength)"
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class CreateGroupViewModel {
|
|||
struct Output {
|
||||
var sectionedItems: Observable<[GroupTagListSectionModel]>
|
||||
var groupTemplates: Observable<[GroupTemplateModel]>
|
||||
var createSucceeded: Signal<Void>
|
||||
}
|
||||
|
||||
let input: Input
|
||||
|
|
@ -36,6 +37,7 @@ class CreateGroupViewModel {
|
|||
|
||||
private let sectionedItems = PublishSubject<[GroupTagListSectionModel]>()
|
||||
private let groupTemplates = PublishSubject<[GroupTemplateModel]>()
|
||||
private let createSucceeded = PublishRelay<Void>()
|
||||
|
||||
private var tagList = ["私密", "游戏", "运动", "美食",
|
||||
"自驾", "聚会", "旅行", "学习"]
|
||||
|
|
@ -123,11 +125,8 @@ class CreateGroupViewModel {
|
|||
requestData: ["group_name": name,
|
||||
"group_type": groupType,
|
||||
"description": desc,
|
||||
"labels": selectedTagList]).subscribe(onNext: { response in
|
||||
DLToast.showSuccess(text: "创建成功") {
|
||||
NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil)
|
||||
AppRouter.shared.popOrDismiss()
|
||||
}
|
||||
"labels": selectedTagList]).subscribe(onNext: { [weak self] _ in
|
||||
self?.createSucceeded.accept(())
|
||||
}, onError: { (error) in
|
||||
guard let code = error.underlyingError?.code else { return }
|
||||
|
||||
|
|
@ -146,7 +145,8 @@ class CreateGroupViewModel {
|
|||
input = Input()
|
||||
output = Output(
|
||||
sectionedItems: sectionedItems.asObservable(),
|
||||
groupTemplates: groupTemplates.asObservable()
|
||||
groupTemplates: groupTemplates.asObservable(),
|
||||
createSucceeded: createSucceeded.asSignal()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ final class GroupChatVC: BaseViewController {
|
|||
/// mention 列表:首行 @所有人 + 成员
|
||||
private var mentionRows: [(userId: String?, nickname: String)] = []
|
||||
private var isMentionPickerVisible = false
|
||||
private var groupRefreshWorkItem: DispatchWorkItem?
|
||||
private var groupInfoRequestID = UUID()
|
||||
|
||||
/// 加载更早历史前记录,用于保持阅读位置
|
||||
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
||||
|
|
@ -261,7 +263,15 @@ final class GroupChatVC: BaseViewController {
|
|||
NotificationCenter.default.rx.notification(.RefreshUserConfigNotification)
|
||||
.observe(on: MainScheduler.instance)
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
self?.requestGroupInfoByKey()
|
||||
self?.scheduleGroupInfoRefresh()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification)
|
||||
.observe(on: MainScheduler.instance)
|
||||
.subscribe(onNext: { [weak self] notification in
|
||||
guard let self, self.shouldRefreshGroupInfo(for: notification) else { return }
|
||||
self.scheduleGroupInfoRefresh()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
|
|
@ -521,7 +531,7 @@ final class GroupChatVC: BaseViewController {
|
|||
}
|
||||
|
||||
private func showMentionPicker() {
|
||||
mentionRows = [(nil, "所有人")] + viewModel.mentionCandidates().map { ($0.user_id, $0.showName) }
|
||||
refreshMentionRows()
|
||||
rootView.mentionTableView.reloadData()
|
||||
rootView.mentionPickerView.isHidden = false
|
||||
isMentionPickerVisible = true
|
||||
|
|
@ -536,6 +546,14 @@ final class GroupChatVC: BaseViewController {
|
|||
isMentionPickerVisible = false
|
||||
}
|
||||
|
||||
private func refreshMentionRows() {
|
||||
mentionRows = [(nil, "所有人")]
|
||||
+ viewModel.mentionCandidates().map { ($0.user_id, $0.showName) }
|
||||
if isMentionPickerVisible {
|
||||
rootView.mentionTableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private func insertMention(userId: String?, nickname: String, replaceTriggerAt: Bool = true) {
|
||||
hideMentionPicker()
|
||||
let insert: String
|
||||
|
|
@ -812,7 +830,8 @@ final class GroupChatVC: BaseViewController {
|
|||
currentVoiceHover = .none
|
||||
bringInputOverlaysToFront()
|
||||
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory() + "voice_\(Int(Date().timeIntervalSince1970)).caf")
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("voice_\(UUID().uuidString).wav")
|
||||
recordFileURL = url
|
||||
|
||||
let startCapture: (Bool) -> Void = { [weak self] enableSpeech in
|
||||
|
|
@ -876,7 +895,12 @@ final class GroupChatVC: BaseViewController {
|
|||
deleteRecordFile()
|
||||
return
|
||||
}
|
||||
sendVoiceMessage(url: url, durationMs: duration * 1000)
|
||||
guard let durationMs = validVoiceDurationMs(at: url) else {
|
||||
dl.show(text: "录音文件无效,请重试")
|
||||
deleteRecordFile()
|
||||
return
|
||||
}
|
||||
sendVoiceMessage(url: url, durationMs: durationMs)
|
||||
case .convert:
|
||||
deleteRecordFile()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
||||
|
|
@ -900,6 +924,20 @@ final class GroupChatVC: BaseViewController {
|
|||
recordFileURL = nil
|
||||
}
|
||||
|
||||
private func validVoiceDurationMs(at url: URL) -> Int? {
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
fileSize.intValue > 44,
|
||||
let audioFile = try? AVAudioFile(forReading: url),
|
||||
audioFile.length > 0,
|
||||
audioFile.processingFormat.sampleRate > 0 else {
|
||||
return nil
|
||||
}
|
||||
let duration = Double(audioFile.length) / audioFile.processingFormat.sampleRate
|
||||
guard duration >= 0.5, duration.isFinite else { return nil }
|
||||
return max(1_000, Int((duration * 1_000).rounded()))
|
||||
}
|
||||
|
||||
private func sendVoiceMessage(url: URL, durationMs: Int) {
|
||||
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs)
|
||||
viewModel.cacheMessage(msg)
|
||||
|
|
@ -907,23 +945,53 @@ final class GroupChatVC: BaseViewController {
|
|||
recvID: "",
|
||||
groupID: viewModel.groupId,
|
||||
offlinePushInfo: nil,
|
||||
onSuccess: { [weak self] _ in
|
||||
self?.viewModel.onReceiveMessage(msg)
|
||||
onSuccess: { [weak self] returnedMsg in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
let sentMessage = returnedMsg ?? msg
|
||||
if sentMessage.soundElem == nil {
|
||||
sentMessage.soundElem = msg.soundElem
|
||||
}
|
||||
sentMessage.soundElem?.soundPath = url.path
|
||||
self.viewModel.onReceiveMessage(sentMessage)
|
||||
}
|
||||
},
|
||||
onProgress: nil as OIMNumberCallback?,
|
||||
onFailure: { code, errMsg in
|
||||
onFailure: { [weak self] code, errMsg in
|
||||
print("Voice send failed: \(code) \(errMsg ?? "")")
|
||||
DispatchQueue.main.async {
|
||||
self?.dl.show(text: (errMsg?.isEmpty == false ? errMsg : "语音发送失败,请重试") ?? "语音发送失败,请重试")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
private func requestGroupInfoByKey() {
|
||||
private func shouldRefreshGroupInfo(for notification: Notification) -> Bool {
|
||||
guard let groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String,
|
||||
!groupKey.isEmpty else { return true }
|
||||
return groupKey == viewModel.groupId
|
||||
}
|
||||
|
||||
private func scheduleGroupInfoRefresh() {
|
||||
groupRefreshWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.requestGroupInfoByKey(showError: false)
|
||||
}
|
||||
groupRefreshWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
|
||||
}
|
||||
|
||||
private func requestGroupInfoByKey(showError: Bool = true) {
|
||||
let requestID = UUID()
|
||||
groupInfoRequestID = requestID
|
||||
GroupService.groupUsers(groupKey: viewModel.groupId)
|
||||
.subscribe(onNext: { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard let self, self.groupInfoRequestID == requestID else { return }
|
||||
guard response.isValid(for: self.viewModel.groupId),
|
||||
let model = response.model else {
|
||||
DLToast.showError(text: response.message ?? "获取圈子成员失败")
|
||||
if showError {
|
||||
DLToast.showError(text: response.message ?? "获取圈子成员失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
self.viewModel.groupModel = model
|
||||
|
|
@ -935,9 +1003,13 @@ final class GroupChatVC: BaseViewController {
|
|||
self.rootView.groupNameLabel.text = "\(model.name)(\(response.list.count))"
|
||||
self.rootView.groupAvatarView.setGroupIcon(model)
|
||||
self.rootView.reviewBtn.isHidden = !model.is_owner
|
||||
self.refreshMentionRows()
|
||||
// self.rootView.reviewDotView.isHidden = response.reviewCount == 0
|
||||
}, onError: { error in
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
}, onError: { [weak self] error in
|
||||
guard let self, self.groupInfoRequestID == requestID else { return }
|
||||
if showError {
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -802,7 +802,7 @@ final class GroupChatViewModel {
|
|||
let type = msg.contentType.rawValue
|
||||
if type == 103, let sound = msg.soundElem {
|
||||
content = "\(sound.duration)"
|
||||
voiceUrl = sound.sourceUrl ?? ""
|
||||
voiceUrl = Self.voiceLocation(from: sound)
|
||||
} else if type == 102, let pic = msg.pictureElem {
|
||||
let layout = Self.pictureLayout(from: pic)
|
||||
imageUrl = layout.url
|
||||
|
|
@ -828,7 +828,7 @@ final class GroupChatViewModel {
|
|||
}
|
||||
} else if let sound = msg.soundElem {
|
||||
content = "\(sound.duration)"
|
||||
voiceUrl = sound.sourceUrl ?? ""
|
||||
voiceUrl = Self.voiceLocation(from: sound)
|
||||
} else if let pic = msg.pictureElem {
|
||||
let layout = Self.pictureLayout(from: pic)
|
||||
imageUrl = layout.url
|
||||
|
|
@ -904,6 +904,14 @@ final class GroupChatViewModel {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func voiceLocation(from sound: OIMSoundElem) -> String {
|
||||
if let localPath = nonEmpty(sound.soundPath),
|
||||
FileManager.default.fileExists(atPath: localPath) {
|
||||
return localPath
|
||||
}
|
||||
return nonEmpty(sound.sourceUrl) ?? nonEmpty(sound.soundPath) ?? ""
|
||||
}
|
||||
|
||||
private func makeQuotePreview(from msg: OIMMessageInfo) -> QuotePreview {
|
||||
let id = msg.clientMsgID
|
||||
let name = msg.senderNickname ?? getUserNickName(id: msg.sendID ?? "")
|
||||
|
|
|
|||
|
|
@ -7,89 +7,239 @@
|
|||
|
||||
import AVFoundation
|
||||
|
||||
final class VoicePlayerManager {
|
||||
final class VoicePlayerManager: NSObject {
|
||||
|
||||
static let shared = VoicePlayerManager()
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var timeObserver: Any?
|
||||
enum State { case idle, playing, paused }
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var downloadTask: URLSessionDataTask?
|
||||
private let audioCache = NSCache<NSString, NSData>()
|
||||
private var currentURL: String?
|
||||
private var playbackID = UUID()
|
||||
private var onStateChange: ((State) -> Void)?
|
||||
private var onFinished: (() -> Void)?
|
||||
|
||||
enum State { case idle, playing, paused }
|
||||
private weak var playbackView: VoicePlaybackView?
|
||||
|
||||
private(set) var state: State = .idle
|
||||
|
||||
private init() {}
|
||||
private override init() {
|
||||
super.init()
|
||||
audioCache.totalCostLimit = 20 * 1_024 * 1_024
|
||||
}
|
||||
|
||||
func play(urlString: String,
|
||||
playbackView: VoicePlaybackView,
|
||||
onStateChange: @escaping (State) -> Void,
|
||||
onFinished: @escaping () -> Void) {
|
||||
// Stop current if different source
|
||||
if currentURL != urlString {
|
||||
stop()
|
||||
let value = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else {
|
||||
onFinished()
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
currentURL = urlString
|
||||
if currentURL == value, state == .paused, let player {
|
||||
guard activateAudioSession() else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
self.playbackView = playbackView
|
||||
self.onStateChange = onStateChange
|
||||
self.onFinished = onFinished
|
||||
player.play()
|
||||
updateState(.playing)
|
||||
return
|
||||
}
|
||||
|
||||
stop()
|
||||
let playbackID = UUID()
|
||||
self.playbackID = playbackID
|
||||
currentURL = value
|
||||
self.playbackView = playbackView
|
||||
self.onStateChange = onStateChange
|
||||
self.onFinished = onFinished
|
||||
|
||||
if player == nil {
|
||||
player = AVPlayer(url: url)
|
||||
if let localURL = Self.localFileURL(from: value) {
|
||||
startPlayer(url: localURL, expectedURL: value)
|
||||
return
|
||||
}
|
||||
guard let remoteURL = Self.remoteURL(from: value) else {
|
||||
finishCurrentPlayback()
|
||||
return
|
||||
}
|
||||
if let cachedData = audioCache.object(forKey: value as NSString) {
|
||||
startPlayer(data: cachedData as Data, expectedURL: value)
|
||||
return
|
||||
}
|
||||
|
||||
if state == .paused {
|
||||
player?.play()
|
||||
state = .playing
|
||||
onStateChange(.playing)
|
||||
playbackView.play()
|
||||
} else {
|
||||
player?.replaceCurrentItem(with: AVPlayerItem(url: url))
|
||||
player?.play()
|
||||
state = .playing
|
||||
onStateChange(.playing)
|
||||
playbackView.play()
|
||||
|
||||
if let obs = timeObserver {
|
||||
player?.removeTimeObserver(obs)
|
||||
}
|
||||
timeObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 10),
|
||||
queue: .main) { [weak self] time in
|
||||
guard let self = self,
|
||||
let duration = self.player?.currentItem?.duration,
|
||||
duration.isValid, !duration.isIndefinite,
|
||||
CMTimeCompare(time, duration) >= 0 else { return }
|
||||
self.stop()
|
||||
self.onFinished?()
|
||||
downloadTask = URLSession.shared.dataTask(with: remoteURL) { [weak self] data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
guard let self,
|
||||
self.playbackID == playbackID,
|
||||
self.currentURL == value else { return }
|
||||
self.downloadTask = nil
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 200
|
||||
guard let data, error == nil, (200..<300).contains(statusCode), !data.isEmpty else {
|
||||
print("Voice download failed: \(error?.localizedDescription ?? "HTTP \(statusCode)")")
|
||||
self.finishCurrentPlayback()
|
||||
return
|
||||
}
|
||||
self.audioCache.setObject(data as NSData, forKey: value as NSString, cost: data.count)
|
||||
self.startPlayer(data: data, expectedURL: value)
|
||||
}
|
||||
}
|
||||
downloadTask?.resume()
|
||||
}
|
||||
|
||||
func isCurrent(url: String) -> Bool { currentURL == url }
|
||||
func isCurrent(url: String) -> Bool {
|
||||
currentURL == url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard state == .playing else { return }
|
||||
player?.pause()
|
||||
state = .paused
|
||||
onStateChange?(.paused)
|
||||
updateState(.paused)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
downloadTask?.cancel()
|
||||
downloadTask = nil
|
||||
playbackID = UUID()
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
if let obs = timeObserver {
|
||||
player?.removeTimeObserver(obs)
|
||||
timeObserver = nil
|
||||
player?.stop()
|
||||
player = nil
|
||||
finishCurrentPlayback()
|
||||
}
|
||||
|
||||
private func startPlayer(url: URL, expectedURL: String) {
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
try start(player: player, expectedURL: expectedURL)
|
||||
} catch {
|
||||
print("Voice playback failed: \(error.localizedDescription)")
|
||||
finishCurrentPlayback()
|
||||
}
|
||||
let prevFinished = onFinished
|
||||
}
|
||||
|
||||
private func startPlayer(data: Data, expectedURL: String) {
|
||||
do {
|
||||
let player = try AVAudioPlayer(data: data)
|
||||
try start(player: player, expectedURL: expectedURL)
|
||||
} catch {
|
||||
print("Voice playback failed: \(error.localizedDescription)")
|
||||
finishCurrentPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
private func start(player: AVAudioPlayer, expectedURL: String) throws {
|
||||
guard currentURL == expectedURL else { return }
|
||||
guard activateAudioSession() else {
|
||||
finishCurrentPlayback()
|
||||
return
|
||||
}
|
||||
player.delegate = self
|
||||
_ = player.prepareToPlay()
|
||||
guard player.duration > 0 else {
|
||||
throw NSError(
|
||||
domain: "VoicePlayerManager",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "invalid audio data"]
|
||||
)
|
||||
}
|
||||
self.player = player
|
||||
guard player.play() else {
|
||||
self.player = nil
|
||||
throw NSError(
|
||||
domain: "VoicePlayerManager",
|
||||
code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "audio player did not start"]
|
||||
)
|
||||
}
|
||||
updateState(.playing)
|
||||
}
|
||||
|
||||
private func updateState(_ state: State) {
|
||||
self.state = state
|
||||
onStateChange?(state)
|
||||
if state == .playing {
|
||||
playbackView?.play()
|
||||
} else {
|
||||
playbackView?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishCurrentPlayback() {
|
||||
let previousFinished = onFinished
|
||||
let previousPlaybackView = playbackView
|
||||
onStateChange = nil
|
||||
onFinished = nil
|
||||
playbackView = nil
|
||||
player = nil
|
||||
currentURL = nil
|
||||
state = .idle
|
||||
prevFinished?()
|
||||
previousPlaybackView?.stop()
|
||||
previousFinished?()
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func activateAudioSession() -> Bool {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio)
|
||||
try session.setActive(true)
|
||||
return true
|
||||
} catch {
|
||||
print("Voice audio session failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func localFileURL(from value: String) -> URL? {
|
||||
if let url = URL(string: value), url.isFileURL,
|
||||
FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: value) {
|
||||
return URL(fileURLWithPath: value)
|
||||
}
|
||||
let relativePath = value.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let localURL = documentsURL.appendingPathComponent(relativePath)
|
||||
if FileManager.default.fileExists(atPath: localURL.path) {
|
||||
return localURL
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func remoteURL(from value: String) -> URL? {
|
||||
if let url = URL(string: value), let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" {
|
||||
return url
|
||||
}
|
||||
guard let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: encoded),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
extension VoicePlayerManager: AVAudioPlayerDelegate {
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
guard self.player === player else { return }
|
||||
finishCurrentPlayback()
|
||||
}
|
||||
|
||||
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
|
||||
guard self.player === player else { return }
|
||||
if let error {
|
||||
print("Voice decode failed: \(error.localizedDescription)")
|
||||
}
|
||||
finishCurrentPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -840,7 +840,20 @@ final class ChatVoiceCapture: @unchecked Sendable {
|
|||
throw NSError(domain: "ChatVoiceCapture", code: -1, userInfo: [NSLocalizedDescriptionKey: "invalid audio format"])
|
||||
}
|
||||
|
||||
audioFile = try AVAudioFile(forWriting: fileURL, settings: format.settings)
|
||||
let fileSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||
AVSampleRateKey: format.sampleRate,
|
||||
AVNumberOfChannelsKey: format.channelCount,
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVLinearPCMIsFloatKey: false,
|
||||
AVLinearPCMIsBigEndianKey: false
|
||||
]
|
||||
audioFile = try AVAudioFile(
|
||||
forWriting: fileURL,
|
||||
settings: fileSettings,
|
||||
commonFormat: format.commonFormat,
|
||||
interleaved: format.isInterleaved
|
||||
)
|
||||
|
||||
if enableSpeech, let recognizer, recognizer.isAvailable {
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ final class GroupViewController: BaseViewController {
|
|||
private var itineraryGroupModel: GroupModel?
|
||||
private var itinerarySchedules: [ScheduleModel] = []
|
||||
private var itineraryGroupKey: String = ""
|
||||
private var groupRefreshWorkItem: DispatchWorkItem?
|
||||
private var groupInfoRequestID = UUID()
|
||||
private var hasAppeared = false
|
||||
|
||||
override func loadView() {
|
||||
rootView = GroupView(frame: UIScreen.main.bounds)
|
||||
|
|
@ -34,27 +37,41 @@ final class GroupViewController: BaseViewController {
|
|||
observeTableViews()
|
||||
bindItineraryPage()
|
||||
requestRecommandGroup()
|
||||
requestGroupInfo()
|
||||
requestGroupInfo(refreshIM: true)
|
||||
|
||||
guard let config = AppContextManager.shared.systemConfig else { return }
|
||||
rootView.cycleScrollView.imageURLStringsGroup = config.groupBannerList
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if hasAppeared {
|
||||
scheduleGroupInfoRefresh()
|
||||
} else {
|
||||
hasAppeared = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenIM
|
||||
private var hasSetupIMListeners = false
|
||||
private var hasRequestedIMData = false
|
||||
private var isRequestingIMData = false
|
||||
|
||||
/// 进入页面加载 IM 会话:圈子集合已由业务接口给出,这里只补未读/最后一条
|
||||
private func loadIMData() {
|
||||
if AppContextManager.shared.isGuest { return }
|
||||
guard !AppContextManager.shared.isGuest, !isRequestingIMData else { return }
|
||||
isRequestingIMData = true
|
||||
DLToast.showLoading()
|
||||
GroupIMService.shared.ensureLogin { [weak self] success in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.isRequestingIMData = false
|
||||
guard success else {
|
||||
DLToast.dismiss()
|
||||
DLToast.showError(text: "IM登录失败")
|
||||
return
|
||||
}
|
||||
self.hasRequestedIMData = true
|
||||
self.setupIMListenersIfNeeded()
|
||||
self.refreshIMData()
|
||||
}
|
||||
|
|
@ -163,14 +180,14 @@ final class GroupViewController: BaseViewController {
|
|||
// MQTT join/leave/dismiss 后刷新群列表
|
||||
NotificationCenter.default.rx.notification(.RefreshIMGroupListNotification)
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
self?.requestGroupInfo()
|
||||
self?.scheduleGroupInfoRefresh()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
NotificationCenter.default.rx.notification(.RefreshUserConfigNotification)
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
self?.requestRecommandGroup()
|
||||
self?.requestGroupInfo()
|
||||
self?.scheduleGroupInfoRefresh()
|
||||
|
||||
guard let config = AppContextManager.shared.systemConfig else { return }
|
||||
self?.rootView.cycleScrollView.imageURLStringsGroup = config.groupBannerList
|
||||
|
|
@ -179,7 +196,7 @@ final class GroupViewController: BaseViewController {
|
|||
|
||||
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification)
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
self?.refreshItineraryPage()
|
||||
self?.scheduleGroupInfoRefresh()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
|
@ -208,20 +225,23 @@ final class GroupViewController: BaseViewController {
|
|||
|
||||
// MARK: - 行程页
|
||||
private func refreshItineraryPage() {
|
||||
let cached = AppContextManager.shared.defaultGroupKey
|
||||
if !cached.isEmpty {
|
||||
applyItineraryGroupKey(cached)
|
||||
if let itineraryGroupModel {
|
||||
applyItineraryGroupModel(itineraryGroupModel)
|
||||
return
|
||||
}
|
||||
GroupService.groupInfo().subscribe(onNext: { [weak self] response in
|
||||
guard let self = self, let model = response.model else { return }
|
||||
self.itineraryGroupModel = model
|
||||
let current = model.groups.first(where: { $0.group_key == model.default_group_key })
|
||||
self.rootView.itineraryPage.updateGroupName(current?.name ?? "")
|
||||
self.applyItineraryGroupKey(model.default_group_key)
|
||||
self.applyItineraryGroupModel(model)
|
||||
}).disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func applyItineraryGroupModel(_ model: GroupModel) {
|
||||
itineraryGroupModel = model
|
||||
let current = model.groups.first(where: { $0.group_key == model.default_group_key })
|
||||
rootView.itineraryPage.updateGroupName(current?.name ?? "")
|
||||
applyItineraryGroupKey(current?.group_key ?? "")
|
||||
}
|
||||
|
||||
private func applyItineraryGroupKey(_ groupKey: String) {
|
||||
if itineraryGroupKey != groupKey {
|
||||
itineraryGroupKey = groupKey
|
||||
|
|
@ -279,9 +299,7 @@ final class GroupViewController: BaseViewController {
|
|||
GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in
|
||||
guard let self = self, let key = groupKey else { return }
|
||||
GroupService.operate(opType: "setdefault", requestData: ["group_key": key])
|
||||
.subscribe(onNext: { _ in
|
||||
NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil)
|
||||
})
|
||||
.subscribe()
|
||||
.disposed(by: self.disposeBag)
|
||||
}
|
||||
}
|
||||
|
|
@ -352,11 +370,29 @@ final class GroupViewController: BaseViewController {
|
|||
}).disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func requestGroupInfo() {
|
||||
GroupService.groupInfo().subscribe { response in
|
||||
guard let model = response.model else { return }
|
||||
self.viewModel.applyBusinessGroups(model.groups)
|
||||
self.loadIMData()
|
||||
}.disposed(by: disposeBag)
|
||||
private func scheduleGroupInfoRefresh() {
|
||||
groupRefreshWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.requestGroupInfo()
|
||||
}
|
||||
groupRefreshWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
|
||||
}
|
||||
|
||||
private func requestGroupInfo(refreshIM: Bool = false) {
|
||||
let requestID = UUID()
|
||||
groupInfoRequestID = requestID
|
||||
GroupService.groupInfo()
|
||||
.subscribe(onNext: { [weak self] response in
|
||||
guard let self,
|
||||
self.groupInfoRequestID == requestID,
|
||||
let model = response.model else { return }
|
||||
self.viewModel.applyBusinessGroups(model.groups)
|
||||
self.applyItineraryGroupModel(model)
|
||||
if refreshIM || !self.hasRequestedIMData {
|
||||
self.loadIMData()
|
||||
}
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class GroupListPopView: UIView {
|
|||
private var selectedGroupKey: String = ""
|
||||
private var currentGroupOnlineCount: Int?
|
||||
private var onlineCountGroupKey: String = ""
|
||||
private weak var presentationOwner: AnyObject?
|
||||
|
||||
private var groupList: [GroupInfoModel] = [] {
|
||||
didSet {
|
||||
|
|
@ -186,10 +187,28 @@ class GroupListPopView: UIView {
|
|||
// MARK: - Public
|
||||
extension GroupListPopView {
|
||||
|
||||
static func updateIfVisible(groupModel: GroupModel,
|
||||
currentMembers: [GroupMemberModel]? = nil,
|
||||
presentationOwner: AnyObject) {
|
||||
let pop = GroupListPopView.shared
|
||||
guard pop.superview != nil,
|
||||
pop.presentationOwner === presentationOwner else { return }
|
||||
|
||||
let selectedGroupKey = pop.selectedGroupKey
|
||||
pop.currentGroupOnlineCount = currentMembers.map { $0.filter(\.is_online).count }
|
||||
pop.groupModel = groupModel
|
||||
if groupModel.groups.contains(where: { $0.group_key == selectedGroupKey }) {
|
||||
pop.selectedGroupKey = selectedGroupKey
|
||||
}
|
||||
pop.onlineCountGroupKey = groupModel.default_group_key
|
||||
pop.tableView.reloadData()
|
||||
}
|
||||
|
||||
/// 通过 window 从底部弹出圈子列表
|
||||
static func show(groupModel: GroupModel,
|
||||
currentMembers: [GroupMemberModel]? = nil,
|
||||
selectedKey: String? = nil,
|
||||
presentationOwner: AnyObject? = nil,
|
||||
completion: @escaping ((String?) -> Void)) {
|
||||
guard let superView = kKeyWindow else { return }
|
||||
|
||||
|
|
@ -204,6 +223,7 @@ extension GroupListPopView {
|
|||
let highlightKey = (selectedKey?.isEmpty == false) ? selectedKey! : groupModel.default_group_key
|
||||
pop.selectedGroupKey = highlightKey
|
||||
pop.onlineCountGroupKey = highlightKey
|
||||
pop.presentationOwner = presentationOwner
|
||||
pop.tableView.reloadData()
|
||||
pop.completion = { text in
|
||||
completion(text)
|
||||
|
|
@ -231,6 +251,7 @@ extension GroupListPopView {
|
|||
pop.infoView.transform = pop.slideOffTransform()
|
||||
} completion: { _ in
|
||||
pop.infoView.transform = .identity
|
||||
pop.presentationOwner = nil
|
||||
pop.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,21 @@ class GroupMemberView2: UIView {
|
|||
refreshRealtimeInteraction()
|
||||
}
|
||||
|
||||
func clearMemberInfo() {
|
||||
currentMemberModel = nil
|
||||
memberNameLab.text = ""
|
||||
ownView.isHidden = true
|
||||
relationIconView.configure(relationIdx: "")
|
||||
updateMoodBadge(0)
|
||||
statusDotView.backgroundColor = UIColor(hexStr: "#D8D8D8")
|
||||
statusLab.text = ""
|
||||
locationLab.text = ""
|
||||
distanceView.isHidden = true
|
||||
applyPhoneUsage(nil)
|
||||
updateRealtimeInteractionVisibility(isCurrentUser: true)
|
||||
refreshRealtimeInteraction()
|
||||
}
|
||||
|
||||
func updatePhoneUsage(_ phoneUsage: PhoneUsageTodayModel?, for userId: String) {
|
||||
guard currentMemberModel?.user_id == userId else { return }
|
||||
applyPhoneUsage(phoneUsage)
|
||||
|
|
|
|||
|
|
@ -75,8 +75,28 @@ class HomeViewController: BaseViewController {
|
|||
private var popupQueue: PopupQueueManager?
|
||||
private static var sosPlayerKey: UInt8 = 0
|
||||
private var groupRefreshWorkItem: DispatchWorkItem?
|
||||
private var groupSwitchWorkItem: DispatchWorkItem?
|
||||
private var groupDataRetryWorkItem: DispatchWorkItem?
|
||||
private var groupInfoRequestID = UUID()
|
||||
private var pendingGroupDataChanges: [GroupDataChange] = []
|
||||
private var hasAppeared = false
|
||||
|
||||
private struct GroupDataChange {
|
||||
let groupKey: String
|
||||
let userId: String
|
||||
let operation: String
|
||||
let source: String
|
||||
|
||||
init(notification: Notification) {
|
||||
groupKey = notification.userInfo?[GroupDataChangeUserInfoKey.groupKey] as? String ?? ""
|
||||
userId = notification.userInfo?[GroupDataChangeUserInfoKey.userId] as? String ?? ""
|
||||
operation = notification.userInfo?[GroupDataChangeUserInfoKey.operation] as? String ?? ""
|
||||
source = notification.userInfo?[GroupDataChangeUserInfoKey.source] as? String ?? ""
|
||||
}
|
||||
|
||||
var isMQTT: Bool {
|
||||
source == GroupDataChangeSource.mqtt
|
||||
}
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
#if !targetEnvironment(simulator)
|
||||
|
|
@ -111,6 +131,11 @@ class HomeViewController: BaseViewController {
|
|||
rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized
|
||||
refreshPigeonInbox()
|
||||
queryUnlockRequests()
|
||||
if hasAppeared {
|
||||
scheduleGroupDataRefresh()
|
||||
} else {
|
||||
hasAppeared = true
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
|
|
@ -273,12 +298,7 @@ class HomeViewController: BaseViewController {
|
|||
|
||||
// 切换圈子
|
||||
rootView.groupMemberView.changeGroupBtn.rx.tap.subscribe(onNext: { [weak self] in
|
||||
guard let self = self, let groupModel = self.viewModel.groupModel else { return }
|
||||
GroupListPopView.show(groupModel: groupModel,
|
||||
currentMembers: self.viewModel.memberList) { [weak self] groupKey in
|
||||
guard let self = self, let key = groupKey else { return }
|
||||
self.requestOperateGroup(groupKey: key)
|
||||
}
|
||||
self?.showLatestGroupPicker()
|
||||
}).disposed(by: disposeBag)
|
||||
|
||||
// 顶部圈子
|
||||
|
|
@ -363,7 +383,7 @@ class HomeViewController: BaseViewController {
|
|||
// 圈子刷新
|
||||
NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification, object: nil)
|
||||
.subscribe { [weak self] notification in
|
||||
self?.requestGroupInfo()
|
||||
self?.scheduleGroupDataRefresh(change: GroupDataChange(notification: notification))
|
||||
}.disposed(by: disposeBag)
|
||||
|
||||
// 查找位置(查看成员位置)
|
||||
|
|
@ -383,7 +403,6 @@ class HomeViewController: BaseViewController {
|
|||
}
|
||||
// 切换到成员所在圈子,完成后定位
|
||||
GroupService.operate(opType: "setdefault", requestData: ["group_key": group_key]).subscribe { _ in
|
||||
self.requestGroupInfo()
|
||||
if let c = coord, CLLocationCoordinate2DIsValid(c) {
|
||||
self.rootView.mapView.setCenter(c, animated: true)
|
||||
self.rootView.mapView.setZoomLevel(16, animated: true)
|
||||
|
|
@ -427,8 +446,10 @@ class HomeViewController: BaseViewController {
|
|||
guard let self = self else { return }
|
||||
let items = sections.flatMap(\.items)
|
||||
guard !items.isEmpty else {
|
||||
self.selectedMemberId = ""
|
||||
self.refreshCoupleMemberList([])
|
||||
self.rootView.setSOSGradientActive(false)
|
||||
self.rootView.groupMemberView.clearMemberInfo()
|
||||
return
|
||||
}
|
||||
let stillValid = items.contains { $0.user_id == self.selectedMemberId }
|
||||
|
|
@ -579,46 +600,213 @@ class HomeViewController: BaseViewController {
|
|||
}.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func requestGroupInfo(isDefaultGroup: Bool=true) {
|
||||
private func scheduleGroupDataRefresh(change: GroupDataChange? = nil) {
|
||||
if let change {
|
||||
pendingGroupDataChanges.append(change)
|
||||
}
|
||||
groupRefreshWorkItem?.cancel()
|
||||
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
let changes = self.pendingGroupDataChanges
|
||||
self.pendingGroupDataChanges.removeAll()
|
||||
self.requestGroupInfo(
|
||||
changes: changes,
|
||||
retryAllowed: changes.contains(where: \.isMQTT),
|
||||
showError: false
|
||||
)
|
||||
}
|
||||
groupRefreshWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
|
||||
}
|
||||
|
||||
private func requestGroupInfo(changes: [GroupDataChange] = [],
|
||||
retryAllowed: Bool = false,
|
||||
showError: Bool = true) {
|
||||
let requestID = UUID()
|
||||
groupInfoRequestID = requestID
|
||||
|
||||
GroupService.groupInfo().subscribe(onNext: { [weak self] response in
|
||||
guard let self,
|
||||
self.groupInfoRequestID == requestID,
|
||||
let model = response.model else { return }
|
||||
self.groupInfoRequestID == requestID else { return }
|
||||
|
||||
let previousGroupKey = self.viewModel.groupModel?.default_group_key
|
||||
let requestedGroupKey = model.default_group_key
|
||||
let isSwitchingGroup = previousGroupKey != nil && previousGroupKey != requestedGroupKey
|
||||
guard let model = response.model else {
|
||||
if showError {
|
||||
DLToast.showError(text: response.message ?? "获取圈子信息失败")
|
||||
}
|
||||
if retryAllowed {
|
||||
self.scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.viewModel.groupModel = model
|
||||
MQTTService.shared.updatePhoneReportContext(
|
||||
groupKey: requestedGroupKey,
|
||||
location: self.phoneReportLocation
|
||||
self.applyGroupModel(
|
||||
model,
|
||||
requestID: requestID,
|
||||
changes: changes,
|
||||
retryAllowed: retryAllowed,
|
||||
showError: showError
|
||||
)
|
||||
NotificationCenter.default.post(name: .RefreshIMGroupListNotification, object: nil)
|
||||
|
||||
if isDefaultGroup {
|
||||
self.rootView.groupMemberView.groupNameLab.text = self.viewModel.groupName
|
||||
}, onError: { [weak self] error in
|
||||
guard let self, self.groupInfoRequestID == requestID else { return }
|
||||
if showError {
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
}
|
||||
|
||||
if isSwitchingGroup || requestedGroupKey.isEmpty {
|
||||
self.clearPhoneUsageState()
|
||||
self.viewModel.clearMembers()
|
||||
self.syncMemberAnnotations([])
|
||||
self.refreshMQTTSubscriptions([])
|
||||
if retryAllowed {
|
||||
self.scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
|
||||
guard !requestedGroupKey.isEmpty else { return }
|
||||
self.requestGroupMembers(groupKey: requestedGroupKey,
|
||||
requestID: requestID)
|
||||
}, onError: { error in
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
}).disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func requestGroupMembers(groupKey: String, requestID: UUID) {
|
||||
private func applyGroupModel(_ model: GroupModel,
|
||||
requestID: UUID,
|
||||
changes: [GroupDataChange],
|
||||
retryAllowed: Bool,
|
||||
showError: Bool) {
|
||||
let previousGroupKey = viewModel.groupModel?.default_group_key ?? ""
|
||||
let requestedGroupKey = model.default_group_key
|
||||
let isSwitchingGroup = previousGroupKey != requestedGroupKey
|
||||
let hasRequestedGroup = model.groups.contains { $0.group_key == requestedGroupKey }
|
||||
|
||||
viewModel.groupModel = model
|
||||
rootView.groupMemberView.groupNameLab.text = viewModel.groupName.isEmpty
|
||||
? "我的圈子"
|
||||
: viewModel.groupName
|
||||
MQTTService.shared.updatePhoneReportContext(
|
||||
groupKey: hasRequestedGroup ? requestedGroupKey : "",
|
||||
location: phoneReportLocation
|
||||
)
|
||||
|
||||
let pickerMembers = isSwitchingGroup ? nil : viewModel.memberList
|
||||
GroupListPopView.updateIfVisible(
|
||||
groupModel: model,
|
||||
currentMembers: pickerMembers,
|
||||
presentationOwner: self
|
||||
)
|
||||
|
||||
if isSwitchingGroup || requestedGroupKey.isEmpty || !hasRequestedGroup {
|
||||
clearPhoneUsageState()
|
||||
viewModel.clearMembers()
|
||||
syncMemberAnnotations([])
|
||||
refreshMQTTSubscriptions([])
|
||||
}
|
||||
|
||||
guard !requestedGroupKey.isEmpty, hasRequestedGroup else {
|
||||
if retryAllowed, shouldRetryGroupList(model: model, changes: changes) {
|
||||
scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let shouldRetryList = retryAllowed && shouldRetryGroupList(model: model, changes: changes)
|
||||
requestGroupMembers(
|
||||
groupKey: requestedGroupKey,
|
||||
requestID: requestID,
|
||||
changes: changes,
|
||||
retryAllowed: retryAllowed && !shouldRetryList,
|
||||
showError: showError
|
||||
)
|
||||
if shouldRetryList {
|
||||
scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRetryGroupList(model: GroupModel,
|
||||
changes: [GroupDataChange]) -> Bool {
|
||||
let currentUserId = AppContextManager.shared.userId
|
||||
let groupKeys = Set(model.groups.map(\.group_key))
|
||||
return changes.contains { change in
|
||||
guard change.isMQTT, !change.groupKey.isEmpty else { return false }
|
||||
switch change.operation {
|
||||
case "join":
|
||||
return (change.userId.isEmpty || change.userId == currentUserId)
|
||||
&& !groupKeys.contains(change.groupKey)
|
||||
case "leave", "kick":
|
||||
return change.userId == currentUserId && groupKeys.contains(change.groupKey)
|
||||
case "dismiss":
|
||||
return groupKeys.contains(change.groupKey)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRetryMembers(_ members: [GroupMemberModel],
|
||||
groupKey: String,
|
||||
changes: [GroupDataChange]) -> Bool {
|
||||
let memberIds = Set(members.map(\.user_id))
|
||||
return changes.contains { change in
|
||||
guard change.isMQTT,
|
||||
change.groupKey == groupKey,
|
||||
!change.userId.isEmpty else { return false }
|
||||
switch change.operation {
|
||||
case "join":
|
||||
return !memberIds.contains(change.userId)
|
||||
case "leave", "kick":
|
||||
return memberIds.contains(change.userId)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleGroupDataRetry(changes: [GroupDataChange]) {
|
||||
groupDataRetryWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.requestGroupInfo(changes: changes, retryAllowed: false, showError: false)
|
||||
}
|
||||
groupDataRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work)
|
||||
}
|
||||
|
||||
private func showLatestGroupPicker() {
|
||||
groupDataRetryWorkItem?.cancel()
|
||||
let cachedModel = viewModel.groupModel
|
||||
let requestID = UUID()
|
||||
groupInfoRequestID = requestID
|
||||
GroupService.groupInfo()
|
||||
.subscribe(onNext: { [weak self] response in
|
||||
guard let self, self.groupInfoRequestID == requestID else { return }
|
||||
guard let model = response.model else {
|
||||
if let cachedModel {
|
||||
self.presentGroupPicker(groupModel: cachedModel)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.applyGroupModel(
|
||||
model,
|
||||
requestID: requestID,
|
||||
changes: [],
|
||||
retryAllowed: false,
|
||||
showError: false
|
||||
)
|
||||
self.presentGroupPicker(groupModel: model)
|
||||
}, onError: { [weak self] _ in
|
||||
guard let self,
|
||||
self.groupInfoRequestID == requestID,
|
||||
let cachedModel else { return }
|
||||
self.presentGroupPicker(groupModel: cachedModel)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func presentGroupPicker(groupModel: GroupModel) {
|
||||
GroupListPopView.show(
|
||||
groupModel: groupModel,
|
||||
currentMembers: viewModel.memberList,
|
||||
presentationOwner: self
|
||||
) { [weak self] groupKey in
|
||||
guard let self, let groupKey else { return }
|
||||
self.requestOperateGroup(groupKey: groupKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestGroupMembers(groupKey: String,
|
||||
requestID: UUID,
|
||||
changes: [GroupDataChange] = [],
|
||||
retryAllowed: Bool = false,
|
||||
showError: Bool = true) {
|
||||
GroupService.groupUsers(groupKey: groupKey)
|
||||
.subscribe(onNext: { [weak self] response in
|
||||
guard let self,
|
||||
|
|
@ -626,7 +814,12 @@ class HomeViewController: BaseViewController {
|
|||
self.viewModel.groupModel?.default_group_key == groupKey else { return }
|
||||
|
||||
guard response.isValid(for: groupKey) else {
|
||||
DLToast.showError(text: response.message ?? "获取圈子成员失败")
|
||||
if showError {
|
||||
DLToast.showError(text: response.message ?? "获取圈子成员失败")
|
||||
}
|
||||
if retryAllowed {
|
||||
self.scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -636,11 +829,27 @@ class HomeViewController: BaseViewController {
|
|||
self.refreshMQTTSubscriptions(self.viewModel.memberList)
|
||||
self.updateOnlineCount()
|
||||
self.requestSelectedMemberPhoneUsage(groupKey: groupKey)
|
||||
if let groupModel = self.viewModel.groupModel {
|
||||
GroupListPopView.updateIfVisible(
|
||||
groupModel: groupModel,
|
||||
currentMembers: self.viewModel.memberList,
|
||||
presentationOwner: self
|
||||
)
|
||||
}
|
||||
if retryAllowed,
|
||||
self.shouldRetryMembers(response.list, groupKey: groupKey, changes: changes) {
|
||||
self.scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
}, onError: { [weak self] error in
|
||||
guard let self,
|
||||
self.groupInfoRequestID == requestID,
|
||||
self.viewModel.groupModel?.default_group_key == groupKey else { return }
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
if showError {
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
}
|
||||
if retryAllowed {
|
||||
self.scheduleGroupDataRetry(changes: changes)
|
||||
}
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
|
@ -715,6 +924,7 @@ class HomeViewController: BaseViewController {
|
|||
}
|
||||
|
||||
private func requestOperateGroup(groupKey: String) {
|
||||
groupDataRetryWorkItem?.cancel()
|
||||
if viewModel.groupModel?.default_group_key != groupKey {
|
||||
groupInfoRequestID = UUID()
|
||||
clearPhoneUsageState()
|
||||
|
|
@ -724,9 +934,7 @@ class HomeViewController: BaseViewController {
|
|||
}
|
||||
|
||||
GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey])
|
||||
.subscribe(onNext: { _ in
|
||||
NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil)
|
||||
}, onError: { error in
|
||||
.subscribe(onNext: { _ in }, onError: { error in
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
|
@ -1143,35 +1351,6 @@ class HomeViewController: BaseViewController {
|
|||
|
||||
// MARK: - MQTT
|
||||
extension HomeViewController {
|
||||
/// join/leave/dismiss 防抖刷新(500ms 内多次触发只调一次)
|
||||
private func debounceGroupSwitch(groupKey: String) {
|
||||
groupSwitchWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.requestOperateGroup(groupKey: groupKey)
|
||||
}
|
||||
groupSwitchWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
|
||||
}
|
||||
|
||||
private func debounceGroupRefresh() {
|
||||
groupRefreshWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.requestGroupMembersFromCache()
|
||||
}
|
||||
groupRefreshWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
|
||||
}
|
||||
|
||||
private func requestGroupMembersFromCache() {
|
||||
let groupKey = AppContextManager.shared.defaultGroupKey
|
||||
guard !groupKey.isEmpty else {
|
||||
requestGroupInfo()
|
||||
return
|
||||
}
|
||||
let requestID = UUID()
|
||||
groupInfoRequestID = requestID
|
||||
requestGroupMembers(groupKey: groupKey, requestID: requestID)
|
||||
}
|
||||
/// 切换圈子时刷新 MQTT 订阅
|
||||
private func refreshMQTTSubscriptions(_ members: [GroupMemberModel]) {
|
||||
// let currentId = AppContextManager.shared.userId
|
||||
|
|
@ -1320,13 +1499,14 @@ extension HomeViewController {
|
|||
self?.applyIncomingLocks([record])
|
||||
}
|
||||
case "emote": // 快捷消息、表情
|
||||
guard let userId = msg.data?.user_id, userId == AppContextManager.shared.userId, // 只接收发给我的
|
||||
guard let targetUserId = msg.data?.user_id,
|
||||
targetUserId == AppContextManager.shared.userId, // 只接收发给我的
|
||||
let index = msg.data?.index, index > 9,
|
||||
let gk = msg.data?.group_key, gk.components(separatedBy: "/").count >= 2 else { break }
|
||||
let groupKey = msg.data?.group_key,
|
||||
groupKey.components(separatedBy: "/").count >= 2 else { break }
|
||||
let firstChar = index.string.prefix(1)
|
||||
let lastChar = String(index.string.suffix(index.string.count-1))
|
||||
let parts = gk.components(separatedBy: "/")
|
||||
let emoteUserId = parts[1]
|
||||
let emoteUserId = userId
|
||||
|
||||
if firstChar == "1" || firstChar == "2" { // 表情
|
||||
let emojiFileName = firstChar == "1" ? "normal_\(lastChar)" : "fun_\(lastChar)"
|
||||
|
|
@ -1359,54 +1539,8 @@ extension HomeViewController {
|
|||
guard let member = self.viewModel.memberList.first(where: { $0.user_id == userId }) else { return }
|
||||
self.showSOSReceiveAlert(for: member)
|
||||
}
|
||||
case "join": // 圈子有成员加入
|
||||
guard let gk = msg.data?.group_key,
|
||||
let defaultGk = self.viewModel.groupModel?.default_group_key,
|
||||
gk == defaultGk else {
|
||||
self.requestGroupInfo(isDefaultGroup: false)
|
||||
return
|
||||
}
|
||||
debounceGroupRefresh()
|
||||
case "leave": // 圈子有成员离开
|
||||
guard let userId = msg.data?.user_id, let gk = msg.data?.group_key,
|
||||
let defaultGk = self.viewModel.groupModel?.default_group_key,
|
||||
defaultGk.hasPrefix(gk) else {
|
||||
self.requestGroupInfo(isDefaultGroup: false)
|
||||
return
|
||||
}
|
||||
// 是当前选中圈子就移除成员
|
||||
if userId == AppContextManager.shared.userId { // 当前用户就切换到第一个圈子
|
||||
guard let model = self.viewModel.groupModel,
|
||||
let groupInfoModel = model.groups.first else { return }
|
||||
if model.groups.count > 1 {
|
||||
self.debounceGroupSwitch(groupKey: groupInfoModel.group_key)
|
||||
}
|
||||
else {
|
||||
debounceGroupRefresh()
|
||||
}
|
||||
}
|
||||
else { // 移除成员
|
||||
guard self.viewModel.memberIsExist(userId: userId) else { return }
|
||||
self.viewModel.removeMember(userId: userId)
|
||||
self.removeAnnotation(userId: userId)
|
||||
// self.rootView.groupMemberView.setupCountData(self.viewModel.memberList.count, 1)
|
||||
MQTTService.shared.unsubscribe(topic: "smartdrive/\(userId)")
|
||||
}
|
||||
case "dismiss": // 圈子解散
|
||||
guard let gk = msg.data?.group_key,
|
||||
let defaultGk = self.viewModel.groupModel?.default_group_key,
|
||||
gk == defaultGk,
|
||||
let model = self.viewModel.groupModel,
|
||||
let groupInfoModel = model.groups.first else {
|
||||
self.requestGroupInfo(isDefaultGroup: false)
|
||||
return
|
||||
}
|
||||
if model.groups.count > 1 {
|
||||
self.debounceGroupSwitch(groupKey: groupInfoModel.group_key)
|
||||
}
|
||||
else {
|
||||
debounceGroupRefresh()
|
||||
}
|
||||
case "join", "leave", "dismiss", "kick":
|
||||
break
|
||||
|
||||
default:
|
||||
print("📩 未处理 type=\(msg.type ?? "")")
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ final class PhoneReportDetailVC: BaseViewController {
|
|||
guard let self, let key = groupKey else { return }
|
||||
GroupService.operate(opType: "setdefault", requestData: ["group_key": key])
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil)
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
.disposed(by: self.disposeBag)
|
||||
|
|
|
|||
|
|
@ -74,8 +74,9 @@ class LaunchViewController: BaseViewController {
|
|||
guard let this = self else { return }
|
||||
#if DEBUG || AdHoc
|
||||
this.showServerSwitchAlert()
|
||||
#endif
|
||||
#else
|
||||
this.getUserConfig()
|
||||
#endif
|
||||
}
|
||||
}).disposed(by: disposeBag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,16 +127,16 @@ final class LockDistractView: UIView {
|
|||
lockBtn.isHidden = primaryAction == .hidden
|
||||
switch primaryAction {
|
||||
case .lock:
|
||||
tipLab.text = "锁定功能只有在圈主的情况下才能使用哟~"
|
||||
tipLab.text = "锁定功能需要在对方开启权限情况下才能使用哟~"
|
||||
lockBtn.setTitle("锁定", for: .normal)
|
||||
case .requestUnlock:
|
||||
tipLab.text = "已被其他圈主锁定,请先申请解锁哟~"
|
||||
lockBtn.setTitle("请求解锁", for: .normal)
|
||||
case .unlock:
|
||||
tipLab.text = "已被你锁定,解锁后才能重新设置哟~"
|
||||
tipLab.text = "TA已经被你锁定,解锁后才能重新设置哟~"
|
||||
lockBtn.setTitle("解锁", for: .normal)
|
||||
case .hidden:
|
||||
tipLab.text = "该成员的APP已被其他圈主锁定"
|
||||
tipLab.text = "该成员的APP已被其他圈主锁定~"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,13 +236,10 @@ final class LockDistractView: UIView {
|
|||
|
||||
private func setupCopyLimit() {
|
||||
copyTF.rx.text.orEmpty
|
||||
.subscribe(onNext: { [weak self] text in
|
||||
.subscribe(onNext: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if text.count > self.copyMaxLength {
|
||||
self.copyTF.text = String(text.prefix(self.copyMaxLength))
|
||||
}
|
||||
let count = self.copyTF.text?.count ?? 0
|
||||
self.copyCountLab.text = "\(count)/\(self.copyMaxLength)"
|
||||
guard let text = self.copyTF.limitCommittedText(to: self.copyMaxLength) else { return }
|
||||
self.copyCountLab.text = "\(text.count)/\(self.copyMaxLength)"
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ class EmergencyContactAddView: UIView {
|
|||
var disposeBag = DisposeBag()
|
||||
|
||||
private func setupRx() {
|
||||
nameTF.rx.text
|
||||
.map { text -> String? in
|
||||
guard let text = text else { return nil }
|
||||
return String(text.prefix(10))
|
||||
}.bind(to: nameTF.rx.text)
|
||||
.disposed(by: disposeBag)
|
||||
let committedName = nameTF.rx.text.orEmpty
|
||||
.compactMap { [weak self] _ in
|
||||
self?.nameTF.limitCommittedText(to: 10)
|
||||
}
|
||||
.share(replay: 1)
|
||||
|
||||
phoneTF.rx.text
|
||||
.map { text -> String? in
|
||||
|
|
@ -28,7 +27,7 @@ class EmergencyContactAddView: UIView {
|
|||
}.bind(to: phoneTF.rx.text)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
let valid = Observable.combineLatest(nameTF.rx.text.orEmpty, phoneTF.rx.text.orEmpty)
|
||||
let valid = Observable.combineLatest(committedName, phoneTF.rx.text.orEmpty)
|
||||
.map { name, phone in
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && phone.count == 11
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,12 +96,9 @@ final class FeedbackVC: BaseViewController {
|
|||
rootView.updateCategory(selectedIndex: sender.tag)
|
||||
}
|
||||
|
||||
private func handleDetailChange(_ text: String) {
|
||||
let limitedText = text.count > 200 ? String(text.prefix(200)) : text
|
||||
if limitedText != text {
|
||||
rootView.detailTextView.text = limitedText
|
||||
rootView.detailTextView.selectedRange = NSRange(location: limitedText.count, length: 0)
|
||||
}
|
||||
private func handleDetailChange(_: String) {
|
||||
guard !rootView.detailTextView.isComposingText,
|
||||
let limitedText = rootView.detailTextView.limitCommittedText(to: 200) else { return }
|
||||
rootView.updateDetail(text: limitedText)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@ final class MinePhotoWallView: UIView {
|
|||
members: [GroupMemberModel],
|
||||
ownerUserId: String,
|
||||
preferUserId: String,
|
||||
memberLimit: Int = -1
|
||||
isCoupleGroup: Bool = false
|
||||
) {
|
||||
isPairMode = memberLimit == 2
|
||||
isPairMode = isCoupleGroup && members.count >= 2
|
||||
self.members = isPairMode ? Array(members.prefix(2)) : members
|
||||
self.ownerUserId = ownerUserId
|
||||
self.clipColorIndices = Self.makeClipColorIndices(for: self.members)
|
||||
|
|
|
|||
|
|
@ -117,27 +117,35 @@ final class MineViewController: BaseViewController {
|
|||
DLToast.showError(text: response.message ?? "获取圈子成员失败")
|
||||
return
|
||||
}
|
||||
self?.reloadPhotoWall(members: response.list, groupKey: groupKey)
|
||||
self?.reloadPhotoWall(
|
||||
members: response.list,
|
||||
groupKey: groupKey,
|
||||
groupInfo: response.model
|
||||
)
|
||||
}, onError: { error in
|
||||
DLToast.showError(text: error.localizedDescription)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func reloadPhotoWall(members: [GroupMemberModel], groupKey: String) {
|
||||
private func reloadPhotoWall(
|
||||
members: [GroupMemberModel],
|
||||
groupKey: String,
|
||||
groupInfo: GroupInfoModel? = nil
|
||||
) {
|
||||
let sorted = viewModel.sortedMembers(members, groupKey: groupKey)
|
||||
photoWallMembers = sorted
|
||||
let ownerId = sorted.first {
|
||||
GroupOwnerMatcher.matches(userId: $0.user_id, groupKey: groupKey)
|
||||
}?.user_id ?? ""
|
||||
let limit = groupModel?.groups.first {
|
||||
let currentGroup = groupInfo ?? groupModel?.groups.first {
|
||||
$0.group_key == groupKey
|
||||
}?.group_template?.limit ?? -1
|
||||
}
|
||||
rootView.photoWall.reload(
|
||||
members: sorted,
|
||||
ownerUserId: ownerId,
|
||||
preferUserId: AppContextManager.shared.userId,
|
||||
memberLimit: limit
|
||||
isCoupleGroup: currentGroup?.isCoupleGroup == true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -580,24 +580,11 @@ final class PigeonMessageView: UIView, UITextFieldDelegate {
|
|||
}
|
||||
|
||||
@objc private func captionChanged() {
|
||||
let text = captionField.text ?? ""
|
||||
if text.count > 20 {
|
||||
captionField.text = String(text.prefix(20))
|
||||
}
|
||||
captionCountLabel.text = "\((captionField.text ?? "").count)/20"
|
||||
guard let text = captionField.limitCommittedText(to: 20) else { return }
|
||||
captionCountLabel.text = "\(text.count)/20"
|
||||
updatePreviewOverlays()
|
||||
}
|
||||
|
||||
func textField(
|
||||
_ textField: UITextField,
|
||||
shouldChangeCharactersIn range: NSRange,
|
||||
replacementString string: String
|
||||
) -> Bool {
|
||||
guard let current = textField.text,
|
||||
let stringRange = Range(range, in: current) else { return true }
|
||||
return current.replacingCharacters(in: stringRange, with: string).count <= 20
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ final class TextInputViewController: UIViewController {
|
|||
|
||||
// MARK: - Binding
|
||||
private func setupBinding() {
|
||||
textView.text = textRelay.value
|
||||
updateCommittedText()
|
||||
|
||||
Observable.merge(
|
||||
textView.rx.didChange.asObservable(),
|
||||
textView.rx.text.map { _ in () },
|
||||
|
|
@ -109,37 +112,10 @@ final class TextInputViewController: UIViewController {
|
|||
)
|
||||
.throttle(.milliseconds(100), scheduler: MainScheduler.instance)
|
||||
.subscribe(onNext: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
if self.textView.text.last == "\n" {
|
||||
self.textView.text = String(self.textView.text.dropLast())
|
||||
self.textView.resignFirstResponder()
|
||||
return
|
||||
}
|
||||
|
||||
let count = self.textView.text.count
|
||||
|
||||
if count > self.maxLength {
|
||||
self.textView.text = String(self.textView.text.prefix(self.maxLength))
|
||||
self.textView.selectedRange = NSRange(location: self.maxLength, length: 0)
|
||||
return
|
||||
}
|
||||
self.countLabel.text = "\(count)/\(self.maxLength)"
|
||||
self?.updateCommittedText()
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
textRelay.asObservable()
|
||||
.bind(to: textView.rx.text)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
// 确定
|
||||
textView.rx.text.orEmpty.map { text in
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !trimmed.isEmpty
|
||||
}
|
||||
.bind(to: confirmBtn.rx.isEnabled)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
confirmBtn.rx.tap.subscribe(onNext: { [weak self] in
|
||||
guard let self = self, let text = self.textView.text else { return }
|
||||
self.confirmAction?(text)
|
||||
|
|
@ -148,6 +124,28 @@ final class TextInputViewController: UIViewController {
|
|||
.disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
private func updateCommittedText() {
|
||||
guard !textView.isComposingText else { return }
|
||||
|
||||
if textView.text.last == "\n" {
|
||||
textView.text = String(textView.text.dropLast())
|
||||
textView.selectedRange = NSRange(
|
||||
location: (textView.text as NSString).length,
|
||||
length: 0
|
||||
)
|
||||
textView.resignFirstResponder()
|
||||
}
|
||||
|
||||
let text: String
|
||||
if maxLength > 0 {
|
||||
text = textView.limitCommittedText(to: maxLength) ?? textView.text
|
||||
} else {
|
||||
text = textView.text
|
||||
}
|
||||
countLabel.text = maxLength > 0 ? "\(text.count)/\(maxLength)" : ""
|
||||
confirmBtn.isEnabled = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
lazy var navBgView: UIImageView = {
|
||||
let iv = UIImageView()
|
||||
|
|
|
|||
|
|
@ -5,11 +5,60 @@
|
|||
// Created by 八条 on 2026/6/1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RxSwift
|
||||
import Moya
|
||||
|
||||
struct GroupService {
|
||||
static let disposeBag = DisposeBag()
|
||||
|
||||
private static let notifyingOperations: Set<String> = [
|
||||
"create", "join", "setdefault", "editmateinfo"
|
||||
]
|
||||
|
||||
private static func postGroupDataChange(operation: String,
|
||||
requestData: [String: Any],
|
||||
responseData: [String: Any]? = nil) {
|
||||
var userInfo: [AnyHashable: Any] = [
|
||||
GroupDataChangeUserInfoKey.operation: operation,
|
||||
GroupDataChangeUserInfoKey.source: GroupDataChangeSource.api
|
||||
]
|
||||
|
||||
let responseGroupKey = responseData?[GroupDataChangeUserInfoKey.groupKey] as? String
|
||||
if let groupKey = requestData[GroupDataChangeUserInfoKey.groupKey] as? String
|
||||
?? responseGroupKey,
|
||||
!groupKey.isEmpty {
|
||||
userInfo[GroupDataChangeUserInfoKey.groupKey] = groupKey
|
||||
}
|
||||
|
||||
let userId = requestData[GroupDataChangeUserInfoKey.userId] as? String
|
||||
?? requestData["review_userid"] as? String
|
||||
?? requestData["kick_userid"] as? String
|
||||
?? (requestData["kick_userid"] as? [String])?.first
|
||||
if let userId, !userId.isEmpty {
|
||||
userInfo[GroupDataChangeUserInfoKey.userId] = userId
|
||||
}
|
||||
|
||||
let post = {
|
||||
NotificationCenter.default.post(
|
||||
name: .RefreshGroupInfoNotification,
|
||||
object: nil,
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
post()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: post)
|
||||
}
|
||||
}
|
||||
|
||||
private static func notifyGroupInfoChange<T>(operation: String,
|
||||
requestData: [String: Any]) -> (T) -> Void {
|
||||
{ _ in
|
||||
postGroupDataChange(operation: operation, requestData: requestData)
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有圈子信息
|
||||
static func groupInfo() -> Observable<UserGroupResponse> {
|
||||
|
|
@ -17,8 +66,8 @@ struct GroupService {
|
|||
return APIProvider.request(token: api)
|
||||
.map(UserGroupResponse.self)
|
||||
.map { response in
|
||||
if let key = response.model?.default_group_key, !key.isEmpty {
|
||||
AppContextManager.shared.defaultGroupKey = key
|
||||
if let model = response.model {
|
||||
AppContextManager.shared.defaultGroupKey = model.default_group_key
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
|
@ -57,6 +106,14 @@ struct GroupService {
|
|||
}
|
||||
return response
|
||||
}
|
||||
.do(onSuccess: { response in
|
||||
guard notifyingOperations.contains(opType) else { return }
|
||||
postGroupDataChange(
|
||||
operation: opType,
|
||||
requestData: requestData,
|
||||
responseData: response.data
|
||||
)
|
||||
})
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +140,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "changename", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "changename", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +151,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "changeicon", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "changeicon", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +162,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "changedescription", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "changedescription", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +173,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "changereview", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "changereview", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +184,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "changelabels", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "changelabels", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +203,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "review", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(ResponseModel.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "review", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +212,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "kick", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(ResponseModel.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "kick", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +223,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "leave", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "leave", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +232,7 @@ struct GroupService {
|
|||
let api = GroupAPI.operate(opType: "dismiss", requestData: requestData).multiTarget
|
||||
return APIProvider.request(token: api)
|
||||
.map(GroupInfoResponse.self)
|
||||
.do(onSuccess: notifyGroupInfoChange(operation: "dismiss", requestData: requestData))
|
||||
.asObservable()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,24 @@ struct UserService {
|
|||
.map(PhoneUnlockAllowResponse.self)
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
/// 确认本机已执行解锁
|
||||
static func phoneUnlockAllowConfirm(
|
||||
os: String = "ios",
|
||||
groupKey: String,
|
||||
userId: String,
|
||||
tokens: [String]
|
||||
) -> Observable<ResponseModel> {
|
||||
let api = UserAPI.phoneUnlockAllowConfirm(
|
||||
os: os,
|
||||
groupKey: groupKey,
|
||||
userId: userId,
|
||||
tokens: tokens
|
||||
).multiTarget
|
||||
return APIProvider.request(token: api, handle: false)
|
||||
.map(ResponseModel.self)
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
static func imToken() -> Observable<ResponseModel> {
|
||||
let api = UserAPI.imToken.multiTarget
|
||||
|
|
|
|||
Loading…
Reference in New Issue