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 }
|
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 {
|
func hasChat(_ id: String) -> Bool {
|
||||||
chats.first(where: { $0.id == id }) != nil
|
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)")
|
logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
|
||||||
if let userId = content.userInfo["userId"] as? Int64,
|
if let userId = content.userInfo["userId"] as? Int64,
|
||||||
userId != chatModel.currentUser?.userId {
|
userId != chatModel.currentUser?.userId {
|
||||||
changeActiveUser(userId)
|
changeActiveUser(userId, viewPwd: nil)
|
||||||
}
|
}
|
||||||
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
|
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
|
||||||
let chatId = content.userInfo["chatId"] as? String {
|
let chatId = content.userInfo["chatId"] as? String {
|
||||||
@ -87,13 +87,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
switch content.categoryIdentifier {
|
switch content.categoryIdentifier {
|
||||||
case ntfCategoryMessageReceived:
|
case ntfCategoryMessageReceived:
|
||||||
let recent = recentInTheSameChat(content)
|
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...
|
// in the chat list...
|
||||||
if model.currentUser?.userId == (content.userInfo["userId"] as? Int64) {
|
if model.currentUser?.userId == userId {
|
||||||
// ... of the current user
|
// ... of the active user
|
||||||
return recent ? [] : [.sound, .list]
|
return recent ? [] : [.sound, .list]
|
||||||
} else {
|
} else {
|
||||||
// ... of different user
|
// ... of inactive user
|
||||||
return recent ? [.banner] : [.sound, .banner, .list]
|
return recent ? [.banner] : [.sound, .banner, .list]
|
||||||
}
|
}
|
||||||
} else if model.chatId == content.targetContentIdentifier {
|
} else if model.chatId == content.targetContentIdentifier {
|
||||||
|
@ -132,21 +132,56 @@ func apiCreateActiveUser(_ p: Profile) throws -> User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listUsers() throws -> [UserInfo] {
|
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 {
|
if case let .usersList(users) = r {
|
||||||
return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending }
|
return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending }
|
||||||
}
|
}
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiSetActiveUser(_ userId: Int64) throws -> User {
|
func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User {
|
||||||
let r = chatSendCmdSync(.apiSetActiveUser(userId: userId))
|
let r = chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
|
||||||
if case let .activeUser(user) = r { return user }
|
if case let .activeUser(user) = r { return user }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool) throws {
|
func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User {
|
||||||
let r = chatSendCmdSync(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues))
|
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 }
|
if case .cmdOk = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@ -209,8 +244,16 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiGetChats() throws -> [ChatData] {
|
func apiGetChats() throws -> [ChatData] {
|
||||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") }
|
let userId = try currentUserId("apiGetChats")
|
||||||
let r = chatSendCmdSync(.apiGetChats(userId: userId))
|
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 }
|
if case let .apiChats(_, chats) = r { return chats }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@ -337,19 +380,27 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
||||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") }
|
let userId = try currentUserId("getUserSMPServers")
|
||||||
let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId))
|
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) }
|
if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
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))
|
try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
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))
|
let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer))
|
||||||
if case let .smpTestResult(_, testFailure) = r {
|
if case let .smpTestResult(_, testFailure) = r {
|
||||||
if let t = testFailure {
|
if let t = testFailure {
|
||||||
@ -361,14 +412,22 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure>
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getChatItemTTL() throws -> ChatItemTTL {
|
func getChatItemTTL() throws -> ChatItemTTL {
|
||||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") }
|
let userId = try currentUserId("getChatItemTTL")
|
||||||
let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId))
|
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) }
|
if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
|
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))
|
try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -539,14 +598,14 @@ func clearChat(_ chat: Chat) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiListContacts() throws -> [Contact] {
|
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))
|
let r = chatSendCmdSync(.apiListContacts(userId: userId))
|
||||||
if case let .contactsList(_, contacts) = r { return contacts }
|
if case let .contactsList(_, contacts) = r { return contacts }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
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))
|
let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
|
||||||
switch r {
|
switch r {
|
||||||
case .userProfileNoChange: return nil
|
case .userProfileNoChange: return nil
|
||||||
@ -574,22 +633,30 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiCreateUserAddress() async throws -> String {
|
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))
|
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
|
||||||
if case let .userContactLinkCreated(_, connReq) = r { return connReq }
|
if case let .userContactLinkCreated(_, connReq) = r { return connReq }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteUserAddress() async throws {
|
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))
|
let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId))
|
||||||
if case .userContactLinkDeleted = r { return }
|
if case .userContactLinkDeleted = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGetUserAddress() throws -> UserContactLink? {
|
func apiGetUserAddress() throws -> UserContactLink? {
|
||||||
guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") }
|
let userId = try currentUserId("apiGetUserAddress")
|
||||||
let r = chatSendCmdSync(.apiShowMyAddress(userId: userId))
|
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 {
|
switch r {
|
||||||
case let .userContactLink(_, contactLink): return contactLink
|
case let .userContactLink(_, contactLink): return contactLink
|
||||||
case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
|
case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
|
||||||
@ -598,7 +665,7 @@ func apiGetUserAddress() throws -> UserContactLink? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func userAddressAutoAccept(_ autoAccept: AutoAccept?) async 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))
|
let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept))
|
||||||
switch r {
|
switch r {
|
||||||
case let .userContactLinkUpdated(_, contactLink): return contactLink
|
case let .userContactLinkUpdated(_, contactLink): return contactLink
|
||||||
@ -793,7 +860,7 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo {
|
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))
|
let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p))
|
||||||
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
|
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
|
||||||
throw r
|
throw r
|
||||||
@ -909,6 +976,13 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
|||||||
throw r
|
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 {
|
func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true) throws {
|
||||||
logger.debug("initializeChat")
|
logger.debug("initializeChat")
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
@ -958,21 +1032,38 @@ func startChat(refreshInvitations: Bool = true) throws {
|
|||||||
chatLastStartGroupDefault.set(Date.now)
|
chatLastStartGroupDefault.set(Date.now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeActiveUser(_ userId: Int64) {
|
func changeActiveUser(_ userId: Int64, viewPwd: String?) {
|
||||||
do {
|
do {
|
||||||
try changeActiveUser_(userId)
|
try changeActiveUser_(userId, viewPwd: viewPwd)
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("Unable to set active user: \(responseError(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
|
let m = ChatModel.shared
|
||||||
m.currentUser = try apiSetActiveUser(userId)
|
m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd)
|
||||||
m.users = try listUsers()
|
m.users = try listUsers()
|
||||||
try getUserChatData()
|
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 {
|
func getUserChatData() throws {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
m.userAddress = try apiGetUserAddress()
|
m.userAddress = try apiGetUserAddress()
|
||||||
@ -982,6 +1073,20 @@ func getUserChatData() throws {
|
|||||||
m.chats = chats.map { Chat.init($0) }
|
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 {
|
class ChatReceiver {
|
||||||
private var receiveLoop: Task<Void, Never>?
|
private var receiveLoop: Task<Void, Never>?
|
||||||
private var receiveMessages = true
|
private var receiveMessages = true
|
||||||
@ -1050,18 +1155,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
m.removeChat(contact.activeConn.id)
|
m.removeChat(contact.activeConn.id)
|
||||||
}
|
}
|
||||||
case let .receivedContactRequest(user, contactRequest):
|
case let .receivedContactRequest(user, contactRequest):
|
||||||
if !active(user) { return }
|
if active(user) {
|
||||||
|
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
if m.hasChat(contactRequest.id) {
|
||||||
if m.hasChat(contactRequest.id) {
|
m.updateChatInfo(cInfo)
|
||||||
m.updateChatInfo(cInfo)
|
} else {
|
||||||
} else {
|
m.addChat(Chat(
|
||||||
m.addChat(Chat(
|
chatInfo: cInfo,
|
||||||
chatInfo: cInfo,
|
chatItems: []
|
||||||
chatItems: []
|
))
|
||||||
))
|
}
|
||||||
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
|
||||||
}
|
}
|
||||||
|
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
||||||
case let .contactUpdated(user, toContact):
|
case let .contactUpdated(user, toContact):
|
||||||
if active(user) && m.hasChat(toContact.id) {
|
if active(user) && m.hasChat(toContact.id) {
|
||||||
let cInfo = ChatInfo.direct(contact: toContact)
|
let cInfo = ChatInfo.direct(contact: toContact)
|
||||||
@ -1304,7 +1409,7 @@ func refreshCallInvitations() throws {
|
|||||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||||
m.ntfCallInvitationAction = nil
|
m.ntfCallInvitationAction = nil
|
||||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
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)
|
activateCall(invitation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1317,6 +1422,7 @@ func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||||
|
if !callInvitation.user.showNotifications { return }
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
|
@ -214,8 +214,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
|
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
|
||||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
||||||
let update = cxCallUpdate(invitation: invitation)
|
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
let update = cxCallUpdate(invitation: invitation)
|
||||||
|
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
NtfManager.shared.notifyCallInvitation(invitation)
|
NtfManager.shared.notifyCallInvitation(invitation)
|
||||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||||
|
@ -29,7 +29,9 @@ struct UserPicker: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
ScrollViewReader { sp in
|
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) {
|
VStack(spacing: 0) {
|
||||||
ForEach(users) { u in
|
ForEach(users) { u in
|
||||||
userView(u)
|
userView(u)
|
||||||
@ -97,14 +99,18 @@ struct UserPicker: View {
|
|||||||
userPickerVisible.toggle()
|
userPickerVisible.toggle()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
do {
|
Task {
|
||||||
try changeActiveUser_(user.userId)
|
do {
|
||||||
userPickerVisible = false
|
try await changeActiveUserAsync_(user.userId, viewPwd: nil)
|
||||||
} catch {
|
await MainActor.run { userPickerVisible = false }
|
||||||
AlertManager.shared.showAlertMsg(
|
} catch {
|
||||||
title: "Error switching profile!",
|
await MainActor.run {
|
||||||
message: "Error: \(responseError(error))"
|
AlertManager.shared.showAlertMsg(
|
||||||
)
|
title: "Error switching profile!",
|
||||||
|
message: "Error: \(responseError(error))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, label: {
|
}, label: {
|
||||||
|
@ -73,11 +73,11 @@ struct DatabaseEncryptionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !initialRandomDBPassphrase && m.chatDbEncrypted == true {
|
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)
|
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
|
||||||
DatabaseKeyField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
|
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
|
||||||
|
|
||||||
settingsRow("lock.rotation") {
|
settingsRow("lock.rotation") {
|
||||||
Button("Update database passphrase") {
|
Button("Update database passphrase") {
|
||||||
@ -255,7 +255,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct DatabaseKeyField: View {
|
struct PassphraseField: View {
|
||||||
@Binding var key: String
|
@Binding var key: String
|
||||||
var placeholder: LocalizedStringKey
|
var placeholder: LocalizedStringKey
|
||||||
var valid: Bool
|
var valid: Bool
|
||||||
|
@ -66,7 +66,7 @@ struct DatabaseErrorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some 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 {
|
private func saveAndOpenButton() -> some View {
|
||||||
|
@ -100,7 +100,7 @@ struct TerminalView: View {
|
|||||||
func sendMessage() {
|
func sendMessage() {
|
||||||
let cmd = ChatCommand.string(composeState.message)
|
let cmd = ChatCommand.string(composeState.message)
|
||||||
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
|
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 {
|
DispatchQueue.main.async {
|
||||||
ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
|
ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
|
||||||
ChatModel.shared.addTerminalItem(.resp(.now, resp))
|
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
|
import SimpleXChat
|
||||||
|
|
||||||
struct PrivacySettings: View {
|
struct PrivacySettings: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
|
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
|
||||||
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
|
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@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_USER_INTERFACE_STYLE = "userInterfaceStyle"
|
||||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
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 DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||||
|
|
||||||
let appDefaults: [String: Any] = [
|
let appDefaults: [String: Any] = [
|
||||||
@ -62,7 +64,9 @@ let appDefaults: [String: Any] = [
|
|||||||
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
|
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
|
||||||
DEFAULT_USER_INTERFACE_STYLE: 0,
|
DEFAULT_USER_INTERFACE_STYLE: 0,
|
||||||
DEFAULT_CONNECT_VIA_LINK_TAB: "scan",
|
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 {
|
enum SimpleXLinkMode: String, Identifiable {
|
||||||
|
@ -9,19 +9,31 @@ import SimpleXChat
|
|||||||
struct UserProfilesView: View {
|
struct UserProfilesView: View {
|
||||||
@EnvironmentObject private var m: ChatModel
|
@EnvironmentObject private var m: ChatModel
|
||||||
@Environment(\.editMode) private var editMode
|
@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 showDeleteConfirmation = false
|
||||||
@State private var userToDelete: Int?
|
@State private var userToDelete: UserInfo?
|
||||||
@State private var alert: UserProfilesAlert?
|
@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 {
|
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 activateUserError(error: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
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 .activateUserError(err): return "activateUserError \(err)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
@ -41,45 +53,103 @@ struct UserProfilesView: View {
|
|||||||
|
|
||||||
private func userProfilesView() -> some View {
|
private func userProfilesView() -> some View {
|
||||||
List {
|
List {
|
||||||
|
if profileHidden {
|
||||||
|
Button {
|
||||||
|
withAnimation { profileHidden = false }
|
||||||
|
} label: {
|
||||||
|
Label("Enter password above to show!", systemImage: "lock.open")
|
||||||
|
}
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
ForEach(m.users) { u in
|
let users = filteredUsers()
|
||||||
|
ForEach(users) { u in
|
||||||
userView(u.user)
|
userView(u.user)
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
.onDelete { indexSet in
|
||||||
if let i = indexSet.first {
|
if let i = indexSet.first {
|
||||||
showDeleteConfirmation = true
|
if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) {
|
||||||
userToDelete = i
|
showDeleteConfirmation = true
|
||||||
|
userToDelete = users[i]
|
||||||
|
} else {
|
||||||
|
alert = .cantDeleteLastUser
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink {
|
if searchTextOrPassword == "" {
|
||||||
CreateProfile()
|
NavigationLink {
|
||||||
} label: {
|
CreateProfile()
|
||||||
Label("Add profile", systemImage: "plus")
|
} label: {
|
||||||
|
Label("Add profile", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Your chat profiles are stored locally, only on your device.")
|
Text("Tap to activate profile.")
|
||||||
|
.font(.body)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar { EditButton() }
|
.toolbar { EditButton() }
|
||||||
.navigationTitle("Your chat profiles")
|
.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) {
|
.confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
|
||||||
deleteModeButton("Profile and server connections", true)
|
deleteModeButton("Profile and server connections", true)
|
||||||
deleteModeButton("Local profile data only", false)
|
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
|
.alert(item: $alert) { alert in
|
||||||
switch alert {
|
switch alert {
|
||||||
case let .deleteUser(index, delSMPQueues):
|
case let .deleteUser(userInfo, delSMPQueues):
|
||||||
return Alert(
|
return Alert(
|
||||||
title: Text("Delete user profile?"),
|
title: Text("Delete user profile?"),
|
||||||
message: Text("All chats and messages will be deleted - this cannot be undone!"),
|
message: Text("All chats and messages will be deleted - this cannot be undone!"),
|
||||||
primaryButton: .destructive(Text("Delete")) {
|
primaryButton: .destructive(Text("Delete")) {
|
||||||
removeUser(index, delSMPQueues)
|
Task { await removeUser(userInfo, delSMPQueues) }
|
||||||
},
|
},
|
||||||
secondaryButton: .cancel()
|
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):
|
case let .activateUserError(error: err):
|
||||||
return Alert(
|
return Alert(
|
||||||
title: Text("Error switching profile!"),
|
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 {
|
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
|
||||||
Button(title, role: .destructive) {
|
Button(title, role: .destructive) {
|
||||||
if let i = userToDelete {
|
if let userInfo = userToDelete {
|
||||||
alert = .deleteUser(index: i, delSMPQueues: delSMPQueues)
|
alert = .deleteUser(userInfo: userInfo, delSMPQueues: delSMPQueues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeUser(_ index: Int, _ delSMPQueues: Bool) {
|
private func removeUser(_ userInfo: UserInfo, _ delSMPQueues: Bool) async {
|
||||||
if index >= m.users.count { return }
|
|
||||||
do {
|
do {
|
||||||
let u = m.users[index].user
|
let u = userInfo.user
|
||||||
if u.activeUser {
|
if u.activeUser {
|
||||||
if let newActive = m.users.first(where: { !$0.user.activeUser }) {
|
if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) {
|
||||||
try changeActiveUser_(newActive.user.userId)
|
try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil)
|
||||||
try deleteUser(u.userId)
|
try await deleteUser(u)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try deleteUser(u.userId)
|
try await deleteUser(u)
|
||||||
}
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
let a = getErrorAlert(error, "Error deleting user profile")
|
let a = getErrorAlert(error, "Error deleting user profile")
|
||||||
alert = .error(title: a.title, error: a.message)
|
alert = .error(title: a.title, error: a.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteUser(_ userId: Int64) throws {
|
func deleteUser(_ user: User) async throws {
|
||||||
try apiDeleteUser(userId, delSMPQueues)
|
try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: userViewPassword(user))
|
||||||
m.users.remove(at: index)
|
await MainActor.run { withAnimation { m.removeUser(user) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func userView(_ user: User) -> some View {
|
private func userView(_ user: User) -> some View {
|
||||||
Button {
|
Button {
|
||||||
do {
|
Task {
|
||||||
try changeActiveUser_(user.userId)
|
do {
|
||||||
} catch {
|
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
|
||||||
alert = .activateUserError(error: responseError(error))
|
} catch {
|
||||||
|
await MainActor.run { alert = .activateUserError(error: responseError(error)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
@ -137,14 +230,75 @@ struct UserProfilesView: View {
|
|||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
Text(user.chatViewName)
|
Text(user.chatViewName)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "checkmark")
|
if user.activeUser {
|
||||||
.foregroundColor(user.activeUser ? .primary : .clear)
|
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)
|
.disabled(user.activeUser)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.deleteDisabled(m.users.count <= 1)
|
.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 {
|
struct UserProfilesView_Previews: PreviewProvider {
|
||||||
|
@ -16,7 +16,7 @@ let logger = Logger()
|
|||||||
|
|
||||||
let suspendingDelay: UInt64 = 2_000_000_000
|
let suspendingDelay: UInt64 = 2_000_000_000
|
||||||
|
|
||||||
typealias NtfStream = AsyncStream<UNMutableNotificationContent>
|
typealias NtfStream = AsyncStream<NSENotification>
|
||||||
|
|
||||||
actor PendingNtfs {
|
actor PendingNtfs {
|
||||||
static let shared = 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)")
|
logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)")
|
||||||
if let s = ntfStreams[id] {
|
if let s = ntfStreams[id] {
|
||||||
logger.debug("PendingNtfs.readStream: has stream")
|
logger.debug("PendingNtfs.readStream: has stream")
|
||||||
var rcvCount = max(1, msgCount)
|
var rcvCount = max(1, msgCount)
|
||||||
for await ntf in s {
|
for await ntf in s {
|
||||||
nse.setBestAttemptNtf(ntf)
|
nse.setBestAttemptNtf(showNotifications ? ntf : .empty)
|
||||||
rcvCount -= 1
|
rcvCount -= 1
|
||||||
if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break }
|
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)")
|
logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)")
|
||||||
if let cont = ntfConts[id] {
|
if let cont = ntfConts[id] {
|
||||||
logger.debug("PendingNtfs.writeStream: writing ntf")
|
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 {
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||||
var bestAttemptNtf: UNMutableNotificationContent?
|
var bestAttemptNtf: NSENotification?
|
||||||
var badgeCount: Int = 0
|
var badgeCount: Int = 0
|
||||||
|
|
||||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
logger.debug("NotificationService.didReceive")
|
logger.debug("NotificationService.didReceive")
|
||||||
setBestAttemptNtf(request.content.mutableCopy() as? UNMutableNotificationContent)
|
if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent {
|
||||||
|
setBestAttemptNtf(ntf)
|
||||||
|
}
|
||||||
self.contentHandler = contentHandler
|
self.contentHandler = contentHandler
|
||||||
registerGroupDefaults()
|
registerGroupDefaults()
|
||||||
let appState = appStateGroupDefault.get()
|
let appState = appStateGroupDefault.get()
|
||||||
@ -112,12 +126,16 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
|
let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
|
||||||
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
|
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
|
||||||
if let connEntity = ntfMsgInfo.connEntity {
|
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 {
|
if let id = connEntity.id {
|
||||||
Task {
|
Task {
|
||||||
logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)")
|
logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)")
|
||||||
await PendingNtfs.shared.createStream(id)
|
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()
|
deliverBestAttemptNtf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -140,16 +158,40 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
ntfBadgeCountGroupDefault.set(badgeCount)
|
ntfBadgeCountGroupDefault.set(badgeCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent?) {
|
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
|
||||||
|
setBestAttemptNtf(.nse(notification: ntf))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBestAttemptNtf(_ ntf: NSENotification) {
|
||||||
logger.debug("NotificationService.setBestAttemptNtf")
|
logger.debug("NotificationService.setBestAttemptNtf")
|
||||||
bestAttemptNtf = ntf
|
if case let .nse(notification) = ntf {
|
||||||
bestAttemptNtf?.badge = badgeCount as NSNumber
|
notification.badge = badgeCount as NSNumber
|
||||||
|
bestAttemptNtf = .nse(notification: notification)
|
||||||
|
} else {
|
||||||
|
bestAttemptNtf = ntf
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deliverBestAttemptNtf() {
|
private func deliverBestAttemptNtf() {
|
||||||
logger.debug("NotificationService.deliverBestAttemptNtf")
|
logger.debug("NotificationService.deliverBestAttemptNtf")
|
||||||
if let handler = contentHandler, let content = bestAttemptNtf {
|
if let handler = contentHandler, let ntf = bestAttemptNtf {
|
||||||
handler(content)
|
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
|
bestAttemptNtf = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -211,15 +253,15 @@ func chatRecvMsg() async -> ChatResponse? {
|
|||||||
private let isInChina = SKStorefront().countryCode == "CHN"
|
private let isInChina = SKStorefront().countryCode == "CHN"
|
||||||
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
|
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)")
|
logger.debug("NotificationService processReceivedMsg: \(res.responseType)")
|
||||||
switch res {
|
switch res {
|
||||||
case let .contactConnected(user, contact, _):
|
case let .contactConnected(user, contact, _):
|
||||||
return (contact.id, createContactConnectedNtf(user, contact))
|
return (contact.id, .nse(notification: createContactConnectedNtf(user, contact)))
|
||||||
// case let .contactConnecting(contact):
|
// case let .contactConnecting(contact):
|
||||||
// TODO profile update
|
// TODO profile update
|
||||||
case let .receivedContactRequest(user, contactRequest):
|
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):
|
case let .newChatItem(user, aChatItem):
|
||||||
let cInfo = aChatItem.chatInfo
|
let cInfo = aChatItem.chatInfo
|
||||||
var cItem = aChatItem.chatItem
|
var cItem = aChatItem.chatItem
|
||||||
@ -240,23 +282,13 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification
|
|||||||
cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem
|
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):
|
case let .callInvitation(invitation):
|
||||||
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
|
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
|
||||||
if useCallKit() {
|
return (
|
||||||
do {
|
invitation.contact.id,
|
||||||
try await CXProvider.reportNewIncomingVoIPPushPayload([
|
useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation))
|
||||||
"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))
|
|
||||||
default:
|
default:
|
||||||
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)")
|
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)")
|
||||||
return nil
|
return nil
|
||||||
|
@ -109,6 +109,7 @@
|
|||||||
5CBD285C29575B8E00EC2CF4 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */; };
|
5CBD285C29575B8E00EC2CF4 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */; };
|
||||||
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; };
|
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; };
|
||||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.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 */; };
|
5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; };
|
||||||
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
|
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
|
||||||
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
@ -675,6 +677,7 @@
|
|||||||
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
|
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
|
||||||
5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */,
|
5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */,
|
||||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||||
|
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */,
|
||||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||||
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
|
5C93292E29239A170090FFF9 /* SMPServersView.swift */,
|
||||||
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
|
5C93293029239BED0090FFF9 /* SMPServerView.swift */,
|
||||||
@ -1028,6 +1031,7 @@
|
|||||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
|
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
|
||||||
5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */,
|
5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */,
|
||||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
|
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
|
||||||
|
5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */,
|
||||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
|
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
|
||||||
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
|
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
|
||||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
|
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
|
||||||
@ -1585,6 +1589,10 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"$(PROJECT_DIR)/Libraries",
|
||||||
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Libraries/ios",
|
"$(PROJECT_DIR)/Libraries/ios",
|
||||||
@ -1631,6 +1639,10 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"$(PROJECT_DIR)/Libraries",
|
||||||
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Libraries/ios",
|
"$(PROJECT_DIR)/Libraries/ios",
|
||||||
|
@ -151,6 +151,11 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
|||||||
let chat = try? parseChatData(jChat) {
|
let chat = try? parseChatData(jChat) {
|
||||||
return .apiChat(user: user, chat: chat)
|
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)
|
json = prettyJSON(j)
|
||||||
@ -185,12 +190,21 @@ func prettyJSON(_ obj: Any) -> String? {
|
|||||||
|
|
||||||
public func responseError(_ err: Error) -> String {
|
public func responseError(_ err: Error) -> String {
|
||||||
if let r = err as? ChatResponse {
|
if let r = err as? ChatResponse {
|
||||||
return String(describing: r)
|
switch r {
|
||||||
|
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
|
||||||
|
case let .chatError(_, chatError): return chatErrorString(chatError)
|
||||||
|
default: return String(describing: r)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return err.localizedDescription
|
return String(describing: err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatErrorString(_ err: ChatError) -> String {
|
||||||
|
if case let .invalidJSON(json) = err { return json }
|
||||||
|
return String(describing: err)
|
||||||
|
}
|
||||||
|
|
||||||
public enum DBMigrationResult: Decodable, Equatable {
|
public enum DBMigrationResult: Decodable, Equatable {
|
||||||
case ok
|
case ok
|
||||||
case errorNotADatabase(dbFile: String)
|
case errorNotADatabase(dbFile: String)
|
||||||
|
@ -16,8 +16,12 @@ public enum ChatCommand {
|
|||||||
case showActiveUser
|
case showActiveUser
|
||||||
case createActiveUser(profile: Profile)
|
case createActiveUser(profile: Profile)
|
||||||
case listUsers
|
case listUsers
|
||||||
case apiSetActiveUser(userId: Int64)
|
case apiSetActiveUser(userId: Int64, viewPwd: String?)
|
||||||
case apiDeleteUser(userId: Int64, delSMPQueues: Bool)
|
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 startChat(subscribe: Bool, expire: Bool)
|
||||||
case apiStopChat
|
case apiStopChat
|
||||||
case apiActivateChat
|
case apiActivateChat
|
||||||
@ -103,8 +107,12 @@ public enum ChatCommand {
|
|||||||
case .showActiveUser: return "/u"
|
case .showActiveUser: return "/u"
|
||||||
case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)"
|
case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)"
|
||||||
case .listUsers: return "/users"
|
case .listUsers: return "/users"
|
||||||
case let .apiSetActiveUser(userId): return "/_user \(userId)"
|
case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))"
|
||||||
case let .apiDeleteUser(userId, delSMPQueues): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))"
|
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 let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))"
|
||||||
case .apiStopChat: return "/_stop"
|
case .apiStopChat: return "/_stop"
|
||||||
case .apiActivateChat: return "/_app activate"
|
case .apiActivateChat: return "/_app activate"
|
||||||
@ -202,6 +210,10 @@ public enum ChatCommand {
|
|||||||
case .createActiveUser: return "createActiveUser"
|
case .createActiveUser: return "createActiveUser"
|
||||||
case .listUsers: return "listUsers"
|
case .listUsers: return "listUsers"
|
||||||
case .apiSetActiveUser: return "apiSetActiveUser"
|
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 .apiDeleteUser: return "apiDeleteUser"
|
||||||
case .startChat: return "startChat"
|
case .startChat: return "startChat"
|
||||||
case .apiStopChat: return "apiStopChat"
|
case .apiStopChat: return "apiStopChat"
|
||||||
@ -304,6 +316,18 @@ public enum ChatCommand {
|
|||||||
switch self {
|
switch self {
|
||||||
case let .apiStorageEncryption(cfg):
|
case let .apiStorageEncryption(cfg):
|
||||||
return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey)))
|
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
|
default: return self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -312,9 +336,21 @@ public enum ChatCommand {
|
|||||||
s == "" ? "" : "***"
|
s == "" ? "" : "***"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func obfuscate(_ s: String?) -> String? {
|
||||||
|
if let s = s {
|
||||||
|
return obfuscate(s)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func onOff(_ b: Bool) -> String {
|
private func onOff(_ b: Bool) -> String {
|
||||||
b ? "on" : "off"
|
b ? "on" : "off"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func maybePwd(_ pwd: String?) -> String {
|
||||||
|
pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct APIResponse: Decodable {
|
struct APIResponse: Decodable {
|
||||||
@ -348,6 +384,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case chatCleared(user: User, chatInfo: ChatInfo)
|
case chatCleared(user: User, chatInfo: ChatInfo)
|
||||||
case userProfileNoChange(user: User)
|
case userProfileNoChange(user: User)
|
||||||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile)
|
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile)
|
||||||
|
case userPrivacy(user: User)
|
||||||
case contactAliasUpdated(user: User, toContact: Contact)
|
case contactAliasUpdated(user: User, toContact: Contact)
|
||||||
case connectionAliasUpdated(user: User, toConnection: PendingContactConnection)
|
case connectionAliasUpdated(user: User, toConnection: PendingContactConnection)
|
||||||
case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
|
case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
|
||||||
@ -424,8 +461,8 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case contactConnectionDeleted(user: User, connection: PendingContactConnection)
|
case contactConnectionDeleted(user: User, connection: PendingContactConnection)
|
||||||
case versionInfo(versionInfo: CoreVersionInfo)
|
case versionInfo(versionInfo: CoreVersionInfo)
|
||||||
case cmdOk(user: User?)
|
case cmdOk(user: User?)
|
||||||
case chatCmdError(user: User?, chatError: ChatError)
|
case chatCmdError(user_: User?, chatError: ChatError)
|
||||||
case chatError(user: User?, chatError: ChatError)
|
case chatError(user_: User?, chatError: ChatError)
|
||||||
|
|
||||||
public var responseType: String {
|
public var responseType: String {
|
||||||
get {
|
get {
|
||||||
@ -456,6 +493,7 @@ public enum ChatResponse: Decodable, Error {
|
|||||||
case .chatCleared: return "chatCleared"
|
case .chatCleared: return "chatCleared"
|
||||||
case .userProfileNoChange: return "userProfileNoChange"
|
case .userProfileNoChange: return "userProfileNoChange"
|
||||||
case .userProfileUpdated: return "userProfileUpdated"
|
case .userProfileUpdated: return "userProfileUpdated"
|
||||||
|
case .userPrivacy: return "userPrivacy"
|
||||||
case .contactAliasUpdated: return "contactAliasUpdated"
|
case .contactAliasUpdated: return "contactAliasUpdated"
|
||||||
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
||||||
case .contactPrefsUpdated: return "contactPrefsUpdated"
|
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 let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
|
||||||
case .userProfileNoChange: return noDetails
|
case .userProfileNoChange: return noDetails
|
||||||
case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile))
|
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 .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact))
|
||||||
case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
|
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))")
|
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 {
|
public enum ChatPagination {
|
||||||
case last(count: Int)
|
case last(count: Int)
|
||||||
case after(chatItemId: Int64, count: Int)
|
case after(chatItemId: Int64, count: Int)
|
||||||
@ -1083,6 +1130,7 @@ public enum ChatError: Decodable {
|
|||||||
case errorAgent(agentError: AgentErrorType)
|
case errorAgent(agentError: AgentErrorType)
|
||||||
case errorStore(storeError: StoreError)
|
case errorStore(storeError: StoreError)
|
||||||
case errorDatabase(databaseError: DatabaseError)
|
case errorDatabase(databaseError: DatabaseError)
|
||||||
|
case invalidJSON(json: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ChatErrorType: Decodable {
|
public enum ChatErrorType: Decodable {
|
||||||
|
@ -22,18 +22,33 @@ public struct User: Decodable, NamedChat, Identifiable {
|
|||||||
public var image: String? { get { profile.image } }
|
public var image: String? { get { profile.image } }
|
||||||
public var localAlias: String { get { "" } }
|
public var localAlias: String { get { "" } }
|
||||||
|
|
||||||
|
public var showNtfs: Bool
|
||||||
|
public var viewPwdHash: UserPwdHash?
|
||||||
|
|
||||||
public var id: Int64 { userId }
|
public var id: Int64 { userId }
|
||||||
|
|
||||||
|
public var hidden: Bool { viewPwdHash != nil }
|
||||||
|
|
||||||
|
public var showNotifications: Bool {
|
||||||
|
activeUser || showNtfs
|
||||||
|
}
|
||||||
|
|
||||||
public static let sampleData = User(
|
public static let sampleData = User(
|
||||||
userId: 1,
|
userId: 1,
|
||||||
userContactId: 1,
|
userContactId: 1,
|
||||||
localDisplayName: "alice",
|
localDisplayName: "alice",
|
||||||
profile: LocalProfile.sampleData,
|
profile: LocalProfile.sampleData,
|
||||||
fullPreferences: FullPreferences.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 struct UserInfo: Decodable, Identifiable {
|
||||||
public var user: User
|
public var user: User
|
||||||
public var unreadCount: Int
|
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_recv_msg_wait(chat_ctrl ctl, int wait);
|
||||||
extern char *chat_parse_markdown(char *str);
|
extern char *chat_parse_markdown(char *str);
|
||||||
extern char *chat_parse_server(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_encrypt_media(char *key, char *frame, int len);
|
||||||
extern char *chat_decrypt_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.M20230206_item_deleted_by_group_member_id
|
||||||
Simplex.Chat.Migrations.M20230303_group_link_role
|
Simplex.Chat.Migrations.M20230303_group_link_role
|
||||||
Simplex.Chat.Migrations.M20230304_file_description
|
Simplex.Chat.Migrations.M20230304_file_description
|
||||||
|
Simplex.Chat.Migrations.M20230317_hidden_profiles
|
||||||
Simplex.Chat.Mobile
|
Simplex.Chat.Mobile
|
||||||
Simplex.Chat.Mobile.WebRTC
|
Simplex.Chat.Mobile.WebRTC
|
||||||
Simplex.Chat.Options
|
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.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
|
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
|
||||||
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
|
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
|
||||||
import Data.Time.Clock.System (SystemTime, systemToUTCTime)
|
import Data.Time.Clock.System (SystemTime, systemToUTCTime)
|
||||||
@ -195,7 +196,7 @@ activeAgentServers ChatConfig {defaultServers} srvSel =
|
|||||||
. map (\ServerCfg {server} -> server)
|
. map (\ServerCfg {server} -> server)
|
||||||
. filter (\ServerCfg {enabled} -> enabled)
|
. 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
|
startChatController subConns enableExpireCIs = do
|
||||||
asks smpAgent >>= resumeAgentClient
|
asks smpAgent >>= resumeAgentClient
|
||||||
users <- fromRight [] <$> runExceptT (withStore' getUsers)
|
users <- fromRight [] <$> runExceptT (withStore' getUsers)
|
||||||
@ -227,7 +228,7 @@ startChatController subConns enableExpireCIs = do
|
|||||||
startExpireCIThread user
|
startExpireCIThread user
|
||||||
setExpireCIFlag user True
|
setExpireCIFlag user True
|
||||||
|
|
||||||
subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m ()
|
subscribeUsers :: forall m. ChatMonad' m => [User] -> m ()
|
||||||
subscribeUsers users = do
|
subscribeUsers users = do
|
||||||
let (us, us') = partition activeUser users
|
let (us, us') = partition activeUser users
|
||||||
subscribe us
|
subscribe us
|
||||||
@ -236,7 +237,7 @@ subscribeUsers users = do
|
|||||||
subscribe :: [User] -> m ()
|
subscribe :: [User] -> m ()
|
||||||
subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections
|
subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections
|
||||||
|
|
||||||
restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
restoreCalls :: ChatMonad' m => m ()
|
||||||
restoreCalls = do
|
restoreCalls = do
|
||||||
savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db)
|
savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db)
|
||||||
let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls
|
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
|
mapM_ hClose fs
|
||||||
atomically $ writeTVar files M.empty
|
atomically $ writeTVar files M.empty
|
||||||
|
|
||||||
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse
|
execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse
|
||||||
execChatCommand s = do
|
execChatCommand s = do
|
||||||
u <- readTVarIO =<< asks currentUser
|
u <- readTVarIO =<< asks currentUser
|
||||||
case parseChatCommand s of
|
case parseChatCommand s of
|
||||||
@ -308,27 +309,61 @@ processChatCommand = \case
|
|||||||
DefaultAgentServers {smp} <- asks $ defaultServers . config
|
DefaultAgentServers {smp} <- asks $ defaultServers . config
|
||||||
pure (smp, [])
|
pure (smp, [])
|
||||||
ListUsers -> CRUsersList <$> withStore' getUsersInfo
|
ListUsers -> CRUsersList <$> withStore' getUsersInfo
|
||||||
APISetActiveUser userId -> do
|
APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do
|
||||||
u <- asks currentUser
|
user' <- privateGetUser userId'
|
||||||
user <- withStore $ \db -> getSetActiveUser db userId
|
validateUserPassword user user' viewPwd_
|
||||||
|
withStore' $ \db -> setActiveUser db userId'
|
||||||
setActive ActiveNone
|
setActive ActiveNone
|
||||||
atomically . writeTVar u $ Just user
|
let user'' = user' {activeUser = True}
|
||||||
pure $ CRActiveUser user
|
asks currentUser >>= atomically . (`writeTVar` Just user'')
|
||||||
SetActiveUser uName -> withUserName uName APISetActiveUser
|
pure $ CRActiveUser user''
|
||||||
APIDeleteUser userId delSMPQueues -> do
|
SetActiveUser uName viewPwd_ -> do
|
||||||
user <- withStore (`getUser` userId)
|
tryError (withStore (`getUserIdByName` uName)) >>= \case
|
||||||
when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId)
|
Left _ -> throwChatError CEUserUnknown
|
||||||
users <- withStore' getUsers
|
Right userId -> processChatCommand $ APISetActiveUser userId viewPwd_
|
||||||
-- shouldn't happen - last user should be active
|
APIHideUser userId' (UserPwd viewPwd) -> withUser $ \_ -> do
|
||||||
when (length users == 1) $ throwChatError (CECantDeleteLastUser userId)
|
user' <- privateGetUser userId'
|
||||||
filesInfo <- withStore' (`getUserFileInfo` user)
|
case viewPwdHash user' of
|
||||||
withChatLock "deleteUser" . procCmd $ do
|
Just _ -> throwChatError $ CEUserAlreadyHidden userId'
|
||||||
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
|
_ -> do
|
||||||
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
|
when (T.null viewPwd) $ throwChatError $ CEEmptyUserPassword userId'
|
||||||
withStore' (`deleteUserRecord` user)
|
users <- withStore' getUsers
|
||||||
setActive ActiveNone
|
unless (length (filter (isNothing . viewPwdHash) users) > 1) $ throwChatError $ CECantHideLastUser userId'
|
||||||
ok_
|
viewPwdHash' <- hashPassword
|
||||||
DeleteUser uName delSMPQueues -> withUserName uName $ \uId -> APIDeleteUser uId delSMPQueues
|
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' $ \_ ->
|
StartChat subConns enableExpireCIs -> withUser' $ \_ ->
|
||||||
asks agentAsync >>= readTVarIO >>= \case
|
asks agentAsync >>= readTVarIO >>= \case
|
||||||
Just _ -> pure CRChatRunning
|
Just _ -> pure CRChatRunning
|
||||||
@ -708,7 +743,7 @@ processChatCommand = \case
|
|||||||
assertDirectAllowed user MDSnd ct XCallInv_
|
assertDirectAllowed user MDSnd ct XCallInv_
|
||||||
calls <- asks currentCalls
|
calls <- asks currentCalls
|
||||||
withChatLock "sendCallInvitation" $ do
|
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
|
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
|
||||||
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
|
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
|
||||||
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
|
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
|
||||||
@ -1210,7 +1245,7 @@ processChatCommand = \case
|
|||||||
gInfo <- withStore $ \db -> getGroupInfo db user groupId
|
gInfo <- withStore $ \db -> getGroupInfo db user groupId
|
||||||
assertUserGroupRole gInfo GRAdmin
|
assertUserGroupRole gInfo GRAdmin
|
||||||
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
|
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
|
||||||
groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
|
||||||
let crClientData = encodeJSON $ CRDataGroup groupLinkId
|
let crClientData = encodeJSON $ CRDataGroup groupLinkId
|
||||||
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
|
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
|
||||||
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
|
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
|
||||||
@ -1426,7 +1461,7 @@ processChatCommand = \case
|
|||||||
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
|
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
|
||||||
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
|
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
|
||||||
(_, xContactId_) -> procCmd $ do
|
(_, xContactId_) -> procCmd $ do
|
||||||
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
let randomXContactId = XContactId <$> drgRandomBytes 16
|
||||||
xContactId <- maybe randomXContactId pure xContactId_
|
xContactId <- maybe randomXContactId pure xContactId_
|
||||||
-- [incognito] generate profile to send
|
-- [incognito] generate profile to send
|
||||||
-- if user makes a contact request using main profile, then turns on incognito mode and repeats the request,
|
-- 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
|
<$> if live
|
||||||
then pure Nothing
|
then pure Nothing
|
||||||
else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime
|
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 :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m ()
|
||||||
assertDirectAllowed user dir ct event =
|
assertDirectAllowed user dir ct event =
|
||||||
@ -1600,7 +1671,7 @@ assertDirectAllowed user dir ct event =
|
|||||||
XCallInv_ -> False
|
XCallInv_ -> False
|
||||||
_ -> True
|
_ -> True
|
||||||
|
|
||||||
startExpireCIThread :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
|
startExpireCIThread :: forall m. ChatMonad' m => User -> m ()
|
||||||
startExpireCIThread user@User {userId} = do
|
startExpireCIThread user@User {userId} = do
|
||||||
expireThreads <- asks expireCIThreads
|
expireThreads <- asks expireCIThreads
|
||||||
atomically (TM.lookup userId expireThreads) >>= \case
|
atomically (TM.lookup userId expireThreads) >>= \case
|
||||||
@ -1619,12 +1690,12 @@ startExpireCIThread user@User {userId} = do
|
|||||||
forM_ ttl $ \t -> expireChatItems user t False
|
forM_ ttl $ \t -> expireChatItems user t False
|
||||||
threadDelay interval
|
threadDelay interval
|
||||||
|
|
||||||
setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m ()
|
setExpireCIFlag :: ChatMonad' m => User -> Bool -> m ()
|
||||||
setExpireCIFlag User {userId} b = do
|
setExpireCIFlag User {userId} b = do
|
||||||
expireFlags <- asks expireCIFlags
|
expireFlags <- asks expireCIFlags
|
||||||
atomically $ TM.insert userId b expireFlags
|
atomically $ TM.insert userId b expireFlags
|
||||||
|
|
||||||
setAllExpireCIFlags :: (MonadUnliftIO m, MonadReader ChatController m) => Bool -> m ()
|
setAllExpireCIFlags :: ChatMonad' m => Bool -> m ()
|
||||||
setAllExpireCIFlags b = do
|
setAllExpireCIFlags b = do
|
||||||
expireFlags <- asks expireCIFlags
|
expireFlags <- asks expireCIFlags
|
||||||
atomically $ do
|
atomically $ do
|
||||||
@ -1841,7 +1912,7 @@ deleteGroupLink_ user gInfo conn = do
|
|||||||
deleteAgentConnectionAsync user $ aConnId conn
|
deleteAgentConnectionAsync user $ aConnId conn
|
||||||
withStore' $ \db -> deleteGroupLink db user gInfo
|
withStore' $ \db -> deleteGroupLink db user gInfo
|
||||||
|
|
||||||
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
agentSubscriber :: ChatMonad' m => m ()
|
||||||
agentSubscriber = do
|
agentSubscriber = do
|
||||||
q <- asks $ subQ . smpAgent
|
q <- asks $ subQ . smpAgent
|
||||||
l <- asks chatLock
|
l <- asks chatLock
|
||||||
@ -2104,7 +2175,7 @@ processAgentMessageConn user _ agentConnId END =
|
|||||||
withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case
|
withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case
|
||||||
RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do
|
RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do
|
||||||
toView $ CRContactAnotherClient user ct
|
toView $ CRContactAnotherClient user ct
|
||||||
showToast (c <> "> ") "connected to another client"
|
whenUserNtfs user $ showToast (c <> "> ") "connected to another client"
|
||||||
unsetActive $ ActiveC c
|
unsetActive $ ActiveC c
|
||||||
entity -> toView $ CRSubscriptionEnd user entity
|
entity -> toView $ CRSubscriptionEnd user entity
|
||||||
processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||||
@ -2237,8 +2308,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
|
||||||
toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile)
|
toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile)
|
||||||
when (directOrUsed ct) $ createFeatureEnabledItems ct
|
when (directOrUsed ct) $ createFeatureEnabledItems ct
|
||||||
setActive $ ActiveC c
|
whenUserNtfs user $ do
|
||||||
showToast (c <> "> ") "connected"
|
setActive $ ActiveC c
|
||||||
|
showToast (c <> "> ") "connected"
|
||||||
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
|
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
|
||||||
forM_ viaUserContactLink $ \userContactLinkId ->
|
forM_ viaUserContactLink $ \userContactLinkId ->
|
||||||
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
|
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
|
||||||
@ -2368,13 +2440,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
let GroupInfo {groupProfile = GroupProfile {description}} = gInfo
|
let GroupInfo {groupProfile = GroupProfile {description}} = gInfo
|
||||||
memberConnectedChatItem gInfo m
|
memberConnectedChatItem gInfo m
|
||||||
forM_ description $ groupDescriptionChatItem gInfo m
|
forM_ description $ groupDescriptionChatItem gInfo m
|
||||||
setActive $ ActiveG gName
|
whenUserNtfs user $ do
|
||||||
showToast ("#" <> gName) "you are connected to group"
|
setActive $ ActiveG gName
|
||||||
|
showToast ("#" <> gName) "you are connected to group"
|
||||||
GCInviteeMember -> do
|
GCInviteeMember -> do
|
||||||
memberConnectedChatItem gInfo m
|
memberConnectedChatItem gInfo m
|
||||||
toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected}
|
toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected}
|
||||||
setActive $ ActiveG gName
|
whenGroupNtfs user gInfo $ do
|
||||||
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
|
setActive $ ActiveG gName
|
||||||
|
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
|
||||||
intros <- withStore' $ \db -> createIntroductions db members m
|
intros <- withStore' $ \db -> createIntroductions db members m
|
||||||
void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m
|
void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m
|
||||||
forM_ intros $ \intro ->
|
forM_ intros $ \intro ->
|
||||||
@ -2622,7 +2696,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
toView $ CRAcceptingGroupJoinRequest user gInfo ct
|
toView $ CRAcceptingGroupJoinRequest user gInfo ct
|
||||||
_ -> do
|
_ -> do
|
||||||
toView $ CRReceivedContactRequest user cReq
|
toView $ CRReceivedContactRequest user cReq
|
||||||
showToast (localDisplayName <> "> ") "wants to connect to you"
|
whenUserNtfs user $
|
||||||
|
showToast (localDisplayName <> "> ") "wants to connect to you"
|
||||||
_ -> pure ()
|
_ -> pure ()
|
||||||
|
|
||||||
incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m ()
|
incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m ()
|
||||||
@ -2703,8 +2778,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
memberConnectedChatItem gInfo m
|
memberConnectedChatItem gInfo m
|
||||||
toView $ CRConnectedToGroupMember user gInfo m
|
toView $ CRConnectedToGroupMember user gInfo m
|
||||||
let g = groupName' gInfo
|
let g = groupName' gInfo
|
||||||
setActive $ ActiveG g
|
whenGroupNtfs user gInfo $ do
|
||||||
showToast ("#" <> g) $ "member " <> c <> " is connected"
|
setActive $ ActiveG g
|
||||||
|
showToast ("#" <> g) $ "member " <> c <> " is connected"
|
||||||
|
|
||||||
probeMatchingContacts :: Contact -> Bool -> m ()
|
probeMatchingContacts :: Contact -> Bool -> m ()
|
||||||
probeMatchingContacts ct connectedIncognito = do
|
probeMatchingContacts ct connectedIncognito = do
|
||||||
@ -2730,7 +2806,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
messageError = toView . CRMessageError user "error"
|
messageError = toView . CRMessageError user "error"
|
||||||
|
|
||||||
newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
|
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
|
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
|
||||||
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
||||||
let ExtMsgContent content fileInvitation_ _ _ = mcExtMsgContent mc
|
let ExtMsgContent content fileInvitation_ _ _ = mcExtMsgContent mc
|
||||||
@ -2744,7 +2820,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
live = fromMaybe False live_
|
live = fromMaybe False live_
|
||||||
ciFile_ <- processFileInvitation fileInvitation_ content $ \db -> createRcvFileTransfer db userId ct
|
ciFile_ <- processFileInvitation fileInvitation_ content $ \db -> createRcvFileTransfer db userId ct
|
||||||
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
|
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
|
||||||
when (enableNtfs chatSettings) $ do
|
whenContactNtfs user ct $ do
|
||||||
showMsgToast (c <> "> ") content formattedText
|
showMsgToast (c <> "> ") content formattedText
|
||||||
setActive $ ActiveC c
|
setActive $ ActiveC c
|
||||||
where
|
where
|
||||||
@ -2811,7 +2887,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
|
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
|
||||||
|
|
||||||
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
|
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
|
let (ExtMsgContent content fInv_ _ _) = mcExtMsgContent mc
|
||||||
if isVoice content && not (groupFeatureAllowed SGFVoice gInfo)
|
if isVoice content && not (groupFeatureAllowed SGFVoice gInfo)
|
||||||
then void $ newChatItem (CIRcvGroupFeatureRejected GFVoice) Nothing Nothing False
|
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
|
ciFile_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m
|
||||||
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
|
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
|
||||||
let g = groupName' gInfo
|
let g = groupName' gInfo
|
||||||
when (enableNtfs chatSettings) $ do
|
whenGroupNtfs user gInfo $ do
|
||||||
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
|
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
|
||||||
setActive $ ActiveG g
|
setActive $ ActiveG g
|
||||||
where
|
where
|
||||||
@ -2896,8 +2972,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation}
|
let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation}
|
||||||
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
|
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
|
||||||
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
||||||
showToast (c <> "> ") "wants to send a file"
|
whenContactNtfs user ct $ do
|
||||||
setActive $ ActiveC c
|
showToast (c <> "> ") "wants to send a file"
|
||||||
|
setActive $ ActiveC c
|
||||||
|
|
||||||
-- TODO remove once XFile is discontinued
|
-- TODO remove once XFile is discontinued
|
||||||
processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
|
processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
|
||||||
@ -2909,8 +2986,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
|
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
|
||||||
groupMsgToView gInfo m ci msgMeta
|
groupMsgToView gInfo m ci msgMeta
|
||||||
let g = groupName' gInfo
|
let g = groupName' gInfo
|
||||||
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
|
whenGroupNtfs user gInfo $ do
|
||||||
setActive $ ActiveG g
|
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
|
||||||
|
setActive $ ActiveG g
|
||||||
|
|
||||||
receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode)
|
receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode)
|
||||||
receiveInlineMode FileInvitation {fileSize, fileInline} mc_ chSize = case fileInline of
|
receiveInlineMode FileInvitation {fileSize, fileInline} mc_ chSize = case fileInline of
|
||||||
@ -3041,7 +3119,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci)
|
toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci)
|
||||||
|
|
||||||
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
|
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
|
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
||||||
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
|
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
|
||||||
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
|
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
|
||||||
@ -3061,7 +3141,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
|||||||
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
|
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
|
||||||
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
||||||
toView $ CRReceivedGroupInvitation user gInfo ct memRole
|
toView $ CRReceivedGroupInvitation user gInfo ct memRole
|
||||||
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
|
whenContactNtfs user ct $
|
||||||
|
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
|
||||||
where
|
where
|
||||||
sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool
|
sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool
|
||||||
sameGroupLinkId (Just gli) (Just gli') = gli == gli'
|
sameGroupLinkId (Just gli) (Just gli') = gli == gli'
|
||||||
@ -3888,17 +3969,26 @@ getCreateActiveUser st = do
|
|||||||
getWithPrompt :: String -> IO String
|
getWithPrompt :: String -> IO String
|
||||||
getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine
|
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_
|
showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_
|
||||||
where
|
where
|
||||||
hideSecret :: FormattedText -> Text
|
hideSecret :: FormattedText -> Text
|
||||||
hideSecret FormattedText {format = Just Secret} = "..."
|
hideSecret FormattedText {format = Just Secret} = "..."
|
||||||
hideSecret FormattedText {text} = text
|
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
|
showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ
|
||||||
|
|
||||||
notificationSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
notificationSubscriber :: ChatMonad' m => m ()
|
||||||
notificationSubscriber = do
|
notificationSubscriber = do
|
||||||
ChatController {notifyQ, sendNotification} <- ask
|
ChatController {notifyQ, sendNotification} <- ask
|
||||||
forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification
|
forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification
|
||||||
@ -3958,8 +4048,8 @@ withStoreCtx ctx_ action = do
|
|||||||
chatCommandP :: Parser ChatCommand
|
chatCommandP :: Parser ChatCommand
|
||||||
chatCommandP =
|
chatCommandP =
|
||||||
choice
|
choice
|
||||||
[ "/mute " *> ((`ShowMessages` False) <$> chatNameP'),
|
[ "/mute " *> ((`ShowMessages` False) <$> chatNameP),
|
||||||
"/unmute " *> ((`ShowMessages` True) <$> chatNameP'),
|
"/unmute " *> ((`ShowMessages` True) <$> chatNameP),
|
||||||
"/create user"
|
"/create user"
|
||||||
*> ( do
|
*> ( do
|
||||||
sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False
|
sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False
|
||||||
@ -3967,10 +4057,18 @@ chatCommandP =
|
|||||||
pure $ CreateActiveUser uProfile sameSmp
|
pure $ CreateActiveUser uProfile sameSmp
|
||||||
),
|
),
|
||||||
"/users" $> ListUsers,
|
"/users" $> ListUsers,
|
||||||
"/_user " *> (APISetActiveUser <$> A.decimal),
|
"/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)),
|
||||||
("/user " <|> "/u ") *> (SetActiveUser <$> displayName),
|
("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)),
|
||||||
"/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP),
|
"/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP),
|
||||||
"/delete user " *> (DeleteUser <$> displayName <*> pure True),
|
"/_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,
|
("/user" <|> "/u") $> ShowActiveUser,
|
||||||
"/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP),
|
"/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP),
|
||||||
"/_start" $> StartChat True True,
|
"/_start" $> StartChat True True,
|
||||||
@ -4199,6 +4297,7 @@ chatCommandP =
|
|||||||
n <- (A.space *> A.takeByteString) <|> pure ""
|
n <- (A.space *> A.takeByteString) <|> pure ""
|
||||||
pure $ if B.null n then name else safeDecodeUtf8 n
|
pure $ if B.null n then name else safeDecodeUtf8 n
|
||||||
textP = safeDecodeUtf8 <$> A.takeByteString
|
textP = safeDecodeUtf8 <$> A.takeByteString
|
||||||
|
pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' '))
|
||||||
msgTextP = jsonP <|> textP
|
msgTextP = jsonP <|> textP
|
||||||
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
||||||
filePath = stringP
|
filePath = stringP
|
||||||
|
@ -19,7 +19,7 @@ import Control.Monad.Except
|
|||||||
import Control.Monad.IO.Unlift
|
import Control.Monad.IO.Unlift
|
||||||
import Control.Monad.Reader
|
import Control.Monad.Reader
|
||||||
import Crypto.Random (ChaChaDRG)
|
import Crypto.Random (ChaChaDRG)
|
||||||
import Data.Aeson (FromJSON, ToJSON)
|
import Data.Aeson (FromJSON (..), ToJSON (..))
|
||||||
import qualified Data.Aeson as J
|
import qualified Data.Aeson as J
|
||||||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||||
import Data.ByteString.Char8 (ByteString)
|
import Data.ByteString.Char8 (ByteString)
|
||||||
@ -182,10 +182,18 @@ data ChatCommand
|
|||||||
= ShowActiveUser
|
= ShowActiveUser
|
||||||
| CreateActiveUser Profile Bool
|
| CreateActiveUser Profile Bool
|
||||||
| ListUsers
|
| ListUsers
|
||||||
| APISetActiveUser UserId
|
| APISetActiveUser UserId (Maybe UserPwd)
|
||||||
| SetActiveUser UserName
|
| SetActiveUser UserName (Maybe UserPwd)
|
||||||
| APIDeleteUser UserId Bool
|
| APIHideUser UserId UserPwd
|
||||||
| DeleteUser UserName Bool
|
| 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}
|
| StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool}
|
||||||
| APIStopChat
|
| APIStopChat
|
||||||
| APIActivateChat
|
| APIActivateChat
|
||||||
@ -406,6 +414,7 @@ data ChatResponse
|
|||||||
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
|
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
|
||||||
| CRUserProfile {user :: User, profile :: Profile}
|
| CRUserProfile {user :: User, profile :: Profile}
|
||||||
| CRUserProfileNoChange {user :: User}
|
| CRUserProfileNoChange {user :: User}
|
||||||
|
| CRUserPrivacy {user :: User}
|
||||||
| CRVersionInfo {versionInfo :: CoreVersionInfo}
|
| CRVersionInfo {versionInfo :: CoreVersionInfo}
|
||||||
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation}
|
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation}
|
||||||
| CRSentConfirmation {user :: User}
|
| CRSentConfirmation {user :: User}
|
||||||
@ -522,6 +531,16 @@ instance ToJSON ChatResponse where
|
|||||||
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
|
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
|
||||||
toEncoding = J.genericToEncoding . 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
|
newtype AgentQueueId = AgentQueueId QueueId
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
@ -683,11 +702,17 @@ instance ToJSON ChatError where
|
|||||||
data ChatErrorType
|
data ChatErrorType
|
||||||
= CENoActiveUser
|
= CENoActiveUser
|
||||||
| CENoConnectionUser {agentConnId :: AgentConnId}
|
| CENoConnectionUser {agentConnId :: AgentConnId}
|
||||||
|
| CEUserUnknown
|
||||||
| CEActiveUserExists -- TODO delete
|
| CEActiveUserExists -- TODO delete
|
||||||
| CEUserExists {contactName :: ContactName}
|
| CEUserExists {contactName :: ContactName}
|
||||||
| CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId}
|
| CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId}
|
||||||
| CECantDeleteActiveUser {userId :: UserId}
|
| CECantDeleteActiveUser {userId :: UserId}
|
||||||
| CECantDeleteLastUser {userId :: UserId}
|
| CECantDeleteLastUser {userId :: UserId}
|
||||||
|
| CECantHideLastUser {userId :: UserId}
|
||||||
|
| CECantUnmuteHiddenUser {userId :: UserId}
|
||||||
|
| CEEmptyUserPassword {userId :: UserId}
|
||||||
|
| CEUserAlreadyHidden {userId :: UserId}
|
||||||
|
| CEUserNotHidden {userId :: UserId}
|
||||||
| CEChatNotStarted
|
| CEChatNotStarted
|
||||||
| CEChatNotStopped
|
| CEChatNotStopped
|
||||||
| CEChatStoreChanged
|
| CEChatStoreChanged
|
||||||
@ -764,7 +789,9 @@ instance ToJSON SQLiteError where
|
|||||||
throwDBError :: ChatMonad m => DatabaseError -> m ()
|
throwDBError :: ChatMonad m => DatabaseError -> m ()
|
||||||
throwDBError = throwError . ChatErrorDatabase
|
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 :: Maybe User -> String -> ChatResponse
|
||||||
chatCmdError user = CRChatCmdError user . ChatError . CECommandError
|
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,
|
active_user INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT CHECK(created_at NOT NULL),
|
created_at TEXT CHECK(created_at NOT NULL),
|
||||||
updated_at TEXT CHECK(updated_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)
|
FOREIGN KEY(user_id, local_display_name)
|
||||||
REFERENCES display_names(user_id, local_display_name)
|
REFERENCES display_names(user_id, local_display_name)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
|
@ -12,12 +12,15 @@ import Control.Monad.Except
|
|||||||
import Control.Monad.Reader
|
import Control.Monad.Reader
|
||||||
import Data.Aeson (ToJSON (..))
|
import Data.Aeson (ToJSON (..))
|
||||||
import qualified Data.Aeson as J
|
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.Char8 as B
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
import Data.Functor (($>))
|
import Data.Functor (($>))
|
||||||
import Data.List (find)
|
import Data.List (find)
|
||||||
import qualified Data.List.NonEmpty as L
|
import qualified Data.List.NonEmpty as L
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
import Data.Word (Word8)
|
import Data.Word (Word8)
|
||||||
import Database.SQLite.Simple (SQLError (..))
|
import Database.SQLite.Simple (SQLError (..))
|
||||||
import qualified Database.SQLite.Simple as DB
|
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_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_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
|
||||||
|
|
||||||
foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: 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 :: CString -> IO CJSONString
|
||||||
cChatParseServer s = newCAString . chatParseServer =<< peekCAString s
|
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 :: String -> String -> ChatOpts
|
||||||
mobileChatOpts dbFilePrefix dbKey =
|
mobileChatOpts dbFilePrefix dbKey =
|
||||||
ChatOpts
|
ChatOpts
|
||||||
@ -241,6 +252,12 @@ chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
|
|||||||
enc :: StrEncoding a => a -> String
|
enc :: StrEncoding a => a -> String
|
||||||
enc = B.unpack . strEncode
|
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}
|
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
|
||||||
deriving (Generic)
|
deriving (Generic)
|
||||||
|
|
||||||
|
@ -39,6 +39,7 @@ module Simplex.Chat.Store
|
|||||||
getUserByContactRequestId,
|
getUserByContactRequestId,
|
||||||
getUserFileInfo,
|
getUserFileInfo,
|
||||||
deleteUserRecord,
|
deleteUserRecord,
|
||||||
|
updateUserPrivacy,
|
||||||
createDirectConnection,
|
createDirectConnection,
|
||||||
createConnReqConnection,
|
createConnReqConnection,
|
||||||
getProfileById,
|
getProfileById,
|
||||||
@ -277,6 +278,7 @@ import Data.Functor (($>))
|
|||||||
import Data.Int (Int64)
|
import Data.Int (Int64)
|
||||||
import Data.List (sortBy, sortOn)
|
import Data.List (sortBy, sortOn)
|
||||||
import Data.List.NonEmpty (NonEmpty)
|
import Data.List.NonEmpty (NonEmpty)
|
||||||
|
import qualified Data.List.NonEmpty as L
|
||||||
import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
|
import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
|
||||||
import Data.Ord (Down (..))
|
import Data.Ord (Down (..))
|
||||||
import Data.Text (Text)
|
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.M20230129_drop_chat_items_group_idx
|
||||||
import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
|
import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
|
||||||
import Simplex.Chat.Migrations.M20230303_group_link_role
|
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.Migrations.M20230304_file_description
|
||||||
import Simplex.Chat.Protocol
|
import Simplex.Chat.Protocol
|
||||||
import Simplex.Chat.Types
|
import Simplex.Chat.Types
|
||||||
@ -412,7 +415,8 @@ schemaMigrations =
|
|||||||
("20230118_recreate_smp_servers", m20230118_recreate_smp_servers),
|
("20230118_recreate_smp_servers", m20230118_recreate_smp_servers),
|
||||||
("20230129_drop_chat_items_group_idx", m20230129_drop_chat_items_group_idx),
|
("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),
|
("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)
|
-- ("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"
|
when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0"
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,?,0,?,?)"
|
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, created_at, updated_at) VALUES (?,?,?,0,?,?,?)"
|
||||||
(auId, displayName, activeUser, currentTs, currentTs)
|
(auId, displayName, activeUser, True, currentTs, currentTs)
|
||||||
userId <- insertedRowId db
|
userId <- insertedRowId db
|
||||||
DB.execute
|
DB.execute
|
||||||
db
|
db
|
||||||
@ -467,7 +471,7 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr
|
|||||||
(profileId, displayName, userId, True, currentTs, currentTs)
|
(profileId, displayName, userId, True, currentTs, currentTs)
|
||||||
contactId <- insertedRowId db
|
contactId <- insertedRowId db
|
||||||
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
|
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.Connection -> IO [UserInfo]
|
||||||
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
||||||
@ -505,16 +509,19 @@ getUsers db =
|
|||||||
userQuery :: Query
|
userQuery :: Query
|
||||||
userQuery =
|
userQuery =
|
||||||
[sql|
|
[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
|
FROM users u
|
||||||
JOIN contacts uct ON uct.contact_id = u.contact_id
|
JOIN contacts uct ON uct.contact_id = u.contact_id
|
||||||
JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_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, 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) =
|
toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences, showNtfs) :. (viewPwdHash_, viewPwdSalt_)) =
|
||||||
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
|
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, viewPwdHash}
|
||||||
in User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences}
|
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.Connection -> UserId -> IO ()
|
||||||
setActiveUser db userId = do
|
setActiveUser db userId = do
|
||||||
@ -581,6 +588,19 @@ deleteUserRecord :: DB.Connection -> User -> IO ()
|
|||||||
deleteUserRecord db User {userId} =
|
deleteUserRecord db User {userId} =
|
||||||
DB.execute db "DELETE FROM users WHERE user_id = ?" (Only 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.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection
|
||||||
createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do
|
createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do
|
||||||
createdAt <- getCurrentTime
|
createdAt <- getCurrentTime
|
||||||
|
@ -110,11 +110,38 @@ data User = User
|
|||||||
localDisplayName :: ContactName,
|
localDisplayName :: ContactName,
|
||||||
profile :: LocalProfile,
|
profile :: LocalProfile,
|
||||||
fullPreferences :: FullPreferences,
|
fullPreferences :: FullPreferences,
|
||||||
activeUser :: Bool
|
activeUser :: Bool,
|
||||||
|
viewPwdHash :: Maybe UserPwdHash,
|
||||||
|
showNtfs :: Bool
|
||||||
}
|
}
|
||||||
deriving (Show, Generic, FromJSON)
|
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
|
data UserInfo = UserInfo
|
||||||
{ user :: User,
|
{ user :: User,
|
||||||
|
@ -116,6 +116,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
|
|||||||
CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus
|
CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus
|
||||||
CRUserProfile u p -> ttyUser u $ viewUserProfile p
|
CRUserProfile u p -> ttyUser u $ viewUserProfile p
|
||||||
CRUserProfileNoChange u -> ttyUser u ["user profile did not change"]
|
CRUserProfileNoChange u -> ttyUser u ["user profile did not change"]
|
||||||
|
CRUserPrivacy u -> ttyUserPrefix u $ viewUserPrivacy u
|
||||||
CRVersionInfo info -> viewVersionInfo logLevel info
|
CRVersionInfo info -> viewVersionInfo logLevel info
|
||||||
CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq
|
CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq
|
||||||
CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
|
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]
|
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 ""]
|
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]
|
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
|
CRChatError u e -> ttyUser' u $ viewChatError logLevel e
|
||||||
where
|
where
|
||||||
ttyUser :: User -> [StyledString] -> [StyledString]
|
ttyUser :: User -> [StyledString] -> [StyledString]
|
||||||
ttyUser _ [] = []
|
ttyUser user@User {showNtfs, activeUser} ss
|
||||||
ttyUser User {userId, localDisplayName = u} ss = prependFirst userPrefix ss
|
| showNtfs || activeUser = ttyUserPrefix user ss
|
||||||
|
| otherwise = []
|
||||||
|
ttyUserPrefix :: User -> [StyledString] -> [StyledString]
|
||||||
|
ttyUserPrefix _ [] = []
|
||||||
|
ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss
|
||||||
where
|
where
|
||||||
userPrefix = case user_ of
|
userPrefix = case user_ of
|
||||||
Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else ""
|
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 <> "] "
|
prefix = "[user: " <> highlight u <> "] "
|
||||||
ttyUser' :: Maybe User -> [StyledString] -> [StyledString]
|
ttyUser' :: Maybe User -> [StyledString] -> [StyledString]
|
||||||
ttyUser' = maybe id ttyUser
|
ttyUser' = maybe id ttyUser
|
||||||
|
ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString]
|
||||||
|
ttyUserPrefix' = maybe id ttyUserPrefix
|
||||||
testViewChats :: [AChat] -> [StyledString]
|
testViewChats :: [AChat] -> [StyledString]
|
||||||
testViewChats chats = [sShow $ map toChatView chats]
|
testViewChats chats = [sShow $ map toChatView chats]
|
||||||
where
|
where
|
||||||
@ -293,14 +300,19 @@ chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState
|
|||||||
_ -> ""
|
_ -> ""
|
||||||
|
|
||||||
viewUsersList :: [UserInfo] -> [StyledString]
|
viewUsersList :: [UserInfo] -> [StyledString]
|
||||||
viewUsersList = map userInfo . sortOn ldn
|
viewUsersList = mapMaybe userInfo . sortOn ldn
|
||||||
where
|
where
|
||||||
ldn (UserInfo User {localDisplayName = n} _) = T.toLower n
|
ldn (UserInfo User {localDisplayName = n} _) = T.toLower n
|
||||||
userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser} count) =
|
userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser, showNtfs, viewPwdHash} count)
|
||||||
ttyFullName n fullName <> active <> unread
|
| activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName <> infoStr
|
||||||
|
| otherwise = Nothing
|
||||||
where
|
where
|
||||||
active = if activeUser then highlight' " (active)" else ""
|
infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")"
|
||||||
unread = if count /= 0 then plain $ " (unread: " <> show count <> ")" else ""
|
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 :: ChatInfo c -> ChatItem c d -> Bool
|
||||||
muted chat ChatItem {chatDir} = case (chat, chatDir) of
|
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)"
|
"(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
|
-- TODO make more generic messages or split
|
||||||
viewSMPServers :: ProtocolTypeI p => [ServerCfg p] -> Bool -> [StyledString]
|
viewSMPServers :: ProtocolTypeI p => [ServerCfg p] -> Bool -> [StyledString]
|
||||||
viewSMPServers servers testView =
|
viewSMPServers servers testView =
|
||||||
@ -1210,9 +1228,15 @@ viewChatError logLevel = \case
|
|||||||
CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError]
|
CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError]
|
||||||
CEActiveUserExists -> ["error: active user already exists"]
|
CEActiveUserExists -> ["error: active user already exists"]
|
||||||
CEUserExists name -> ["user with the name " <> ttyContact name <> " 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]
|
CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId]
|
||||||
CECantDeleteActiveUser _ -> ["cannot delete active user"]
|
CECantDeleteActiveUser _ -> ["cannot delete active user"]
|
||||||
CECantDeleteLastUser _ -> ["cannot delete last 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"]
|
CEChatNotStarted -> ["error: chat not started"]
|
||||||
CEChatNotStopped -> ["error: chat not stopped"]
|
CEChatNotStopped -> ["error: chat not stopped"]
|
||||||
CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
|
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 "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser
|
||||||
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser
|
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 "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
|
describe "chat item expiration" $ do
|
||||||
it "set chat item TTL" testSetChatItemTTL
|
it "set chat item TTL" testSetChatItemTTL
|
||||||
describe "queue rotation" $ do
|
describe "queue rotation" $ do
|
||||||
@ -787,13 +788,13 @@ testMuteContact =
|
|||||||
connectUsers alice bob
|
connectUsers alice bob
|
||||||
alice #> "@bob hello"
|
alice #> "@bob hello"
|
||||||
bob <# "alice> hello"
|
bob <# "alice> hello"
|
||||||
bob ##> "/mute alice"
|
bob ##> "/mute @alice"
|
||||||
bob <## "ok"
|
bob <## "ok"
|
||||||
alice #> "@bob hi"
|
alice #> "@bob hi"
|
||||||
(bob </)
|
(bob </)
|
||||||
bob ##> "/contacts"
|
bob ##> "/contacts"
|
||||||
bob <## "alice (Alice) (muted, you can /unmute @alice)"
|
bob <## "alice (Alice) (muted, you can /unmute @alice)"
|
||||||
bob ##> "/unmute alice"
|
bob ##> "/unmute @alice"
|
||||||
bob <## "ok"
|
bob <## "ok"
|
||||||
bob ##> "/contacts"
|
bob ##> "/contacts"
|
||||||
bob <## "alice (Alice)"
|
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 <## ("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
|
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 :: HasCallStack => FilePath -> IO ()
|
||||||
testSetChatItemTTL =
|
testSetChatItemTTL =
|
||||||
testChat2 aliceProfile bobProfile $
|
testChat2 aliceProfile bobProfile $
|
||||||
|
@ -25,16 +25,16 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e
|
|||||||
|
|
||||||
activeUserExists :: String
|
activeUserExists :: String
|
||||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
#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
|
#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
|
#endif
|
||||||
|
|
||||||
activeUser :: String
|
activeUser :: String
|
||||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
#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
|
#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
|
#endif
|
||||||
|
|
||||||
chatStarted :: String
|
chatStarted :: String
|
||||||
@ -73,7 +73,7 @@ pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <>
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
userJSON :: String
|
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
|
parsedMarkdown :: String
|
||||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||||
|
Loading…
Reference in New Issue
Block a user