core, iOS: hidden and muted user profiles (#2025)
* core, ios: profile privacy design * migration * core: user profile privacy * update nix dependencies * update simplexmq * import stateTVar * update core library * update UI * update hide/show user profile * update API, UI, fix test * update api, UI, test * update api call * fix api * update UI for hidden profiles * filter notifications on hidden/muted profiles when inactive, alerts * updates * update schema, test, icon
This commit is contained in:
committed by
GitHub
parent
bcdf502ce6
commit
06a0dbd0f2
@@ -67,6 +67,31 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
: users.first { $0.user.userId == userId }?.user
|
||||
}
|
||||
|
||||
func getUserIndex(_ user: User) -> Int? {
|
||||
users.firstIndex { $0.user.userId == user.userId }
|
||||
}
|
||||
|
||||
func updateUser(_ user: User) {
|
||||
if let i = getUserIndex(user) {
|
||||
users[i].user = user
|
||||
}
|
||||
if currentUser?.userId == user.userId {
|
||||
currentUser = user
|
||||
}
|
||||
}
|
||||
|
||||
func removeUser(_ user: User) {
|
||||
if let i = getUserIndex(user), users[i].user.userId != currentUser?.userId {
|
||||
users.remove(at: i)
|
||||
}
|
||||
}
|
||||
|
||||
func hasChat(_ id: String) -> Bool {
|
||||
chats.first(where: { $0.id == id }) != nil
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
|
||||
if let userId = content.userInfo["userId"] as? Int64,
|
||||
userId != chatModel.currentUser?.userId {
|
||||
changeActiveUser(userId)
|
||||
changeActiveUser(userId, viewPwd: nil)
|
||||
}
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
|
||||
let chatId = content.userInfo["chatId"] as? String {
|
||||
@@ -87,13 +87,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
switch content.categoryIdentifier {
|
||||
case ntfCategoryMessageReceived:
|
||||
let recent = recentInTheSameChat(content)
|
||||
if model.chatId == nil {
|
||||
let userId = content.userInfo["userId"] as? Int64
|
||||
if let userId = userId, let user = model.getUser(userId), !user.showNotifications {
|
||||
// ... inactive user with disabled notifications
|
||||
return []
|
||||
} else if model.chatId == nil {
|
||||
// in the chat list...
|
||||
if model.currentUser?.userId == (content.userInfo["userId"] as? Int64) {
|
||||
// ... of the current user
|
||||
if model.currentUser?.userId == userId {
|
||||
// ... of the active user
|
||||
return recent ? [] : [.sound, .list]
|
||||
} else {
|
||||
// ... of different user
|
||||
// ... of inactive user
|
||||
return recent ? [.banner] : [.sound, .banner, .list]
|
||||
}
|
||||
} else if model.chatId == content.targetContentIdentifier {
|
||||
|
||||
@@ -132,21 +132,56 @@ func apiCreateActiveUser(_ p: Profile) throws -> User {
|
||||
}
|
||||
|
||||
func listUsers() throws -> [UserInfo] {
|
||||
let r = chatSendCmdSync(.listUsers)
|
||||
return try listUsersResponse(chatSendCmdSync(.listUsers))
|
||||
}
|
||||
|
||||
func listUsersAsync() async throws -> [UserInfo] {
|
||||
return try listUsersResponse(await chatSendCmd(.listUsers))
|
||||
}
|
||||
|
||||
private func listUsersResponse(_ r: ChatResponse) throws -> [UserInfo] {
|
||||
if case let .usersList(users) = r {
|
||||
return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending }
|
||||
}
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetActiveUser(_ userId: Int64) throws -> User {
|
||||
let r = chatSendCmdSync(.apiSetActiveUser(userId: userId))
|
||||
func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User {
|
||||
let r = chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool) throws {
|
||||
let r = chatSendCmdSync(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues))
|
||||
func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
let r = await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
|
||||
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
|
||||
func apiUnhideUser(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
try await setUserPrivacy_(.apiUnhideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
|
||||
func apiMuteUser(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
try await setUserPrivacy_(.apiMuteUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
|
||||
func apiUnmuteUser(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||
try await setUserPrivacy_(.apiUnmuteUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
|
||||
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
|
||||
let r = await chatSendCmd(cmd)
|
||||
if case let .userPrivacy(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws {
|
||||
let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
@@ -209,8 +244,16 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th
|
||||
}
|
||||
|
||||
func apiGetChats() throws -> [ChatData] {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") }
|
||||
let r = chatSendCmdSync(.apiGetChats(userId: userId))
|
||||
let userId = try currentUserId("apiGetChats")
|
||||
return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId)))
|
||||
}
|
||||
|
||||
func apiGetChatsAsync() async throws -> [ChatData] {
|
||||
let userId = try currentUserId("apiGetChats")
|
||||
return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId)))
|
||||
}
|
||||
|
||||
private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
if case let .apiChats(_, chats) = r { return chats }
|
||||
throw r
|
||||
}
|
||||
@@ -337,19 +380,27 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
||||
}
|
||||
|
||||
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") }
|
||||
let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId))
|
||||
let userId = try currentUserId("getUserSMPServers")
|
||||
return try userSMPServersResponse(chatSendCmdSync(.apiGetUserSMPServers(userId: userId)))
|
||||
}
|
||||
|
||||
func getUserSMPServersAsync() async throws -> ([ServerCfg], [String]) {
|
||||
let userId = try currentUserId("getUserSMPServersAsync")
|
||||
return try userSMPServersResponse(await chatSendCmd(.apiGetUserSMPServers(userId: userId)))
|
||||
}
|
||||
|
||||
private func userSMPServersResponse(_ r: ChatResponse) throws -> ([ServerCfg], [String]) {
|
||||
if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setUserSMPServers: no current user") }
|
||||
let userId = try currentUserId("setUserSMPServers")
|
||||
try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers))
|
||||
}
|
||||
|
||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") }
|
||||
let userId = try currentUserId("testSMPServer")
|
||||
let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer))
|
||||
if case let .smpTestResult(_, testFailure) = r {
|
||||
if let t = testFailure {
|
||||
@@ -361,14 +412,22 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure>
|
||||
}
|
||||
|
||||
func getChatItemTTL() throws -> ChatItemTTL {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") }
|
||||
let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId))
|
||||
let userId = try currentUserId("getChatItemTTL")
|
||||
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
|
||||
}
|
||||
|
||||
func getChatItemTTLAsync() async throws -> ChatItemTTL {
|
||||
let userId = try currentUserId("getChatItemTTLAsync")
|
||||
return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId)))
|
||||
}
|
||||
|
||||
private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL {
|
||||
if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setChatItemTTL: no current user") }
|
||||
let userId = try currentUserId("setChatItemTTL")
|
||||
try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds))
|
||||
}
|
||||
|
||||
@@ -539,14 +598,14 @@ func clearChat(_ chat: Chat) async {
|
||||
}
|
||||
|
||||
func apiListContacts() throws -> [Contact] {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiListContacts: no current user") }
|
||||
let userId = try currentUserId("apiListContacts")
|
||||
let r = chatSendCmdSync(.apiListContacts(userId: userId))
|
||||
if case let .contactsList(_, contacts) = r { return contacts }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiUpdateProfile: no current user") }
|
||||
let userId = try currentUserId("apiUpdateProfile")
|
||||
let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
|
||||
switch r {
|
||||
case .userProfileNoChange: return nil
|
||||
@@ -574,22 +633,30 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
|
||||
}
|
||||
|
||||
func apiCreateUserAddress() async throws -> String {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiCreateUserAddress: no current user") }
|
||||
let userId = try currentUserId("apiCreateUserAddress")
|
||||
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
||||
if case let .userContactLinkCreated(_, connReq) = r { return connReq }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteUserAddress() async throws {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiDeleteUserAddress: no current user") }
|
||||
let userId = try currentUserId("apiDeleteUserAddress")
|
||||
let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId))
|
||||
if case .userContactLinkDeleted = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetUserAddress() throws -> UserContactLink? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") }
|
||||
let r = chatSendCmdSync(.apiShowMyAddress(userId: userId))
|
||||
let userId = try currentUserId("apiGetUserAddress")
|
||||
return try userAddressResponse(chatSendCmdSync(.apiShowMyAddress(userId: userId)))
|
||||
}
|
||||
|
||||
func apiGetUserAddressAsync() async throws -> UserContactLink? {
|
||||
let userId = try currentUserId("apiGetUserAddressAsync")
|
||||
return try userAddressResponse(await chatSendCmd(.apiShowMyAddress(userId: userId)))
|
||||
}
|
||||
|
||||
private func userAddressResponse(_ r: ChatResponse) throws -> UserContactLink? {
|
||||
switch r {
|
||||
case let .userContactLink(_, contactLink): return contactLink
|
||||
case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
|
||||
@@ -598,7 +665,7 @@ func apiGetUserAddress() throws -> UserContactLink? {
|
||||
}
|
||||
|
||||
func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("userAddressAutoAccept: no current user") }
|
||||
let userId = try currentUserId("userAddressAutoAccept")
|
||||
let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept))
|
||||
switch r {
|
||||
case let .userContactLinkUpdated(_, contactLink): return contactLink
|
||||
@@ -793,7 +860,7 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
||||
}
|
||||
|
||||
func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiNewGroup: no current user") }
|
||||
let userId = try currentUserId("apiNewGroup")
|
||||
let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p))
|
||||
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
|
||||
throw r
|
||||
@@ -909,6 +976,13 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
||||
throw r
|
||||
}
|
||||
|
||||
private func currentUserId(_ funcName: String) throws -> Int64 {
|
||||
if let userId = ChatModel.shared.currentUser?.userId {
|
||||
return userId
|
||||
}
|
||||
throw RuntimeError("\(funcName): no current user")
|
||||
}
|
||||
|
||||
func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true) throws {
|
||||
logger.debug("initializeChat")
|
||||
let m = ChatModel.shared
|
||||
@@ -958,21 +1032,38 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
}
|
||||
|
||||
func changeActiveUser(_ userId: Int64) {
|
||||
func changeActiveUser(_ userId: Int64, viewPwd: String?) {
|
||||
do {
|
||||
try changeActiveUser_(userId)
|
||||
try changeActiveUser_(userId, viewPwd: viewPwd)
|
||||
} catch let error {
|
||||
logger.error("Unable to set active user: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveUser_(_ userId: Int64) throws {
|
||||
private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
|
||||
let m = ChatModel.shared
|
||||
m.currentUser = try apiSetActiveUser(userId)
|
||||
m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd)
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
|
||||
func changeActiveUserAsync_(_ userId: Int64, viewPwd: String?) async throws {
|
||||
let currentUser = try await apiSetActiveUserAsync(userId, viewPwd: viewPwd)
|
||||
let users = try await listUsersAsync()
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
m.currentUser = currentUser
|
||||
m.users = users
|
||||
}
|
||||
try await getUserChatDataAsync()
|
||||
await MainActor.run {
|
||||
if var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
|
||||
invitation.user = currentUser
|
||||
activateCall(invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getUserChatData() throws {
|
||||
let m = ChatModel.shared
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
@@ -982,6 +1073,20 @@ func getUserChatData() throws {
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
}
|
||||
|
||||
private func getUserChatDataAsync() async throws {
|
||||
let userAddress = try await apiGetUserAddressAsync()
|
||||
let servers = try await getUserSMPServersAsync()
|
||||
let chatItemTTL = try await getChatItemTTLAsync()
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
m.userAddress = userAddress
|
||||
(m.userSMPServers, m.presetSMPServers) = servers
|
||||
m.chatItemTTL = chatItemTTL
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
}
|
||||
}
|
||||
|
||||
class ChatReceiver {
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
@@ -1050,18 +1155,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.removeChat(contact.activeConn.id)
|
||||
}
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
if !active(user) { return }
|
||||
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
if m.hasChat(contactRequest.id) {
|
||||
m.updateChatInfo(cInfo)
|
||||
} else {
|
||||
m.addChat(Chat(
|
||||
chatInfo: cInfo,
|
||||
chatItems: []
|
||||
))
|
||||
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
if m.hasChat(contactRequest.id) {
|
||||
m.updateChatInfo(cInfo)
|
||||
} else {
|
||||
m.addChat(Chat(
|
||||
chatInfo: cInfo,
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
}
|
||||
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
||||
case let .contactUpdated(user, toContact):
|
||||
if active(user) && m.hasChat(toContact.id) {
|
||||
let cInfo = ChatInfo.direct(contact: toContact)
|
||||
@@ -1304,7 +1409,7 @@ func refreshCallInvitations() throws {
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last {
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
}
|
||||
}
|
||||
@@ -1317,6 +1422,7 @@ func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
|
||||
}
|
||||
|
||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||
if !callInvitation.user.showNotifications { return }
|
||||
let m = ChatModel.shared
|
||||
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||
if let error = error {
|
||||
|
||||
@@ -214,8 +214,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
|
||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
let update = cxCallUpdate(invitation: invitation)
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
}
|
||||
} else {
|
||||
NtfManager.shared.notifyCallInvitation(invitation)
|
||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||
|
||||
@@ -29,7 +29,9 @@ struct UserPicker: View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
ScrollViewReader { sp in
|
||||
let users = m.users.sorted { u, _ in u.user.activeUser }
|
||||
let users = m.users
|
||||
.filter({ u in u.user.activeUser || !u.user.hidden })
|
||||
.sorted { u, _ in u.user.activeUser }
|
||||
VStack(spacing: 0) {
|
||||
ForEach(users) { u in
|
||||
userView(u)
|
||||
@@ -97,14 +99,18 @@ struct UserPicker: View {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try changeActiveUser_(user.userId)
|
||||
userPickerVisible = false
|
||||
} catch {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error switching profile!",
|
||||
message: "Error: \(responseError(error))"
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await changeActiveUserAsync_(user.userId, viewPwd: nil)
|
||||
await MainActor.run { userPickerVisible = false }
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error switching profile!",
|
||||
message: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
|
||||
@@ -73,11 +73,11 @@ struct DatabaseEncryptionView: View {
|
||||
}
|
||||
|
||||
if !initialRandomDBPassphrase && m.chatDbEncrypted == true {
|
||||
DatabaseKeyField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
||||
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
||||
}
|
||||
|
||||
DatabaseKeyField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
|
||||
DatabaseKeyField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
|
||||
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
|
||||
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
|
||||
|
||||
settingsRow("lock.rotation") {
|
||||
Button("Update database passphrase") {
|
||||
@@ -255,7 +255,7 @@ struct DatabaseEncryptionView: View {
|
||||
}
|
||||
|
||||
|
||||
struct DatabaseKeyField: View {
|
||||
struct PassphraseField: View {
|
||||
@Binding var key: String
|
||||
var placeholder: LocalizedStringKey
|
||||
var valid: Bool
|
||||
|
||||
@@ -66,7 +66,7 @@ struct DatabaseErrorView: View {
|
||||
}
|
||||
|
||||
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
|
||||
DatabaseKeyField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||
}
|
||||
|
||||
private func saveAndOpenButton() -> some View {
|
||||
|
||||
@@ -100,7 +100,7 @@ struct TerminalView: View {
|
||||
func sendMessage() {
|
||||
let cmd = ChatCommand.string(composeState.message)
|
||||
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
|
||||
let resp = ChatResponse.chatCmdError(user: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
|
||||
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
|
||||
ChatModel.shared.addTerminalItem(.resp(.now, resp))
|
||||
|
||||
84
apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift
Normal file
84
apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// ProfilePrivacyView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 17/03/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct HiddenProfileView: View {
|
||||
@State var user: User
|
||||
@Binding var profileHidden: Bool
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State private var hidePassword = ""
|
||||
@State private var confirmHidePassword = ""
|
||||
@State private var saveErrorAlert = false
|
||||
@State private var savePasswordError: String?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Text("Hide profile")
|
||||
.font(.title)
|
||||
.bold()
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section() {
|
||||
ProfilePreview(profileOf: user)
|
||||
.padding(.leading, -8)
|
||||
}
|
||||
|
||||
Section {
|
||||
PassphraseField(key: $hidePassword, placeholder: "Password to show", valid: true, showStrength: true)
|
||||
PassphraseField(key: $confirmHidePassword, placeholder: "Confirm password", valid: confirmValid)
|
||||
|
||||
settingsRow("lock") {
|
||||
Button("Save profile password") {
|
||||
Task {
|
||||
do {
|
||||
let u = try await apiHideUser(user.userId, viewPwd: hidePassword)
|
||||
await MainActor.run {
|
||||
m.updateUser(u)
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation { profileHidden = true }
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
saveErrorAlert = true
|
||||
savePasswordError = responseError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(saveDisabled)
|
||||
} header: {
|
||||
Text("Hidden profile password")
|
||||
} footer: {
|
||||
Text("To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page.")
|
||||
.font(.body)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $saveErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error saving user password"),
|
||||
message: Text(savePasswordError ?? "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var confirmValid: Bool { confirmHidePassword == "" || hidePassword == confirmHidePassword }
|
||||
|
||||
var saveDisabled: Bool { hidePassword == "" || confirmHidePassword == "" || !confirmValid }
|
||||
}
|
||||
|
||||
struct ProfilePrivacyView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HiddenProfileView(user: User.sampleData, profileHidden: Binding.constant(false))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct PrivacySettings: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
|
||||
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
@@ -40,6 +40,8 @@ let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue"
|
||||
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
||||
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
|
||||
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
@@ -62,7 +64,9 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
|
||||
DEFAULT_USER_INTERFACE_STYLE: 0,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: "scan",
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
|
||||
]
|
||||
|
||||
enum SimpleXLinkMode: String, Identifiable {
|
||||
|
||||
@@ -9,19 +9,31 @@ import SimpleXChat
|
||||
struct UserProfilesView: View {
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@Environment(\.editMode) private var editMode
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
|
||||
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var userToDelete: Int?
|
||||
@State private var userToDelete: UserInfo?
|
||||
@State private var alert: UserProfilesAlert?
|
||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var searchTextOrPassword = ""
|
||||
@State private var selectedUser: User?
|
||||
@State private var profileHidden = false
|
||||
|
||||
private enum UserProfilesAlert: Identifiable {
|
||||
case deleteUser(index: Int, delSMPQueues: Bool)
|
||||
case deleteUser(userInfo: UserInfo, delSMPQueues: Bool)
|
||||
case cantDeleteLastUser
|
||||
case hiddenProfilesNotice
|
||||
case muteProfileAlert
|
||||
case activateUserError(error: String)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .deleteUser(index, delSMPQueues): return "deleteUser \(index) \(delSMPQueues)"
|
||||
case let .deleteUser(userInfo, delSMPQueues): return "deleteUser \(userInfo.user.userId) \(delSMPQueues)"
|
||||
case .cantDeleteLastUser: return "cantDeleteLastUser"
|
||||
case .hiddenProfilesNotice: return "hiddenProfilesNotice"
|
||||
case .muteProfileAlert: return "muteProfileAlert"
|
||||
case let .activateUserError(err): return "activateUserError \(err)"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
@@ -41,45 +53,103 @@ struct UserProfilesView: View {
|
||||
|
||||
private func userProfilesView() -> some View {
|
||||
List {
|
||||
if profileHidden {
|
||||
Button {
|
||||
withAnimation { profileHidden = false }
|
||||
} label: {
|
||||
Label("Enter password above to show!", systemImage: "lock.open")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(m.users) { u in
|
||||
let users = filteredUsers()
|
||||
ForEach(users) { u in
|
||||
userView(u.user)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
if let i = indexSet.first {
|
||||
showDeleteConfirmation = true
|
||||
userToDelete = i
|
||||
if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) {
|
||||
showDeleteConfirmation = true
|
||||
userToDelete = users[i]
|
||||
} else {
|
||||
alert = .cantDeleteLastUser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
CreateProfile()
|
||||
} label: {
|
||||
Label("Add profile", systemImage: "plus")
|
||||
if searchTextOrPassword == "" {
|
||||
NavigationLink {
|
||||
CreateProfile()
|
||||
} label: {
|
||||
Label("Add profile", systemImage: "plus")
|
||||
}
|
||||
.frame(height: 44)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(height: 44)
|
||||
.padding(.vertical, 4)
|
||||
} footer: {
|
||||
Text("Your chat profiles are stored locally, only on your device.")
|
||||
Text("Tap to activate profile.")
|
||||
.font(.body)
|
||||
.padding(.top, 8)
|
||||
|
||||
}
|
||||
}
|
||||
.toolbar { EditButton() }
|
||||
.navigationTitle("Your chat profiles")
|
||||
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.onAppear {
|
||||
if showHiddenProfilesNotice && m.users.count > 1 {
|
||||
alert = .hiddenProfilesNotice
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
|
||||
deleteModeButton("Profile and server connections", true)
|
||||
deleteModeButton("Local profile data only", false)
|
||||
}
|
||||
.sheet(item: $selectedUser) { user in
|
||||
HiddenProfileView(user: user, profileHidden: $profileHidden)
|
||||
}
|
||||
.onChange(of: profileHidden) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
withAnimation { profileHidden = false }
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
case let .deleteUser(index, delSMPQueues):
|
||||
case let .deleteUser(userInfo, delSMPQueues):
|
||||
return Alert(
|
||||
title: Text("Delete user profile?"),
|
||||
message: Text("All chats and messages will be deleted - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
removeUser(index, delSMPQueues)
|
||||
Task { await removeUser(userInfo, delSMPQueues) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .cantDeleteLastUser:
|
||||
return Alert(
|
||||
title: Text("Can't delete user profile!"),
|
||||
message: m.users.count > 1
|
||||
? Text("There should be at least one visible user profile.")
|
||||
: Text("There should be at least use user profile.")
|
||||
)
|
||||
case .hiddenProfilesNotice:
|
||||
return Alert(
|
||||
title: Text("Make profile private!"),
|
||||
message: Text("You can hide or mute a user profile - swipe it to the right.\nSimpleX Lock must be enabled."),
|
||||
primaryButton: .default(Text("Don't show again")) {
|
||||
showHiddenProfilesNotice = false
|
||||
},
|
||||
secondaryButton: .default(Text("Ok"))
|
||||
)
|
||||
case .muteProfileAlert:
|
||||
return Alert(
|
||||
title: Text("Muted when inactive!"),
|
||||
message: Text("You will still receive calls and notifications from muted profiles when they are active."),
|
||||
primaryButton: .default(Text("Don't show again")) {
|
||||
showMuteProfileAlert = false
|
||||
},
|
||||
secondaryButton: .default(Text("Ok"))
|
||||
)
|
||||
case let .activateUserError(error: err):
|
||||
return Alert(
|
||||
title: Text("Error switching profile!"),
|
||||
@@ -91,43 +161,66 @@ struct UserProfilesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func filteredUsers() -> [UserInfo] {
|
||||
let s = searchTextOrPassword.trimmingCharacters(in: .whitespaces)
|
||||
let lower = s.localizedLowercase
|
||||
return m.users.filter { u in
|
||||
if (u.user.activeUser || u.user.viewPwdHash == nil) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) {
|
||||
return true
|
||||
}
|
||||
if let ph = u.user.viewPwdHash {
|
||||
return s != "" && chatPasswordHash(s, ph.salt) == ph.hash
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleUsersCount: Int {
|
||||
m.users.filter({ u in !u.user.hidden }).count
|
||||
}
|
||||
|
||||
private func userViewPassword(_ user: User) -> String? {
|
||||
user.activeUser || !user.hidden ? nil : searchTextOrPassword
|
||||
}
|
||||
|
||||
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
|
||||
Button(title, role: .destructive) {
|
||||
if let i = userToDelete {
|
||||
alert = .deleteUser(index: i, delSMPQueues: delSMPQueues)
|
||||
if let userInfo = userToDelete {
|
||||
alert = .deleteUser(userInfo: userInfo, delSMPQueues: delSMPQueues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeUser(_ index: Int, _ delSMPQueues: Bool) {
|
||||
if index >= m.users.count { return }
|
||||
private func removeUser(_ userInfo: UserInfo, _ delSMPQueues: Bool) async {
|
||||
do {
|
||||
let u = m.users[index].user
|
||||
let u = userInfo.user
|
||||
if u.activeUser {
|
||||
if let newActive = m.users.first(where: { !$0.user.activeUser }) {
|
||||
try changeActiveUser_(newActive.user.userId)
|
||||
try deleteUser(u.userId)
|
||||
if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) {
|
||||
try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil)
|
||||
try await deleteUser(u)
|
||||
}
|
||||
} else {
|
||||
try deleteUser(u.userId)
|
||||
try await deleteUser(u)
|
||||
}
|
||||
} catch let error {
|
||||
let a = getErrorAlert(error, "Error deleting user profile")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
|
||||
func deleteUser(_ userId: Int64) throws {
|
||||
try apiDeleteUser(userId, delSMPQueues)
|
||||
m.users.remove(at: index)
|
||||
func deleteUser(_ user: User) async throws {
|
||||
try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: userViewPassword(user))
|
||||
await MainActor.run { withAnimation { m.removeUser(user) } }
|
||||
}
|
||||
}
|
||||
|
||||
private func userView(_ user: User) -> some View {
|
||||
Button {
|
||||
do {
|
||||
try changeActiveUser_(user.userId)
|
||||
} catch {
|
||||
alert = .activateUserError(error: responseError(error))
|
||||
Task {
|
||||
do {
|
||||
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
|
||||
} catch {
|
||||
await MainActor.run { alert = .activateUserError(error: responseError(error)) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -137,14 +230,75 @@ struct UserProfilesView: View {
|
||||
.padding(.trailing, 12)
|
||||
Text(user.chatViewName)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(user.activeUser ? .primary : .clear)
|
||||
if user.activeUser {
|
||||
Image(systemName: "checkmark").foregroundColor(.primary)
|
||||
} else if user.hidden {
|
||||
Image(systemName: "lock").foregroundColor(.secondary)
|
||||
} else if !user.showNtfs {
|
||||
Image(systemName: "speaker.slash").foregroundColor(.secondary)
|
||||
} else {
|
||||
Image(systemName: "checkmark").foregroundColor(.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(user.activeUser)
|
||||
.foregroundColor(.primary)
|
||||
.deleteDisabled(m.users.count <= 1)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if user.hidden {
|
||||
Button("Unhide") {
|
||||
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: userViewPassword(user)) }
|
||||
}
|
||||
.tint(.green)
|
||||
} else {
|
||||
if visibleUsersCount > 1 && prefPerformLA {
|
||||
Button("Hide") {
|
||||
selectedUser = user
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
Group {
|
||||
if user.showNtfs {
|
||||
Button("Mute") {
|
||||
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
|
||||
try await apiMuteUser(user.userId, viewPwd: userViewPassword(user))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Unmute") {
|
||||
setUserPrivacy(user) { try await apiUnmuteUser(user.userId, viewPwd: userViewPassword(user)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setUserPrivacy(_ user: User, successAlert: UserProfilesAlert? = nil, _ api: @escaping () async throws -> User) {
|
||||
Task {
|
||||
do {
|
||||
let u = try await api()
|
||||
await MainActor.run {
|
||||
withAnimation { m.updateUser(u) }
|
||||
if successAlert != nil {
|
||||
alert = successAlert
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
let a = getErrorAlert(error, "Error updating user privacy")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
|
||||
var cPwd = pwd.cString(using: .utf8)!
|
||||
var cSalt = salt.cString(using: .utf8)!
|
||||
let cHash = chat_password_hash(&cPwd, &cSalt)!
|
||||
let hash = fromCString(cHash)
|
||||
return hash
|
||||
}
|
||||
|
||||
struct UserProfilesView_Previews: PreviewProvider {
|
||||
|
||||
@@ -16,7 +16,7 @@ let logger = Logger()
|
||||
|
||||
let suspendingDelay: UInt64 = 2_000_000_000
|
||||
|
||||
typealias NtfStream = AsyncStream<UNMutableNotificationContent>
|
||||
typealias NtfStream = AsyncStream<NSENotification>
|
||||
|
||||
actor PendingNtfs {
|
||||
static let shared = PendingNtfs()
|
||||
@@ -33,13 +33,13 @@ actor PendingNtfs {
|
||||
}
|
||||
}
|
||||
|
||||
func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1) async {
|
||||
func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1, showNotifications: Bool) async {
|
||||
logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)")
|
||||
if let s = ntfStreams[id] {
|
||||
logger.debug("PendingNtfs.readStream: has stream")
|
||||
var rcvCount = max(1, msgCount)
|
||||
for await ntf in s {
|
||||
nse.setBestAttemptNtf(ntf)
|
||||
nse.setBestAttemptNtf(showNotifications ? ntf : .empty)
|
||||
rcvCount -= 1
|
||||
if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break }
|
||||
}
|
||||
@@ -47,7 +47,7 @@ actor PendingNtfs {
|
||||
}
|
||||
}
|
||||
|
||||
func writeStream(_ id: String, _ ntf: UNMutableNotificationContent) {
|
||||
func writeStream(_ id: String, _ ntf: NSENotification) {
|
||||
logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)")
|
||||
if let cont = ntfConts[id] {
|
||||
logger.debug("PendingNtfs.writeStream: writing ntf")
|
||||
@@ -56,16 +56,30 @@ actor PendingNtfs {
|
||||
}
|
||||
}
|
||||
|
||||
enum NSENotification {
|
||||
case nse(notification: UNMutableNotificationContent)
|
||||
case callkit(invitation: RcvCallInvitation)
|
||||
case empty
|
||||
|
||||
var categoryIdentifier: String? {
|
||||
switch self {
|
||||
case let .nse(ntf): return ntf.categoryIdentifier
|
||||
case .callkit: return ntfCategoryCallInvitation
|
||||
case .empty: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
var bestAttemptNtf: UNMutableNotificationContent?
|
||||
var bestAttemptNtf: NSENotification?
|
||||
var badgeCount: Int = 0
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
logger.debug("NotificationService.didReceive")
|
||||
setBestAttemptNtf(request.content.mutableCopy() as? UNMutableNotificationContent)
|
||||
if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent {
|
||||
setBestAttemptNtf(ntf)
|
||||
}
|
||||
self.contentHandler = contentHandler
|
||||
registerGroupDefaults()
|
||||
let appState = appStateGroupDefault.get()
|
||||
@@ -112,12 +126,16 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
|
||||
if let connEntity = ntfMsgInfo.connEntity {
|
||||
setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity))
|
||||
setBestAttemptNtf(
|
||||
ntfMsgInfo.user.showNotifications
|
||||
? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity))
|
||||
: .empty
|
||||
)
|
||||
if let id = connEntity.id {
|
||||
Task {
|
||||
logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)")
|
||||
await PendingNtfs.shared.createStream(id)
|
||||
await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count)
|
||||
await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications)
|
||||
deliverBestAttemptNtf()
|
||||
}
|
||||
}
|
||||
@@ -140,16 +158,40 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
ntfBadgeCountGroupDefault.set(badgeCount)
|
||||
}
|
||||
|
||||
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent?) {
|
||||
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
|
||||
setBestAttemptNtf(.nse(notification: ntf))
|
||||
}
|
||||
|
||||
func setBestAttemptNtf(_ ntf: NSENotification) {
|
||||
logger.debug("NotificationService.setBestAttemptNtf")
|
||||
bestAttemptNtf = ntf
|
||||
bestAttemptNtf?.badge = badgeCount as NSNumber
|
||||
if case let .nse(notification) = ntf {
|
||||
notification.badge = badgeCount as NSNumber
|
||||
bestAttemptNtf = .nse(notification: notification)
|
||||
} else {
|
||||
bestAttemptNtf = ntf
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverBestAttemptNtf() {
|
||||
logger.debug("NotificationService.deliverBestAttemptNtf")
|
||||
if let handler = contentHandler, let content = bestAttemptNtf {
|
||||
handler(content)
|
||||
if let handler = contentHandler, let ntf = bestAttemptNtf {
|
||||
switch ntf {
|
||||
case let .nse(content): handler(content)
|
||||
case let .callkit(invitation):
|
||||
CXProvider.reportNewIncomingVoIPPushPayload([
|
||||
"displayName": invitation.contact.displayName,
|
||||
"contactId": invitation.contact.id,
|
||||
"media": invitation.callType.media.rawValue
|
||||
]) { error in
|
||||
if error == nil {
|
||||
handler(UNMutableNotificationContent())
|
||||
} else {
|
||||
logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)")
|
||||
handler(createCallInvitationNtf(invitation))
|
||||
}
|
||||
}
|
||||
case .empty: handler(UNMutableNotificationContent())
|
||||
}
|
||||
bestAttemptNtf = nil
|
||||
}
|
||||
}
|
||||
@@ -211,15 +253,15 @@ func chatRecvMsg() async -> ChatResponse? {
|
||||
private let isInChina = SKStorefront().countryCode == "CHN"
|
||||
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
|
||||
|
||||
func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? {
|
||||
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
|
||||
logger.debug("NotificationService processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactConnected(user, contact, _):
|
||||
return (contact.id, createContactConnectedNtf(user, contact))
|
||||
return (contact.id, .nse(notification: createContactConnectedNtf(user, contact)))
|
||||
// case let .contactConnecting(contact):
|
||||
// TODO profile update
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(user, contactRequest))
|
||||
return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest)))
|
||||
case let .newChatItem(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
var cItem = aChatItem.chatItem
|
||||
@@ -240,23 +282,13 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification
|
||||
cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem
|
||||
}
|
||||
}
|
||||
return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil
|
||||
return cItem.showMutableNotification ? (aChatItem.chatId, .nse(notification: createMessageReceivedNtf(user, cInfo, cItem))) : nil
|
||||
case let .callInvitation(invitation):
|
||||
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
|
||||
if useCallKit() {
|
||||
do {
|
||||
try await CXProvider.reportNewIncomingVoIPPushPayload([
|
||||
"displayName": invitation.contact.displayName,
|
||||
"contactId": invitation.contact.id,
|
||||
"media": invitation.callType.media.rawValue
|
||||
])
|
||||
logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)")
|
||||
return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent))
|
||||
} catch let error {
|
||||
logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
return (invitation.contact.id, createCallInvitationNtf(invitation))
|
||||
return (
|
||||
invitation.contact.id,
|
||||
useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation))
|
||||
)
|
||||
default:
|
||||
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)")
|
||||
return nil
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
5CBD285C29575B8E00EC2CF4 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */; };
|
||||
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; };
|
||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; };
|
||||
5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */; };
|
||||
5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; };
|
||||
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
|
||||
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
|
||||
@@ -361,6 +362,7 @@
|
||||
5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = "<group>"; };
|
||||
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = "<group>"; };
|
||||
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = "<group>"; };
|
||||
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenProfileView.swift; sourceTree = "<group>"; };
|
||||
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = "<group>"; };
|
||||
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
|
||||
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -675,6 +677,7 @@
|
||||
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
|
||||
5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */,
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */,
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
|
||||
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
|
||||
@@ -1028,6 +1031,7 @@
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
|
||||
5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */,
|
||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
|
||||
5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */,
|
||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
|
||||
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
|
||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
|
||||
@@ -1585,6 +1589,10 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/ios",
|
||||
@@ -1631,6 +1639,10 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries",
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/ios",
|
||||
|
||||
@@ -151,6 +151,11 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
||||
let chat = try? parseChatData(jChat) {
|
||||
return .apiChat(user: user, chat: chat)
|
||||
}
|
||||
} else if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
let user: User? = try? decodeObject(jError["user_"] as Any)
|
||||
return .chatCmdError(user_: user, chatError: .invalidJSON(json: prettyJSON(jError) ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
json = prettyJSON(j)
|
||||
@@ -185,12 +190,21 @@ func prettyJSON(_ obj: Any) -> String? {
|
||||
|
||||
public func responseError(_ err: Error) -> String {
|
||||
if let r = err as? ChatResponse {
|
||||
return String(describing: r)
|
||||
switch r {
|
||||
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
|
||||
case let .chatError(_, chatError): return chatErrorString(chatError)
|
||||
default: return String(describing: r)
|
||||
}
|
||||
} else {
|
||||
return err.localizedDescription
|
||||
return String(describing: err)
|
||||
}
|
||||
}
|
||||
|
||||
func chatErrorString(_ err: ChatError) -> String {
|
||||
if case let .invalidJSON(json) = err { return json }
|
||||
return String(describing: err)
|
||||
}
|
||||
|
||||
public enum DBMigrationResult: Decodable, Equatable {
|
||||
case ok
|
||||
case errorNotADatabase(dbFile: String)
|
||||
|
||||
@@ -16,8 +16,12 @@ public enum ChatCommand {
|
||||
case showActiveUser
|
||||
case createActiveUser(profile: Profile)
|
||||
case listUsers
|
||||
case apiSetActiveUser(userId: Int64)
|
||||
case apiDeleteUser(userId: Int64, delSMPQueues: Bool)
|
||||
case apiSetActiveUser(userId: Int64, viewPwd: String?)
|
||||
case apiHideUser(userId: Int64, viewPwd: String)
|
||||
case apiUnhideUser(userId: Int64, viewPwd: String?)
|
||||
case apiMuteUser(userId: Int64, viewPwd: String?)
|
||||
case apiUnmuteUser(userId: Int64, viewPwd: String?)
|
||||
case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?)
|
||||
case startChat(subscribe: Bool, expire: Bool)
|
||||
case apiStopChat
|
||||
case apiActivateChat
|
||||
@@ -103,8 +107,12 @@ public enum ChatCommand {
|
||||
case .showActiveUser: return "/u"
|
||||
case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)"
|
||||
case .listUsers: return "/users"
|
||||
case let .apiSetActiveUser(userId): return "/_user \(userId)"
|
||||
case let .apiDeleteUser(userId, delSMPQueues): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))"
|
||||
case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))"
|
||||
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
|
||||
case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId)\(maybePwd(viewPwd))"
|
||||
case let .apiMuteUser(userId, viewPwd): return "/_mute user \(userId)\(maybePwd(viewPwd))"
|
||||
case let .apiUnmuteUser(userId, viewPwd): return "/_unmute user \(userId)\(maybePwd(viewPwd))"
|
||||
case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))"
|
||||
case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))"
|
||||
case .apiStopChat: return "/_stop"
|
||||
case .apiActivateChat: return "/_app activate"
|
||||
@@ -202,6 +210,10 @@ public enum ChatCommand {
|
||||
case .createActiveUser: return "createActiveUser"
|
||||
case .listUsers: return "listUsers"
|
||||
case .apiSetActiveUser: return "apiSetActiveUser"
|
||||
case .apiHideUser: return "apiHideUser"
|
||||
case .apiUnhideUser: return "apiUnhideUser"
|
||||
case .apiMuteUser: return "apiMuteUser"
|
||||
case .apiUnmuteUser: return "apiUnmuteUser"
|
||||
case .apiDeleteUser: return "apiDeleteUser"
|
||||
case .startChat: return "startChat"
|
||||
case .apiStopChat: return "apiStopChat"
|
||||
@@ -304,6 +316,18 @@ public enum ChatCommand {
|
||||
switch self {
|
||||
case let .apiStorageEncryption(cfg):
|
||||
return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey)))
|
||||
case let .apiSetActiveUser(userId, viewPwd):
|
||||
return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd))
|
||||
case let .apiHideUser(userId, viewPwd):
|
||||
return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd))
|
||||
case let .apiUnhideUser(userId, viewPwd):
|
||||
return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd))
|
||||
case let .apiMuteUser(userId, viewPwd):
|
||||
return .apiMuteUser(userId: userId, viewPwd: obfuscate(viewPwd))
|
||||
case let .apiUnmuteUser(userId, viewPwd):
|
||||
return .apiUnmuteUser(userId: userId, viewPwd: obfuscate(viewPwd))
|
||||
case let .apiDeleteUser(userId, delSMPQueues, viewPwd):
|
||||
return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
|
||||
default: return self
|
||||
}
|
||||
}
|
||||
@@ -312,9 +336,21 @@ public enum ChatCommand {
|
||||
s == "" ? "" : "***"
|
||||
}
|
||||
|
||||
private func obfuscate(_ s: String?) -> String? {
|
||||
if let s = s {
|
||||
return obfuscate(s)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func onOff(_ b: Bool) -> String {
|
||||
b ? "on" : "off"
|
||||
}
|
||||
|
||||
private func maybePwd(_ pwd: String?) -> String {
|
||||
pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
|
||||
}
|
||||
}
|
||||
|
||||
struct APIResponse: Decodable {
|
||||
@@ -348,6 +384,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatCleared(user: User, chatInfo: ChatInfo)
|
||||
case userProfileNoChange(user: User)
|
||||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile)
|
||||
case userPrivacy(user: User)
|
||||
case contactAliasUpdated(user: User, toContact: Contact)
|
||||
case connectionAliasUpdated(user: User, toConnection: PendingContactConnection)
|
||||
case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
|
||||
@@ -424,8 +461,8 @@ public enum ChatResponse: Decodable, Error {
|
||||
case contactConnectionDeleted(user: User, connection: PendingContactConnection)
|
||||
case versionInfo(versionInfo: CoreVersionInfo)
|
||||
case cmdOk(user: User?)
|
||||
case chatCmdError(user: User?, chatError: ChatError)
|
||||
case chatError(user: User?, chatError: ChatError)
|
||||
case chatCmdError(user_: User?, chatError: ChatError)
|
||||
case chatError(user_: User?, chatError: ChatError)
|
||||
|
||||
public var responseType: String {
|
||||
get {
|
||||
@@ -456,6 +493,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatCleared: return "chatCleared"
|
||||
case .userProfileNoChange: return "userProfileNoChange"
|
||||
case .userProfileUpdated: return "userProfileUpdated"
|
||||
case .userPrivacy: return "userPrivacy"
|
||||
case .contactAliasUpdated: return "contactAliasUpdated"
|
||||
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
||||
case .contactPrefsUpdated: return "contactPrefsUpdated"
|
||||
@@ -564,6 +602,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
|
||||
case .userProfileNoChange: return noDetails
|
||||
case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile))
|
||||
case let .userPrivacy(u): return withUser(u, "")
|
||||
case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact))
|
||||
case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
|
||||
case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")
|
||||
@@ -653,6 +692,14 @@ public enum ChatResponse: Decodable, Error {
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserPrivacyCfg: Encodable {
|
||||
var currViewPwd: String
|
||||
var showNtfs: Bool
|
||||
var forceIncognito: Bool
|
||||
var viewPwd: String
|
||||
var wipePwd: String
|
||||
}
|
||||
|
||||
public enum ChatPagination {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
@@ -1083,6 +1130,7 @@ public enum ChatError: Decodable {
|
||||
case errorAgent(agentError: AgentErrorType)
|
||||
case errorStore(storeError: StoreError)
|
||||
case errorDatabase(databaseError: DatabaseError)
|
||||
case invalidJSON(json: String)
|
||||
}
|
||||
|
||||
public enum ChatErrorType: Decodable {
|
||||
|
||||
@@ -22,18 +22,33 @@ public struct User: Decodable, NamedChat, Identifiable {
|
||||
public var image: String? { get { profile.image } }
|
||||
public var localAlias: String { get { "" } }
|
||||
|
||||
public var showNtfs: Bool
|
||||
public var viewPwdHash: UserPwdHash?
|
||||
|
||||
public var id: Int64 { userId }
|
||||
|
||||
public var hidden: Bool { viewPwdHash != nil }
|
||||
|
||||
public var showNotifications: Bool {
|
||||
activeUser || showNtfs
|
||||
}
|
||||
|
||||
public static let sampleData = User(
|
||||
userId: 1,
|
||||
userContactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: LocalProfile.sampleData,
|
||||
fullPreferences: FullPreferences.sampleData,
|
||||
activeUser: true
|
||||
activeUser: true,
|
||||
showNtfs: true
|
||||
)
|
||||
}
|
||||
|
||||
public struct UserPwdHash: Decodable {
|
||||
public var hash: String
|
||||
public var salt: String
|
||||
}
|
||||
|
||||
public struct UserInfo: Decodable, Identifiable {
|
||||
public var user: User
|
||||
public var unreadCount: Int
|
||||
|
||||
@@ -22,5 +22,6 @@ extern char *chat_recv_msg(chat_ctrl ctl);
|
||||
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
|
||||
extern char *chat_parse_markdown(char *str);
|
||||
extern char *chat_parse_server(char *str);
|
||||
extern char *chat_password_hash(char *pwd, char *salt);
|
||||
extern char *chat_encrypt_media(char *key, char *frame, int len);
|
||||
extern char *chat_decrypt_media(char *key, char *frame, int len);
|
||||
|
||||
Reference in New Issue
Block a user