feat(sdk): 更新 SDK 设计文档和 API 重构
- 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制
这个提交包含在:
父节点
3249e4b4e5
当前提交
e7067d03cb
@ -1,13 +1,22 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class XuqmSDK {
|
public final class XuqmSDK: NSObject {
|
||||||
|
|
||||||
public static let shared = XuqmSDK()
|
public static let shared = XuqmSDK()
|
||||||
private(set) var config: SDKConfig?
|
private(set) var config: SDKConfig?
|
||||||
private(set) var tokenStore: TokenStore?
|
private(set) var tokenStore: TokenStore?
|
||||||
|
|
||||||
private init() {}
|
public private(set) var currentUserId: String?
|
||||||
|
public var onUserSigExpired: (() -> Void)?
|
||||||
|
|
||||||
|
private var userSig: String?
|
||||||
|
private var userSigTimer: Timer?
|
||||||
|
private var cachedDeviceToken: String?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
public func initialize(config: SDKConfig) {
|
public func initialize(config: SDKConfig) {
|
||||||
self.config = config
|
self.config = config
|
||||||
@ -21,4 +30,93 @@ public final class XuqmSDK {
|
|||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func login(userId: String, userSig: String) async {
|
||||||
|
self.currentUserId = userId
|
||||||
|
self.userSig = userSig
|
||||||
|
startUserSigExpirationTimer(userSig: userSig)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await ImSDK.shared.loginWithUserSig(userId, userSig)
|
||||||
|
} catch {
|
||||||
|
// IM login failed; silently ignored per facade pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cachedDeviceToken {
|
||||||
|
do {
|
||||||
|
try await PushSDK.shared.registerToken(cachedDeviceToken, userId: userId)
|
||||||
|
} catch {
|
||||||
|
// Push registration failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func logout() async {
|
||||||
|
userSigTimer?.invalidate()
|
||||||
|
userSigTimer = nil
|
||||||
|
|
||||||
|
ImSDK.shared.disconnect()
|
||||||
|
|
||||||
|
if let userId = currentUserId {
|
||||||
|
do {
|
||||||
|
try await PushSDK.shared.unregisterToken(userId: userId)
|
||||||
|
} catch {
|
||||||
|
// Push unregistration failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStore?.clear()
|
||||||
|
currentUserId = nil
|
||||||
|
userSig = nil
|
||||||
|
cachedDeviceToken = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func registerDeviceToken(_ deviceToken: Data) {
|
||||||
|
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||||
|
self.cachedDeviceToken = token
|
||||||
|
Task { @MainActor in
|
||||||
|
if let userId = self.currentUserId {
|
||||||
|
try? await PushSDK.shared.registerToken(token, userId: userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startUserSigExpirationTimer(userSig: String) {
|
||||||
|
userSigTimer?.invalidate()
|
||||||
|
guard let expDate = extractExpirationDate(from: userSig) else { return }
|
||||||
|
let interval = expDate.timeIntervalSinceNow - 300 // 5 minutes before expiry
|
||||||
|
guard interval > 0 else {
|
||||||
|
onUserSigExpired?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userSigTimer = Timer.scheduledTimer(
|
||||||
|
timeInterval: interval,
|
||||||
|
target: self,
|
||||||
|
selector: #selector(userSigDidExpire),
|
||||||
|
userInfo: nil,
|
||||||
|
repeats: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func userSigDidExpire() {
|
||||||
|
onUserSigExpired?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractExpirationDate(from userSig: String) -> Date? {
|
||||||
|
let parts = userSig.split(separator: ".")
|
||||||
|
guard parts.count >= 2 else { return nil }
|
||||||
|
var base64 = String(parts[1])
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
let padding = 4 - base64.count % 4
|
||||||
|
if padding != 4 {
|
||||||
|
base64 += String(repeating: "=", count: padding)
|
||||||
|
}
|
||||||
|
guard let data = Data(base64Encoded: base64),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let exp = json["exp"] as? TimeInterval else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Date(timeIntervalSince1970: exp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,34 @@ public final class ImSDK {
|
|||||||
private weak var delegate: ImEventDelegate?
|
private weak var delegate: ImEventDelegate?
|
||||||
private var currentUserId: String?
|
private var currentUserId: String?
|
||||||
|
|
||||||
|
public private(set) var connectionState: ImConnectionState = .disconnected
|
||||||
|
private var connectionStateListeners: [(ImConnectionState) -> Void] = []
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
public func addConnectionStateListener(_ listener: @escaping (ImConnectionState) -> Void) {
|
||||||
|
connectionStateListeners.append(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateConnectionState(_ state: ImConnectionState) {
|
||||||
|
connectionState = state
|
||||||
|
for listener in connectionStateListeners {
|
||||||
|
listener(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loginWithUserSig(_ userId: String, _ userSig: String) async throws {
|
||||||
|
let config = XuqmSDK.shared.requireConfig()
|
||||||
|
currentUserId = userId
|
||||||
|
XuqmSDK.shared.tokenStore?.save(userSig)
|
||||||
|
client?.disconnect()
|
||||||
|
client = ImClient(token: userSig, appId: config.appId)
|
||||||
|
client?.setCurrentUserId(userId)
|
||||||
|
client?.delegate = self
|
||||||
|
updateConnectionState(.connecting)
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws {
|
public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws {
|
||||||
let config = XuqmSDK.shared.requireConfig()
|
let config = XuqmSDK.shared.requireConfig()
|
||||||
|
|
||||||
@ -30,7 +56,8 @@ public final class ImSDK {
|
|||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
client = ImClient(token: res.token, appId: config.appId)
|
client = ImClient(token: res.token, appId: config.appId)
|
||||||
client?.setCurrentUserId(userId)
|
client?.setCurrentUserId(userId)
|
||||||
client?.delegate = delegate
|
client?.delegate = self
|
||||||
|
updateConnectionState(.connecting)
|
||||||
client?.connect()
|
client?.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,13 +84,14 @@ public final class ImSDK {
|
|||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
client = ImClient(token: res.imToken, appId: config.appId)
|
client = ImClient(token: res.imToken, appId: config.appId)
|
||||||
client?.setCurrentUserId(res.profile.userId)
|
client?.setCurrentUserId(res.profile.userId)
|
||||||
client?.delegate = delegate
|
client?.delegate = self
|
||||||
|
updateConnectionState(.connecting)
|
||||||
client?.connect()
|
client?.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setDelegate(_ delegate: ImEventDelegate) {
|
public func setDelegate(_ delegate: ImEventDelegate) {
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
client?.delegate = delegate
|
client?.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
|
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
|
||||||
@ -818,6 +846,7 @@ public final class ImSDK {
|
|||||||
public func disconnect() {
|
public func disconnect() {
|
||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
client = nil
|
client = nil
|
||||||
|
updateConnectionState(.disconnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchHistoryInternal(
|
private func fetchHistoryInternal(
|
||||||
@ -870,3 +899,37 @@ public final class ImSDK {
|
|||||||
return formatter.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension ImSDK: ImEventDelegate {
|
||||||
|
public func imClientDidConnect() {
|
||||||
|
updateConnectionState(.connected)
|
||||||
|
delegate?.imClientDidConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imClientDidDisconnect(reason: String?) {
|
||||||
|
updateConnectionState(.disconnected)
|
||||||
|
delegate?.imClientDidDisconnect(reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imClientDidReceiveMessage(_ message: ImMessage) {
|
||||||
|
delegate?.imClientDidReceiveMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imClientDidReceiveGroupMessage(_ message: ImMessage) {
|
||||||
|
delegate?.imClientDidReceiveGroupMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imClientDidReadMessage(_ message: ImMessage) {
|
||||||
|
delegate?.imClientDidReadMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imClientDidReceiveRevokedMessage(_ message: ImMessage) {
|
||||||
|
delegate?.imClientDidReceiveRevokedMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func imClientDidError(_ error: String) {
|
||||||
|
updateConnectionState(.disconnected)
|
||||||
|
delegate?.imClientDidError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public enum ImConnectionState: String, Sendable {
|
||||||
|
case connected
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
}
|
||||||
|
|
||||||
public enum ChatType: String, Codable, Sendable {
|
public enum ChatType: String, Codable, Sendable {
|
||||||
case single = "SINGLE"
|
case single = "SINGLE"
|
||||||
case group = "GROUP"
|
case group = "GROUP"
|
||||||
|
|||||||
@ -49,6 +49,31 @@ public final class PushSDK: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func registerFcmToken(_ token: String, userId: String) async throws {
|
||||||
|
try await registerToken(token, userId: userId, vendor: .fcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isFcmAvailable: Bool {
|
||||||
|
#if canImport(FirebaseMessaging)
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unregisterToken(userId: String, vendor: PushVendor = .apns) async throws {
|
||||||
|
let config = XuqmSDK.shared.requireConfig()
|
||||||
|
let _: EmptyResponse = try await ApiClient.shared.request(
|
||||||
|
path: "/api/push/unregister",
|
||||||
|
method: "POST",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "appId", value: config.appId),
|
||||||
|
URLQueryItem(name: "userId", value: userId),
|
||||||
|
URLQueryItem(name: "vendor", value: vendor.rawValue),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public func userNotificationCenter(
|
public func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
|
|||||||
130
TEST_REPORT.md
普通文件
130
TEST_REPORT.md
普通文件
@ -0,0 +1,130 @@
|
|||||||
|
# iOS SDK 测试报告
|
||||||
|
|
||||||
|
> **生成时间**: 2026-05-01
|
||||||
|
> **版本**: 0.1.0
|
||||||
|
> **测试状态**: 部分功能待测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试环境
|
||||||
|
|
||||||
|
| 项目 | 版本/配置 |
|
||||||
|
|------|-----------|
|
||||||
|
| Xcode | 16.0 |
|
||||||
|
| iOS 模拟器 | iPhone 16 Pro(iOS 18.0) |
|
||||||
|
| Swift | 5.9+ |
|
||||||
|
| Swift Tools Version | 5.9 |
|
||||||
|
| 最低 iOS 版本 | iOS 14 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试用例清单
|
||||||
|
|
||||||
|
### TC-01 SDK 初始化测试
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证 SDK 初始化及模块配置 |
|
||||||
|
| **测试步骤** | 1. 在 `AppDelegate` 中调用 `XuqmSDK.shared.initialize(config: SDKConfig(appId:appSecret:))` <br> 2. 确认 `XuqmSDK.shared.requireConfig()` 返回有效配置 <br> 3. 确认 `ApiClient.shared` 已配置 baseURL 与拦截器 |
|
||||||
|
| **预期结果** | 1. 初始化成功,无 fatalError <br> 2. `config.appId` 与传入值一致 <br> 3. `TokenStore` 已实例化 |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-02 IM 登录/登出测试(UserSig 模式)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证 UserSig 鉴权登录与登出流程 |
|
||||||
|
| **测试步骤** | 1. 调用 `XuqmSDK.shared.login(userId: "user_001", userSig: "xxx")` <br> 2. 观察 `ImSDK.shared.loginWithUserSig` 内部触发 WebSocket 连接 <br> 3. 监听 `ImEventDelegate.imClientDidConnect()` <br> 4. 调用 `XuqmSDK.shared.logout()` <br> 5. 确认 `ImSDK.shared.disconnect()` 执行,Push Token 解注册 |
|
||||||
|
| **预期结果** | 1. `currentUserId` 被赋值 <br> 2. WebSocket 连接成功,状态变为 `.connecting` → `.connected` <br> 3. delegate `imClientDidConnect()` 触发 <br> 4. 登出后 `currentUserId` 置 nil <br> 5. `PushSDK.shared.unregisterToken` 被调用 |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-03 单聊消息收发测试
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证单聊消息发送、接收、历史与已读 |
|
||||||
|
| **测试步骤** | 1. 调用 `ImSDK.shared.sendTextMessage(toId:chatType:content:)` <br> 2. 接收方通过 `ImEventDelegate.imClientDidReceiveMessage(_:)` 接收 <br> 3. 调用 `fetchHistory(toId:page:size:)` <br> 4. 调用 `markRead(targetId:chatType:)` <br> 5. 发送方重新拉取历史,确认 `status == .read` |
|
||||||
|
| **预期结果** | 1. 返回 `ImMessage`,`status` 为 `.sending` 或 `.sent` <br> 2. 接收方实时收到消息,未读角标 +1 <br> 3. 历史消息返回 `[ImMessage]` <br> 4. `markRead` HTTP 200,未读清零 <br> 5. 发送方消息状态更新为 `.read` |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-04 群聊消息收发测试
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证群创建、订阅、群消息收发与历史 |
|
||||||
|
| **测试步骤** | 1. 调用 `createGroup(name:memberIds:groupType:)` <br> 2. 双端调用 `subscribeGroup(_:)` <br> 3. 发送方调用 `sendTextMessage(toId:chatType:content:)`(chatType=.group) <br> 4. 接收方通过 `imClientDidReceiveGroupMessage(_:)` 接收 <br> 5. 双端调用 `fetchGroupHistory(groupId:page:size:)` |
|
||||||
|
| **预期结果** | 1. 返回 `ImGroup`,`memberIds` 包含指定用户 <br> 2. WebSocket 订阅 `/topic/group/{groupId}` 成功 <br> 3. 群消息发送成功 <br> 4. 群成员实时收到消息 <br> 5. 群历史正确分页 |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-05 连接状态监听测试(新增)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证 `connectionState` 属性与 `addConnectionStateListener` 回调 |
|
||||||
|
| **测试步骤** | 1. 添加 `addConnectionStateListener { state in print(state) }` <br> 2. 触发登录,观察状态流转 <br> 3. 手动断开网络,观察重连状态 <br> 4. 恢复网络,观察恢复为 `.connected` <br> 5. 调用 `disconnect()`,观察 `.disconnected` |
|
||||||
|
| **预期结果** | 1. 监听器被加入数组 <br> 2. 登录时状态变化:`.disconnected` → `.connecting` → `.connected` <br> 3. 断网后状态变为 `.disconnected` 并触发重连 <br> 4. 恢复网络后回到 `.connected` <br> 5. `disconnect()` 后状态为 `.disconnected`,监听器仍保留(不移除) |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-06 Push 设备注册测试(APNs + FCM)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证 APNs 设备 Token 获取、注册与 FCM 备选方案 |
|
||||||
|
| **测试步骤** | 1. 在 `AppDelegate` 中调用 `PushSDK.shared.requestAuthorization()` <br> 2. 系统授权后 `UIApplication.shared.registerForRemoteNotifications()` <br> 3. 在 `didRegisterForRemoteNotificationsWithDeviceToken` 中调用 `XuqmSDK.shared.registerDeviceToken(_:)` <br> 4. 确认 `PushSDK.shared.registerToken(token:userId:vendor:)` 调用(vendor=.apns) <br> 5. 若集成 Firebase,验证 `registerFcmToken` 路径 |
|
||||||
|
| **预期结果** | 1. `requestAuthorization` 返回 `true` <br> 2. 系统弹窗申请通知权限 <br> 3. `cachedDeviceToken` 被保存 <br> 4. `/api/push/register` 返回 200 <br> 5. FCM 路径返回 vendor=FCM,注册成功 |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-07 版本更新检查测试
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证 UpdateSDK 检查 App Store 更新 |
|
||||||
|
| **测试步骤** | 1. 调用 `UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)` <br> 2. 若 `needsUpdate=true` 且 `forceUpdate=true`,调用 `openAppStore(url:)` <br> 3. 观察是否能正确跳转到 App Store 或下载页 |
|
||||||
|
| **预期结果** | 1. 返回 `AppUpdateInfo`,`platform=IOS` <br> 2. `forceUpdate` 为布尔值,下载链接有效 <br> 3. `UIApplication.shared.open` 成功跳转 |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TC-08 UserSig 过期检测测试(新增)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **测试目的** | 验证 `XuqmSDK` 解析 UserSig JWT exp 并在过期前触发回调 |
|
||||||
|
| **测试步骤** | 1. 生成一个 exp 为当前时间 + 6 分钟的 UserSig JWT <br> 2. 调用 `XuqmSDK.shared.login(userId:userSig:)` <br> 3. 设置 `XuqmSDK.shared.onUserSigExpired = { ... }` <br> 4. 等待 1 分钟,观察 Timer 是否在正确时间点触发 <br> 5. 触发后调用 `logout()`,确认 Timer 被 `invalidate` |
|
||||||
|
| **预期结果** | 1. `extractExpirationDate` 正确解析 JWT payload <br> 2. `startUserSigExpirationTimer` 创建 `Timer` <br> 3. 到期前 5 分钟触发 `onUserSigExpired?()` <br> 4. 回调在主线程执行 <br> 5. `logout()` 后 `userSigTimer` 为 nil,无内存泄漏 |
|
||||||
|
| **实际结果** | 待测试 |
|
||||||
|
| **通过状态** | ⬜ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试汇总
|
||||||
|
|
||||||
|
| 用例编号 | 用例名称 | 状态 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| TC-01 | SDK 初始化测试 | ⬜ 待测试 |
|
||||||
|
| TC-02 | IM 登录/登出测试(UserSig 模式) | ⬜ 待测试 |
|
||||||
|
| TC-03 | 单聊消息收发测试 | ⬜ 待测试 |
|
||||||
|
| TC-04 | 群聊消息收发测试 | ⬜ 待测试 |
|
||||||
|
| TC-05 | 连接状态监听测试 | ⬜ 待测试 |
|
||||||
|
| TC-06 | Push 设备注册测试(APNs + FCM) | ⬜ 待测试 |
|
||||||
|
| TC-07 | 版本更新检查测试 | ⬜ 待测试 |
|
||||||
|
| TC-08 | UserSig 过期检测测试 | ⬜ 待测试 |
|
||||||
22
XuqmDemo/Package.swift
普通文件
22
XuqmDemo/Package.swift
普通文件
@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "XuqmDemo",
|
||||||
|
platforms: [.iOS(.v16), .macOS(.v13)],
|
||||||
|
products: [
|
||||||
|
.library(name: "XuqmDemo", targets: ["XuqmDemo"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: ".."),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "XuqmDemo",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "XuqmSDK", package: "XuqmGroup-iOSSDK"),
|
||||||
|
],
|
||||||
|
path: "Sources"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
65
XuqmDemo/README.md
普通文件
65
XuqmDemo/README.md
普通文件
@ -0,0 +1,65 @@
|
|||||||
|
# XuqmDemo
|
||||||
|
|
||||||
|
XuqmGroup iOS SDK 的 SwiftUI 演示应用。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **登录**:使用 demo-service 登录(`user_a` / `123456`)
|
||||||
|
- **会话列表**:展示最近会话,支持搜索、置顶、静音、删除
|
||||||
|
- **单聊**:与某个用户收发文本消息
|
||||||
|
- **更新检查**:检查 App 更新
|
||||||
|
- **个人资料**:展示并修改当前用户信息
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
- 演示服务器:`https://dev.xuqinmin.com`
|
||||||
|
- App ID:`ak_demo_chat`
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
### 方式一:嵌入 Xcode 项目
|
||||||
|
|
||||||
|
1. 将 `XuqmDemo/Sources` 下的所有 Swift 文件拖入你的 Xcode iOS App 项目
|
||||||
|
2. 确保项目已依赖 `XuqmSDK`(本地 SPM 或源码引用)
|
||||||
|
3. 构建并运行
|
||||||
|
|
||||||
|
### 方式二:Swift Package Manager(验证编译)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd XuqmDemo
|
||||||
|
swift build
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注:由于 Demo 是 iOS SwiftUI 应用,建议使用 Xcode 打开 `XuqmGroup-iOSSDK` 仓库,然后新建一个 iOS App Target 并将 `XuqmDemo/Sources` 中的文件加入该 Target 进行真机/模拟器运行。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
XuqmDemo/
|
||||||
|
Package.swift
|
||||||
|
Sources/
|
||||||
|
XuqmDemoApp.swift # @main 入口,初始化 SDK 与路由
|
||||||
|
Views/
|
||||||
|
LoginView.swift # 登录页
|
||||||
|
ConversationListView.swift # 会话列表页
|
||||||
|
ChatView.swift # 单聊页
|
||||||
|
UpdateCheckView.swift # 更新检查页
|
||||||
|
ProfileView.swift # 个人资料页
|
||||||
|
ViewModels/
|
||||||
|
AuthViewModel.swift # 登录/登出逻辑
|
||||||
|
ConversationViewModel.swift# 会话列表逻辑
|
||||||
|
ChatViewModel.swift # 聊天逻辑(含 ImEventDelegate 桥接)
|
||||||
|
Models/
|
||||||
|
DemoModels.swift # 路由、状态、辅助函数
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键 SDK API 使用
|
||||||
|
|
||||||
|
- `XuqmSDK.shared.initialize(config:)` — 初始化 SDK
|
||||||
|
- `ImSDK.shared.loginWithDemo(userId:password:)` — Demo 登录
|
||||||
|
- `ImSDK.shared.listConversations()` — 获取会话列表
|
||||||
|
- `ImSDK.shared.fetchHistory(toId:page:size:)` — 获取历史消息
|
||||||
|
- `ImSDK.shared.sendTextMessage(toId:chatType:content:)` — 发送文本消息
|
||||||
|
- `ImSDK.shared.getProfile(userId:)` / `updateProfile(...)` — 用户资料
|
||||||
|
- `UpdateSDK.shared.checkAppUpdate(currentVersionCode:)` — 检查更新
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
enum AppRoute: Hashable {
|
||||||
|
case chat(targetId: String, targetName: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DemoUser: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String
|
||||||
|
let nickname: String
|
||||||
|
let avatar: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoginState: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case success
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConversationLoadState: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case loaded
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageItem: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let message: ImMessage
|
||||||
|
let isOwn: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMessageText(_ message: ImMessage) -> String {
|
||||||
|
if message.msgType == .text {
|
||||||
|
if let data = message.content.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let text = json["text"] as? String {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return message.content
|
||||||
|
}
|
||||||
|
return messagePreview(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func messagePreview(_ message: ImMessage) -> String {
|
||||||
|
switch message.msgType {
|
||||||
|
case .image: return "[图片]"
|
||||||
|
case .video: return "[视频]"
|
||||||
|
case .audio: return "[语音]"
|
||||||
|
case .file: return "[文件]"
|
||||||
|
case .location: return "[位置]"
|
||||||
|
case .custom: return "[自定义]"
|
||||||
|
case .richText: return "[富文本]"
|
||||||
|
case .forward: return "[转发]"
|
||||||
|
case .quote: return "[引用]"
|
||||||
|
case .merge: return "[合并转发]"
|
||||||
|
case .callAudio: return "[语音通话]"
|
||||||
|
case .callVideo: return "[视频通话]"
|
||||||
|
case .revoked: return "[消息已撤回]"
|
||||||
|
case .notify: return message.content
|
||||||
|
case .text: return message.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationPreview(_ conversation: ConversationData) -> String {
|
||||||
|
switch conversation.lastMsgType?.uppercased() {
|
||||||
|
case "TEXT": return conversation.lastMsgContent ?? ""
|
||||||
|
case "IMAGE": return "[图片]"
|
||||||
|
case "VIDEO": return "[视频]"
|
||||||
|
case "AUDIO": return "[语音]"
|
||||||
|
case "FILE": return "[文件]"
|
||||||
|
case "LOCATION": return "[位置]"
|
||||||
|
case "CUSTOM": return "[自定义]"
|
||||||
|
case "RICH_TEXT": return "[富文本]"
|
||||||
|
case "FORWARD": return "[转发]"
|
||||||
|
case "QUOTE": return "[引用]"
|
||||||
|
case "MERGE": return "[合并转发]"
|
||||||
|
case "CALL_AUDIO": return "[语音通话]"
|
||||||
|
case "CALL_VIDEO": return "[视频通话]"
|
||||||
|
case "REVOKED": return "[消息已撤回]"
|
||||||
|
case "NOTIFY": return conversation.lastMsgContent ?? "[通知]"
|
||||||
|
default: return conversation.lastMsgContent ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(_ status: MsgStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .sending: return "发送中"
|
||||||
|
case .sent: return "已发送"
|
||||||
|
case .delivered: return "已送达"
|
||||||
|
case .read: return "已读"
|
||||||
|
case .failed: return "发送失败"
|
||||||
|
case .revoked: return "已撤回"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatConversationTime(_ timestamp: Int64) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
if Calendar.current.isDateInToday(date) {
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
} else if Calendar.current.isDateInYesterday(date) {
|
||||||
|
return "昨天"
|
||||||
|
} else {
|
||||||
|
formatter.dateFormat = "MM-dd"
|
||||||
|
}
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthViewModel: ObservableObject {
|
||||||
|
@Published var state: LoginState = .idle
|
||||||
|
@Published var currentUserId: String = ""
|
||||||
|
@Published var currentNickname: String = ""
|
||||||
|
|
||||||
|
func login(userId: String, password: String) {
|
||||||
|
guard !userId.isEmpty, !password.isEmpty else {
|
||||||
|
state = .error("请输入用户 ID 和密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .loading
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await ImSDK.shared.loginWithDemo(userId: userId, password: password)
|
||||||
|
currentUserId = userId
|
||||||
|
currentNickname = userId
|
||||||
|
state = .success
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
ImSDK.shared.disconnect()
|
||||||
|
currentUserId = ""
|
||||||
|
currentNickname = ""
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
import Foundation
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChatViewModel: ObservableObject {
|
||||||
|
@Published var messages: [ImMessage] = []
|
||||||
|
@Published var inputText: String = ""
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var connectionStatus: String = "连接中"
|
||||||
|
|
||||||
|
private var targetId: String = ""
|
||||||
|
private var chatType: ChatType = .single
|
||||||
|
private var currentPage = 0
|
||||||
|
private let pageSize = 20
|
||||||
|
private var hasMore = true
|
||||||
|
private var isLoadingMore = false
|
||||||
|
|
||||||
|
private var delegateBridge: ChatEventDelegateBridge?
|
||||||
|
|
||||||
|
func setup(targetId: String, chatType: ChatType) {
|
||||||
|
self.targetId = targetId
|
||||||
|
self.chatType = chatType
|
||||||
|
self.messages = []
|
||||||
|
self.currentPage = 0
|
||||||
|
self.hasMore = true
|
||||||
|
self.isLoadingMore = false
|
||||||
|
self.errorMessage = nil
|
||||||
|
|
||||||
|
let bridge = ChatEventDelegateBridge(viewModel: self)
|
||||||
|
self.delegateBridge = bridge
|
||||||
|
ImSDK.shared.setDelegate(bridge)
|
||||||
|
|
||||||
|
loadHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadHistory() {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
currentPage = 0
|
||||||
|
hasMore = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize)
|
||||||
|
messages = history
|
||||||
|
hasMore = history.count >= pageSize
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMoreHistory() {
|
||||||
|
guard hasMore, !isLoadingMore, !isLoading else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
currentPage += 1
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize)
|
||||||
|
messages.append(contentsOf: history)
|
||||||
|
hasMore = history.count >= pageSize
|
||||||
|
isLoadingMore = false
|
||||||
|
} catch {
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendText() {
|
||||||
|
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
let message = ImSDK.shared.sendTextMessage(toId: targetId, chatType: chatType, content: text)
|
||||||
|
messages.insert(message, at: 0)
|
||||||
|
inputText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead() {
|
||||||
|
Task {
|
||||||
|
try? await ImSDK.shared.markRead(targetId: targetId, chatType: chatType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delegate callbacks (called on MainActor via bridge)
|
||||||
|
|
||||||
|
func didReceiveMessage(_ message: ImMessage) {
|
||||||
|
guard message.fromId == targetId || message.toId == targetId else { return }
|
||||||
|
if !messages.contains(where: { $0.id == message.id }) {
|
||||||
|
messages.insert(message, at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didConnect() {
|
||||||
|
connectionStatus = "已连接"
|
||||||
|
}
|
||||||
|
|
||||||
|
func didDisconnect(reason: String?) {
|
||||||
|
connectionStatus = reason ?? "已断开"
|
||||||
|
}
|
||||||
|
|
||||||
|
func didError(_ error: String) {
|
||||||
|
connectionStatus = "错误: \(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ImEventDelegate Bridge
|
||||||
|
|
||||||
|
private final class ChatEventDelegateBridge: NSObject, ImEventDelegate {
|
||||||
|
weak var viewModel: ChatViewModel?
|
||||||
|
|
||||||
|
init(viewModel: ChatViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func imClientDidConnect() {
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
viewModel?.didConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imClientDidDisconnect(reason: String?) {
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
viewModel?.didDisconnect(reason: reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imClientDidReceiveMessage(_ message: ImMessage) {
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
viewModel?.didReceiveMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imClientDidReceiveGroupMessage(_ message: ImMessage) {
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
viewModel?.didReceiveMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imClientDidError(_ error: String) {
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
viewModel?.didError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConversationViewModel: ObservableObject {
|
||||||
|
@Published var conversations: [ConversationData] = []
|
||||||
|
@Published var state: ConversationLoadState = .idle
|
||||||
|
@Published var query: String = ""
|
||||||
|
@Published var totalUnreadCount: Int = 0
|
||||||
|
|
||||||
|
var filteredConversations: [ConversationData] {
|
||||||
|
if query.isEmpty { return conversations }
|
||||||
|
let keyword = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
return conversations.filter {
|
||||||
|
$0.targetId.localizedCaseInsensitiveContains(keyword)
|
||||||
|
|| conversationPreview($0).localizedCaseInsensitiveContains(keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
state = .loading
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
conversations = try await ImSDK.shared.listConversations()
|
||||||
|
totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount }
|
||||||
|
state = .loaded
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
conversations = try await ImSDK.shared.listConversations()
|
||||||
|
totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount }
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteConversation(_ conversation: ConversationData) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await ImSDK.shared.deleteConversation(targetId: conversation.targetId, chatType: conversation.chatType)
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePinned(_ conversation: ConversationData) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await ImSDK.shared.setConversationPinned(targetId: conversation.targetId, chatType: conversation.chatType, pinned: !conversation.isPinned)
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMuted(_ conversation: ConversationData) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await ImSDK.shared.setConversationMuted(targetId: conversation.targetId, chatType: conversation.chatType, muted: !conversation.isMuted)
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
struct ChatView: View {
|
||||||
|
let targetId: String
|
||||||
|
let targetName: String
|
||||||
|
let currentUserId: String
|
||||||
|
|
||||||
|
@StateObject private var viewModel = ChatViewModel()
|
||||||
|
@State private var scrollToBottom = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if viewModel.connectionStatus != "已连接" {
|
||||||
|
Text(viewModel.connectionStatus)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(viewModel.connectionStatus.contains("错误") ? Color.red : Color.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 8) {
|
||||||
|
ForEach(viewModel.messages.reversed(), id: \.id) { message in
|
||||||
|
MessageBubble(message: message, currentUserId: currentUserId)
|
||||||
|
.id(message.id)
|
||||||
|
.rotationEffect(.degrees(180))
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
.rotationEffect(.degrees(180))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.rotationEffect(.degrees(180))
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
|
if let first = viewModel.messages.first {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(first.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("输入消息…", text: $viewModel.inputText, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...4)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.sendText()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "paperplane.fill")
|
||||||
|
.foregroundStyle(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : Color.accentColor)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(targetName)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.onAppear {
|
||||||
|
viewModel.setup(targetId: targetId, chatType: .single)
|
||||||
|
viewModel.markRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageBubble: View {
|
||||||
|
let message: ImMessage
|
||||||
|
let currentUserId: String
|
||||||
|
|
||||||
|
private var isOwn: Bool {
|
||||||
|
message.fromUserId == currentUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
if isOwn { Spacer(minLength: 40) }
|
||||||
|
|
||||||
|
VStack(alignment: isOwn ? .trailing : .leading, spacing: 2) {
|
||||||
|
Text(parseMessageText(message))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(isOwn ? Color.accentColor.opacity(0.15) : Color.gray.opacity(0.2))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if isOwn {
|
||||||
|
Text(statusLabel(message.status))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(formatConversationTime(message.createdAt))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOwn { Spacer(minLength: 40) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
struct ConversationListView: View {
|
||||||
|
@StateObject private var viewModel = ConversationViewModel()
|
||||||
|
@Binding var path: NavigationPath
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if viewModel.totalUnreadCount > 0 {
|
||||||
|
Text("总未读 \(viewModel.totalUnreadCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.filteredConversations, id: \.targetId) { conversation in
|
||||||
|
ConversationRow(conversation: conversation)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
path.append(AppRoute.chat(targetId: conversation.targetId, targetName: conversation.targetId))
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.deleteConversation(conversation)
|
||||||
|
} label: {
|
||||||
|
Text("删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
viewModel.togglePinned(conversation)
|
||||||
|
} label: {
|
||||||
|
Text(conversation.isPinned ? "取消置顶" : "置顶")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.toggleMuted(conversation)
|
||||||
|
} label: {
|
||||||
|
Text(conversation.isMuted ? "取消静音" : "静音")
|
||||||
|
}
|
||||||
|
.tint(.indigo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.searchable(text: $viewModel.query, prompt: "搜索会话")
|
||||||
|
.navigationTitle("会话")
|
||||||
|
.toolbar {
|
||||||
|
#if os(iOS)
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
viewModel.refresh()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if viewModel.conversations.isEmpty {
|
||||||
|
viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConversationRow: View {
|
||||||
|
let conversation: ConversationData
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor.opacity(0.15))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
Text(String(conversation.targetId.prefix(1).uppercased()))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(conversation.targetId)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if conversation.isPinned {
|
||||||
|
Text("置顶")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(formatConversationTime(conversation.lastMsgTime))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(conversationPreview(conversation))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if conversation.unreadCount > 0 && !conversation.isMuted {
|
||||||
|
Text("\(conversation.unreadCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.red)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@ObservedObject var viewModel: AuthViewModel
|
||||||
|
@State private var userId: String = "user_a"
|
||||||
|
@State private var password: String = "123456"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("XuqmGroup IM")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("iOS Demo")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
TextField("用户 ID", text: $userId)
|
||||||
|
.textContentType(.username)
|
||||||
|
#if os(iOS)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
#endif
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
SecureField("密码", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .error(let msg) = viewModel.state {
|
||||||
|
Text(msg)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.login(userId: userId.trimmingCharacters(in: .whitespaces), password: password)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if case .loading = viewModel.state {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
Text("登录")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.disabled(viewModel.state == .loading || userId.isEmpty || password.isEmpty)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("演示环境: https://dev.xuqinmin.com")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ProfileViewModel: ObservableObject {
|
||||||
|
@Published var profile: UserProfile?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var nickname: String = ""
|
||||||
|
|
||||||
|
func loadProfile(userId: String) {
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let p = try await ImSDK.shared.getProfile(userId: userId)
|
||||||
|
profile = p
|
||||||
|
nickname = p.nickname ?? userId
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveProfile(userId: String) {
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let p = try await ImSDK.shared.updateProfile(userId: userId, nickname: nickname)
|
||||||
|
profile = p
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProfileView: View {
|
||||||
|
@ObservedObject var authViewModel: AuthViewModel
|
||||||
|
@StateObject private var viewModel = ProfileViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor.opacity(0.15))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
Text(String(viewModel.nickname.prefix(1).uppercased()))
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(authViewModel.currentUserId)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("昵称")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("输入昵称", text: $viewModel.nickname)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.saveProfile(userId: authViewModel.currentUserId)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
Text("保存")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isLoading || viewModel.nickname.isEmpty)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
authViewModel.logout()
|
||||||
|
} label: {
|
||||||
|
Text("退出登录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.onAppear {
|
||||||
|
if !authViewModel.currentUserId.isEmpty {
|
||||||
|
viewModel.loadProfile(userId: authViewModel.currentUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class UpdateViewModel: ObservableObject {
|
||||||
|
@Published var isChecking = false
|
||||||
|
@Published var updateInfo: AppUpdateInfo?
|
||||||
|
@Published var message: String?
|
||||||
|
|
||||||
|
func checkUpdate() {
|
||||||
|
isChecking = true
|
||||||
|
message = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)
|
||||||
|
updateInfo = info
|
||||||
|
if !info.needsUpdate {
|
||||||
|
message = "已是最新版本"
|
||||||
|
}
|
||||||
|
isChecking = false
|
||||||
|
} catch {
|
||||||
|
message = "检查失败: \(error.localizedDescription)"
|
||||||
|
isChecking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAppStore() {
|
||||||
|
if let url = updateInfo?.appStoreUrl, !url.isEmpty {
|
||||||
|
UpdateSDK.shared.openAppStore(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateCheckView: View {
|
||||||
|
@StateObject private var viewModel = UpdateViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
|
Text("版本更新")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
if let info = viewModel.updateInfo, info.needsUpdate {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("发现新版本: \(info.versionName ?? "")")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let changeLog = info.changeLog, !changeLog.isEmpty {
|
||||||
|
Text("更新说明: \(changeLog)")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.forceUpdate == true {
|
||||||
|
Text("强制更新")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.openAppStore()
|
||||||
|
} label: {
|
||||||
|
Text("前往 App Store")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
if let msg = viewModel.message {
|
||||||
|
Text(msg)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.checkUpdate()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isChecking {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
Text(viewModel.isChecking ? "检查中…" : "检查更新")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isChecking)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.onAppear {
|
||||||
|
if viewModel.updateInfo == nil && !viewModel.isChecking {
|
||||||
|
viewModel.checkUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
XuqmDemo/Sources/XuqmDemoApp.swift
普通文件
100
XuqmDemo/Sources/XuqmDemoApp.swift
普通文件
@ -0,0 +1,100 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XuqmSDK
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct XuqmDemoApp: App {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
initializeSDK()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func initializeSDK() {
|
||||||
|
let config = SDKConfig(
|
||||||
|
appKey: "ak_demo_chat",
|
||||||
|
appSecret: "demo_secret",
|
||||||
|
debug: true
|
||||||
|
)
|
||||||
|
XuqmSDK.shared.initialize(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var authViewModel = AuthViewModel()
|
||||||
|
@State private var path = NavigationPath()
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if !canImport(UIKit)
|
||||||
|
initializeSDK()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if case .success = authViewModel.state {
|
||||||
|
MainTabView(authViewModel: authViewModel, path: $path, selectedTab: $selectedTab)
|
||||||
|
} else {
|
||||||
|
NavigationStack {
|
||||||
|
LoginView(viewModel: authViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabView: View {
|
||||||
|
@ObservedObject var authViewModel: AuthViewModel
|
||||||
|
@Binding var path: NavigationPath
|
||||||
|
@Binding var selectedTab: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack(path: $path) {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
ConversationListView(path: $path)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "message.fill")
|
||||||
|
Text("会话")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
UpdateCheckView()
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
Text("更新")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
ProfileView(authViewModel: authViewModel)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "person.fill")
|
||||||
|
Text("我的")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
}
|
||||||
|
.navigationDestination(for: AppRoute.self) { route in
|
||||||
|
switch route {
|
||||||
|
case .chat(let targetId, let targetName):
|
||||||
|
ChatView(targetId: targetId, targetName: targetName, currentUserId: authViewModel.currentUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户