This commit is contained in:
linshujie 2026-09-04 18:22:21 +08:00
parent 36a99221a8
commit 5674603e94
28 changed files with 1131 additions and 458 deletions

View File

@ -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"
@ -359,6 +362,17 @@ extension UserAPI: MultiTargetProtocol {
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()
params["platform_id"] = 1

View File

@ -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

View File

@ -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) ) {
//
@ -24,4 +30,51 @@ extension UITextField {
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)
}
}

View File

@ -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
} else {
pendingReceipts = []
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)
}
for receipt in pendingReceipts {
restoreLocalUnlockSuppression(from: receipt)
} else {
pendingConfirmations = []
}
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 confirmation = PendingAppUnlockConfirmation(
userId: userId,
os: record.os.trimmed.isEmpty ? "ios" : record.os.trimmed,
groupKey: record.groupKey,
tokens: record.tokens
)
let isPending = pendingConfirmations.contains {
$0.userId == confirmation.userId
&& $0.os == confirmation.os
&& $0.groupKey == confirmation.groupKey
&& $0.tokens == confirmation.tokens
}
let receipt = PendingAppUnlockReceipt(userId: userId, payload: payload)
pendingReceipts.append(receipt)
savePendingReceipts()
flushPendingReceipts()
if !isPending {
pendingConfirmations.append(confirmation)
savePendingConfirmations()
}
retryPendingConfirmations()
}
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 encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
guard let data = try? encoder.encode(payload) else { return nil }
return String(data: data, encoding: .utf8)
}
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 !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
)
guard messageId >= 0, messageId <= Int(UInt16.max) else {
cancelExpectedOutgoingEcho(receipt.payload)
break
}
inFlightReceipts[UInt16(messageId)] = receipt
.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
}
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,59 +572,35 @@ 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
let payload = try? JSONDecoder().decode(LegacyMqttAppUnlockReceiptPayload.self, from: data) else {
return nil
}
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 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)
}
}
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 }
}
private func performOnMain(_ work: @escaping () -> Void) {
if Thread.isMainThread {
work()
@ -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?()
}
}

View File

@ -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)
]

View File

@ -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()
}
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 {

View File

@ -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)
@ -75,6 +83,12 @@ class CreateGroupVC: BaseViewController {
})
.disposed(by: disposeBag)
viewModel.output.createSucceeded
.emit(onNext: { [weak self] in
self?.handleCreateSucceeded()
})
.disposed(by: disposeBag)
viewModel.output.sectionedItems
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
@ -101,6 +115,20 @@ class CreateGroupVC: BaseViewController {
.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> = {
RxCollectionViewSectionedReloadDataSource<GroupTagListSectionModel> { datasource, collectionView, indexPath, item in

View File

@ -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)

View File

@ -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()
)
}
}

View File

@ -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 {
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
}, onError: { [weak self] error in
guard let self, self.groupInfoRequestID == requestID else { return }
if showError {
DLToast.showError(text: error.localizedDescription)
}
})
.disposed(by: disposeBag)
}

View File

@ -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 ?? "")

View File

@ -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)
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
}
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?()
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()
}
let prevFinished = onFinished
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()
}
}
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()
}
}

View File

@ -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()

View File

@ -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 }
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)
}
})
.disposed(by: disposeBag)
}
}

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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
}
if isSwitchingGroup || requestedGroupKey.isEmpty {
self.clearPhoneUsageState()
self.viewModel.clearMembers()
self.syncMemberAnnotations([])
self.refreshMQTTSubscriptions([])
}
guard !requestedGroupKey.isEmpty else { return }
self.requestGroupMembers(groupKey: requestedGroupKey,
requestID: requestID)
}, onError: { error in
}, onError: { [weak self] error in
guard let self, self.groupInfoRequestID == requestID else { return }
if showError {
DLToast.showError(text: error.localizedDescription)
}
if retryAllowed {
self.scheduleGroupDataRetry(changes: changes)
}
}).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 {
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 }
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 ?? "")")

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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

View File

@ -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()

View File

@ -5,20 +5,69 @@
// 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> {
let api = GroupAPI.groupInfo.multiTarget
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()
}
}

View File

@ -192,6 +192,24 @@ struct UserService {
.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
return APIProvider.request(token: api, handle: false)