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:
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,8 +1155,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.removeChat(contact.activeConn.id)
|
||||
}
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
if !active(user) { return }
|
||||
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
if m.hasChat(contactRequest.id) {
|
||||
m.updateChatInfo(cInfo)
|
||||
@ -1060,8 +1164,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
chatInfo: cInfo,
|
||||
chatItems: []
|
||||
))
|
||||
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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,16 +99,20 @@ struct UserPicker: View {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
do {
|
||||
try changeActiveUser_(user.userId)
|
||||
userPickerVisible = false
|
||||
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: {
|
||||
HStack(spacing: 0) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .tertiarySystemFill))
|
||||
|
@ -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,17 +53,30 @@ 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 {
|
||||
if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) {
|
||||
showDeleteConfirmation = true
|
||||
userToDelete = i
|
||||
userToDelete = users[i]
|
||||
} else {
|
||||
alert = .cantDeleteLastUser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if searchTextOrPassword == "" {
|
||||
NavigationLink {
|
||||
CreateProfile()
|
||||
} label: {
|
||||
@ -59,27 +84,72 @@ struct UserProfilesView: View {
|
||||
}
|
||||
.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 {
|
||||
Task {
|
||||
do {
|
||||
try changeActiveUser_(user.userId)
|
||||
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
|
||||
} catch {
|
||||
alert = .activateUserError(error: responseError(error))
|
||||
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")
|
||||
if case let .nse(notification) = ntf {
|
||||
notification.badge = badgeCount as NSNumber
|
||||
bestAttemptNtf = .nse(notification: notification)
|
||||
} else {
|
||||
bestAttemptNtf = ntf
|
||||
bestAttemptNtf?.badge = badgeCount as NSNumber
|
||||
}
|
||||
}
|
||||
|
||||
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,10 +190,19 @@ func prettyJSON(_ obj: Any) -> String? {
|
||||
|
||||
public func responseError(_ err: Error) -> String {
|
||||
if let r = err as? ChatResponse {
|
||||
return String(describing: r)
|
||||
} else {
|
||||
return err.localizedDescription
|
||||
switch r {
|
||||
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
|
||||
case let .chatError(_, chatError): return chatErrorString(chatError)
|
||||
default: return String(describing: r)
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
|
@ -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);
|
||||
|
@ -86,6 +86,7 @@ library
|
||||
Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
|
||||
Simplex.Chat.Migrations.M20230303_group_link_role
|
||||
Simplex.Chat.Migrations.M20230304_file_description
|
||||
Simplex.Chat.Migrations.M20230317_hidden_profiles
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.WebRTC
|
||||
Simplex.Chat.Options
|
||||
|
@ -41,6 +41,7 @@ import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
|
||||
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
|
||||
import Data.Time.Clock.System (SystemTime, systemToUTCTime)
|
||||
@ -195,7 +196,7 @@ activeAgentServers ChatConfig {defaultServers} srvSel =
|
||||
. map (\ServerCfg {server} -> server)
|
||||
. filter (\ServerCfg {enabled} -> enabled)
|
||||
|
||||
startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => Bool -> Bool -> m (Async ())
|
||||
startChatController :: forall m. ChatMonad' m => Bool -> Bool -> m (Async ())
|
||||
startChatController subConns enableExpireCIs = do
|
||||
asks smpAgent >>= resumeAgentClient
|
||||
users <- fromRight [] <$> runExceptT (withStore' getUsers)
|
||||
@ -227,7 +228,7 @@ startChatController subConns enableExpireCIs = do
|
||||
startExpireCIThread user
|
||||
setExpireCIFlag user True
|
||||
|
||||
subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m ()
|
||||
subscribeUsers :: forall m. ChatMonad' m => [User] -> m ()
|
||||
subscribeUsers users = do
|
||||
let (us, us') = partition activeUser users
|
||||
subscribe us
|
||||
@ -236,7 +237,7 @@ subscribeUsers users = do
|
||||
subscribe :: [User] -> m ()
|
||||
subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections
|
||||
|
||||
restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
restoreCalls :: ChatMonad' m => m ()
|
||||
restoreCalls = do
|
||||
savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db)
|
||||
let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls
|
||||
@ -260,7 +261,7 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles,
|
||||
mapM_ hClose fs
|
||||
atomically $ writeTVar files M.empty
|
||||
|
||||
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse
|
||||
execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse
|
||||
execChatCommand s = do
|
||||
u <- readTVarIO =<< asks currentUser
|
||||
case parseChatCommand s of
|
||||
@ -308,27 +309,61 @@ processChatCommand = \case
|
||||
DefaultAgentServers {smp} <- asks $ defaultServers . config
|
||||
pure (smp, [])
|
||||
ListUsers -> CRUsersList <$> withStore' getUsersInfo
|
||||
APISetActiveUser userId -> do
|
||||
u <- asks currentUser
|
||||
user <- withStore $ \db -> getSetActiveUser db userId
|
||||
APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do
|
||||
user' <- privateGetUser userId'
|
||||
validateUserPassword user user' viewPwd_
|
||||
withStore' $ \db -> setActiveUser db userId'
|
||||
setActive ActiveNone
|
||||
atomically . writeTVar u $ Just user
|
||||
pure $ CRActiveUser user
|
||||
SetActiveUser uName -> withUserName uName APISetActiveUser
|
||||
APIDeleteUser userId delSMPQueues -> do
|
||||
user <- withStore (`getUser` userId)
|
||||
when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId)
|
||||
let user'' = user' {activeUser = True}
|
||||
asks currentUser >>= atomically . (`writeTVar` Just user'')
|
||||
pure $ CRActiveUser user''
|
||||
SetActiveUser uName viewPwd_ -> do
|
||||
tryError (withStore (`getUserIdByName` uName)) >>= \case
|
||||
Left _ -> throwChatError CEUserUnknown
|
||||
Right userId -> processChatCommand $ APISetActiveUser userId viewPwd_
|
||||
APIHideUser userId' (UserPwd viewPwd) -> withUser $ \_ -> do
|
||||
user' <- privateGetUser userId'
|
||||
case viewPwdHash user' of
|
||||
Just _ -> throwChatError $ CEUserAlreadyHidden userId'
|
||||
_ -> do
|
||||
when (T.null viewPwd) $ throwChatError $ CEEmptyUserPassword userId'
|
||||
users <- withStore' getUsers
|
||||
-- shouldn't happen - last user should be active
|
||||
when (length users == 1) $ throwChatError (CECantDeleteLastUser userId)
|
||||
filesInfo <- withStore' (`getUserFileInfo` user)
|
||||
withChatLock "deleteUser" . procCmd $ do
|
||||
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
||||
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
|
||||
withStore' (`deleteUserRecord` user)
|
||||
setActive ActiveNone
|
||||
ok_
|
||||
DeleteUser uName delSMPQueues -> withUserName uName $ \uId -> APIDeleteUser uId delSMPQueues
|
||||
unless (length (filter (isNothing . viewPwdHash) users) > 1) $ throwChatError $ CECantHideLastUser userId'
|
||||
viewPwdHash' <- hashPassword
|
||||
setUserPrivacy user' {viewPwdHash = viewPwdHash', showNtfs = False}
|
||||
where
|
||||
hashPassword = do
|
||||
salt <- drgRandomBytes 16
|
||||
let hash = B64UrlByteString $ C.sha512Hash $ encodeUtf8 viewPwd <> salt
|
||||
pure $ Just UserPwdHash {hash, salt = B64UrlByteString salt}
|
||||
APIUnhideUser userId' viewPwd_ -> withUser $ \user -> do
|
||||
user' <- privateGetUser userId'
|
||||
case viewPwdHash user' of
|
||||
Nothing -> throwChatError $ CEUserNotHidden userId'
|
||||
_ -> do
|
||||
validateUserPassword user user' viewPwd_
|
||||
setUserPrivacy user' {viewPwdHash = Nothing, showNtfs = True}
|
||||
APIMuteUser userId' viewPwd_ -> withUser $ \user -> do
|
||||
user' <- privateGetUser userId'
|
||||
validateUserPassword user user' viewPwd_
|
||||
setUserPrivacy user' {showNtfs = False}
|
||||
APIUnmuteUser userId' viewPwd_ -> withUser $ \user -> do
|
||||
user' <- privateGetUser userId'
|
||||
case viewPwdHash user' of
|
||||
Just _ -> throwChatError $ CECantUnmuteHiddenUser userId'
|
||||
_ -> do
|
||||
validateUserPassword user user' viewPwd_
|
||||
setUserPrivacy user' {showNtfs = True}
|
||||
HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand $ APIHideUser userId viewPwd
|
||||
UnhideUser -> withUser $ \User {userId} -> processChatCommand $ APIUnhideUser userId Nothing
|
||||
MuteUser -> withUser $ \User {userId} -> processChatCommand $ APIMuteUser userId Nothing
|
||||
UnmuteUser -> withUser $ \User {userId} -> processChatCommand $ APIUnmuteUser userId Nothing
|
||||
APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do
|
||||
user' <- privateGetUser userId'
|
||||
validateUserPassword user user' viewPwd_
|
||||
checkDeleteChatUser user'
|
||||
withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues
|
||||
DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_
|
||||
StartChat subConns enableExpireCIs -> withUser' $ \_ ->
|
||||
asks agentAsync >>= readTVarIO >>= \case
|
||||
Just _ -> pure CRChatRunning
|
||||
@ -708,7 +743,7 @@ processChatCommand = \case
|
||||
assertDirectAllowed user MDSnd ct XCallInv_
|
||||
calls <- asks currentCalls
|
||||
withChatLock "sendCallInvitation" $ do
|
||||
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
callId <- CallId <$> drgRandomBytes 16
|
||||
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
|
||||
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
|
||||
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
|
||||
@ -1210,7 +1245,7 @@ processChatCommand = \case
|
||||
gInfo <- withStore $ \db -> getGroupInfo db user groupId
|
||||
assertUserGroupRole gInfo GRAdmin
|
||||
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
|
||||
groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
|
||||
let crClientData = encodeJSON $ CRDataGroup groupLinkId
|
||||
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
|
||||
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
|
||||
@ -1426,7 +1461,7 @@ processChatCommand = \case
|
||||
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
|
||||
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
|
||||
(_, xContactId_) -> procCmd $ do
|
||||
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
let randomXContactId = XContactId <$> drgRandomBytes 16
|
||||
xContactId <- maybe randomXContactId pure xContactId_
|
||||
-- [incognito] generate profile to send
|
||||
-- if user makes a contact request using main profile, then turns on incognito mode and repeats the request,
|
||||
@ -1584,6 +1619,42 @@ processChatCommand = \case
|
||||
<$> if live
|
||||
then pure Nothing
|
||||
else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime
|
||||
drgRandomBytes :: Int -> m ByteString
|
||||
drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n)
|
||||
privateGetUser :: UserId -> m User
|
||||
privateGetUser userId =
|
||||
tryError (withStore (`getUser` userId)) >>= \case
|
||||
Left _ -> throwChatError CEUserUnknown
|
||||
Right user -> pure user
|
||||
validateUserPassword :: User -> User -> Maybe UserPwd -> m ()
|
||||
validateUserPassword User {userId} User {userId = userId', viewPwdHash} viewPwd_ =
|
||||
forM_ viewPwdHash $ \pwdHash ->
|
||||
let pwdOk = case viewPwd_ of
|
||||
Nothing -> userId == userId'
|
||||
Just (UserPwd viewPwd) -> validPassword viewPwd pwdHash
|
||||
in unless pwdOk $ throwChatError CEUserUnknown
|
||||
validPassword :: Text -> UserPwdHash -> Bool
|
||||
validPassword pwd UserPwdHash {hash = B64UrlByteString hash, salt = B64UrlByteString salt} =
|
||||
hash == C.sha512Hash (encodeUtf8 pwd <> salt)
|
||||
setUserPrivacy :: User -> m ChatResponse
|
||||
setUserPrivacy user = do
|
||||
asks currentUser >>= atomically . (`writeTVar` Just user)
|
||||
withStore' (`updateUserPrivacy` user)
|
||||
pure $ CRUserPrivacy user
|
||||
checkDeleteChatUser :: User -> m ()
|
||||
checkDeleteChatUser user@User {userId} = do
|
||||
when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId)
|
||||
users <- withStore' getUsers
|
||||
unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $
|
||||
throwChatError (CECantDeleteLastUser userId)
|
||||
setActive ActiveNone
|
||||
deleteChatUser :: User -> Bool -> m ChatResponse
|
||||
deleteChatUser user delSMPQueues = do
|
||||
filesInfo <- withStore' (`getUserFileInfo` user)
|
||||
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
||||
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
|
||||
withStore' (`deleteUserRecord` user)
|
||||
ok_
|
||||
|
||||
assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m ()
|
||||
assertDirectAllowed user dir ct event =
|
||||
@ -1600,7 +1671,7 @@ assertDirectAllowed user dir ct event =
|
||||
XCallInv_ -> False
|
||||
_ -> True
|
||||
|
||||
startExpireCIThread :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
|
||||
startExpireCIThread :: forall m. ChatMonad' m => User -> m ()
|
||||
startExpireCIThread user@User {userId} = do
|
||||
expireThreads <- asks expireCIThreads
|
||||
atomically (TM.lookup userId expireThreads) >>= \case
|
||||
@ -1619,12 +1690,12 @@ startExpireCIThread user@User {userId} = do
|
||||
forM_ ttl $ \t -> expireChatItems user t False
|
||||
threadDelay interval
|
||||
|
||||
setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m ()
|
||||
setExpireCIFlag :: ChatMonad' m => User -> Bool -> m ()
|
||||
setExpireCIFlag User {userId} b = do
|
||||
expireFlags <- asks expireCIFlags
|
||||
atomically $ TM.insert userId b expireFlags
|
||||
|
||||
setAllExpireCIFlags :: (MonadUnliftIO m, MonadReader ChatController m) => Bool -> m ()
|
||||
setAllExpireCIFlags :: ChatMonad' m => Bool -> m ()
|
||||
setAllExpireCIFlags b = do
|
||||
expireFlags <- asks expireCIFlags
|
||||
atomically $ do
|
||||
@ -1841,7 +1912,7 @@ deleteGroupLink_ user gInfo conn = do
|
||||
deleteAgentConnectionAsync user $ aConnId conn
|
||||
withStore' $ \db -> deleteGroupLink db user gInfo
|
||||
|
||||
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
agentSubscriber :: ChatMonad' m => m ()
|
||||
agentSubscriber = do
|
||||
q <- asks $ subQ . smpAgent
|
||||
l <- asks chatLock
|
||||
@ -2104,7 +2175,7 @@ processAgentMessageConn user _ agentConnId END =
|
||||
withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case
|
||||
RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do
|
||||
toView $ CRContactAnotherClient user ct
|
||||
showToast (c <> "> ") "connected to another client"
|
||||
whenUserNtfs user $ showToast (c <> "> ") "connected to another client"
|
||||
unsetActive $ ActiveC c
|
||||
entity -> toView $ CRSubscriptionEnd user entity
|
||||
processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
@ -2237,6 +2308,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||
toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile)
|
||||
when (directOrUsed ct) $ createFeatureEnabledItems ct
|
||||
whenUserNtfs user $ do
|
||||
setActive $ ActiveC c
|
||||
showToast (c <> "> ") "connected"
|
||||
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
|
||||
@ -2368,11 +2440,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
let GroupInfo {groupProfile = GroupProfile {description}} = gInfo
|
||||
memberConnectedChatItem gInfo m
|
||||
forM_ description $ groupDescriptionChatItem gInfo m
|
||||
whenUserNtfs user $ do
|
||||
setActive $ ActiveG gName
|
||||
showToast ("#" <> gName) "you are connected to group"
|
||||
GCInviteeMember -> do
|
||||
memberConnectedChatItem gInfo m
|
||||
toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected}
|
||||
whenGroupNtfs user gInfo $ do
|
||||
setActive $ ActiveG gName
|
||||
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
|
||||
intros <- withStore' $ \db -> createIntroductions db members m
|
||||
@ -2622,6 +2696,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
toView $ CRAcceptingGroupJoinRequest user gInfo ct
|
||||
_ -> do
|
||||
toView $ CRReceivedContactRequest user cReq
|
||||
whenUserNtfs user $
|
||||
showToast (localDisplayName <> "> ") "wants to connect to you"
|
||||
_ -> pure ()
|
||||
|
||||
@ -2703,6 +2778,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
memberConnectedChatItem gInfo m
|
||||
toView $ CRConnectedToGroupMember user gInfo m
|
||||
let g = groupName' gInfo
|
||||
whenGroupNtfs user gInfo $ do
|
||||
setActive $ ActiveG g
|
||||
showToast ("#" <> g) $ "member " <> c <> " is connected"
|
||||
|
||||
@ -2730,7 +2806,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
messageError = toView . CRMessageError user "error"
|
||||
|
||||
newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
|
||||
newContentMessage ct@Contact {localDisplayName = c, contactUsed, chatSettings} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
|
||||
newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
|
||||
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
|
||||
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
||||
let ExtMsgContent content fileInvitation_ _ _ = mcExtMsgContent mc
|
||||
@ -2744,7 +2820,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
live = fromMaybe False live_
|
||||
ciFile_ <- processFileInvitation fileInvitation_ content $ \db -> createRcvFileTransfer db userId ct
|
||||
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
|
||||
when (enableNtfs chatSettings) $ do
|
||||
whenContactNtfs user ct $ do
|
||||
showMsgToast (c <> "> ") content formattedText
|
||||
setActive $ ActiveC c
|
||||
where
|
||||
@ -2811,7 +2887,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
|
||||
|
||||
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
|
||||
newGroupContentMessage gInfo@GroupInfo {chatSettings} m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
|
||||
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
|
||||
let (ExtMsgContent content fInv_ _ _) = mcExtMsgContent mc
|
||||
if isVoice content && not (groupFeatureAllowed SGFVoice gInfo)
|
||||
then void $ newChatItem (CIRcvGroupFeatureRejected GFVoice) Nothing Nothing False
|
||||
@ -2822,7 +2898,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
ciFile_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m
|
||||
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
|
||||
let g = groupName' gInfo
|
||||
when (enableNtfs chatSettings) $ do
|
||||
whenGroupNtfs user gInfo $ do
|
||||
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
|
||||
setActive $ ActiveG g
|
||||
where
|
||||
@ -2896,6 +2972,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation}
|
||||
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
|
||||
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
||||
whenContactNtfs user ct $ do
|
||||
showToast (c <> "> ") "wants to send a file"
|
||||
setActive $ ActiveC c
|
||||
|
||||
@ -2909,6 +2986,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
|
||||
groupMsgToView gInfo m ci msgMeta
|
||||
let g = groupName' gInfo
|
||||
whenGroupNtfs user gInfo $ do
|
||||
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
|
||||
setActive $ ActiveG g
|
||||
|
||||
@ -3041,7 +3119,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci)
|
||||
|
||||
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
|
||||
processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} msg msgMeta = do
|
||||
processGroupInvitation ct inv msg msgMeta = do
|
||||
let Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} = ct
|
||||
GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv
|
||||
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
||||
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
|
||||
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
|
||||
@ -3061,6 +3141,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
|
||||
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
||||
toView $ CRReceivedGroupInvitation user gInfo ct memRole
|
||||
whenContactNtfs user ct $
|
||||
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
|
||||
where
|
||||
sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool
|
||||
@ -3888,17 +3969,26 @@ getCreateActiveUser st = do
|
||||
getWithPrompt :: String -> IO String
|
||||
getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine
|
||||
|
||||
showMsgToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> MsgContent -> Maybe MarkdownList -> m ()
|
||||
whenUserNtfs :: ChatMonad' m => User -> m () -> m ()
|
||||
whenUserNtfs User {showNtfs, activeUser} = when $ showNtfs || activeUser
|
||||
|
||||
whenContactNtfs :: ChatMonad' m => User -> Contact -> m () -> m ()
|
||||
whenContactNtfs user Contact {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings)
|
||||
|
||||
whenGroupNtfs :: ChatMonad' m => User -> GroupInfo -> m () -> m ()
|
||||
whenGroupNtfs user GroupInfo {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings)
|
||||
|
||||
showMsgToast :: ChatMonad' m => Text -> MsgContent -> Maybe MarkdownList -> m ()
|
||||
showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_
|
||||
where
|
||||
hideSecret :: FormattedText -> Text
|
||||
hideSecret FormattedText {format = Just Secret} = "..."
|
||||
hideSecret FormattedText {text} = text
|
||||
|
||||
showToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> Text -> m ()
|
||||
showToast :: ChatMonad' m => Text -> Text -> m ()
|
||||
showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ
|
||||
|
||||
notificationSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
notificationSubscriber :: ChatMonad' m => m ()
|
||||
notificationSubscriber = do
|
||||
ChatController {notifyQ, sendNotification} <- ask
|
||||
forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification
|
||||
@ -3958,8 +4048,8 @@ withStoreCtx ctx_ action = do
|
||||
chatCommandP :: Parser ChatCommand
|
||||
chatCommandP =
|
||||
choice
|
||||
[ "/mute " *> ((`ShowMessages` False) <$> chatNameP'),
|
||||
"/unmute " *> ((`ShowMessages` True) <$> chatNameP'),
|
||||
[ "/mute " *> ((`ShowMessages` False) <$> chatNameP),
|
||||
"/unmute " *> ((`ShowMessages` True) <$> chatNameP),
|
||||
"/create user"
|
||||
*> ( do
|
||||
sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False
|
||||
@ -3967,10 +4057,18 @@ chatCommandP =
|
||||
pure $ CreateActiveUser uProfile sameSmp
|
||||
),
|
||||
"/users" $> ListUsers,
|
||||
"/_user " *> (APISetActiveUser <$> A.decimal),
|
||||
("/user " <|> "/u ") *> (SetActiveUser <$> displayName),
|
||||
"/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP),
|
||||
"/delete user " *> (DeleteUser <$> displayName <*> pure True),
|
||||
"/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)),
|
||||
("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)),
|
||||
"/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_unhide user " *> (APIUnhideUser <$> A.decimal <*> optional (A.space *> jsonP)),
|
||||
"/_mute user " *> (APIMuteUser <$> A.decimal <*> optional (A.space *> jsonP)),
|
||||
"/_unmute user " *> (APIUnmuteUser <$> A.decimal <*> optional (A.space *> jsonP)),
|
||||
"/hide user " *> (HideUser <$> pwdP),
|
||||
"/unhide user" $> UnhideUser,
|
||||
"/mute user" $> MuteUser,
|
||||
"/unmute user" $> UnmuteUser,
|
||||
"/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)),
|
||||
"/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)),
|
||||
("/user" <|> "/u") $> ShowActiveUser,
|
||||
"/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP),
|
||||
"/_start" $> StartChat True True,
|
||||
@ -4199,6 +4297,7 @@ chatCommandP =
|
||||
n <- (A.space *> A.takeByteString) <|> pure ""
|
||||
pure $ if B.null n then name else safeDecodeUtf8 n
|
||||
textP = safeDecodeUtf8 <$> A.takeByteString
|
||||
pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' '))
|
||||
msgTextP = jsonP <|> textP
|
||||
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
||||
filePath = stringP
|
||||
|
@ -19,7 +19,7 @@ import Control.Monad.Except
|
||||
import Control.Monad.IO.Unlift
|
||||
import Control.Monad.Reader
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import Data.Aeson (FromJSON (..), ToJSON (..))
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
@ -182,10 +182,18 @@ data ChatCommand
|
||||
= ShowActiveUser
|
||||
| CreateActiveUser Profile Bool
|
||||
| ListUsers
|
||||
| APISetActiveUser UserId
|
||||
| SetActiveUser UserName
|
||||
| APIDeleteUser UserId Bool
|
||||
| DeleteUser UserName Bool
|
||||
| APISetActiveUser UserId (Maybe UserPwd)
|
||||
| SetActiveUser UserName (Maybe UserPwd)
|
||||
| APIHideUser UserId UserPwd
|
||||
| APIUnhideUser UserId (Maybe UserPwd)
|
||||
| APIMuteUser UserId (Maybe UserPwd)
|
||||
| APIUnmuteUser UserId (Maybe UserPwd)
|
||||
| HideUser UserPwd
|
||||
| UnhideUser
|
||||
| MuteUser
|
||||
| UnmuteUser
|
||||
| APIDeleteUser UserId Bool (Maybe UserPwd)
|
||||
| DeleteUser UserName Bool (Maybe UserPwd)
|
||||
| StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool}
|
||||
| APIStopChat
|
||||
| APIActivateChat
|
||||
@ -406,6 +414,7 @@ data ChatResponse
|
||||
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
|
||||
| CRUserProfile {user :: User, profile :: Profile}
|
||||
| CRUserProfileNoChange {user :: User}
|
||||
| CRUserPrivacy {user :: User}
|
||||
| CRVersionInfo {versionInfo :: CoreVersionInfo}
|
||||
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation}
|
||||
| CRSentConfirmation {user :: User}
|
||||
@ -522,6 +531,16 @@ instance ToJSON ChatResponse where
|
||||
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
|
||||
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
|
||||
|
||||
newtype UserPwd = UserPwd {unUserPwd :: Text}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance FromJSON UserPwd where
|
||||
parseJSON v = UserPwd <$> parseJSON v
|
||||
|
||||
instance ToJSON UserPwd where
|
||||
toJSON (UserPwd p) = toJSON p
|
||||
toEncoding (UserPwd p) = toEncoding p
|
||||
|
||||
newtype AgentQueueId = AgentQueueId QueueId
|
||||
deriving (Eq, Show)
|
||||
|
||||
@ -683,11 +702,17 @@ instance ToJSON ChatError where
|
||||
data ChatErrorType
|
||||
= CENoActiveUser
|
||||
| CENoConnectionUser {agentConnId :: AgentConnId}
|
||||
| CEUserUnknown
|
||||
| CEActiveUserExists -- TODO delete
|
||||
| CEUserExists {contactName :: ContactName}
|
||||
| CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId}
|
||||
| CECantDeleteActiveUser {userId :: UserId}
|
||||
| CECantDeleteLastUser {userId :: UserId}
|
||||
| CECantHideLastUser {userId :: UserId}
|
||||
| CECantUnmuteHiddenUser {userId :: UserId}
|
||||
| CEEmptyUserPassword {userId :: UserId}
|
||||
| CEUserAlreadyHidden {userId :: UserId}
|
||||
| CEUserNotHidden {userId :: UserId}
|
||||
| CEChatNotStarted
|
||||
| CEChatNotStopped
|
||||
| CEChatStoreChanged
|
||||
@ -764,7 +789,9 @@ instance ToJSON SQLiteError where
|
||||
throwDBError :: ChatMonad m => DatabaseError -> m ()
|
||||
throwDBError = throwError . ChatErrorDatabase
|
||||
|
||||
type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m)
|
||||
type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m)
|
||||
|
||||
type ChatMonad m = (ChatMonad' m, MonadError ChatError m)
|
||||
|
||||
chatCmdError :: Maybe User -> String -> ChatResponse
|
||||
chatCmdError user = CRChatCmdError user . ChatError . CECommandError
|
||||
|
14
src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs
Normal file
14
src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs
Normal file
@ -0,0 +1,14 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20230317_hidden_profiles where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20230317_hidden_profiles :: Query
|
||||
m20230317_hidden_profiles =
|
||||
[sql|
|
||||
ALTER TABLE users ADD COLUMN view_pwd_hash BLOB;
|
||||
ALTER TABLE users ADD COLUMN view_pwd_salt BLOB;
|
||||
ALTER TABLE users ADD COLUMN show_ntfs INTEGER NOT NULL DEFAULT 1;
|
||||
|]
|
@ -30,7 +30,10 @@ CREATE TABLE users(
|
||||
active_user INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT CHECK(created_at NOT NULL),
|
||||
updated_at TEXT CHECK(updated_at NOT NULL),
|
||||
agent_user_id INTEGER CHECK(agent_user_id NOT NULL), -- 1 for active user
|
||||
agent_user_id INTEGER CHECK(agent_user_id NOT NULL),
|
||||
view_pwd_hash BLOB,
|
||||
view_pwd_salt BLOB,
|
||||
show_ntfs INTEGER NOT NULL DEFAULT 1, -- 1 for active user
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
|
@ -12,12 +12,15 @@ import Control.Monad.Except
|
||||
import Control.Monad.Reader
|
||||
import Data.Aeson (ToJSON (..))
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.ByteString.Base64.URL as U
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Functor (($>))
|
||||
import Data.List (find)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (fromMaybe)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Word (Word8)
|
||||
import Database.SQLite.Simple (SQLError (..))
|
||||
import qualified Database.SQLite.Simple as DB
|
||||
@ -65,6 +68,8 @@ foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO C
|
||||
|
||||
foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString
|
||||
|
||||
foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString
|
||||
|
||||
foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
|
||||
|
||||
foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
|
||||
@ -122,6 +127,12 @@ cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s
|
||||
cChatParseServer :: CString -> IO CJSONString
|
||||
cChatParseServer s = newCAString . chatParseServer =<< peekCAString s
|
||||
|
||||
cChatPasswordHash :: CString -> CString -> IO CString
|
||||
cChatPasswordHash cPwd cSalt = do
|
||||
pwd <- peekCAString cPwd
|
||||
salt <- peekCAString cSalt
|
||||
newCAString $ chatPasswordHash pwd salt
|
||||
|
||||
mobileChatOpts :: String -> String -> ChatOpts
|
||||
mobileChatOpts dbFilePrefix dbKey =
|
||||
ChatOpts
|
||||
@ -241,6 +252,12 @@ chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
|
||||
enc :: StrEncoding a => a -> String
|
||||
enc = B.unpack . strEncode
|
||||
|
||||
chatPasswordHash :: String -> String -> String
|
||||
chatPasswordHash pwd salt = either (const "") passwordHash salt'
|
||||
where
|
||||
salt' = U.decode $ B.pack salt
|
||||
passwordHash = B.unpack . U.encode . C.sha512Hash . (encodeUtf8 (T.pack pwd) <>)
|
||||
|
||||
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
|
||||
deriving (Generic)
|
||||
|
||||
|
@ -39,6 +39,7 @@ module Simplex.Chat.Store
|
||||
getUserByContactRequestId,
|
||||
getUserFileInfo,
|
||||
deleteUserRecord,
|
||||
updateUserPrivacy,
|
||||
createDirectConnection,
|
||||
createConnReqConnection,
|
||||
getProfileById,
|
||||
@ -277,6 +278,7 @@ import Data.Functor (($>))
|
||||
import Data.Int (Int64)
|
||||
import Data.List (sortBy, sortOn)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
|
||||
import Data.Ord (Down (..))
|
||||
import Data.Text (Text)
|
||||
@ -345,6 +347,7 @@ import Simplex.Chat.Migrations.M20230118_recreate_smp_servers
|
||||
import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx
|
||||
import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
|
||||
import Simplex.Chat.Migrations.M20230303_group_link_role
|
||||
import Simplex.Chat.Migrations.M20230317_hidden_profiles
|
||||
-- import Simplex.Chat.Migrations.M20230304_file_description
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Types
|
||||
@ -412,7 +415,8 @@ schemaMigrations =
|
||||
("20230118_recreate_smp_servers", m20230118_recreate_smp_servers),
|
||||
("20230129_drop_chat_items_group_idx", m20230129_drop_chat_items_group_idx),
|
||||
("20230206_item_deleted_by_group_member_id", m20230206_item_deleted_by_group_member_id),
|
||||
("20230303_group_link_role", m20230303_group_link_role)
|
||||
("20230303_group_link_role", m20230303_group_link_role),
|
||||
("20230317_hidden_profiles", m20230317_hidden_profiles)
|
||||
-- ("20230304_file_description", m20230304_file_description)
|
||||
]
|
||||
|
||||
@ -449,8 +453,8 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr
|
||||
when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0"
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,?,0,?,?)"
|
||||
(auId, displayName, activeUser, currentTs, currentTs)
|
||||
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, created_at, updated_at) VALUES (?,?,?,0,?,?,?)"
|
||||
(auId, displayName, activeUser, True, currentTs, currentTs)
|
||||
userId <- insertedRowId db
|
||||
DB.execute
|
||||
db
|
||||
@ -467,7 +471,7 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr
|
||||
(profileId, displayName, userId, True, currentTs, currentTs)
|
||||
contactId <- insertedRowId db
|
||||
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
|
||||
pure $ toUser (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences)
|
||||
pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences, True) :. (Nothing, Nothing)
|
||||
|
||||
getUsersInfo :: DB.Connection -> IO [UserInfo]
|
||||
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
||||
@ -505,16 +509,19 @@ getUsers db =
|
||||
userQuery :: Query
|
||||
userQuery =
|
||||
[sql|
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.preferences
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.preferences, u.show_ntfs, u.view_pwd_hash, u.view_pwd_salt
|
||||
FROM users u
|
||||
JOIN contacts uct ON uct.contact_id = u.contact_id
|
||||
JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id
|
||||
|]
|
||||
|
||||
toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User
|
||||
toUser (userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
|
||||
in User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences}
|
||||
toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences, Bool) :. (Maybe B64UrlByteString, Maybe B64UrlByteString) -> User
|
||||
toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences, showNtfs) :. (viewPwdHash_, viewPwdSalt_)) =
|
||||
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, viewPwdHash}
|
||||
where
|
||||
profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
|
||||
fullPreferences = mergePreferences Nothing userPreferences
|
||||
viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_
|
||||
|
||||
setActiveUser :: DB.Connection -> UserId -> IO ()
|
||||
setActiveUser db userId = do
|
||||
@ -581,6 +588,19 @@ deleteUserRecord :: DB.Connection -> User -> IO ()
|
||||
deleteUserRecord db User {userId} =
|
||||
DB.execute db "DELETE FROM users WHERE user_id = ?" (Only userId)
|
||||
|
||||
updateUserPrivacy :: DB.Connection -> User -> IO ()
|
||||
updateUserPrivacy db User {userId, showNtfs, viewPwdHash} =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE users
|
||||
SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ?
|
||||
WHERE user_id = ?
|
||||
|]
|
||||
(hashSalt viewPwdHash :. (showNtfs, userId))
|
||||
where
|
||||
hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt))
|
||||
|
||||
createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection
|
||||
createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do
|
||||
createdAt <- getCurrentTime
|
||||
|
@ -110,11 +110,38 @@ data User = User
|
||||
localDisplayName :: ContactName,
|
||||
profile :: LocalProfile,
|
||||
fullPreferences :: FullPreferences,
|
||||
activeUser :: Bool
|
||||
activeUser :: Bool,
|
||||
viewPwdHash :: Maybe UserPwdHash,
|
||||
showNtfs :: Bool
|
||||
}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
instance ToJSON User where
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
newtype B64UrlByteString = B64UrlByteString ByteString
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance FromField B64UrlByteString where fromField f = B64UrlByteString <$> fromField f
|
||||
|
||||
instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField m
|
||||
|
||||
instance StrEncoding B64UrlByteString where
|
||||
strEncode (B64UrlByteString m) = strEncode m
|
||||
strP = B64UrlByteString <$> strP
|
||||
|
||||
instance FromJSON B64UrlByteString where
|
||||
parseJSON = strParseJSON "B64UrlByteString"
|
||||
|
||||
instance ToJSON B64UrlByteString where
|
||||
toJSON = strToJSON
|
||||
toEncoding = strToJEncoding
|
||||
|
||||
data UserPwdHash = UserPwdHash {hash :: B64UrlByteString, salt :: B64UrlByteString}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON UserPwdHash where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data UserInfo = UserInfo
|
||||
{ user :: User,
|
||||
|
@ -116,6 +116,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
|
||||
CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus
|
||||
CRUserProfile u p -> ttyUser u $ viewUserProfile p
|
||||
CRUserProfileNoChange u -> ttyUser u ["user profile did not change"]
|
||||
CRUserPrivacy u -> ttyUserPrefix u $ viewUserPrivacy u
|
||||
CRVersionInfo info -> viewVersionInfo logLevel info
|
||||
CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq
|
||||
CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
|
||||
@ -229,12 +230,16 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
|
||||
CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo]
|
||||
CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""]
|
||||
CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning]
|
||||
CRChatCmdError u e -> ttyUser' u $ viewChatError logLevel e
|
||||
CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e
|
||||
CRChatError u e -> ttyUser' u $ viewChatError logLevel e
|
||||
where
|
||||
ttyUser :: User -> [StyledString] -> [StyledString]
|
||||
ttyUser _ [] = []
|
||||
ttyUser User {userId, localDisplayName = u} ss = prependFirst userPrefix ss
|
||||
ttyUser user@User {showNtfs, activeUser} ss
|
||||
| showNtfs || activeUser = ttyUserPrefix user ss
|
||||
| otherwise = []
|
||||
ttyUserPrefix :: User -> [StyledString] -> [StyledString]
|
||||
ttyUserPrefix _ [] = []
|
||||
ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss
|
||||
where
|
||||
userPrefix = case user_ of
|
||||
Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else ""
|
||||
@ -242,6 +247,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
|
||||
prefix = "[user: " <> highlight u <> "] "
|
||||
ttyUser' :: Maybe User -> [StyledString] -> [StyledString]
|
||||
ttyUser' = maybe id ttyUser
|
||||
ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString]
|
||||
ttyUserPrefix' = maybe id ttyUserPrefix
|
||||
testViewChats :: [AChat] -> [StyledString]
|
||||
testViewChats chats = [sShow $ map toChatView chats]
|
||||
where
|
||||
@ -293,14 +300,19 @@ chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState
|
||||
_ -> ""
|
||||
|
||||
viewUsersList :: [UserInfo] -> [StyledString]
|
||||
viewUsersList = map userInfo . sortOn ldn
|
||||
viewUsersList = mapMaybe userInfo . sortOn ldn
|
||||
where
|
||||
ldn (UserInfo User {localDisplayName = n} _) = T.toLower n
|
||||
userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser} count) =
|
||||
ttyFullName n fullName <> active <> unread
|
||||
userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser, showNtfs, viewPwdHash} count)
|
||||
| activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName <> infoStr
|
||||
| otherwise = Nothing
|
||||
where
|
||||
active = if activeUser then highlight' " (active)" else ""
|
||||
unread = if count /= 0 then plain $ " (unread: " <> show count <> ")" else ""
|
||||
infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")"
|
||||
info =
|
||||
[highlight' "active" | activeUser]
|
||||
<> [highlight' "hidden" | isJust viewPwdHash]
|
||||
<> ["muted" | not showNtfs]
|
||||
<> [plain ("unread: " <> show count) | count /= 0]
|
||||
|
||||
muted :: ChatInfo c -> ChatItem c d -> Bool
|
||||
muted chat ChatItem {chatDir} = case (chat, chatDir) of
|
||||
@ -722,6 +734,12 @@ viewUserProfile Profile {displayName, fullName} =
|
||||
"(the updated profile will be sent to all your contacts)"
|
||||
]
|
||||
|
||||
viewUserPrivacy :: User -> [StyledString]
|
||||
viewUserPrivacy User {showNtfs, viewPwdHash} =
|
||||
[ "user messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)",
|
||||
"user profile is " <> if isJust viewPwdHash then "hidden" else "visible"
|
||||
]
|
||||
|
||||
-- TODO make more generic messages or split
|
||||
viewSMPServers :: ProtocolTypeI p => [ServerCfg p] -> Bool -> [StyledString]
|
||||
viewSMPServers servers testView =
|
||||
@ -1210,9 +1228,15 @@ viewChatError logLevel = \case
|
||||
CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError]
|
||||
CEActiveUserExists -> ["error: active user already exists"]
|
||||
CEUserExists name -> ["user with the name " <> ttyContact name <> " already exists"]
|
||||
CEUserUnknown -> ["user does not exist or incorrect password"]
|
||||
CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId]
|
||||
CECantDeleteActiveUser _ -> ["cannot delete active user"]
|
||||
CECantDeleteLastUser _ -> ["cannot delete last user"]
|
||||
CECantHideLastUser _ -> ["cannot hide the only not hidden user"]
|
||||
CECantUnmuteHiddenUser _ -> ["cannot unmute hidden user"]
|
||||
CEEmptyUserPassword _ -> ["cannot set empty password"]
|
||||
CEUserAlreadyHidden _ -> ["user is already hidden"]
|
||||
CEUserNotHidden _ -> ["user is not hidden"]
|
||||
CEChatNotStarted -> ["error: chat not started"]
|
||||
CEChatNotStopped -> ["error: chat not stopped"]
|
||||
CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
|
||||
|
@ -62,6 +62,7 @@ chatDirectTests = do
|
||||
it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser
|
||||
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser
|
||||
it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages
|
||||
it "user profile privacy: hide profiles and notificaitons" testUserPrivacy
|
||||
describe "chat item expiration" $ do
|
||||
it "set chat item TTL" testSetChatItemTTL
|
||||
describe "queue rotation" $ do
|
||||
@ -787,13 +788,13 @@ testMuteContact =
|
||||
connectUsers alice bob
|
||||
alice #> "@bob hello"
|
||||
bob <# "alice> hello"
|
||||
bob ##> "/mute alice"
|
||||
bob ##> "/mute @alice"
|
||||
bob <## "ok"
|
||||
alice #> "@bob hi"
|
||||
(bob </)
|
||||
bob ##> "/contacts"
|
||||
bob <## "alice (Alice) (muted, you can /unmute @alice)"
|
||||
bob ##> "/unmute alice"
|
||||
bob ##> "/unmute @alice"
|
||||
bob <## "ok"
|
||||
bob ##> "/contacts"
|
||||
bob <## "alice (Alice)"
|
||||
@ -1502,6 +1503,104 @@ testUsersTimedMessages tmp = do
|
||||
alice <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))")
|
||||
alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") -- to remove feature items
|
||||
|
||||
testUserPrivacy :: HasCallStack => FilePath -> IO ()
|
||||
testUserPrivacy =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
connectUsers alice bob
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
-- connect using second user
|
||||
connectUsers alice bob
|
||||
alice #> "@bob hello"
|
||||
bob <# "alisa> hello"
|
||||
bob #> "@alisa hey"
|
||||
alice <# "bob> hey"
|
||||
-- hide user profile
|
||||
alice ##> "/hide user my_password"
|
||||
userHidden alice
|
||||
-- shows messages when active
|
||||
bob #> "@alisa hello again"
|
||||
alice <# "bob> hello again"
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice)"
|
||||
-- does not show messages to user
|
||||
bob #> "@alisa this won't show"
|
||||
(alice </)
|
||||
-- does not show hidden user
|
||||
alice ##> "/users"
|
||||
alice <## "alice (Alice) (active)"
|
||||
(alice </)
|
||||
-- requires password to switch to the user
|
||||
alice ##> "/user alisa"
|
||||
alice <## "user does not exist or incorrect password"
|
||||
alice ##> "/user alisa wrong_password"
|
||||
alice <## "user does not exist or incorrect password"
|
||||
alice ##> "/user alisa my_password"
|
||||
showActiveUser alice "alisa"
|
||||
-- shows hidden user when active
|
||||
alice ##> "/users"
|
||||
alice <## "alice (Alice)"
|
||||
alice <## "alisa (active, hidden, muted)"
|
||||
-- hidden message is saved
|
||||
alice ##> "/tail"
|
||||
alice
|
||||
<##? [ "bob> Disappearing messages: off",
|
||||
"bob> Full deletion: off",
|
||||
"bob> Voice messages: enabled",
|
||||
"@bob hello",
|
||||
"bob> hey",
|
||||
"bob> hello again",
|
||||
"bob> this won't show"
|
||||
]
|
||||
-- change profile password
|
||||
alice ##> "/unmute user"
|
||||
alice <## "cannot unmute hidden user"
|
||||
alice ##> "/hide user password"
|
||||
alice <## "user is already hidden"
|
||||
alice ##> "/unhide user"
|
||||
userVisible alice
|
||||
alice ##> "/hide user new_password"
|
||||
userHidden alice
|
||||
alice ##> "/_delete user 1 del_smp=on"
|
||||
alice <## "cannot delete last user"
|
||||
alice ##> "/_hide user 1 \"password\""
|
||||
alice <## "cannot hide the only not hidden user"
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice)"
|
||||
-- change profile privacy for inactive user via API requires correct password
|
||||
alice ##> "/_unmute user 2"
|
||||
alice <## "cannot unmute hidden user"
|
||||
alice ##> "/_hide user 2 \"password\""
|
||||
alice <## "user is already hidden"
|
||||
alice ##> "/_unhide user 2"
|
||||
alice <## "user does not exist or incorrect password"
|
||||
alice ##> "/_unhide user 2 \"wrong_password\""
|
||||
alice <## "user does not exist or incorrect password"
|
||||
alice ##> "/_unhide user 2 \"new_password\""
|
||||
userVisible alice
|
||||
alice ##> "/_hide user 2 \"another_password\""
|
||||
userHidden alice
|
||||
-- check new password
|
||||
alice ##> "/user alisa another_password"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice)"
|
||||
alice ##> "/_delete user 2 del_smp=on"
|
||||
alice <## "user does not exist or incorrect password"
|
||||
alice ##> "/_delete user 2 del_smp=on \"wrong_password\""
|
||||
alice <## "user does not exist or incorrect password"
|
||||
alice ##> "/_delete user 2 del_smp=on \"another_password\""
|
||||
alice <## "ok"
|
||||
alice <## "completed deleting user"
|
||||
where
|
||||
userHidden alice = do
|
||||
alice <## "user messages are hidden (use /tail to view)"
|
||||
alice <## "user profile is hidden"
|
||||
userVisible alice = do
|
||||
alice <## "user messages are shown"
|
||||
alice <## "user profile is visible"
|
||||
|
||||
testSetChatItemTTL :: HasCallStack => FilePath -> IO ()
|
||||
testSetChatItemTTL =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
@ -25,16 +25,16 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e
|
||||
|
||||
activeUserExists :: String
|
||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||
activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}"
|
||||
activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}"
|
||||
#else
|
||||
activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}"
|
||||
activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}"
|
||||
#endif
|
||||
|
||||
activeUser :: String
|
||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}}"
|
||||
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}}"
|
||||
#else
|
||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}"
|
||||
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}"
|
||||
#endif
|
||||
|
||||
chatStarted :: String
|
||||
@ -73,7 +73,7 @@ pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <>
|
||||
#endif
|
||||
|
||||
userJSON :: String
|
||||
userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}"
|
||||
userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}"
|
||||
|
||||
parsedMarkdown :: String
|
||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||
|
Loading…
Reference in New Issue
Block a user