Merge branch 'master' into master-ios

This commit is contained in:
spaced4ndy
2023-08-08 19:05:23 +04:00
105 changed files with 3768 additions and 1149 deletions
+3 -3
View File
@@ -104,7 +104,7 @@ jobs:
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-22.04'
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac
@@ -156,7 +156,7 @@ jobs:
- name: Linux make AppImage
id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-22.04'
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
shell: bash
run: |
scripts/desktop/make-appimage-linux.sh
@@ -170,7 +170,7 @@ jobs:
scripts/desktop/build-lib-mac.sh
cd apps/multiplatform
./gradlew packageDmg
echo "::set-output name=package_path::$(echo $PWD/release/main/dmg/simplex-*.dmg)"
echo "::set-output name=package_path::$(echo $PWD/release/main/dmg/SimpleX-*.dmg)"
- name: Linux upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
+44 -24
View File
@@ -28,6 +28,17 @@ struct ContentView: View {
@State private var showWhatsNew = false
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var chatListActionSheet: ChatListActionSheet? = nil
private enum ChatListActionSheet: Identifiable {
case connectViaUrl(action: ConnReqType, link: String)
var id: String {
switch self {
case .connectViaUrl: return "connectViaUrl \(link)"
}
}
}
var body: some View {
ZStack {
@@ -80,6 +91,11 @@ struct ContentView: View {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
.actionSheet(item: $chatListActionSheet) { sheet in
switch sheet {
case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link)
}
}
} else {
OnboardingView(onboarding: step)
}
@@ -132,7 +148,9 @@ struct ContentView: View {
}
}
prefShowLANotice = true
connectViaUrl()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
}
@@ -265,36 +283,38 @@ struct ContentView: View {
secondaryButton: .cancel()
)
}
}
func connectViaUrl() {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
AlertManager.shared.showAlert(connectViaUrlAlert(url))
func connectViaUrl() {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
var path = url.path
logger.debug("ContentView.connectViaUrl path: \(path)")
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
let action: ConnReqType = path == "contact" ? .contact : .invitation
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
chatListActionSheet = .connectViaUrl(action: action, link: link)
} else {
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
}
}
}
}
func connectViaUrlAlert(_ url: URL) -> Alert {
var path = url.path
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
let action: ConnReqType = path == "contact" ? .contact : .invitation
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet {
let title: LocalizedStringKey
if case .contact = action { title = "Connect via contact link?" }
else { title = "Connect via one-time link?" }
return Alert(
switch action {
case .contact: title = "Connect via contact link"
case .invitation: title = "Connect via one-time link"
}
return ActionSheet(
title: Text(title),
message: Text("Your profile will be sent to the contact that you received this link from"),
primaryButton: .default(Text("Connect")) {
connectViaLink(link)
},
secondaryButton: .cancel()
buttons: [
.default(Text("Use current profile")) { connectViaLink(link, incognito: false) },
.default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) },
.cancel()
]
)
} else {
return Alert(title: Text("Error: URL is invalid"))
}
}
+6 -2
View File
@@ -43,9 +43,8 @@ final class ChatModel: ObservableObject {
@Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
@Published var incognito: Bool = incognitoGroupDefault.get()
// pending notification actions
@Published var ntfContactRequest: ChatId?
@Published var ntfContactRequest: NTFContactRequest?
@Published var ntfCallInvitationAction: (ChatId, NtfCallAction)?
// current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@@ -589,6 +588,11 @@ final class ChatModel: ObservableObject {
}
}
struct NTFContactRequest {
var incognito: Bool
var chatId: String
}
struct UnreadChatItemCounts {
var totalBelow: Int
var unreadBelow: Int
+16 -8
View File
@@ -12,6 +12,7 @@ import UIKit
import SimpleXChat
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
@@ -41,12 +42,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
userId != chatModel.currentUser?.userId {
changeActiveUser(userId, viewPwd: nil)
}
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
let chatId = content.userInfo["chatId"] as? String {
let incognito = action == ntfActionAcceptContactIncognito
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
Task { await acceptContactRequest(contactRequest) }
Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) }
} else {
chatModel.ntfContactRequest = chatId
chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId)
}
} else if let (chatId, ntfAction) = ntfCallAction(content, action) {
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
@@ -134,11 +136,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
UNUserNotificationCenter.current().setNotificationCategories([
UNNotificationCategory(
identifier: ntfCategoryContactRequest,
actions: [UNNotificationAction(
identifier: ntfActionAcceptContact,
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
options: .foreground
)],
actions: [
UNNotificationAction(
identifier: ntfActionAcceptContact,
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
options: .foreground
), UNNotificationAction(
identifier: ntfActionAcceptContactIncognito,
title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
options: .foreground
)
],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification")
),
+17 -18
View File
@@ -252,12 +252,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r
}
func apiSetIncognito(incognito: Bool) throws {
let r = chatSendCmdSync(.setIncognito(incognito: incognito))
if case .cmdOk = r { return }
throw r
}
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
@@ -564,19 +558,25 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo
return nil
}
func apiAddContact() async -> String? {
func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user")
return nil
}
let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false)
if case let .invitation(_, connReqInvitation) = r { return connReqInvitation }
let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) }
AlertManager.shared.showAlert(connectionErrorAlert(r))
return nil
}
func apiConnect(connReq: String) async -> ConnReqType? {
let (connReqType, alert) = await apiConnect_(connReq: connReq)
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito))
if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection }
throw r
}
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert {
AlertManager.shared.showAlert(alert)
return nil
@@ -585,12 +585,12 @@ func apiConnect(connReq: String) async -> ConnReqType? {
}
}
func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) {
func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return (nil, nil)
}
let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq))
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
switch r {
case .sentConfirmation: return (.invitation, nil)
case .sentInvitation: return (.contact, nil)
@@ -766,8 +766,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac
}
}
func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? {
let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact }
@@ -875,8 +875,8 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
}
}
func acceptContactRequest(_ contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(contactReqId: contactRequest.apiId) {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
}
@@ -1110,7 +1110,6 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(getXFTPCfg())
try apiSetIncognito(incognito: incognitoGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
+3 -3
View File
@@ -139,10 +139,10 @@ struct SimpleXApp: App {
let chat = chatModel.getChat(id) {
loadChat(chat: chat)
}
if let chatId = chatModel.ntfContactRequest {
if let ncr = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
Task { await acceptContactRequest(contactRequest) }
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
}
}
} catch let error {
@@ -143,7 +143,12 @@ struct ChatInfoView: View {
if let customUserProfile = customUserProfile {
Section("Incognito") {
infoRow("Your random profile", customUserProfile.chatViewName)
HStack {
Text("Your random profile")
Spacer()
Text(customUserProfile.chatViewName)
.foregroundStyle(.indigo)
}
}
}
@@ -57,7 +57,7 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
groupPreferencesButton($groupInfo)
if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
@@ -19,6 +19,7 @@ struct GroupMemberInfoView: View {
@State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@State private var connectToMemberDialog: Bool = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true
@@ -28,7 +29,6 @@ struct GroupMemberInfoView: View {
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
case connectViaMemberAddressAlert(contactLink: String)
case connRequestSentAlert(type: ConnReqType)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
case other(alert: Alert)
@@ -40,7 +40,6 @@ struct GroupMemberInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case .connectViaMemberAddressAlert: return "connectViaMemberAddressAlert"
case .connRequestSentAlert: return "connRequestSentAlert"
case let .error(title, _): return "error \(title)"
case let .other(alert): return "other \(alert)"
@@ -144,7 +143,7 @@ struct GroupMemberInfoView: View {
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
@@ -203,7 +202,6 @@ struct GroupMemberInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .connectViaMemberAddressAlert(contactLink): return connectViaMemberAddressAlert(contactLink)
case let .connRequestSentAlert(type): return connReqSentAlert(type)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .other(alert): return alert
@@ -213,26 +211,19 @@ struct GroupMemberInfoView: View {
func connectViaAddressButton(_ contactLink: String) -> some View {
Button {
alert = .connectViaMemberAddressAlert(contactLink: contactLink)
connectToMemberDialog = true
} label: {
Label("Connect", systemImage: "link")
}
.confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) {
Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) }
Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) }
}
}
func connectViaMemberAddressAlert(_ contactLink: String) -> Alert {
return Alert(
title: Text("Connect directly?"),
message: Text("Сonnection request will be sent to this group member."),
primaryButton: .default(Text("Connect")) {
connectViaAddress(contactLink)
},
secondaryButton: .cancel()
)
}
func connectViaAddress(_ contactLink: String) {
func connectViaAddress(incognito: Bool, contactLink: String) {
Task {
let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink)
if let connReqType = connReqType {
alert = .connRequestSentAlert(type: connReqType)
} else if let connectAlert = connectAlert {
@@ -19,7 +19,7 @@ struct ScanCodeView: View {
VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
.cornerRadius(12)
Text("Scan security code from your contact's app.")
.padding(.top)
}
@@ -64,7 +64,7 @@ struct VerifyCodeView: View {
HStack {
NavigationLink {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitleDisplayMode(.large)
.navigationTitle("Scan code")
} label: {
Label("Scan code", systemImage: "qrcode")
@@ -222,9 +222,15 @@ struct ChatListNavLink: View {
ContactRequestView(contactRequest: contactRequest, chat: chat)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
Task { await acceptContactRequest(contactRequest) }
} label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") }
.tint(chatModel.incognito ? .indigo : .accentColor)
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { Label("Accept", systemImage: "checkmark") }
.tint(.accentColor)
Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: {
Label("Accept incognito", systemImage: "theatermasks")
}
.tint(.indigo)
Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: {
@@ -234,9 +240,10 @@ struct ChatListNavLink: View {
}
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } }
Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
}
}
@@ -60,8 +60,6 @@ struct ChatListView: View {
chatList
}
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onAppear() { connectViaUrl() }
.onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable {
AlertManager.shared.showAlert(Alert(
@@ -108,11 +106,6 @@ struct ChatListView: View {
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
if (chatModel.incognito) {
Image(systemName: "theatermasks")
.foregroundColor(.indigo)
.padding(.trailing, 8)
}
Text("Chats")
.font(.headline)
if chatModel.chats.count > 0 {
@@ -41,11 +41,9 @@ struct ChatPreviewView: View {
ZStack(alignment: .topTrailing) {
chatMessagePreview(cItem)
if case .direct = chat.chatInfo {
chatStatusImage()
.padding(.top, 24)
.frame(maxWidth: .infinity, alignment: .trailing)
}
chatStatusImage()
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.trailing, 8)
@@ -59,12 +57,9 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
if case let .group(groupInfo) = chat.chatInfo {
switch (groupInfo.membership.memberStatus) {
case .memLeft:
groupInactiveIcon()
case .memRemoved:
groupInactiveIcon()
case .memGroupDeleted:
groupInactiveIcon()
case .memLeft: groupInactiveIcon()
case .memRemoved: groupInactiveIcon()
case .memGroupDeleted: groupInactiveIcon()
default: EmptyView()
}
} else {
@@ -74,7 +69,7 @@ struct ChatPreviewView: View {
@ViewBuilder private func groupInactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary)
.foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
}
@@ -198,10 +193,7 @@ struct ChatPreviewView: View {
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
: (chatModel.incognito
? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")")
: chatPreviewInfoText("you are invited to group")
)
: chatPreviewInfoText("you are invited to group")
}
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
@@ -229,7 +221,7 @@ struct ChatPreviewView: View {
switch chat.chatInfo {
case let .direct(contact):
switch (chatModel.contactNetworkStatus(contact)) {
case .connected: EmptyView()
case .connected: incognitoIcon(chat.chatInfo.incognito)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
@@ -240,11 +232,23 @@ struct ChatPreviewView: View {
ProgressView()
}
default:
EmptyView()
incognitoIcon(chat.chatInfo.incognito)
}
}
}
@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
if incognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
} else {
EmptyView()
}
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
@@ -15,6 +15,7 @@ struct ContactConnectionInfo: View {
@State var contactConnection: PendingContactConnection
@State private var alert: CCInfoAlert?
@State private var localAlias = ""
@State private var showIncognitoSheet = false
@FocusState private var aliasTextFieldFocused: Bool
enum CCInfoAlert: Identifiable {
@@ -31,19 +32,14 @@ struct ContactConnectionInfo: View {
var body: some View {
NavigationView {
List {
let v = List {
Group {
Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection")
Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection")
.font(.largeTitle)
.bold()
.padding(.bottom, 16)
.padding(.bottom)
Text(contactConnectionText(contactConnection))
.padding(.bottom, 16)
if let connReqInv = contactConnection.connReqInv {
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv)
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -65,10 +61,16 @@ struct ContactConnectionInfo: View {
if contactConnection.initiated,
let connReqInv = contactConnection.connReqInv {
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv)
QRCode(uri: connReqInv)
incognitoEnabled()
shareLinkButton(connReqInv)
oneTimeLinkLearnMoreButton()
} else {
incognitoEnabled()
oneTimeLinkLearnMoreButton()
}
} footer: {
sharedProfileInfo(contactConnection.incognito)
}
Section {
@@ -80,6 +82,14 @@ struct ContactConnectionInfo: View {
}
}
}
if #available(iOS 16, *) {
v
} else {
// navigationBarHidden is added conditionally,
// because the view jumps in iOS 17 if this is added,
// and on iOS 16+ it is hidden without it.
v.navigationBarHidden(true)
}
}
.alert(item: $alert) { _alert in
switch _alert {
@@ -128,6 +138,30 @@ struct ContactConnectionInfo: View {
)
: "You will be connected when your contact's device is online, please wait or check later!"
}
@ViewBuilder private func incognitoEnabled() -> some View {
if contactConnection.incognito {
ZStack(alignment: .leading) {
Image(systemName: "theatermasks.fill")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(Color.indigo)
.font(.system(size: 14))
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
showIncognitoSheet = true
}
.padding(.leading, 36)
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
}
}
struct ContactConnectionInfo_Previews: PreviewProvider {
@@ -58,10 +58,14 @@ struct ContactConnectionView: View {
}
.padding(.bottom, 2)
Text(contactConnection.description)
.frame(alignment: .topLeading)
.padding(.horizontal, 8)
.padding(.bottom, 2)
ZStack(alignment: .topTrailing) {
Text(contactConnection.description)
.frame(maxWidth: .infinity, alignment: .leading)
incognitoIcon(contactConnection.incognito)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.horizontal, 8)
Spacer()
}
@@ -24,7 +24,7 @@ struct ContactRequestView: View {
Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(chatModel.incognito ? .indigo : .accentColor)
.foregroundColor(.accentColor)
.padding(.leading, 8)
.frame(alignment: .topLeading)
Spacer()
@@ -12,38 +12,92 @@ import SimpleXChat
struct AddContactView: View {
@EnvironmentObject private var chatModel: ChatModel
var contactConnection: PendingContactConnection? = nil
@Binding var contactConnection: PendingContactConnection?
var connReqInvitation: String
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
List {
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section("1-time link") {
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
VStack {
List {
Section {
if connReqInvitation != "" {
QRCode(uri: connReqInvitation)
} else {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(2)
.frame(maxWidth: .infinity)
.padding(.vertical)
}
IncognitoToggle(incognitoEnabled: $incognitoDefault)
.disabled(contactConnection == nil)
shareLinkButton(connReqInvitation)
oneTimeLinkLearnMoreButton()
} header: {
Text("1-time link")
} footer: {
sharedProfileInfo(incognitoDefault)
}
}
}
.onAppear { chatModel.connReqInv = connReqInvitation }
.onChange(of: incognitoDefault) { incognito in
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
ChatModel.shared.updateContactConnection(conn)
}
}
} catch {
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
}
}
}
}
}
@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View {
if connReqInvitation != "" {
QRCode(uri: connReqInvitation)
} else {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(2)
.frame(maxWidth: .infinity)
.padding(.vertical)
struct IncognitoToggle: View {
@Binding var incognitoEnabled: Bool
@State private var showIncognitoSheet = false
var body: some View {
ZStack(alignment: .leading) {
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
.font(.system(size: 14))
Toggle(isOn: $incognitoEnabled) {
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
showIncognitoSheet = true
}
}
.padding(.leading, 36)
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
shareLinkButton(connReqInvitation)
oneTimeLinkLearnMoreButton()
}
private func shareLinkButton(_ connReqInvitation: String) -> some View {
func sharedProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
}
func shareLinkButton(_ connReqInvitation: String) -> some View {
Button {
showShareSheet(items: [connReqInvitation])
} label: {
@@ -65,26 +119,11 @@ func oneTimeLinkLearnMoreButton() -> some View {
}
}
struct OneTimeLinkProfileText: View {
@EnvironmentObject private var chatModel: ChatModel
var contactConnection: PendingContactConnection? = nil
var connReqInvitation: String
var body: some View {
HStack {
if (contactConnection?.incognito ?? chatModel.incognito) {
Image(systemName: "theatermasks").foregroundColor(.indigo)
Text("A random profile will be sent to your contact")
} else {
Image(systemName: "info.circle").foregroundColor(.secondary)
Text("Your chat profile will be sent to your contact")
}
}
}
}
struct AddContactView_Previews: PreviewProvider {
static var previews: some View {
AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
AddContactView(
contactConnection: Binding.constant(PendingContactConnection.getSampleData()),
connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D"
)
}
}
@@ -47,21 +47,13 @@ struct AddGroupView: View {
.padding(.vertical, 4)
Text("The group is fully decentralized it is visible only to the members.")
.padding(.bottom, 4)
if (m.incognito) {
HStack {
Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote)
Spacer().frame(width: 8)
Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote)
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to group members").font(.footnote)
}
.padding(.bottom)
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to group members").font(.footnote)
}
.padding(.bottom)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
enum CreateLinkTab {
case oneTime
@@ -24,6 +25,7 @@ struct CreateLinkView: View {
@EnvironmentObject var m: ChatModel
@State var selection: CreateLinkTab
@State var connReqInvitation: String = ""
@State var contactConnection: PendingContactConnection? = nil
@State private var creatingConnReq = false
var viaNavLink = false
@@ -39,7 +41,7 @@ struct CreateLinkView: View {
private func createLinkView() -> some View {
TabView(selection: $selection) {
AddContactView(connReqInvitation: connReqInvitation)
AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation)
.tabItem {
Label(
connReqInvitation == ""
@@ -56,7 +58,7 @@ struct CreateLinkView: View {
.tag(CreateLinkTab.longTerm)
}
.onChange(of: selection) { _ in
if case .oneTime = selection, connReqInvitation == "" && !creatingConnReq {
if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq {
createInvitation()
}
}
@@ -69,12 +71,14 @@ struct CreateLinkView: View {
private func createInvitation() {
creatingConnReq = true
Task {
let connReq = await apiAddContact()
await MainActor.run {
if let connReq = connReq {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
connReqInvitation = connReq
contactConnection = pcc
m.connReqInv = connReq
} else {
}
} else {
await MainActor.run {
creatingConnReq = false
}
}
@@ -10,13 +10,13 @@ import SwiftUI
import SimpleXChat
enum NewChatAction: Identifiable {
case createLink(link: String)
case createLink(link: String, connection: PendingContactConnection)
case connectViaLink
case createGroup
var id: String {
switch self {
case let .createLink(link): return "createLink \(link)"
case let .createLink(link, _): return "createLink \(link)"
case .connectViaLink: return "connectViaLink"
case .createGroup: return "createGroup"
}
@@ -41,8 +41,8 @@ struct NewChatButton: View {
}
.sheet(item: $actionSheet) { sheet in
switch sheet {
case let .createLink(link):
CreateLinkView(selection: .oneTime, connReqInvitation: link)
case let .createLink(link, pcc):
CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc)
case .connectViaLink: ConnectViaLinkView()
case .createGroup: AddGroupView()
}
@@ -51,8 +51,8 @@ struct NewChatButton: View {
func addContactAction() {
Task {
if let connReq = await apiAddContact() {
actionSheet = .createLink(link: connReq)
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
actionSheet = .createLink(link: connReq, connection: pcc)
}
}
}
@@ -63,9 +63,9 @@ enum ConnReqType: Equatable {
case invitation
}
func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) {
func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) {
Task {
if let connReqType = await apiConnect(connReq: connectionLink) {
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
DispatchQueue.main.async {
dismiss?()
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
@@ -100,12 +100,12 @@ func checkCRDataGroup(_ crData: CReqClientData) -> Bool {
return crData.type == "group" && crData.groupLinkId != nil
}
func groupLinkAlert(_ connectionLink: String) -> Alert {
func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert {
return Alert(
title: Text("Connect via group link?"),
message: Text("You will join a group this link refers to and connect to its group members."),
primaryButton: .default(Text("Connect")) {
connectViaLink(connectionLink)
primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) {
connectViaLink(connectionLink, incognito: incognito)
},
secondaryButton: .cancel()
)
@@ -7,76 +7,77 @@
//
import SwiftUI
import SimpleXChat
struct PasteToConnectView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@State private var connectionLink: String = ""
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@FocusState private var linkEditorFocused: Bool
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Connect via link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical)
Text("Paste the link you received into the box below to connect with your contact.")
.padding(.bottom, 4)
if (chatModel.incognito) {
HStack {
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
Spacer().frame(width: 8)
Text("A random profile will be sent to the contact that you received this link from").font(.footnote)
List {
Text("Connect via link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.onTapGesture { linkEditorFocused = false }
Section {
linkEditor()
Button {
if connectionLink == "" {
connectionLink = UIPasteboard.general.string ?? ""
} else {
connectionLink = ""
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your profile will be sent to the contact that you received this link from").font(.footnote)
} label: {
if connectionLink == "" {
settingsRow("doc.plaintext") { Text("Paste") }
} else {
settingsRow("multiply") { Text("Clear") }
}
.padding(.bottom)
}
Button {
connect()
} label: {
settingsRow("link") { Text("Connect") }
}
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
sharedProfileInfo(incognitoDefault)
+ Text("\n\n")
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
}
}
private func linkEditor() -> some View {
ZStack {
Group {
if connectionLink.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact…", comment: "placeholder")))
.foregroundColor(.secondary)
.disabled(true)
}
TextEditor(text: $connectionLink)
.onSubmit(connect)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.allowsTightening(false)
.frame(height: 180)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
HStack(spacing: 20) {
if connectionLink == "" {
Button {
connectionLink = UIPasteboard.general.string ?? ""
} label: {
Label("Paste", systemImage: "doc.plaintext")
}
} else {
Button {
connectionLink = ""
} label: {
Label("Clear", systemImage: "multiply")
}
}
Spacer()
Button(action: connect, label: {
Label("Connect", systemImage: "link")
})
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
}
.frame(height: 48)
.padding(.bottom)
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
.focused($linkEditorFocused)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.allowsTightening(false)
.padding(.horizontal, -5)
.padding(.top, -8)
.frame(height: 180, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -85,9 +86,9 @@ struct PasteToConnectView: View {
if let crData = parseLinkQueryData(link),
checkCRDataGroup(crData) {
dismiss()
AlertManager.shared.showAlert(groupLinkAlert(link))
AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault))
} else {
connectViaLink(link, dismiss)
connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault)
}
}
}
@@ -7,11 +7,12 @@
//
import SwiftUI
import SimpleXChat
import CodeScanner
struct ScanToConnectView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
ScrollView {
@@ -19,34 +20,35 @@ struct ScanToConnectView: View {
Text("Scan QR code")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical)
if (chatModel.incognito) {
HStack {
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
Spacer().frame(width: 8)
Text("A random profile will be sent to your contact").font(.footnote)
}
.padding(.bottom)
} else {
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to your contact").font(.footnote)
}
.padding(.bottom)
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
IncognitoToggle(incognitoEnabled: $incognitoDefault)
.padding(.horizontal)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(uiColor: .systemBackground))
)
.padding(.top)
Group {
sharedProfileInfo(incognitoDefault)
+ Text("\n\n")
+ Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
}
ZStack {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
}
.padding(.bottom)
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
.padding(.bottom)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.horizontal)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(Color(.systemGroupedBackground))
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
@@ -55,9 +57,9 @@ struct ScanToConnectView: View {
if let crData = parseLinkQueryData(r.string),
checkCRDataGroup(crData) {
dismiss()
AlertManager.shared.showAlert(groupLinkAlert(r.string))
AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault))
} else {
Task { connectViaLink(r.string, dismiss) }
Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) }
}
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
@@ -18,10 +18,9 @@ struct IncognitoHelp: View {
ScrollView {
VStack(alignment: .leading) {
Group {
Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.")
Text("Incognito mode protects your privacy by using a new random profile for each contact.")
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.")
}
.padding(.bottom)
}
@@ -21,11 +21,10 @@ struct ScanProtocolServer: View {
.font(.largeTitle)
.bold()
.padding(.vertical)
ZStack {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
}
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
.padding(.top)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -131,7 +131,6 @@ struct SettingsView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var sceneDelegate: SceneDelegate
@Binding var showSettings: Bool
@State private var settingsSheet: SettingsSheet?
var body: some View {
ZStack {
@@ -161,8 +160,6 @@ struct SettingsView: View {
settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") }
}
incognitoRow()
NavigationLink {
UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared)
.navigationTitle("SimpleX address")
@@ -298,39 +295,6 @@ struct SettingsView: View {
}
.navigationTitle("Your settings")
}
.sheet(item: $settingsSheet) { sheet in
switch sheet {
case .incognitoInfo: IncognitoHelp()
}
}
}
@ViewBuilder private func incognitoRow() -> some View {
ZStack(alignment: .leading) {
Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks")
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.foregroundColor(chatModel.incognito ? Color.indigo : .secondary)
Toggle(isOn: $chatModel.incognito) {
HStack(spacing: 6) {
Text("Incognito")
Image(systemName: "info.circle")
.foregroundColor(.accentColor)
.font(.system(size: 14))
}
.onTapGesture {
settingsSheet = .incognitoInfo
}
}
.onChange(of: chatModel.incognito) { incognito in
incognitoGroupDefault.set(incognito)
do {
try apiSetIncognito(incognito: incognito)
} catch {
logger.error("apiSetIncognito: cannot set incognito \(responseError(error))")
}
}
.padding(.leading, indent)
}
}
private func chatDatabaseRow() -> some View {
@@ -351,12 +315,6 @@ struct SettingsView: View {
}
}
private enum SettingsSheet: Identifiable {
case incognitoInfo
var id: SettingsSheet { get { self } }
}
private enum NotificationAlert {
case enable
case error(LocalizedStringKey, String)
@@ -219,7 +219,6 @@ func startChat() -> DBMigrationResult? {
let justStarted = try apiStartChat()
chatStarted = true
if justStarted {
try apiSetIncognito(incognito: incognitoGroupDefault.get())
chatLastStartGroupDefault.set(Date.now)
Task { await receiveMessages() }
}
@@ -352,12 +351,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r
}
func apiSetIncognito(incognito: Bool) throws {
let r = sendSimpleXCmd(.setIncognito(incognito: incognito))
if case .cmdOk = r { return }
throw r
}
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
guard apiGetActiveUser() != nil else {
logger.debug("no active user")
+28 -28
View File
@@ -24,6 +24,11 @@
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; };
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */; };
5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */; };
5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038F2A7EAA41006ACFE8 /* libffi.a */; };
5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403902A7EAA41006ACFE8 /* libgmp.a */; };
5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403912A7EAA41006ACFE8 /* libgmpxx.a */; };
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; };
@@ -137,11 +142,6 @@
5CE2BA97284537A800EC33A6 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CE2BA96284537A800EC33A6 /* dummy.m */; };
5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5CE2BAA62845617C00EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; platformFilter = ios; };
5CE41C6C2A780D9D00FBE3A4 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C672A780D9D00FBE3A4 /* libffi.a */; };
5CE41C6D2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C682A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a */; };
5CE41C6E2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C692A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a */; };
5CE41C6F2A780D9D00FBE3A4 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C6A2A780D9D00FBE3A4 /* libgmp.a */; };
5CE41C702A780D9D00FBE3A4 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C6B2A780D9D00FBE3A4 /* libgmpxx.a */; };
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; };
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
@@ -263,6 +263,11 @@
5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = "<group>"; };
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a"; sourceTree = "<group>"; };
5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a"; sourceTree = "<group>"; };
5C04038F2A7EAA41006ACFE8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C0403902A7EAA41006ACFE8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C0403912A7EAA41006ACFE8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = "<group>"; };
@@ -415,11 +420,6 @@
5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = SimpleXChat.docc; sourceTree = "<group>"; };
5CE2BA8A2845332200EC33A6 /* SimpleX.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SimpleX.h; sourceTree = "<group>"; };
5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = "<group>"; };
5CE41C672A780D9D00FBE3A4 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CE41C682A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a"; sourceTree = "<group>"; };
5CE41C692A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a"; sourceTree = "<group>"; };
5CE41C6A2A780D9D00FBE3A4 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CE41C6B2A780D9D00FBE3A4 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = "<group>"; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
@@ -501,13 +501,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CE41C6E2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a in Frameworks */,
5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */,
5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CE41C6C2A780D9D00FBE3A4 /* libffi.a in Frameworks */,
5CE41C6D2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a in Frameworks */,
5CE41C6F2A780D9D00FBE3A4 /* libgmp.a in Frameworks */,
5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */,
5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5CE41C702A780D9D00FBE3A4 /* libgmpxx.a in Frameworks */,
5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -568,11 +568,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5CE41C672A780D9D00FBE3A4 /* libffi.a */,
5CE41C6A2A780D9D00FBE3A4 /* libgmp.a */,
5CE41C6B2A780D9D00FBE3A4 /* libgmpxx.a */,
5CE41C682A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a */,
5CE41C692A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a */,
5C04038F2A7EAA41006ACFE8 /* libffi.a */,
5C0403902A7EAA41006ACFE8 /* libgmp.a */,
5C0403912A7EAA41006ACFE8 /* libgmpxx.a */,
5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */,
5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1478,7 +1478,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 163;
CURRENT_PROJECT_VERSION = 164;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1520,7 +1520,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 163;
CURRENT_PROJECT_VERSION = 164;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1600,7 +1600,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 163;
CURRENT_PROJECT_VERSION = 164;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1632,7 +1632,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 163;
CURRENT_PROJECT_VERSION = 164;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1664,7 +1664,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 163;
CURRENT_PROJECT_VERSION = 164;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1688,7 +1688,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.2;
MARKETING_VERSION = 5.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1710,7 +1710,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 163;
CURRENT_PROJECT_VERSION = 164;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1734,7 +1734,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.2;
MARKETING_VERSION = 5.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
+15 -11
View File
@@ -32,7 +32,6 @@ public enum ChatCommand {
case setTempFolder(tempFolder: String)
case setFilesFolder(filesFolder: String)
case apiSetXFTPConfig(config: XFTPFileConfig?)
case setIncognito(incognito: Bool)
case apiExportArchive(config: ArchiveConfig)
case apiImportArchive(config: ArchiveConfig)
case apiDeleteStorage
@@ -83,8 +82,9 @@ public enum ChatCommand {
case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64)
case apiVerifyContact(contactId: Int64, connectionCode: String?)
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
case apiAddContact(userId: Int64)
case apiConnect(userId: Int64, connReq: String)
case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiConnect(userId: Int64, incognito: Bool, connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
case apiClearChat(type: ChatType, id: Int64)
case apiListContacts(userId: Int64)
@@ -97,7 +97,7 @@ public enum ChatCommand {
case apiShowMyAddress(userId: Int64)
case apiSetProfileAddress(userId: Int64, on: Bool)
case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?)
case apiAcceptContact(contactReqId: Int64)
case apiAcceptContact(incognito: Bool, contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
// WebRTC calls
case apiSendCallInvitation(contact: Contact, callType: CallType)
@@ -148,7 +148,6 @@ public enum ChatCommand {
} else {
return "/_xftp off"
}
case let .setIncognito(incognito): return "/incognito \(onOff(incognito))"
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
case .apiDeleteStorage: return "/_db delete"
@@ -213,8 +212,9 @@ public enum ChatCommand {
case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)"
case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)"
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
case let .apiAddContact(userId): return "/_connect \(userId)"
case let .apiConnect(userId, connReq): return "/_connect \(userId) \(connReq)"
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case let .apiListContacts(userId): return "/_contacts \(userId)"
@@ -227,7 +227,7 @@ public enum ChatCommand {
case let .apiShowMyAddress(userId): return "/_show_address \(userId)"
case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))"
case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))"
case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)"
@@ -274,7 +274,6 @@ public enum ChatCommand {
case .setTempFolder: return "setTempFolder"
case .setFilesFolder: return "setFilesFolder"
case .apiSetXFTPConfig: return "apiSetXFTPConfig"
case .setIncognito: return "setIncognito"
case .apiExportArchive: return "apiExportArchive"
case .apiImportArchive: return "apiImportArchive"
case .apiDeleteStorage: return "apiDeleteStorage"
@@ -326,6 +325,7 @@ public enum ChatCommand {
case .apiVerifyContact: return "apiVerifyContact"
case .apiVerifyGroupMember: return "apiVerifyGroupMember"
case .apiAddContact: return "apiAddContact"
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
case .apiConnect: return "apiConnect"
case .apiDeleteChat: return "apiDeleteChat"
case .apiClearChat: return "apiClearChat"
@@ -448,7 +448,8 @@ public enum ChatResponse: Decodable, Error {
case contactCode(user: User, contact: Contact, connectionCode: String)
case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String)
case connectionVerified(user: User, verified: Bool, expectedCode: String)
case invitation(user: User, connReqInvitation: String)
case invitation(user: User, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: User, toConnection: PendingContactConnection)
case sentConfirmation(user: User)
case sentInvitation(user: User)
case contactAlreadyExists(user: User, contact: Contact)
@@ -582,6 +583,7 @@ public enum ChatResponse: Decodable, Error {
case .groupMemberCode: return "groupMemberCode"
case .connectionVerified: return "connectionVerified"
case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
case .contactAlreadyExists: return "contactAlreadyExists"
@@ -713,7 +715,8 @@ public enum ChatResponse: Decodable, Error {
case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .invitation(u, connReqInvitation): return withUser(u, connReqInvitation)
case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation)
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
@@ -1449,6 +1452,7 @@ public enum ChatErrorType: Decodable {
case serverProtocol
case agentCommandError(message: String)
case invalidFileDescription(message: String)
case connectionIncognitoChangeProhibited
case internalError(message: String)
case exception(message: String)
}
+1 -1
View File
@@ -29,7 +29,7 @@ let GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE = "networkEnableKeepAlive"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt"
let GROUP_DEFAULT_INCOGNITO = "incognito"
public let GROUP_DEFAULT_INCOGNITO = "incognito"
let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase"
let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase"
public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades"
@@ -118,6 +118,7 @@ object NtfManager {
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
val actionButton = when (action) {
NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(MR.strings.accept)
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO -> generalGetString(MR.strings.accept_contact_incognito_button)
}
builder.addAction(0, actionButton, actionPendingIntent)
}
@@ -260,7 +261,8 @@ object NtfManager {
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val m = SimplexApp.context.chatModel
when (intent.action) {
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, chatId)
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, incognito = false, chatId)
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO.name -> ntfManager.acceptContactRequestAction(userId, incognito = true, chatId)
RejectCallAction -> {
val invitation = m.callInvitations[chatId]
if (invitation != null) {
@@ -13,7 +13,8 @@ actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
cameraPermissionState.launchPermissionRequest()
}
ConnectContactLayout(
chatModelIncognito = chatModel.incognito.value,
close
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
close = close
)
}
@@ -80,7 +80,6 @@ object ChatModel {
}
val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
val incognito by lazy { mutableStateOf(ChatController.appPrefs.incognito.get()) }
// current WebRTC call
val callManager = CallManager(this)
@@ -331,7 +331,6 @@ object ChatController {
if (justStarted) {
chatModel.currentUser.value = user
chatModel.userCreated.value = true
apiSetIncognito(chatModel.incognito.value)
getUserChatData()
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
@@ -546,12 +545,6 @@ object ChatController {
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
}
suspend fun apiSetIncognito(incognito: Boolean) {
val r = sendCmd(CC.SetIncognito(incognito))
if (r is CR.CmdOk) return
throw Exception("failed to set incognito: ${r.responseType} ${r.details}")
}
suspend fun apiExportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiExportArchive(config))
if (r is CR.CmdOk) return
@@ -819,14 +812,14 @@ object ChatController {
suspend fun apiAddContact(): String? {
suspend fun apiAddContact(incognito: Boolean): Pair<String, PendingContactConnection>? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiAddContact: no current user")
return null
}
val r = sendCmd(CC.APIAddContact(userId))
val r = sendCmd(CC.APIAddContact(userId, incognito))
return when (r) {
is CR.Invitation -> r.connReqInvitation
is CR.Invitation -> r.connReqInvitation to r.connection
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r)
@@ -836,12 +829,19 @@ object ChatController {
}
}
suspend fun apiConnect(connReq: String): Boolean {
suspend fun apiSetConnectionIncognito(connId: Long, incognito: Boolean): PendingContactConnection? {
val r = sendCmd(CC.ApiSetConnectionIncognito(connId, incognito))
if (r is CR.ConnectionIncognitoUpdated) return r.toConnection
Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiConnect: no current user")
return false
}
val r = sendCmd(CC.APIConnect(userId, connReq))
val r = sendCmd(CC.APIConnect(userId, incognito, connReq))
when {
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
@@ -998,8 +998,8 @@ object ChatController {
return null
}
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
suspend fun apiAcceptContactRequest(incognito: Boolean, contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(incognito, contactReqId))
return when {
r is CR.AcceptingContactRequest -> r.contact
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
@@ -1805,7 +1805,6 @@ sealed class CC {
class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
class SetIncognito(val incognito: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
@@ -1850,8 +1849,9 @@ sealed class CC {
class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC()
class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC()
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class APIAddContact(val userId: Long): CC()
class APIConnect(val userId: Long, val connReq: String): CC()
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
@@ -1872,7 +1872,7 @@ sealed class CC {
class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
class ApiEndCall(val contact: Contact): CC()
class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
class ApiAcceptContact(val contactReqId: Long): CC()
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
@@ -1908,7 +1908,6 @@ sealed class CC {
is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
is SetIncognito -> "/incognito ${onOff(incognito)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
@@ -1956,8 +1955,9 @@ sealed class CC {
is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId"
is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else ""
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is APIAddContact -> "/_connect $userId"
is APIConnect -> "/_connect $userId $connReq"
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
@@ -1971,7 +1971,7 @@ sealed class CC {
is ApiShowMyAddress -> "/_show_address $userId"
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}"
is ApiRejectCall -> "/_call reject @${contact.apiId}"
@@ -2006,7 +2006,6 @@ sealed class CC {
is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder"
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
is SetIncognito -> "setIncognito"
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
@@ -2052,6 +2051,7 @@ sealed class CC {
is APIVerifyContact -> "apiVerifyContact"
is APIVerifyGroupMember -> "apiVerifyGroupMember"
is APIAddContact -> "apiAddContact"
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is APIConnect -> "apiConnect"
is ApiDeleteChat -> "apiDeleteChat"
is ApiClearChat -> "apiClearChat"
@@ -3249,7 +3249,8 @@ sealed class CR {
@Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR()
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: User, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR()
@@ -3378,6 +3379,7 @@ sealed class CR {
is GroupMemberCode -> "groupMemberCode"
is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
is ContactAlreadyExists -> "contactAlreadyExists"
@@ -3503,6 +3505,7 @@ sealed class CR {
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, connReqInvitation)
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is SentConfirmation -> withUser(user, noDetails())
is SentInvitation -> withUser(user, noDetails())
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
@@ -3822,6 +3825,7 @@ sealed class ChatErrorType {
is ServerProtocol -> "serverProtocol"
is AgentCommandError -> "agentCommandError"
is InvalidFileDescription -> "invalidFileDescription"
is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited"
is InternalError -> "internalError"
is CEException -> "exception $message"
}
@@ -3895,6 +3899,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("serverProtocol") object ServerProtocol: ChatErrorType()
@Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType()
@Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType()
@Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType()
@Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType()
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
}
@@ -10,7 +10,8 @@ import chat.simplex.res.MR
import kotlinx.coroutines.delay
enum class NotificationAction {
ACCEPT_CONTACT_REQUEST
ACCEPT_CONTACT_REQUEST,
ACCEPT_CONTACT_REQUEST_INCOGNITO
}
lateinit var ntfManager: NtfManager
@@ -29,7 +30,10 @@ abstract class NtfManager {
displayName = cInfo.displayName,
msgText = generalGetString(MR.strings.notification_new_contact_request),
image = cInfo.image,
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, cInfo.id) })
listOf(
NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, incognito = false, cInfo.id) },
NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO to { acceptContactRequestAction(user.userId, incognito = true, cInfo.id) }
)
)
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
@@ -37,7 +41,7 @@ abstract class NtfManager {
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun acceptContactRequestAction(userId: Long?, chatId: ChatId) {
fun acceptContactRequestAction(userId: Long?, incognito: Boolean, chatId: ChatId) {
val isCurrentUser = ChatModel.currentUser.value?.userId == userId
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
(ChatModel.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
@@ -45,7 +49,7 @@ abstract class NtfManager {
null
}
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
acceptContactRequest(apiId, cInfo, isCurrentUser, ChatModel)
acceptContactRequest(incognito, apiId, cInfo, isCurrentUser, ChatModel)
cancelNotificationsForChat(chatId)
}
@@ -5,6 +5,7 @@ import InfoRowEllipsis
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionTextFooter
import SectionView
@@ -271,7 +272,10 @@ fun ChatInfoLayout(
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
InfoRow(generalGetString(MR.strings.incognito_random_profile), customUserProfile.chatViewName)
SectionItemViewSpaceBetween {
Text(generalGetString(MR.strings.incognito_random_profile))
Text(customUserProfile.chatViewName, color = Indigo)
}
}
SectionDividerSpaced()
}
@@ -127,7 +127,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
chatModelIncognito = chatModel.incognito.value,
back = {
hideKeyboard(view)
AudioPlayer.stop()
@@ -379,7 +378,6 @@ fun ChatLayout(
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
@@ -465,7 +463,7 @@ fun ChatLayout(
) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed,
@@ -634,7 +632,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
@@ -1184,7 +1181,6 @@ fun PreviewChatLayout() {
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = { _, _ -> },
@@ -1252,7 +1248,6 @@ fun PreviewGroupChatLayout() {
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
showMemberInfo = { _, _ -> },
@@ -20,17 +20,16 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.InfoAboutIncognito
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.group.GroupPreferencesView
import chat.simplex.res.MR
@Composable
@@ -41,7 +40,6 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
BackHandler(onBack = close)
AddGroupMembersLayout(
chatModel.incognito.value,
groupInfo = groupInfo,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel, searchText.value.text),
@@ -92,7 +90,6 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
@Composable
fun AddGroupMembersLayout(
chatModelIncognito: Boolean,
groupInfo: GroupInfo,
creatingGroup: Boolean,
contactsToAdd: List<Contact>,
@@ -107,19 +104,31 @@ fun AddGroupMembersLayout(
removeContact: (Long) -> Unit,
close: () -> Unit,
) {
@Composable fun profileText() {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
painterResource(MR.images.ic_info),
null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier.padding(end = 10.dp).size(20.dp)
)
Text(generalGetString(MR.strings.group_main_profile_sent), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2)
}
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(MR.strings.button_add_members))
InfoAboutIncognito(
chatModelIncognito,
false,
generalGetString(MR.strings.group_unsupported_incognito_main_profile_sent),
generalGetString(MR.strings.group_main_profile_sent),
true
)
profileText()
Spacer(Modifier.size(DEFAULT_PADDING))
Row(
Modifier.fillMaxWidth(),
@@ -350,7 +359,6 @@ fun showProhibitedToInviteIncognitoAlertDialog() {
fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
chatModelIncognito = false,
groupInfo = GroupInfo.sampleData,
creatingGroup = false,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
@@ -3,6 +3,7 @@ package chat.simplex.common.views.chat.group
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
@@ -445,19 +446,42 @@ private fun updateMemberRoleDialog(
}
fun connectViaMemberAddressAlert(connReqUri: String) {
AlertManager.shared.showAlertDialog(
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.connect_via_member_address_alert_title),
text = generalGetString(MR.strings.connect_via_member_address_alert_desc),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting")
connectViaUri(chatModel, linkType, uri)
text = AnnotatedString(generalGetString(MR.strings.connect_via_member_address_alert_desc)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting")
connectViaUri(chatModel, linkType, uri, incognito = false)
}
}
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting incognito")
connectViaUri(chatModel, linkType, uri, incognito = true)
}
}
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
},
}
)
}
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -13,9 +14,12 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.deleteGroupDialog
@@ -23,9 +27,7 @@ import chat.simplex.common.views.chat.group.leaveGroupDialog
import chat.simplex.common.views.chat.item.InvalidJSONView
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@@ -46,7 +48,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -55,7 +57,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -63,7 +65,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
@@ -320,11 +322,20 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
@Composable
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
if (chatModel.incognito.value) stringResource(MR.strings.accept_contact_incognito_button) else stringResource(MR.strings.accept_contact_button),
if (chatModel.incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_check),
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
stringResource(MR.strings.accept_contact_button),
painterResource(MR.images.ic_check),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel)
showMenu.value = false
}
)
ItemAction(
stringResource(MR.strings.accept_contact_incognito_button),
painterResource(MR.images.ic_theater_comedy),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel)
showMenu.value = false
}
)
@@ -430,19 +441,37 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
}
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.accept_connection_request__question),
text = generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified),
confirmText = if (chatModel.incognito.value) generalGetString(MR.strings.accept_contact_incognito_button) else generalGetString(MR.strings.accept_contact_button),
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
dismissText = generalGetString(MR.strings.reject_contact_button),
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel)
}) {
Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel)
}) {
Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
rejectContactRequest(contactRequest, chatModel)
}) {
Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
}
}
)
}
fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(apiId)
val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId)
if (contact != null && isCurrentUser && contactRequest != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
@@ -457,38 +486,6 @@ fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: Cha
}
}
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(
if (connection.initiated) MR.strings.you_invited_your_contact
else MR.strings.you_accepted_connection
),
text = generalGetString(
if (connection.viaContactUri) MR.strings.you_will_be_connected_when_your_connection_request_is_accepted
else MR.strings.you_will_be_connected_when_your_contacts_device_is_online
),
buttons = {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = {
AlertManager.shared.hideAlert()
deleteContactConnectionAlert(connection, chatModel) {}
}) {
Text(stringResource(MR.strings.delete_verb))
}
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(MR.strings.ok))
}
}
}
)
}
fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_pending_connection__question),
@@ -662,7 +659,6 @@ fun PreviewChatListNavLinkDirect() {
),
null,
null,
false,
null,
null,
stopped = false,
@@ -702,7 +698,6 @@ fun PreviewChatListNavLinkGroup() {
),
null,
null,
false,
null,
null,
stopped = false,
@@ -727,7 +722,7 @@ fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chatLinkPreview = {
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
ContactRequestView(ChatInfo.ContactRequest.sampleData)
},
click = {},
dropdownMenuItems = null,
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -13,9 +14,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
@@ -221,14 +224,6 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (chatModel.incognito.value) {
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
stringResource(MR.strings.incognito),
tint = Indigo,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
Text(
stringResource(MR.strings.your_chats),
color = MaterialTheme.colors.onBackground,
@@ -317,17 +312,37 @@ fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) {
ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link)
ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link)
}
AlertManager.shared.showAlertDialog(
AlertManager.shared.showAlertDialogButtonsColumn(
title = title,
text = if (linkType == ConnectionLinkType.GROUP)
generalGetString(MR.strings.you_will_join_group)
AnnotatedString(generalGetString(MR.strings.you_will_join_group))
else
generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = {
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, linkType, uri)
AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, linkType, uri, incognito = false)
}
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting incognito")
connectViaUri(chatModel, linkType, uri, incognito = true)
}
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
@@ -33,7 +33,6 @@ fun ChatPreviewView(
chat: Chat,
chatModelDraft: ComposeState?,
chatModelDraftChatId: ChatId?,
chatModelIncognito: Boolean,
currentUserProfileDisplayName: String?,
contactNetworkStatus: NetworkStatus?,
stopped: Boolean,
@@ -138,7 +137,7 @@ fun ChatPreviewView(
}
@Composable
fun chatPreviewText(chatModelIncognito: Boolean) {
fun chatPreviewText() {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
val (text: CharSequence, inlineTextContent) = when {
@@ -175,7 +174,7 @@ fun ChatPreviewView(
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo))
GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary)
else -> {}
}
@@ -184,6 +183,37 @@ fun ChatPreviewView(
}
}
@Composable
fun chatStatusImage() {
if (cInfo is ChatInfo.Direct) {
val descr = contactNetworkStatus?.statusString
when (contactNetworkStatus) {
is NetworkStatus.Connected ->
IncognitoIcon(chat.chatInfo.incognito)
is NetworkStatus.Error ->
Icon(
painterResource(MR.images.ic_error),
contentDescription = descr,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(19.dp)
)
else ->
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
}
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
}
Row {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 72.dp)
@@ -199,14 +229,14 @@ fun ChatPreviewView(
chatPreviewTitle()
val height = with(LocalDensity.current) { 46.sp.toDp() }
Row(Modifier.heightIn(min = height)) {
chatPreviewText(chatModelIncognito)
chatPreviewText()
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Box(
contentAlignment = Alignment.TopEnd
) {
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Text(
ts,
color = MaterialTheme.colors.secondary,
@@ -262,24 +292,33 @@ fun ChatPreviewView(
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
Modifier.padding(top = 52.dp),
contentAlignment = Alignment.Center
) {
ChatStatusImage(contactNetworkStatus)
}
Box(
Modifier.padding(top = 50.dp),
contentAlignment = Alignment.Center
) {
chatStatusImage()
}
}
}
}
@Composable
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
fun IncognitoIcon(incognito: Boolean) {
if (incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
@Composable
private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
return if (groupInfo.membership.memberIncognito)
String.format(stringResource(MR.strings.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
else if (chatModelIncognito)
String.format(stringResource(MR.strings.group_preview_join_as), currentUserProfileDisplayName ?: "")
else
stringResource(MR.strings.group_preview_you_are_invited)
}
@@ -289,28 +328,6 @@ fun unreadCountStr(n: Int): String {
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation)
}
@Composable
fun ChatStatusImage(s: NetworkStatus?) {
val descr = s?.statusString
if (s is NetworkStatus.Error) {
Icon(
painterResource(MR.images.ic_error),
contentDescription = descr,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(19.dp)
)
} else if (s !is NetworkStatus.Connected) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
}
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
@@ -319,6 +336,6 @@ fun ChatStatusImage(s: NetworkStatus?) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
ChatPreviewView(Chat.sampleData, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
}
}
@@ -39,16 +39,22 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
val height = with(LocalDensity.current) { 46.sp.toDp() }
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
}
val ts = getTimestampText(contactConnection.updatedAt)
Column(
Modifier.fillMaxHeight(),
Box(
contentAlignment = Alignment.TopEnd
) {
val ts = getTimestampText(contactConnection.updatedAt)
Text(
ts,
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
Box(
Modifier.padding(top = 50.dp),
contentAlignment = Alignment.Center
) {
IncognitoIcon(contactConnection.incognito)
}
}
}
}
@@ -18,7 +18,7 @@ import chat.simplex.common.model.getTimestampText
import chat.simplex.res.MR
@Composable
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
Row {
ChatInfoImage(contactRequest, size = 72.dp)
Column(
@@ -32,7 +32,7 @@ fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.Con
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
color = MaterialTheme.colors.primary
)
val height = with(LocalDensity.current) { 46.sp.toDp() }
Text(stringResource(MR.strings.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
@@ -121,14 +121,6 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.incognito.value) {
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
stringResource(MR.strings.incognito),
tint = Indigo,
modifier = Modifier.padding(10.dp).size(26.dp)
)
}
}
},
onTitleClick = null,
@@ -1,7 +1,10 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
@@ -48,19 +51,20 @@ fun TextEditor(
Modifier
.fillMaxWidth()
.padding(contentPadding)
.heightIn(min = 52.dp),
// .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(26.dp)),
.heightIn(min = 52.dp)
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
val modifier = modifier
val textFieldModifier = modifier
.fillMaxWidth()
.navigationBarsWithImePadding()
.onFocusChanged { focused = it.isFocused }
.padding(10.dp)
BasicTextField(
value = value.value,
onValueChange = { value.value = it },
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -1,7 +1,7 @@
package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
@@ -14,20 +14,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@Composable
fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
fun AddContactView(
chatModel: ChatModel,
connReqInvitation: String,
contactConnection: MutableState<PendingContactConnection?>
) {
val clipboard = LocalClipboardManager.current
AddContactLayout(
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
connReq = connReqInvitation,
connIncognito = connIncognito,
contactConnection = contactConnection,
share = { clipboard.shareText(connReqInvitation) },
learnMore = {
ModalManager.center.showModal {
@@ -45,57 +51,63 @@ fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
}
@Composable
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit, learnMore: () -> Unit) {
fun AddContactLayout(
chatModel: ChatModel,
incognitoPref: SharedPreference<Boolean>,
connReq: String,
contactConnection: MutableState<PendingContactConnection?>,
share: () -> Unit,
learnMore: () -> Unit
) {
val incognito = remember { mutableStateOf(incognitoPref.get()) }
LaunchedEffect(incognito.value) {
withApi {
val contactConnVal = contactConnection.value
if (contactConnVal != null) {
chatModel.controller.apiSetConnectionIncognito(contactConnVal.pccConnId, incognito.value)?.let {
contactConnection.value = it
chatModel.updateContactConnection(it)
}
}
}
}
Column(
Modifier
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(MR.strings.add_contact))
OneTimeLinkProfileText(connIncognito)
SectionSpacer()
SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) {
OneTimeLinkSection(connReq, share, learnMore)
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
} else {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
}
SectionTextFooter(sharedProfileInfo(chatModel, incognito.value))
SectionBottomSpacer()
}
}
@Composable
fun OneTimeLinkProfileText(connIncognito: Boolean) {
Row(Modifier.padding(horizontal = DEFAULT_PADDING)) {
InfoAboutIncognito(
connIncognito,
true,
generalGetString(MR.strings.incognito_random_profile_description),
generalGetString(MR.strings.your_profile_will_be_sent)
)
}
}
@Composable
fun ColumnScope.OneTimeLinkSection(connReq: String, share: () -> Unit, learnMore: () -> Unit) {
if (connReq.isNotEmpty()) {
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
} else {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
}
@Composable
fun ShareLinkButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -117,39 +129,38 @@ fun OneTimeLinkLearnMoreButton(onClick: () -> Unit) {
}
@Composable
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String, centered: Boolean = false) {
if (chatModelIncognito) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start
) {
Icon(
if (supportedIncognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_info),
stringResource(MR.strings.incognito),
tint = if (supportedIncognito) Indigo else WarningOrange,
modifier = Modifier.padding(end = 10.dp).size(20.dp)
)
Text(onText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2)
}
fun IncognitoToggle(
incognitoPref: SharedPreference<Boolean>,
incognito: MutableState<Boolean>,
onClickInfo: () -> Unit
) {
SettingsActionItemWithContent(
icon = if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy),
text = null,
click = onClickInfo,
iconColor = if (incognito.value) Indigo else MaterialTheme.colors.secondary,
extraPadding = false
) {
SharedPreferenceToggleWithIcon(
stringResource(MR.strings.incognito),
painterResource(MR.images.ic_info),
stopped = false,
onClickInfo = onClickInfo,
preference = incognitoPref,
preferenceState = incognito
)
}
}
fun sharedProfileInfo(
chatModel: ChatModel,
incognito: Boolean
): String {
val name = chatModel.currentUser.value?.displayName ?: ""
return if (incognito) {
generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared)
} else {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start
) {
Icon(
painterResource(MR.images.ic_info),
stringResource(MR.strings.incognito),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.padding(end = 10.dp).size(20.dp)
)
Text(offText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2)
}
String.format(generalGetString(MR.strings.connect__your_profile_will_be_shared), name)
}
}
@@ -162,8 +173,10 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean
fun PreviewAddContactView() {
SimpleXTheme {
AddContactLayout(
chatModel = ChatModel,
incognitoPref = SharedPreference({ false }, {}),
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
connIncognito = false,
contactConnection = mutableStateOf(PendingContactConnection.getSampleData()),
share = {},
learnMore = {},
)
@@ -2,16 +2,18 @@ package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionDividerSpaced
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
@@ -19,10 +21,10 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.LocalAliasEditor
import chat.simplex.common.views.chatlist.deleteContactConnectionAlert
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.platform.shareText
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@Composable
@@ -49,10 +51,10 @@ fun ContactConnectionInfoView(
}
val clipboard = LocalClipboardManager.current
ContactConnectionInfoLayout(
chatModel = chatModel,
connReq = connReqInvitation,
contactConnection,
connIncognito = contactConnection.incognito,
focusAlias,
contactConnection = contactConnection,
focusAlias = focusAlias,
deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) },
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) },
@@ -73,22 +75,43 @@ fun ContactConnectionInfoView(
@Composable
private fun ContactConnectionInfoLayout(
chatModel: ChatModel,
connReq: String?,
contactConnection: PendingContactConnection,
connIncognito: Boolean,
focusAlias: Boolean,
deleteConnection: () -> Unit,
onLocalAliasChanged: (String) -> Unit,
share: () -> Unit,
learnMore: () -> Unit,
) {
@Composable fun incognitoEnabled() {
if (contactConnection.incognito) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_theater_comedy_filled),
text = null,
click = { ModalManager.start.showModal { IncognitoView() } },
iconColor = Indigo,
extraPadding = false
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(MR.strings.incognito), Modifier.padding(end = 4.dp))
Icon(
painterResource(MR.images.ic_info),
null,
tint = MaterialTheme.colors.primary
)
}
}
}
}
Column(
Modifier
.verticalScroll(rememberScrollState()),
) {
AppBarTitle(
stringResource(
if (contactConnection.initiated) MR.strings.you_invited_your_contact
if (contactConnection.initiated) MR.strings.you_invited_a_contact
else MR.strings.you_accepted_connection
)
)
@@ -101,7 +124,6 @@ private fun ContactConnectionInfoLayout(
),
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
)
OneTimeLinkProfileText(connIncognito)
if (contactConnection.groupLinkId == null) {
LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
@@ -109,11 +131,20 @@ private fun ContactConnectionInfoLayout(
SectionView {
if (!connReq.isNullOrEmpty() && contactConnection.initiated) {
OneTimeLinkSection(connReq, share, learnMore)
QRCode(
connReq, Modifier
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
incognitoEnabled()
ShareLinkButton(share)
OneTimeLinkLearnMoreButton(learnMore)
} else {
incognitoEnabled()
OneTimeLinkLearnMoreButton(learnMore)
}
}
SectionTextFooter(sharedProfileInfo(chatModel, contactConnection.incognito))
SectionDividerSpaced(maxBottomPadding = false)
@@ -149,9 +180,9 @@ private fun setContactAlias(contactConnection: PendingContactConnection, localAl
private fun PreviewContactConnectionInfoView() {
SimpleXTheme {
ContactConnectionInfoLayout(
chatModel = ChatModel,
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
PendingContactConnection.getSampleData(),
connIncognito = false,
contactConnection = PendingContactConnection.getSampleData(),
focusAlias = false,
deleteConnection = {},
onLocalAliasChanged = {},
@@ -10,6 +10,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.views.helpers.ModalManager
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.usersettings.UserAddressView
@@ -23,10 +24,16 @@ enum class CreateLinkTab {
fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
val selection = remember { mutableStateOf(initialSelection) }
val connReqInvitation = rememberSaveable { m.connReqInv }
val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable { mutableStateOf(null) }
val creatingConnReq = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selection.value) {
if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && !creatingConnReq.value) {
createInvitation(m, creatingConnReq, connReqInvitation)
if (
selection.value == CreateLinkTab.ONE_TIME
&& connReqInvitation.value.isNullOrEmpty()
&& contactConnection.value == null
&& !creatingConnReq.value
) {
createInvitation(m, creatingConnReq, connReqInvitation, contactConnection)
}
}
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv].
@@ -42,9 +49,12 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
}
val tabTitles = CreateLinkTab.values().map {
when {
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() -> stringResource(MR.strings.create_one_time_link)
it == CreateLinkTab.ONE_TIME -> stringResource(MR.strings.one_time_link)
it == CreateLinkTab.LONG_TERM -> stringResource(MR.strings.your_simplex_contact_address)
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && contactConnection.value == null ->
stringResource(MR.strings.create_one_time_link)
it == CreateLinkTab.ONE_TIME ->
stringResource(MR.strings.one_time_link)
it == CreateLinkTab.LONG_TERM ->
stringResource(MR.strings.your_simplex_contact_address)
else -> ""
}
}
@@ -56,7 +66,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
Column(Modifier.weight(1f)) {
when (selection.value) {
CreateLinkTab.ONE_TIME -> {
AddContactView(connReqInvitation.value ?: "", m.incognito.value)
AddContactView(m, connReqInvitation.value ?: "", contactConnection)
}
CreateLinkTab.LONG_TERM -> {
UserAddressView(m, viaCreateLinkView = true, close = {})
@@ -89,12 +99,18 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
}
}
private fun createInvitation(m: ChatModel, creatingConnReq: MutableState<Boolean>, connReqInvitation: MutableState<String?>) {
private fun createInvitation(
m: ChatModel,
creatingConnReq: MutableState<Boolean>,
connReqInvitation: MutableState<String?>,
contactConnection: MutableState<PendingContactConnection?>
) {
creatingConnReq.value = true
withApi {
val connReq = m.controller.apiAddContact()
if (connReq != null) {
connReqInvitation.value = connReq
val r = m.controller.apiAddContact(incognito = m.controller.appPrefs.incognito.get())
if (r != null) {
connReqInvitation.value = r.first
contactConnection.value = r.second
} else {
creatingConnReq.value = false
}
@@ -1,22 +1,26 @@
package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.desktop.ui.tooling.preview.Preview
import chat.simplex.common.platform.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.TAG
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.IncognitoView
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import java.net.URI
@@ -25,85 +29,98 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
val connectionLink = remember { mutableStateOf("") }
val clipboard = LocalClipboardManager.current
PasteToConnectLayout(
chatModel.incognito.value,
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
connectionLink = connectionLink,
pasteFromClipboard = {
connectionLink.value = clipboard.getText()?.text ?: return@PasteToConnectLayout
},
connectViaLink = { connReqUri ->
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(chatModel, linkType, uri)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
}
},
close = close
)
}
@Composable
fun PasteToConnectLayout(
chatModelIncognito: Boolean,
chatModel: ChatModel,
incognitoPref: SharedPreference<Boolean>,
connectionLink: MutableState<String>,
pasteFromClipboard: () -> Unit,
connectViaLink: (String) -> Unit,
close: () -> Unit
) {
val incognito = remember { mutableStateOf(incognitoPref.get()) }
fun connectViaLink(connReqUri: String) {
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(chatModel, linkType, uri, incognito = incognito.value)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_connection_link),
text = generalGetString(MR.strings.this_string_is_not_a_connection_link)
)
}
}
Column(
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween,
) {
AppBarTitle(stringResource(MR.strings.connect_via_link), false)
Text(stringResource(MR.strings.paste_connection_link_below_to_connect))
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(MR.strings.incognito_random_profile_from_contact_description),
generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)
)
Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) {
TextEditor(connectionLink, Modifier.height(180.dp), contentPadding = PaddingValues())
TextEditor(
connectionLink,
Modifier.height(180.dp),
contentPadding = PaddingValues(),
placeholder = stringResource(MR.strings.paste_the_link_you_received_to_connect_with_your_contact)
)
}
Row(
Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.Start,
) {
if (connectionLink.value == "") {
SimpleButton(text = stringResource(MR.strings.paste_button), icon = painterResource(MR.images.ic_content_paste)) {
pasteFromClipboard()
}
} else {
SimpleButton(text = stringResource(MR.strings.clear_verb), icon = painterResource(MR.images.ic_close)) {
connectionLink.value = ""
}
}
Spacer(Modifier.weight(1f).fillMaxWidth())
SimpleButton(text = stringResource(MR.strings.connect_button), icon = painterResource(MR.images.ic_link)) {
connectViaLink(connectionLink.value)
}
if (connectionLink.value == "") {
SettingsActionItem(
painterResource(MR.images.ic_content_paste),
stringResource(MR.strings.paste_button),
click = pasteFromClipboard,
)
} else {
SettingsActionItem(
painterResource(MR.images.ic_close),
stringResource(MR.strings.clear_verb),
click = { connectionLink.value = "" },
)
}
Text(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link))
SettingsActionItem(
painterResource(MR.images.ic_link),
stringResource(MR.strings.connect_button),
click = { connectViaLink(connectionLink.value) },
)
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
SectionTextFooter(
buildAnnotatedString {
append(sharedProfileInfo(chatModel, incognito.value))
append("\n\n")
append(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link))
}
)
SectionBottomSpacer()
}
}
@@ -117,17 +134,11 @@ fun PasteToConnectLayout(
fun PreviewPasteToConnectTextbox() {
SimpleXTheme {
PasteToConnectLayout(
chatModelIncognito = false,
chatModel = ChatModel,
incognitoPref = SharedPreference({ false }, {}),
connectionLink = remember { mutableStateOf("") },
pasteFromClipboard = {},
connectViaLink = { link ->
try {
println(link)
// withApi { chatModel.controller.apiConnect(link) }
} catch (e: Exception) {
e.printStackTrace()
}
},
close = {}
)
}
}
@@ -37,7 +37,7 @@ fun QRCode(
bitmap = qr,
contentDescription = stringResource(MR.strings.image_descr_qr_code),
Modifier
.widthIn(max = 500.dp)
.widthIn(max = 360.dp)
.then(modifier)
.clickable {
scope.launch {
@@ -1,23 +1,23 @@
package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.desktop.ui.tooling.preview.Preview
import chat.simplex.common.platform.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.TAG
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.json
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -26,36 +26,6 @@ import java.net.URI
@Composable
expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit)
@Composable
fun QRCodeScanner(close: () -> Unit) {
QRCodeScanner { connReqUri ->
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(ChatModel, linkType, uri)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_QR_code),
text = generalGetString(MR.strings.this_QR_code_is_not_a_link)
)
}
}
}
enum class ConnectionLinkType {
CONTACT, INVITATION, GROUP
}
@@ -93,8 +63,8 @@ fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) {
}
}
suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI): Boolean {
val r = chatModel.controller.apiConnect(uri.toString())
suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI, incognito: Boolean): Boolean {
val r = chatModel.controller.apiConnect(incognito, uri.toString())
if (r) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent),
@@ -110,28 +80,65 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri:
}
@Composable
fun ConnectContactLayout(chatModelIncognito: Boolean, close: () -> Unit) {
fun ConnectContactLayout(
chatModel: ChatModel,
incognitoPref: SharedPreference<Boolean>,
close: () -> Unit
) {
val incognito = remember { mutableStateOf(incognitoPref.get()) }
@Composable
fun QRCodeScanner(close: () -> Unit) {
QRCodeScanner { connReqUri ->
try {
val uri = URI(connReqUri)
withUriAction(uri) { linkType ->
val action = suspend {
Log.d(TAG, "connectViaUri: connecting")
if (connectViaUri(ChatModel, linkType, uri, incognito = incognito.value)) {
close()
}
}
if (linkType == ConnectionLinkType.GROUP) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.connect_via_group_link),
text = generalGetString(MR.strings.you_will_join_group),
confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb),
onConfirm = { withApi { action() } }
)
} else action()
}
} catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_QR_code),
text = generalGetString(MR.strings.this_QR_code_is_not_a_link)
)
}
}
}
Column(
Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.SpaceBetween
) {
AppBarTitle(stringResource(MR.strings.scan_QR_code), false)
InfoAboutIncognito(
chatModelIncognito,
true,
generalGetString(MR.strings.incognito_random_profile_description),
generalGetString(MR.strings.your_profile_will_be_sent)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = 12.dp)
) { QRCodeScanner(close) }
Text(
annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
lineHeight = 22.sp
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
SectionTextFooter(
buildAnnotatedString {
append(sharedProfileInfo(chatModel, incognito.value))
append("\n\n")
append(annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link))
}
)
SectionBottomSpacer()
}
}
@@ -150,7 +157,8 @@ fun URI.getQueryParameter(param: String): String? {
fun PreviewConnectContactLayout() {
SimpleXTheme {
ConnectContactLayout(
chatModelIncognito = false,
chatModel = ChatModel,
incognitoPref = SharedPreference({ false }, {}),
close = {},
)
}
@@ -31,7 +31,6 @@ fun IncognitoLayout() {
Text(generalGetString(MR.strings.incognito_info_protects))
Text(generalGetString(MR.strings.incognito_info_allows))
Text(generalGetString(MR.strings.incognito_info_share))
Text(generalGetString(MR.strings.incognito_info_find))
SectionBottomSpacer()
}
}
@@ -37,16 +37,12 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
MaintainIncognitoState(chatModel)
if (user != null) {
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
SettingsLayout(
profile = user.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
chatModel.incognito,
chatModel.controller.appPrefs.incognito,
user.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
@@ -118,8 +114,6 @@ fun SettingsLayout(
profile: LocalProfile,
stopped: Boolean,
encrypted: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: SharedPreference<Boolean>,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@@ -155,7 +149,6 @@ fun SettingsLayout(
}
val profileHidden = rememberSaveable { mutableStateOf(false) }
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
}
@@ -212,43 +205,6 @@ expect fun SettingsSectionApp(
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
)
@Composable
fun SettingsIncognitoActionItem(
incognitoPref: SharedPreference<Boolean>,
incognito: MutableState<Boolean>,
stopped: Boolean,
onClickInfo: () -> Unit,
) {
SettingsPreferenceItemWithInfo(
if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy),
if (incognito.value) Indigo else MaterialTheme.colors.secondary,
stringResource(MR.strings.incognito),
stopped,
onClickInfo,
incognitoPref,
incognito
)
}
@Composable
fun MaintainIncognitoState(chatModel: ChatModel) {
// Cache previous value and once it changes in background, update it via API
var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) }
LaunchedEffect(chatModel.incognito.value) {
// Don't do anything if nothing changed
if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect
try {
chatModel.controller.apiSetIncognito(chatModel.incognito.value)
} catch (e: Exception) {
// Rollback the state
chatModel.controller.appPrefs.incognito.set(cachedIncognito)
// Crash the app
throw e
}
cachedIncognito = chatModel.incognito.value
}
}
@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) {
SectionItemViewWithIcon(openDatabaseView) {
Row(
@@ -453,21 +409,6 @@ fun SettingsPreferenceItem(
}
}
@Composable
fun SettingsPreferenceItemWithInfo(
icon: Painter,
iconTint: Color,
text: String,
stopped: Boolean,
onClickInfo: () -> Unit,
pref: SharedPreference<Boolean>,
prefState: MutableState<Boolean>? = null
) {
SettingsActionItemWithContent(icon, null, click = if (stopped) null else onClickInfo, iconColor = iconTint, extraPadding = true,) {
SharedPreferenceToggleWithIcon(text, painterResource(MR.images.ic_info), stopped, onClickInfo, pref, prefState)
}
}
@Composable
fun PreferenceToggle(
text: String,
@@ -523,8 +464,6 @@ fun PreviewSettingsLayout() {
profile = LocalProfile.sampleData,
stopped = false,
encrypted = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = SharedPreference({ false }, {}),
userDisplayName = "Alice",
setPerformLA = { _ -> },
showModal = { {} },
@@ -7,9 +7,12 @@
<string name="connect_via_contact_link">Connect via contact link?</string>
<string name="connect_via_invitation_link">Connect via invitation link?</string>
<string name="connect_via_group_link">Connect via group link?</string>
<string name="connect_use_current_profile">Use current profile</string>
<string name="connect_use_new_incognito_profile">Use new incognito profile</string>
<string name="profile_will_be_sent_to_contact_sending_link">Your profile will be sent to the contact that you received this link from.</string>
<string name="you_will_join_group">You will join a group this link refers to and connect to its group members.</string>
<string name="connect_via_link_verb">Connect</string>
<string name="connect_via_link_incognito">Connect incognito</string>
<!-- MainActivity.kt -->
<string name="opening_database">Opening database…</string>
@@ -443,7 +446,7 @@
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">You invited your contact</string>
<string name="you_invited_a_contact">You invited a contact</string>
<string name="you_accepted_connection">You accepted connection</string>
<string name="delete_pending_connection__question">Delete pending connection?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">The contact you shared this link with will NOT be able to connect!</string>
@@ -489,11 +492,13 @@
<string name="your_chat_profile_will_be_sent_to_your_contact">Your chat profile will be sent\nto your contact</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[If you cannot meet in person, you can <b>scan QR code in the video call</b>, or your contact can share an invitation link.]]></string>
<string name="share_invitation_link">Share 1-time link</string>
<string name="paste_connection_link_below_to_connect">Paste the link you received into the box below to connect with your contact.</string>
<string name="your_profile_will_be_sent">Your chat profile will be sent to your contact</string>
<string name="paste_the_link_you_received_to_connect_with_your_contact">Paste the link you received to connect with your contact</string>
<string name="learn_more">Learn more</string>
<string name="learn_more_about_address">About SimpleX address</string>
<string name="connect__a_new_random_profile_will_be_shared">A new random profile will be shared.</string>
<string name="connect__your_profile_will_be_shared">Your profile %1$s will be shared.</string>
<!-- Add Contact Learn More - AddContactLearnMore.kt -->
<string name="scan_qr_to_connect_to_contact">To connect, your contact can scan QR code or use the link in the app.</string>
<string name="if_you_cant_meet_in_person">If you can\'t meet in person, show QR code in a video call, or share the link.</string>
@@ -1247,7 +1252,6 @@
<string name="group_is_decentralized">The group is fully decentralized it is visible only to the members.</string>
<string name="group_display_name_field">Group display name:</string>
<string name="group_full_name_field">Group full name:</string>
<string name="group_unsupported_incognito_main_profile_sent">Incognito mode is not supported here - your main profile will be sent to group members</string>
<string name="group_main_profile_sent">Your chat profile will be sent to group members</string>
@@ -1301,13 +1305,10 @@
<!-- Incognito mode -->
<string name="incognito">Incognito</string>
<string name="incognito_random_profile">Your random profile</string>
<string name="incognito_random_profile_description">A random profile will be sent to your contact</string>
<string name="incognito_random_profile_from_contact_description">A random profile will be sent to the contact that you received this link from</string>
<string name="incognito_info_protects">Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.</string>
<string name="incognito_info_protects">Incognito mode protects your privacy by using a new random profile for each contact.</string>
<string name="incognito_info_allows">It allows having many anonymous connections without any shared data between them in a single chat profile.</string>
<string name="incognito_info_share">When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</string>
<string name="incognito_info_find">To find the profile used for an incognito connection, tap the contact or group name on top of the chat.</string>
<!-- Default themes -->
<string name="theme_system">System</string>
@@ -201,7 +201,7 @@
<string name="mark_unread">Označit jako nepřečteno</string>
<string name="mute_chat">Ztlumit</string>
<string name="unmute_chat">Zrušit ztlumení</string>
<string name="you_invited_your_contact">Pozvali jste kontakt</string>
<string name="you_invited_a_contact">Pozvali jste kontakt</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Kontakt, se kterým jste tento odkaz sdíleli, se NEBUDE moci připojit!</string>
<string name="connection_you_accepted_will_be_cancelled">Připojení, které jste přijali, bude zrušeno!</string>
<string name="icon_descr_help">help</string>
@@ -292,7 +292,7 @@
<string name="mute_chat">Stummschalten</string>
<string name="unmute_chat">Stummschaltung aufheben</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Sie haben Ihren Kontakt eingeladen</string>
<string name="you_invited_a_contact">Sie haben Ihren Kontakt eingeladen</string>
<string name="you_accepted_connection">Sie haben die Verbindung akzeptiert</string>
<string name="delete_pending_connection__question">Ausstehende Verbindung löschen?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Der Kontakt, mit dem Sie diesen Link geteilt haben, kann sich NICHT verbinden!</string>
@@ -914,7 +914,7 @@
<string name="you_are_observer">Tu rol es observador</string>
<string name="verify_security_code">Comprobar código de seguridad</string>
<string name="you_accepted_connection">Has aceptado la conexión</string>
<string name="you_invited_your_contact">Has invitado a tu contacto</string>
<string name="you_invited_a_contact">Has invitado a tu contacto</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Te conectarás al grupo cuando el dispositivo anfitrión esté en línea, por favor espera o compruébalo más tarde.</string>
<string name="your_settings">Tu configuración</string>
<string name="your_SMP_servers">Servidores SMP</string>
@@ -1232,7 +1232,7 @@
<string name="group_preview_you_are_invited">sinut on kutsuttu ryhmään</string>
<string name="unmute_chat">Poista mykistys</string>
<string name="you_accepted_connection">Hyväksyit yhteyden</string>
<string name="you_invited_your_contact">Kutsuit kontaktisi</string>
<string name="you_invited_a_contact">Kutsuit kontaktisi</string>
<string name="call_connection_via_relay">releellä</string>
<string name="database_downgrade_warning">Varoitus: saatat menettää joitain tietoja!</string>
<string name="icon_descr_address">SimpleX Osoite</string>
@@ -221,7 +221,7 @@
<string name="mark_read">Marquer comme lu</string>
<string name="mark_unread">Marquer non lu</string>
<string name="set_contact_name">Définir le nom du contact</string>
<string name="you_invited_your_contact">Vous avez invité votre contact</string>
<string name="you_invited_a_contact">Vous avez invité votre contact</string>
<string name="you_accepted_connection">Vous avez accepté la connexion</string>
<string name="delete_pending_connection__question">Supprimer la connexion en attente \?</string>
<string name="connection_you_accepted_will_be_cancelled">La connexion que vous avez acceptée sera annulée !</string>
@@ -568,7 +568,7 @@
<string name="icon_descr_address">Indirizzo di SimpleX</string>
<string name="icon_descr_simplex_team">Squadra di SimpleX</string>
<string name="you_accepted_connection">Hai accettato la connessione</string>
<string name="you_invited_your_contact">Hai invitato il contatto</string>
<string name="you_invited_a_contact">Hai invitato il contatto</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Il tuo profilo di chat verrà inviato
\nal tuo contatto</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Il tuo contatto deve essere in linea per completare la connessione.
@@ -1182,7 +1182,7 @@
<string name="waiting_for_video">ממתין לסרטון</string>
<string name="waiting_for_file">ממתין לקובץ</string>
<string name="voice_messages_prohibited">הודעות קוליות אסורות!</string>
<string name="you_invited_your_contact">הזמנת את איש הקשר שלך</string>
<string name="you_invited_a_contact">הזמנת את איש הקשר שלך</string>
<string name="you_can_share_your_address">באפשרותכם לשתף את הכתובת שלכם כקישור או כקוד QR – כל אחד יכול להתחבר אליכם.</string>
<string name="you_can_accept_or_reject_connection">כאשר אנשים מבקשים להתחבר, באפשרותך לקבל או לדחות זאת.</string>
<string name="you_can_also_connect_by_clicking_the_link"><![CDATA[באפשרותכם גם להתחבר על־ידי לחיצה על הקישור. אם הוא נפתח בדפדפן, ליחצו על הכפתור <b>פתח באפליקציה</b>.]]></string>
@@ -715,7 +715,7 @@
<string name="thank_you_for_installing_simplex">SimpleX Chatをご利用いただきありがとうございます!</string>
<string name="use_camera_button">カメラ</string>
<string name="you_accepted_connection">繋がりを承認しました</string>
<string name="you_invited_your_contact">連絡先に招待を送りました</string>
<string name="you_invited_a_contact">連絡先に招待を送りました</string>
<string name="connection_you_accepted_will_be_cancelled">承認ずみの接続がキャンセルされます!</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">あなたからリンクを受けた連絡先が接続できなくなります!</string>
<string name="icon_descr_address">SimpleXアドレス</string>
@@ -682,7 +682,7 @@
<string name="connection_you_accepted_will_be_cancelled">De door u geaccepteerde verbinding wordt geannuleerd!</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Het contact met wie je deze link hebt gedeeld, kan GEEN verbinding maken!</string>
<string name="you_accepted_connection">Je hebt de verbinding geaccepteerd</string>
<string name="you_invited_your_contact">Je hebt je contactpersoon uitgenodigd</string>
<string name="you_invited_a_contact">Je hebt je contactpersoon uitgenodigd</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Uw contactpersoon moet online zijn om de verbinding te voltooien.
\nU kunt deze verbinding verbreken en het contact verwijderen (en later proberen met een nieuwe link).</string>
<string name="image_descr_qr_code">QR code</string>
@@ -257,7 +257,7 @@
<string name="unmute_chat">Wyłącz wyciszenie</string>
<string name="contact_wants_to_connect_with_you">chce się z Tobą połączyć!</string>
<string name="you_accepted_connection">Zaakceptowałeś połączenie</string>
<string name="you_invited_your_contact">Zaprosiłeś swój kontakt</string>
<string name="you_invited_a_contact">Zaprosiłeś swój kontakt</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Twój kontakt musi być online, aby połączenie zostało zakończone.
\nMożesz anulować to połączenie i usunąć kontakt (i spróbować później z nowym linkiem).</string>
<string name="connect_button">Połącz</string>
@@ -473,7 +473,7 @@
<string name="simplex_service_notification_text">Recebendo mensagens…</string>
<string name="large_file">Aruivo grande!</string>
<string name="mark_read">Marcado como lido</string>
<string name="you_invited_your_contact">Você convidou seu contato</string>
<string name="you_invited_a_contact">Você convidou seu contato</string>
<string name="invalid_QR_code">Código QR inválido</string>
<string name="icon_descr_more_button">Mais</string>
<string name="you_will_be_connected_when_group_host_device_is_online">Você será conectado ao grupo quando o dispositivo do host do grupo estiver online, por favor aguarde ou verifique mais tarde!</string>
@@ -294,7 +294,7 @@
<string name="mute_chat">Без звука</string>
<string name="unmute_chat">Уведомлять</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Вы пригласили Ваш контакт</string>
<string name="you_invited_a_contact">Вы пригласили Ваш контакт</string>
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
<string name="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому Вы отправили эту ссылку, не сможет соединиться!</string>
@@ -1126,7 +1126,7 @@
<string name="to_start_a_new_chat_help_header">เพื่อเริ่มแชทใหม่</string>
<string name="gallery_video_button">วิดีโอ</string>
<string name="unmute_chat">เปิดเสียง</string>
<string name="you_invited_your_contact">คุณได้เชิญผู้ติดต่อของคุณ</string>
<string name="you_invited_a_contact">คุณได้เชิญผู้ติดต่อของคุณ</string>
<string name="you_accepted_connection">คุณยอมรับการเชื่อมต่อ</string>
<string name="contact_wants_to_connect_with_you">ต้องการเชื่อมต่อกับคุณ!</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">ผู้ติดต่อของคุณจะต้องออนไลน์เพื่อให้การเชื่อมต่อเสร็จสมบูรณ์
@@ -226,7 +226,7 @@
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app"><![CDATA[📱 мобільний: торкніться <b>Відкрийте в мобільному додатку</b>, потім торкніться <b>Підключіть</b> в додатку.]]></string>
<string name="mute_chat">Вимкнути звук</string>
<string name="unmute_chat">Увімкнути звук</string>
<string name="you_invited_your_contact">Ви запросили свого контакта</string>
<string name="you_invited_a_contact">Ви запросили свого контакта</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, якому ви надали це посилання, НЕ зможе підключитися!</string>
<string name="icon_descr_profile_image_placeholder">заповнювач зображення профілю</string>
<string name="image_descr_qr_code">QR-код</string>
@@ -902,7 +902,7 @@
<string name="voice_messages_prohibited">语音消息禁止发送!</string>
<string name="you_need_to_allow_to_send_voice">您需要允许您的联系人发送语音消息才能发送它们。</string>
<string name="scan_QR_code">扫描二维码</string>
<string name="you_invited_your_contact">您邀请了您的联系人</string>
<string name="you_invited_a_contact">您邀请了您的联系人</string>
<string name="contact_wants_to_connect_with_you">想要与您连接!</string>
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">您的联系人需要在线才能完成连接。
\n您可以取消此连接并删除联系人(稍后尝试使用新链接)。</string>
@@ -533,7 +533,7 @@
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[你可以透過 <font color="#0088ff">連接到 SimpleX Chat 開發人員提出任何問題並同意更新</font>。]]></string>
<string name="to_start_a_new_chat_help_header">開啟新的對話</string>
<string name="set_contact_name">設定聯絡人名稱</string>
<string name="you_invited_your_contact">你已邀請了你的聯絡人</string>
<string name="you_invited_a_contact">你已邀請了你的聯絡人</string>
<string name="you_accepted_connection">你接受了連接</string>
<string name="delete_pending_connection__question">刪除等待中的連接?</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">當聯絡人發現此連結後,嘗試點擊的聯絡人將無法連接!</string>
@@ -1,7 +1,13 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.DialogParams
import chat.simplex.res.MR
@@ -21,6 +27,8 @@ actual fun DefaultDialog(
) {
Dialog(
undecorated = true,
transparent = true,
resizable = false,
title = "",
onCloseRequest = onDismissRequest,
onPreviewKeyEvent = { event ->
@@ -29,7 +37,12 @@ actual fun DefaultDialog(
} else false
}
) {
content()
Surface(
Modifier
.border(border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.3F)), shape = RoundedCornerShape(8))
) {
content()
}
}
}
@@ -6,7 +6,8 @@ import chat.simplex.common.model.ChatModel
@Composable
actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
ConnectContactLayout(
chatModelIncognito = chatModel.incognito.value,
chatModel = chatModel,
incognitoPref = chatModel.controller.appPrefs.incognito,
close = close
)
}
+4 -3
View File
@@ -25,10 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.3-beta.2
android.version_code=140
android.version_name=5.3-beta.3
android.version_code=141
desktop.version_name=1.0.1
desktop.version_name=1.1.0
desktop.version_code=3
kotlin.version=1.8.20
gradle.plugin.version=7.4.2
+2 -67
View File
@@ -1,76 +1,11 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Concurrent (forkIO)
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad.Reader
import qualified Data.Text as T
import Options
import Simplex.Chat.Bot
import Simplex.Chat.Controller
import Broadcast.Bot
import Broadcast.Options
import Simplex.Chat.Core
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Types
import System.Directory (getAppUserDataDirectory)
main :: IO ()
main = do
opts <- welcomeGetOpts
simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ broadcastBot opts
welcomeGetOpts :: IO BroadcastBotOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot"
putStrLn $ "SimpleX Chat Bot v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
pure opts
broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO ()
broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do
initializeBotAddress cc
race_ (forever $ void getLine) . forever $ do
(_, resp) <- atomically . readTBQueue $ outputQ cc
case resp of
CRContactConnected _ ct _ -> do
contactConnected ct
sendMessage cc ct welcomeMessage
CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc})
| publisher `elem` publishers ->
if allowContent mc
then do
sendChatCmd cc "/contacts" >>= \case
CRContactsList _ cts -> void . forkIO $ do
let cts' = filter broadcastTo cts
forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc
sendReply $ "Forwarded to " <> show (length cts') <> " contact(s)"
r -> putStrLn $ "Error getting contacts list: " <> show r
else sendReply "!1 Message is not supported!"
| otherwise -> do
sendReply prohibitedMessage
deleteMessage cc ct $ chatItemId' ci
where
sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . textMsgContent
publisher = Publisher {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
allowContent = \case
MCText _ -> True
MCLink {} -> True
MCImage {} -> True
_ -> False
broadcastTo ct'@Contact {activeConn = conn@Connection {connStatus}} =
(connStatus == ConnSndReady || connStatus == ConnReady)
&& not (connDisabled conn)
&& contactId' ct' /= contactId' ct
_ -> pure ()
where
contactConnected ct = putStrLn $ T.unpack (localDisplayName' ct) <> " connected"
@@ -0,0 +1,71 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Broadcast.Bot where
import Control.Concurrent (forkIO)
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad.Reader
import qualified Data.Text as T
import Broadcast.Options
import Simplex.Chat.Bot
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Types
import System.Directory (getAppUserDataDirectory)
welcomeGetOpts :: IO BroadcastBotOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot"
putStrLn $ "SimpleX Chat Bot v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
pure opts
broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO ()
broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do
initializeBotAddress cc
race_ (forever $ void getLine) . forever $ do
(_, resp) <- atomically . readTBQueue $ outputQ cc
case resp of
CRContactConnected _ ct _ -> do
contactConnected ct
sendMessage cc ct welcomeMessage
CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc})
| publisher `elem` publishers ->
if allowContent mc
then do
sendChatCmd cc ListContacts >>= \case
CRContactsList _ cts -> void . forkIO $ do
let cts' = filter broadcastTo cts
forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc
sendReply $ "Forwarded to " <> show (length cts') <> " contact(s)"
r -> putStrLn $ "Error getting contacts list: " <> show r
else sendReply "!1 Message is not supported!"
| otherwise -> do
sendReply prohibitedMessage
deleteMessage cc ct $ chatItemId' ci
where
sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . textMsgContent
publisher = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
allowContent = \case
MCText _ -> True
MCLink {} -> True
MCImage {} -> True
_ -> False
broadcastTo ct'@Contact {activeConn = conn@Connection {connStatus}} =
(connStatus == ConnSndReady || connStatus == ConnReady)
&& not (connDisabled conn)
&& contactId' ct' /= contactId' ct
_ -> pure ()
where
contactConnected ct = putStrLn $ T.unpack (localDisplayName' ct) <> " connected"
@@ -4,48 +4,33 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Options where
module Broadcast.Options where
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Int (Int64)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Options.Applicative
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts, coreChatOptsP)
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Util (safeDecodeUtf8)
data Publisher = Publisher
{ contactId :: Int64,
localDisplayName :: Text
}
deriving (Eq)
data BroadcastBotOpts = BroadcastBotOpts
{ coreOptions :: CoreChatOpts,
publishers :: [Publisher],
publishers :: [KnownContact],
welcomeMessage :: String,
prohibitedMessage :: String
}
defaultWelcomeMessage :: [Publisher] -> String
defaultWelcomeMessage ps = "Hello! I am a broadcast bot.\nI broadcast messages to all connected users from " <> publisherNames ps <> "."
defaultWelcomeMessage :: [KnownContact] -> String
defaultWelcomeMessage ps = "Hello! I am a broadcast bot.\nI broadcast messages to all connected users from " <> knownContactNames ps <> "."
defaultProhibitedMessage :: [Publisher] -> String
defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> publisherNames ps <> ". Your message is deleted."
publisherNames :: [Publisher] -> String
publisherNames = T.unpack . T.intercalate ", " . map (("@" <>) . localDisplayName)
defaultProhibitedMessage :: [KnownContact] -> String
defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted."
broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts
broadcastBotOpts appDir defaultDbFileName = do
coreOptions <- coreChatOptsP appDir defaultDbFileName
publishers <-
option
parsePublishers
parseKnownContacts
( long "publishers"
<> metavar "PUBLISHERS"
<> help "Comma-separated list of publishers in the format CONTACT_ID:DISPLAY_NAME whose messages will be broadcasted"
@@ -74,17 +59,6 @@ broadcastBotOpts appDir defaultDbFileName = do
prohibitedMessage = fromMaybe (defaultProhibitedMessage publishers) prohibitedMessage_
}
parsePublishers :: ReadM [Publisher]
parsePublishers = eitherReader $ parseAll publishersP . encodeUtf8 . T.pack
publishersP :: A.Parser [Publisher]
publishersP = publisherP `A.sepBy1` A.char ','
where
publisherP = do
contactId <- A.decimal <* A.char ':'
localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ")
pure Publisher {contactId, localDisplayName}
getBroadcastBotOpts :: FilePath -> FilePath -> IO BroadcastBotOpts
getBroadcastBotOpts appDir defaultDbFileName =
execParser $
+1 -1
View File
@@ -28,7 +28,7 @@ main = do
t <- withTerminal pure
simplexChatTerminal terminalChatConfig opts t
else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do
r <- sendChatCmd cc chatCmd
r <- sendChatCmdStr cc chatCmd
ts <- getCurrentTime
tz <- getCurrentTimeZone
putStrLn $ serializeChatResponse (Just user) ts tz r
+15
View File
@@ -0,0 +1,15 @@
{-# LANGUAGE NamedFieldPuns #-}
module Main where
import Directory.Options
import Directory.Service
import Directory.Store
import Simplex.Chat.Core
import Simplex.Chat.Terminal (terminalChatConfig)
main :: IO ()
main = do
opts@DirectoryOpts {directoryLog} <- welcomeGetOpts
st <- restoreDirectoryStore directoryLog
simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ directoryService st opts
+5
View File
@@ -0,0 +1,5 @@
# SimpleX Directory Service
The service is currently a chat bot that allows to register and search for groups.
Superusers are configured via CLI options.
@@ -0,0 +1,155 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
module Directory.Events
( DirectoryEvent (..),
DirectoryCmd (..),
ADirectoryCmd (..),
DirectoryRole (..),
SDirectoryRole (..),
crDirectoryEvent,
)
where
import Control.Applicative ((<|>))
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import Data.Text (Text)
import qualified Data.Text as T
import Directory.Store
import Simplex.Chat.Controller
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Types
import Data.Char (isSpace)
import Data.Either (fromRight)
data DirectoryEvent
= DEContactConnected Contact
| DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole}
| DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember}
| DEGroupUpdated {contactId :: ContactId, fromGroup :: GroupInfo, toGroup :: GroupInfo}
| DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed
| DEServiceRoleChanged GroupInfo GroupMemberRole
| DEContactRemovedFromGroup ContactId GroupInfo
| DEContactLeftGroup ContactId GroupInfo
| DEServiceRemovedFromGroup GroupInfo
| DEGroupDeleted GroupInfo
| DEUnsupportedMessage Contact ChatItemId
| DEItemEditIgnored Contact
| DEItemDeleteIgnored Contact
| DEContactCommand Contact ChatItemId ADirectoryCmd
deriving (Show)
crDirectoryEvent :: ChatResponse -> Maybe DirectoryEvent
crDirectoryEvent = \case
CRContactConnected {contact} -> Just $ DEContactConnected contact
CRReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole}
CRUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember
CRGroupUpdated {fromGroup, toGroup, member_} -> (\contactId -> DEGroupUpdated {contactId, fromGroup, toGroup}) <$> (memberContactId =<< member_)
CRMemberRole {groupInfo, member, toRole}
| groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole
| otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member
CRDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember
CRLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member
CRDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo
CRGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo
CRChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct
CRChatItemDeleted {deletedChatItem = AChatItem _ SMDRcv (DirectChat ct) _, byUser = False} -> Just $ DEItemDeleteIgnored ct
CRNewChatItem {chatItem = AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}} ->
Just $ case (mc, itemLive) of
(MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly directoryCmdP $ T.dropWhileEnd isSpace t
_ -> DEUnsupportedMessage ct ciId
where
ciId = chatItemId' ci
err = ADC SDRUser DCUnknownCommand
_ -> Nothing
data DirectoryRole = DRUser | DRSuperUser
data SDirectoryRole (r :: DirectoryRole) where
SDRUser :: SDirectoryRole 'DRUser
SDRSuperUser :: SDirectoryRole 'DRSuperUser
deriving instance Show (SDirectoryRole r)
data DirectoryCmdTag (r :: DirectoryRole) where
DCHelp_ :: DirectoryCmdTag 'DRUser
DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser
DCListUserGroups_ :: DirectoryCmdTag 'DRUser
DCDeleteGroup_ :: DirectoryCmdTag 'DRUser
DCApproveGroup_ :: DirectoryCmdTag 'DRSuperUser
DCRejectGroup_ :: DirectoryCmdTag 'DRSuperUser
DCSuspendGroup_ :: DirectoryCmdTag 'DRSuperUser
DCResumeGroup_ :: DirectoryCmdTag 'DRSuperUser
DCListLastGroups_ :: DirectoryCmdTag 'DRSuperUser
deriving instance Show (DirectoryCmdTag r)
data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r)
data DirectoryCmd (r :: DirectoryRole) where
DCHelp :: DirectoryCmd 'DRUser
DCSearchGroup :: Text -> DirectoryCmd 'DRUser
DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser
DCListUserGroups :: DirectoryCmd 'DRUser
DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser
DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRSuperUser
DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser
DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser
DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser
DCListLastGroups :: Int -> DirectoryCmd 'DRSuperUser
DCUnknownCommand :: DirectoryCmd 'DRUser
DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r
deriving instance Show (DirectoryCmd r)
data ADirectoryCmd = forall r. ADC (SDirectoryRole r) (DirectoryCmd r)
deriving instance Show ADirectoryCmd
directoryCmdP :: Parser ADirectoryCmd
directoryCmdP =
(A.char '/' *> cmdStrP) <|> (ADC SDRUser . DCSearchGroup <$> A.takeText)
where
cmdStrP =
(tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t)))
<|> pure (ADC SDRUser DCUnknownCommand)
tagP = A.takeTill (== ' ') >>= \case
"help" -> u DCHelp_
"h" -> u DCHelp_
"confirm" -> u DCConfirmDuplicateGroup_
"list" -> u DCListUserGroups_
"delete" -> u DCDeleteGroup_
"approve" -> su DCApproveGroup_
"reject" -> su DCRejectGroup_
"suspend" -> su DCSuspendGroup_
"resume" -> su DCResumeGroup_
"last" -> su DCListLastGroups_
_ -> fail "bad command tag"
where
u = pure . ADCT SDRUser
su = pure . ADCT SDRSuperUser
cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r)
cmdP = \case
DCHelp_ -> pure DCHelp
DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup
DCListUserGroups_ -> pure DCListUserGroups
DCDeleteGroup_ -> gc DCDeleteGroup
DCApproveGroup_ -> do
(groupId, displayName) <- gc (,)
groupApprovalId <- A.space *> A.decimal
pure $ DCApproveGroup {groupId, displayName, groupApprovalId}
DCRejectGroup_ -> gc DCRejectGroup
DCSuspendGroup_ -> gc DCSuspendGroup
DCResumeGroup_ -> gc DCResumeGroup
DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10)
where
gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> A.takeTill (== ' ')
@@ -0,0 +1,84 @@
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Directory.Options
( DirectoryOpts (..),
getDirectoryOpts,
mkChatOpts,
)
where
import Options.Applicative
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts, coreChatOptsP)
data DirectoryOpts = DirectoryOpts
{ coreOptions :: CoreChatOpts,
superUsers :: [KnownContact],
directoryLog :: Maybe FilePath,
serviceName :: String,
testing :: Bool
}
directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts
directoryOpts appDir defaultDbFileName = do
coreOptions <- coreChatOptsP appDir defaultDbFileName
superUsers <-
option
parseKnownContacts
( long "super-users"
<> metavar "SUPER_USERS"
<> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory"
)
directoryLog <-
Just <$>
strOption
( long "directory-file"
<> metavar "DIRECTORY_FILE"
<> help "Append only log for directory state"
)
serviceName <-
strOption
( long "service-name"
<> metavar "SERVICE_NAME"
<> help "The display name of the directory service bot, without *'s and spaces (SimpleX-Directory)"
<> value "SimpleX-Directory"
)
pure
DirectoryOpts
{ coreOptions,
superUsers,
directoryLog,
serviceName,
testing = False
}
getDirectoryOpts :: FilePath -> FilePath -> IO DirectoryOpts
getDirectoryOpts appDir defaultDbFileName =
execParser $
info
(helper <*> versionOption <*> directoryOpts appDir defaultDbFileName)
(header versionStr <> fullDesc <> progDesc "Start SimpleX Directory Service with DB_FILE, DIRECTORY_FILE and SUPER_USERS options")
where
versionStr = versionString versionNumber
versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version")
versionAndUpdate = versionStr <> "\n" <> updateStr
mkChatOpts :: DirectoryOpts -> ChatOpts
mkChatOpts DirectoryOpts {coreOptions} =
ChatOpts
{ coreOptions,
chatCmd = "",
chatCmdDelay = 3,
chatServerPort = Nothing,
optFilesFolder = Nothing,
showReactions = False,
allowInstantFiles = True,
autoAcceptFileSize = 0,
muteNotifications = True,
maintenance = False
}
@@ -0,0 +1,539 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE MultiWayIf #-}
module Directory.Service
( welcomeGetOpts,
directoryService,
)
where
import Control.Concurrent (forkIO)
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad.Reader
import qualified Data.ByteString.Char8 as B
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import Directory.Events
import Directory.Options
import Directory.Store
import Simplex.Chat.Bot
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Messages
-- import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Options
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Types
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>))
import System.Directory (getAppUserDataDirectory)
data GroupProfileUpdate = GPNoServiceLink | GPServiceLinkAdded | GPServiceLinkRemoved | GPHasServiceLink | GPServiceLinkError
data DuplicateGroup
= DGUnique -- display name or full name is unique
| DGRegistered -- the group with the same names is registered, additional confirmation is required
| DGReserved -- the group with the same names is listed, the registration is not allowed
data GroupRolesStatus
= GRSOk
| GRSServiceNotAdmin
| GRSContactNotOwner
| GRSBadRoles
deriving (Eq)
welcomeGetOpts :: IO DirectoryOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service"
unless testing $ do
putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
pure opts
directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO ()
directoryService st DirectoryOpts {superUsers, serviceName, testing} User {userId} cc = do
initializeBotAddress' (not testing) cc
race_ (forever $ void getLine) . forever $ do
(_, resp) <- atomically . readTBQueue $ outputQ cc
forM_ (crDirectoryEvent resp) $ \case
DEContactConnected ct -> deContactConnected ct
DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole
DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner
DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup
DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role
DEServiceRoleChanged g role -> deServiceRoleChanged g role
DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g
DEContactLeftGroup ctId g -> deContactLeftGroup ctId g
DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g
DEGroupDeleted _g -> pure ()
DEUnsupportedMessage _ct _ciId -> pure ()
DEItemEditIgnored _ct -> pure ()
DEItemDeleteIgnored _ct -> pure ()
DEContactCommand ct ciId aCmd -> case aCmd of
ADC SDRUser cmd -> deUserCommand ct ciId cmd
ADC SDRSuperUser cmd -> deSuperUserCommand ct ciId cmd
where
withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId
notifySuperUsers s = withSuperUsers $ \contactId -> sendMessage' cc contactId s
notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId
ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId
withGroupReg GroupInfo {groupId, localDisplayName} err action = do
atomically (getGroupReg st groupId) >>= \case
Just gr -> action gr
Nothing -> putStrLn $ T.unpack $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId
groupInfoText GroupProfile {displayName = n, fullName = fn, description = d} =
n <> (if n == fn || T.null fn then "" else " (" <> fn <> ")") <> maybe "" ("\nWelcome message:\n" <>) d
userGroupReference gr GroupInfo {groupProfile = GroupProfile {displayName}} = userGroupReference' gr displayName
userGroupReference' GroupReg {userGroupRegId} displayName = groupReference' userGroupRegId displayName
groupReference GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = groupReference' groupId displayName
groupReference' groupId displayName = "ID " <> show groupId <> " (" <> T.unpack displayName <> ")"
groupAlreadyListed GroupInfo {groupProfile = GroupProfile {displayName, fullName}} =
T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already listed in the directory, please choose another name."
getGroups :: Text -> IO (Maybe [(GroupInfo, GroupSummary)])
getGroups search =
sendChatCmd cc (APIListGroups userId Nothing $ Just $ T.unpack search) >>= \case
CRGroupsList {groups} -> pure $ Just groups
_ -> pure Nothing
getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup)
getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} =
getGroups fullName >>= mapM duplicateGroup
where
sameGroup (GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}}, _) =
gId /= groupId && n == displayName && fn == fullName
duplicateGroup [] = pure DGUnique
duplicateGroup groups = do
let gs = filter sameGroup groups
if null gs
then pure DGUnique
else do
(lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st)
let reserved = any (\(GroupInfo {groupId = gId}, _) -> gId `S.member` lgs || gId `S.member` rgs) gs
pure $ if reserved then DGReserved else DGRegistered
processInvitation :: Contact -> GroupInfo -> IO ()
processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do
void $ addGroupReg st ct g GRSProposed
r <- sendChatCmd cc $ APIJoinGroup groupId
sendMessage cc ct $ T.unpack $ case r of
CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> ""
_ -> "Error joining group " <> displayName <> ", please re-send the invitation!"
deContactConnected :: Contact -> IO ()
deContactConnected ct = do
unless testing $ putStrLn $ T.unpack (localDisplayName' ct) <> " connected"
sendMessage cc ct $
"Welcome to " <> serviceName <> " service!\n\
\Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\
\For example, send _privacy_ to find groups about privacy."
deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO ()
deGroupInvitation ct g@GroupInfo {groupProfile = GroupProfile {displayName, fullName}} fromMemberRole memberRole = do
case badRolesMsg $ groupRolesStatus fromMemberRole memberRole of
Just msg -> sendMessage cc ct msg
Nothing -> getDuplicateGroup g >>= \case
Just DGUnique -> processInvitation ct g
Just DGRegistered -> askConfirmation
Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g
Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers."
where
askConfirmation = do
ugrId <- addGroupReg st ct g GRSPendingConfirmation
sendMessage cc ct $ T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already submitted to the directory.\nTo confirm the registration, please send:"
sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> T.unpack displayName
badRolesMsg :: GroupRolesStatus -> Maybe String
badRolesMsg = \case
GRSOk -> Nothing
GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group"
GRSContactNotOwner -> Just "You must grant directory service *admin* role to register the group"
GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group"
getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Maybe GroupRolesStatus)
getGroupRolesStatus GroupInfo {membership = GroupMember {memberRole = serviceRole}} gr =
rStatus <$$> getGroupMember gr
where
rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole
groupRolesStatus :: GroupMemberRole -> GroupMemberRole -> GroupRolesStatus
groupRolesStatus contactRole serviceRole = case (contactRole, serviceRole) of
(GROwner, GRAdmin) -> GRSOk
(_, GRAdmin) -> GRSServiceNotAdmin
(GROwner, _) -> GRSContactNotOwner
_ -> GRSBadRoles
getGroupMember :: GroupReg -> IO (Maybe GroupMember)
getGroupMember GroupReg {dbGroupId, dbOwnerMemberId} =
readTVarIO dbOwnerMemberId
$>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId)
where
resp = \case
CRGroupMemberInfo {member} -> Just member
_ -> Nothing
deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO ()
deServiceJoinedGroup ctId g owner =
withGroupReg g "joined group" $ \gr ->
when (ctId `isOwner` gr) $ do
setGroupRegOwner st gr owner
let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g
notifyOwner gr $ T.unpack $ "Joined the group " <> displayName <> ", creating the link…"
sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case
CRGroupLinkCreated {connReqContact} -> do
setGroupStatus st gr GRSPendingUpdate
notifyOwner gr
"Created the public link to join the group via this directory service that is always online.\n\n\
\Please add it to the group welcome message.\n\
\For example, add:"
notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode connReqContact)
CRChatCmdError _ (ChatError e) -> case e of
CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin."
CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group."
CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined"
CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active"
_ -> notifyOwner gr $ unexpectedError "can't create group link"
_ -> notifyOwner gr $ unexpectedError "can't create group link"
deGroupUpdated :: ContactId -> GroupInfo -> GroupInfo -> IO ()
deGroupUpdated ctId fromGroup toGroup =
unless (sameProfile p p') $ do
withGroupReg toGroup "group updated" $ \gr -> do
let userGroupRef = userGroupReference gr toGroup
readTVarIO (groupRegStatus gr) >>= \case
GRSPendingConfirmation -> pure ()
GRSProposed -> pure ()
GRSPendingUpdate -> groupProfileUpdate >>= \case
GPNoServiceLink ->
when (ctId `isOwner` gr) $ notifyOwner gr $ "The profile updated for " <> userGroupRef <> ", but the group link is not added to the welcome message."
GPServiceLinkAdded
| ctId `isOwner` gr -> groupLinkAdded gr
| otherwise -> notifyOwner gr "The group link is added by another group member, your registration will not be processed.\n\nPlease update the group profile yourself."
GPServiceLinkRemoved -> when (ctId `isOwner` gr) $ notifyOwner gr $ "The group link of " <> userGroupRef <> " is removed from the welcome message, please add it."
GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr
GPServiceLinkError -> do
when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers."
putStrLn $ "Error: no group link for " <> userGroupRef
GRSPendingApproval n -> processProfileChange gr $ n + 1
GRSActive -> processProfileChange gr 1
GRSSuspended -> processProfileChange gr 1
GRSSuspendedBadRoles -> processProfileChange gr 1
GRSRemoved -> pure ()
where
isInfix l d_ = l `T.isInfixOf` fromMaybe "" d_
GroupInfo {groupId, groupProfile = p} = fromGroup
GroupInfo {groupProfile = p'} = toGroup
sameProfile
GroupProfile {displayName = n, fullName = fn, image = i, description = d}
GroupProfile {displayName = n', fullName = fn', image = i', description = d'} =
n == n' && fn == fn' && i == i' && d == d'
groupLinkAdded gr = do
getDuplicateGroup toGroup >>= \case
Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers."
Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup
_ -> do
let gaId = 1
setGroupStatus st gr $ GRSPendingApproval gaId
notifyOwner gr $ "Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message.\nYou will be notified once the group is added to the directory - it may take up to 24 hours."
checkRolesSendToApprove gr gaId
processProfileChange gr n' = do
setGroupStatus st gr GRSPendingUpdate
let userGroupRef = userGroupReference gr toGroup
groupRef = groupReference toGroup
groupProfileUpdate >>= \case
GPNoServiceLink -> do
notifyOwner gr $ "The group profile is updated " <> userGroupRef <> ", but no link is added to the welcome message.\n\nThe group will remain hidden from the directory until the group link is added and the group is re-approved."
GPServiceLinkRemoved -> do
notifyOwner gr $ "The group link for " <> userGroupRef <> " is removed from the welcome message.\n\nThe group is hidden from the directory until the group link is added and the group is re-approved."
notifySuperUsers $ "The group link is removed from " <> groupRef <> ", de-listed."
GPServiceLinkAdded -> do
setGroupStatus st gr $ GRSPendingApproval n'
notifyOwner gr $ "The group link is added to " <> userGroupRef <> "!\nIt is hidden from the directory until approved."
notifySuperUsers $ "The group link is added to " <> groupRef <> "."
checkRolesSendToApprove gr n'
GPHasServiceLink -> do
setGroupStatus st gr $ GRSPendingApproval n'
notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved."
notifySuperUsers $ "The group " <> groupRef <> " is updated."
checkRolesSendToApprove gr n'
GPServiceLinkError -> putStrLn $ "Error: no group link for " <> groupRef <> " pending approval."
groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId)
where
profileUpdate = \case
CRGroupLink {connReqContact} ->
let groupLink = safeDecodeUtf8 $ strEncode connReqContact
hadLinkBefore = groupLink `isInfix` description p
hasLinkNow = groupLink `isInfix` description p'
in if
| hadLinkBefore && hasLinkNow -> GPHasServiceLink
| hadLinkBefore -> GPServiceLinkRemoved
| hasLinkNow -> GPServiceLinkAdded
| otherwise -> GPNoServiceLink
_ -> GPServiceLinkError
checkRolesSendToApprove gr gaId = do
(badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case
Nothing -> notifyOwner gr "Error: getGroupRolesStatus. Please notify the developers."
Just (Just msg) -> notifyOwner gr msg
Just Nothing -> sendToApprove toGroup gr gaId
sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO ()
sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do
ct_ <- getContact cc dbContactId
let text = maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_
<> groupInfoText p <> "\n\nTo approve send:"
msg = maybe (MCText text) (\image -> MCImage {text, image}) image'
withSuperUsers $ \cId -> do
sendComposedMessage' cc cId Nothing msg
sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> T.unpack displayName <> " " <> show gaId
deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO ()
deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole =
withGroupReg g "contact role changed" $ \gr -> do
let userGroupRef = userGroupReference gr g
uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole
when (ctId `isOwner` gr) $ do
readTVarIO (groupRegStatus gr) >>= \case
GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do
setGroupStatus st gr GRSActive
notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again."
notifySuperUsers $ "The group " <> groupRef <> " is listed " <> suCtRole
GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do
sendToApprove g gr gaId
notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval."
GRSActive -> when (rStatus /= GRSOk) $ do
setGroupStatus st gr GRSSuspendedBadRoles
notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory."
notifySuperUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole
_ -> pure ()
where
rStatus = groupRolesStatus contactRole serviceRole
groupRef = groupReference g
ctRole = "*" <> B.unpack (strEncode contactRole) <> "*"
suCtRole = "(user role is set to " <> ctRole <> ")."
deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO ()
deServiceRoleChanged g serviceRole = do
withGroupReg g "service role changed" $ \gr -> do
let userGroupRef = userGroupReference gr g
uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole
readTVarIO (groupRegStatus gr) >>= \case
GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $
whenContactIsOwner gr $ do
setGroupStatus st gr GRSActive
notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again."
notifySuperUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole
GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $
whenContactIsOwner gr $ do
sendToApprove g gr gaId
notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval."
GRSActive -> when (serviceRole /= GRAdmin) $ do
setGroupStatus st gr GRSSuspendedBadRoles
notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory."
notifySuperUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole
_ -> pure ()
where
groupRef = groupReference g
srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*"
suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")."
whenContactIsOwner gr action =
getGroupMember gr >>=
mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action)
deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO ()
deContactRemovedFromGroup ctId g =
withGroupReg g "contact removed" $ \gr -> do
when (ctId `isOwner` gr) $ do
setGroupStatus st gr GRSRemoved
notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory."
notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)."
deContactLeftGroup :: ContactId -> GroupInfo -> IO ()
deContactLeftGroup ctId g =
withGroupReg g "contact left" $ \gr -> do
when (ctId `isOwner` gr) $ do
setGroupStatus st gr GRSRemoved
notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory."
notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)."
deServiceRemovedFromGroup :: GroupInfo -> IO ()
deServiceRemovedFromGroup g =
withGroupReg g "service removed" $ \gr -> do
setGroupStatus st gr GRSRemoved
notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory."
notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)."
deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO ()
deUserCommand ct ciId = \case
DCHelp ->
sendMessage cc ct $
"You must be the owner to add the group to the directory:\n\
\1. Invite " <> serviceName <> " bot to your group as *admin*.\n\
\2. " <> serviceName <> " bot will create a public group link for the new members to join even when you are offline.\n\
\3. You will then need to add this link to the group welcome message.\n\
\4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\
\Start from inviting the bot to your group as admin - it will guide you through the process"
DCSearchGroup s ->
getGroups s >>= \case
Just groups ->
atomically (filterListedGroups st groups) >>= \case
[] -> sendReply "No groups found"
gs -> do
sendReply $ "Found " <> show (length gs) <> " group(s)" <> if length gs > 10 then ", sending 10." else ""
void . forkIO $ forM_ (take 10 gs) $
\(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do
let membersStr = tshow currentMembers <> " members"
text = groupInfoText p <> "\n" <> membersStr
msg = maybe (MCText text) (\image -> MCImage {text, image}) image_
sendComposedMessage cc ct Nothing msg
Nothing -> sendReply "Error: getGroups. Please notify the developers."
DCConfirmDuplicateGroup ugrId gName ->
atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case
Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found"
Just GroupReg {dbGroupId, groupRegStatus} -> do
getGroup cc dbGroupId >>= \case
Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found"
Just g@GroupInfo {groupProfile = GroupProfile {displayName}}
| displayName == gName ->
readTVarIO groupRegStatus >>= \case
GRSPendingConfirmation -> do
getDuplicateGroup g >>= \case
Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers."
Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g
_ -> processInvitation ct g
_ -> sendReply $ "Error: the group ID " <> show ugrId <> " (" <> T.unpack displayName <> ") is not pending confirmation."
| otherwise -> sendReply $ "Group ID " <> show ugrId <> " has the display name " <> T.unpack displayName
DCListUserGroups ->
atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do
sendReply $ show (length grs) <> " registered group(s)"
void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} ->
sendGroupInfo ct gr userGroupRegId Nothing
DCDeleteGroup _ugrId _gName -> pure ()
DCUnknownCommand -> sendReply "Unknown command"
DCCommandError tag -> sendReply $ "Command error: " <> show tag
where
sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent
deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO ()
deSuperUserCommand ct ciId cmd
| superUser `elem` superUsers = case cmd of
DCApproveGroup {groupId, displayName = n, groupApprovalId} -> do
getGroupAndReg groupId n >>= \case
Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)."
Just (g, gr) ->
readTVarIO (groupRegStatus gr) >>= \case
GRSPendingApproval gaId
| gaId == groupApprovalId -> do
getDuplicateGroup g >>= \case
Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers."
Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory."
_ -> do
getGroupRolesStatus g gr >>= \case
Just GRSOk -> do
setGroupStatus st gr GRSActive
sendReply "Group approved!"
notifyOwner gr $ "The group " <> userGroupReference' gr n <> " is approved and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved."
Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin
Just GRSContactNotOwner -> replyNotApproved "user is not an owner."
Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin
Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers."
where
replyNotApproved reason = sendReply $ "Group is not approved: " <> reason
serviceNotAdmin = serviceName <> " is not an admin."
| otherwise -> sendReply "Incorrect approval code"
_ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval."
where
groupRef = groupReference' groupId n
DCRejectGroup _gaId _gName -> pure ()
DCSuspendGroup groupId gName -> do
let groupRef = groupReference' groupId gName
getGroupAndReg groupId gName >>= \case
Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)."
Just (_, gr) ->
readTVarIO (groupRegStatus gr) >>= \case
GRSActive -> do
setGroupStatus st gr GRSSuspended
notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is suspended and hidden from directory. Please contact the administrators."
sendReply "Group suspended!"
_ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended."
DCResumeGroup groupId gName -> do
let groupRef = groupReference' groupId gName
getGroupAndReg groupId gName >>= \case
Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)."
Just (_, gr) ->
readTVarIO (groupRegStatus gr) >>= \case
GRSSuspended -> do
setGroupStatus st gr GRSActive
notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is listed in the directory again!"
sendReply "Group listing resumed!"
_ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed."
DCListLastGroups count ->
readTVarIO (groupRegs st) >>= \grs -> do
sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "")
void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do
ct_ <- getContact cc dbContactId
let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_
sendGroupInfo ct gr dbGroupId $ Just ownerStr
DCCommandError tag -> sendReply $ "Command error: " <> show tag
| otherwise = sendReply "You are not allowed to use this command"
where
superUser = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct}
sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent
getGroupAndReg :: GroupId -> GroupName -> IO (Maybe (GroupInfo, GroupReg))
getGroupAndReg gId gName =
getGroup cc gId
$>>= \g@GroupInfo {groupProfile = GroupProfile {displayName}} ->
if displayName == gName
then atomically (getGroupReg st gId)
$>>= \gr -> pure $ Just (g, gr)
else pure Nothing
sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO ()
sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do
grStatus <- readTVarIO $ groupRegStatus gr
let statusStr = "Status: " <> groupRegStatusText grStatus
getGroupAndSummary cc dbGroupId >>= \case
Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do
let membersStr = tshow currentMembers <> " members"
text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr]
msg = maybe (MCText text) (\image -> MCImage {text, image}) image_
sendComposedMessage cc ct Nothing msg
Nothing -> do
let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr]
sendComposedMessage cc ct Nothing $ MCText text
getContact :: ChatController -> ContactId -> IO (Maybe Contact)
getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) (CPLast 0) Nothing)
where
resp :: ChatResponse -> Maybe Contact
resp = \case
CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct
_ -> Nothing
getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo)
getGroup cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId)
where
resp :: ChatResponse -> Maybe GroupInfo
resp = \case
CRGroupInfo {groupInfo} -> Just groupInfo
_ -> Nothing
getGroupAndSummary :: ChatController -> GroupId -> IO (Maybe (GroupInfo, GroupSummary))
getGroupAndSummary cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId)
where
resp = \case
CRGroupInfo {groupInfo, groupSummary} -> Just (groupInfo, groupSummary)
_ -> Nothing
unexpectedError :: String -> String
unexpectedError err = "Unexpected error: " <> err <> ", please notify the developers."
@@ -0,0 +1,328 @@
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Directory.Store
( DirectoryStore (..),
GroupReg (..),
GroupRegStatus (..),
UserGroupRegId,
GroupApprovalId,
restoreDirectoryStore,
addGroupReg,
setGroupStatus,
setGroupRegOwner,
getGroupReg,
getUserGroupReg,
getUserGroupRegs,
filterListedGroups,
groupRegStatusText,
)
where
import Control.Concurrent.STM
import Control.Monad
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Composition ((.:))
import Data.Int (Int64)
import Data.List (find, foldl', sortOn)
import Data.Map (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (isJust)
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import Simplex.Chat.Types
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util (ifM)
import System.IO (Handle, IOMode (..), openFile, BufferMode (..), hSetBuffering)
import System.Directory (renameFile, doesFileExist)
data DirectoryStore = DirectoryStore
{ groupRegs :: TVar [GroupReg],
listedGroups :: TVar (Set GroupId),
reservedGroups :: TVar (Set GroupId),
directoryLogFile :: Maybe Handle
}
data GroupReg = GroupReg
{ dbGroupId :: GroupId,
userGroupRegId :: UserGroupRegId,
dbContactId :: ContactId,
dbOwnerMemberId :: TVar (Maybe GroupMemberId),
groupRegStatus :: TVar GroupRegStatus
}
data GroupRegData = GroupRegData
{ dbGroupId_ :: GroupId,
userGroupRegId_ :: UserGroupRegId,
dbContactId_ :: ContactId,
dbOwnerMemberId_ :: Maybe GroupMemberId,
groupRegStatus_ :: GroupRegStatus
}
type UserGroupRegId = Int64
type GroupApprovalId = Int64
data GroupRegStatus
= GRSPendingConfirmation
| GRSProposed
| GRSPendingUpdate
| GRSPendingApproval GroupApprovalId
| GRSActive
| GRSSuspended
| GRSSuspendedBadRoles
| GRSRemoved
data DirectoryStatus = DSListed | DSReserved | DSRegistered
groupRegStatusText :: GroupRegStatus -> Text
groupRegStatusText = \case
GRSPendingConfirmation -> "pending confirmation (duplicate names)"
GRSProposed -> "proposed"
GRSPendingUpdate -> "pending profile update"
GRSPendingApproval _ -> "pending admin approval"
GRSActive -> "active"
GRSSuspended -> "suspended by admin"
GRSSuspendedBadRoles -> "suspended because roles changed"
GRSRemoved -> "removed"
grDirectoryStatus :: GroupRegStatus -> DirectoryStatus
grDirectoryStatus = \case
GRSActive -> DSListed
GRSSuspended -> DSReserved
GRSSuspendedBadRoles -> DSReserved
_ -> DSRegistered
addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId
addGroupReg st ct GroupInfo {groupId} grStatus = do
grData <- atomically addGroupReg_
logGCreate st grData
pure $ userGroupRegId_ grData
where
addGroupReg_ = do
let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus}
gr <- dataToGroupReg grData
stateTVar (groupRegs st) $ \grs ->
let ugrId = 1 + foldl' maxUgrId 0 grs
grData' = grData {userGroupRegId_ = ugrId}
gr' = gr {userGroupRegId = ugrId}
in (grData', gr' : grs)
ctId = contactId' ct
maxUgrId mx GroupReg {dbContactId, userGroupRegId}
| dbContactId == ctId && userGroupRegId > mx = userGroupRegId
| otherwise = mx
setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO ()
setGroupStatus st gr grStatus = do
logGUpdateStatus st (dbGroupId gr) grStatus
atomically $ do
writeTVar (groupRegStatus gr) grStatus
updateListing st $ dbGroupId gr
where
updateListing = case grDirectoryStatus grStatus of
DSListed -> listGroup
DSReserved -> reserveGroup
DSRegistered -> unlistGroup
setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO ()
setGroupRegOwner st gr owner = do
let memberId = groupMemberId' owner
logGUpdateOwner st (dbGroupId gr) memberId
atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId)
getGroupReg :: DirectoryStore -> GroupId -> STM (Maybe GroupReg)
getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVar (groupRegs st)
getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> STM (Maybe GroupReg)
getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVar (groupRegs st)
getUserGroupRegs :: DirectoryStore -> ContactId -> STM [GroupReg]
getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVar (groupRegs st)
filterListedGroups :: DirectoryStore -> [(GroupInfo, GroupSummary)] -> STM [(GroupInfo, GroupSummary)]
filterListedGroups st gs = do
lgs <- readTVar $ listedGroups st
pure $ filter (\(GroupInfo {groupId}, _) -> groupId `S.member` lgs) gs
listGroup :: DirectoryStore -> GroupId -> STM ()
listGroup st gId = do
modifyTVar' (listedGroups st) $ S.insert gId
modifyTVar' (reservedGroups st) $ S.delete gId
reserveGroup :: DirectoryStore -> GroupId -> STM ()
reserveGroup st gId = do
modifyTVar' (listedGroups st) $ S.delete gId
modifyTVar' (reservedGroups st) $ S.insert gId
unlistGroup :: DirectoryStore -> GroupId -> STM ()
unlistGroup st gId = do
modifyTVar' (listedGroups st) $ S.delete gId
modifyTVar' (reservedGroups st) $ S.delete gId
data DirectoryLogRecord
= GRCreate GroupRegData
| GRUpdateStatus GroupId GroupRegStatus
| GRUpdateOwner GroupId GroupMemberId
data DLRTag = GRCreate_ | GRUpdateStatus_ | GRUpdateOwner_
logDLR :: DirectoryStore -> DirectoryLogRecord -> IO ()
logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r)
logGCreate :: DirectoryStore -> GroupRegData -> IO ()
logGCreate st = logDLR st . GRCreate
logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO ()
logGUpdateStatus st = logDLR st .: GRUpdateStatus
logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO ()
logGUpdateOwner st = logDLR st .: GRUpdateOwner
instance StrEncoding DLRTag where
strEncode = \case
GRCreate_ -> "GCREATE"
GRUpdateStatus_ -> "GSTATUS"
GRUpdateOwner_ -> "GOWNER"
strP =
A.takeTill (== ' ') >>= \case
"GCREATE" -> pure GRCreate_
"GSTATUS" -> pure GRUpdateStatus_
"GOWNER" -> pure GRUpdateOwner_
_ -> fail "invalid DLRTag"
instance StrEncoding DirectoryLogRecord where
strEncode = \case
GRCreate gr -> strEncode (GRCreate_, gr)
GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus)
GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId)
strP =
strP >>= \case
GRCreate_ -> GRCreate <$> (A.space *> strP)
GRUpdateStatus_ -> GRUpdateStatus <$> (A.space *> A.decimal) <*> (A.space *> strP)
GRUpdateOwner_ -> GRUpdateOwner <$> (A.space *> A.decimal) <*> (A.space *> A.decimal)
instance StrEncoding GroupRegData where
strEncode GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} =
B.unwords
[ "group_id=" <> strEncode dbGroupId_,
"user_group_id=" <> strEncode userGroupRegId_,
"contact_id=" <> strEncode dbContactId_,
"owner_member_id=" <> strEncode dbOwnerMemberId_,
"status=" <> strEncode groupRegStatus_
]
strP = do
dbGroupId_ <- "group_id=" *> strP_
userGroupRegId_ <- "user_group_id=" *> strP_
dbContactId_ <- "contact_id=" *> strP_
dbOwnerMemberId_ <- "owner_member_id=" *> strP_
groupRegStatus_ <- "status=" *> strP
pure GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_}
instance StrEncoding GroupRegStatus where
strEncode = \case
GRSPendingConfirmation -> "pending_confirmation"
GRSProposed -> "proposed"
GRSPendingUpdate -> "pending_update"
GRSPendingApproval gaId -> "pending_approval:" <> strEncode gaId
GRSActive -> "active"
GRSSuspended -> "suspended"
GRSSuspendedBadRoles -> "suspended_bad_roles"
GRSRemoved -> "removed"
strP =
A.takeTill (\c -> c == ' ' || c == ':') >>= \case
"pending_confirmation" -> pure GRSPendingConfirmation
"proposed" -> pure GRSProposed
"pending_update" -> pure GRSPendingUpdate
"pending_approval" -> GRSPendingApproval <$> (A.char ':' *> A.decimal)
"active" -> pure GRSActive
"suspended" -> pure GRSSuspended
"suspended_bad_roles" -> pure GRSSuspendedBadRoles
"removed" -> pure GRSRemoved
_ -> fail "invalid GroupRegStatus"
dataToGroupReg :: GroupRegData -> STM GroupReg
dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do
dbOwnerMemberId <- newTVar dbOwnerMemberId_
groupRegStatus <- newTVar groupRegStatus_
pure
GroupReg
{ dbGroupId = dbGroupId_,
userGroupRegId = userGroupRegId_,
dbContactId = dbContactId_,
dbOwnerMemberId,
groupRegStatus
}
restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore
restoreDirectoryStore = \case
Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= new . Just)
Nothing -> new Nothing
where
new = atomically . newDirectoryStore
newFile f = do
h <- openFile f WriteMode
hSetBuffering h LineBuffering
pure h
restore f = do
grs <- readDirectoryData f
renameFile f (f <> ".bak")
h <- writeDirectoryData f grs -- compact
atomically $ mkDirectoryStore h grs
emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId)
emptyStoreData = ([], S.empty, S.empty)
newDirectoryStore :: Maybe Handle -> STM DirectoryStore
newDirectoryStore = (`mkDirectoryStore_` emptyStoreData)
mkDirectoryStore :: Handle -> [GroupRegData] -> STM DirectoryStore
mkDirectoryStore h groups =
foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h)
where
addGroupRegData (!grs, !listed, !reserved) gr@GroupRegData {dbGroupId_ = gId} = do
gr' <- dataToGroupReg gr
let grs' = gr' : grs
pure $ case grDirectoryStatus $ groupRegStatus_ gr of
DSListed -> (grs', S.insert gId listed, reserved)
DSReserved -> (grs', listed, S.insert gId reserved)
DSRegistered -> (grs', listed, reserved)
mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> STM DirectoryStore
mkDirectoryStore_ h (grs, listed, reserved) = do
groupRegs <- newTVar grs
listedGroups <- newTVar listed
reservedGroups <- newTVar reserved
pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h}
readDirectoryData :: FilePath -> IO [GroupRegData]
readDirectoryData f =
sortOn dbGroupId_ . M.elems
<$> (foldM processDLR M.empty . B.lines =<< B.readFile f)
where
processDLR :: Map GroupId GroupRegData -> ByteString -> IO (Map GroupId GroupRegData)
processDLR m l = case strDecode l of
Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l))
Right r -> case r of
GRCreate gr@GroupRegData {dbGroupId_ = gId} -> do
when (isJust $ M.lookup gId m) $
putStrLn $ "Warning: duplicate group with ID " <> show gId <> ", group replaced."
pure $ M.insert gId gr m
GRUpdateStatus gId groupRegStatus_ -> case M.lookup gId m of
Just gr -> pure $ M.insert gId gr {groupRegStatus_} m
Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <>", status update ignored.")
GRUpdateOwner gId grOwnerId -> case M.lookup gId m of
Just gr -> pure $ M.insert gId gr {dbOwnerMemberId_ = Just grOwnerId} m
Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <>", owner update ignored.")
writeDirectoryData :: FilePath -> [GroupRegData] -> IO Handle
writeDirectoryData f grs = do
h <- openFile f WriteMode
hSetBuffering h LineBuffering
forM_ grs $ B.hPutStrLn h . strEncode . GRCreate
pure h
+15 -3
View File
@@ -10,6 +10,7 @@ copyright: 2020-22 simplex.chat
category: Web, System, Services, Cryptography
extra-source-files:
- README.md
- cabal.project
dependencies:
- aeson == 2.0.*
@@ -91,8 +92,16 @@ executables:
- -threaded
simplex-broadcast-bot:
source-dirs: apps/simplex-broadcast-bot
main: Main.hs
source-dirs: apps/simplex-broadcast-bot/src
main: ../Main.hs
dependencies:
- simplex-chat
ghc-options:
- -threaded
simplex-directory-service:
source-dirs: apps/simplex-directory-service/src
main: ../Main.hs
dependencies:
- simplex-chat
ghc-options:
@@ -100,7 +109,10 @@ executables:
tests:
simplex-chat-test:
source-dirs: tests
source-dirs:
- tests
- apps/simplex-broadcast-bot/src
- apps/simplex-directory-service/src
main: Test.hs
dependencies:
- simplex-chat
+68 -3
View File
@@ -28,6 +28,7 @@ library
Simplex.Chat
Simplex.Chat.Archive
Simplex.Chat.Bot
Simplex.Chat.Bot.KnownContacts
Simplex.Chat.Call
Simplex.Chat.Controller
Simplex.Chat.Core
@@ -275,12 +276,13 @@ executable simplex-bot-advanced
cpp-options: -DswiftJSON
executable simplex-broadcast-bot
main-is: Main.hs
main-is: ../Main.hs
other-modules:
Options
Broadcast.Bot
Broadcast.Options
Paths_simplex_chat
hs-source-dirs:
apps/simplex-broadcast-bot
apps/simplex-broadcast-bot/src
ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded
build-depends:
aeson ==2.0.*
@@ -375,10 +377,65 @@ executable simplex-chat
if flag(swift)
cpp-options: -DswiftJSON
executable simplex-directory-service
main-is: ../Main.hs
other-modules:
Directory.Events
Directory.Options
Directory.Service
Directory.Store
Paths_simplex_chat
hs-source-dirs:
apps/simplex-directory-service/src
ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded
build-depends:
aeson ==2.0.*
, ansi-terminal >=0.10 && <0.12
, async ==2.2.*
, attoparsec ==0.14.*
, base >=4.7 && <5
, base64-bytestring >=1.0 && <1.3
, bytestring ==0.10.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, memory ==0.15.*
, mtl ==2.2.*
, network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell ==2.16.*
, terminal ==0.2.*
, text ==1.2.*
, time ==1.9.*
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip ==1.7.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
test-suite simplex-chat-test
type: exitcode-stdio-1.0
main-is: Test.hs
other-modules:
Bots.BroadcastTests
Bots.DirectoryTests
ChatClient
ChatTests
ChatTests.Direct
@@ -392,9 +449,17 @@ test-suite simplex-chat-test
SchemaDump
ViewTests
WebRTCTests
Broadcast.Bot
Broadcast.Options
Directory.Events
Directory.Options
Directory.Service
Directory.Store
Paths_simplex_chat
hs-source-dirs:
tests
apps/simplex-broadcast-bot/src
apps/simplex-directory-service/src
ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded
build-depends:
aeson ==2.0.*
+77 -47
View File
@@ -193,7 +193,6 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
rcvFiles <- newTVarIO M.empty
currentCalls <- atomically TM.empty
filesFolder <- newTVarIO optFilesFolder
incognitoMode <- newTVarIO False
chatStoreChanged <- newTVarIO False
expireCIThreads <- newTVarIO M.empty
expireCIFlags <- newTVarIO M.empty
@@ -202,7 +201,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
showLiveItems <- newTVarIO False
userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg
tempDirectory <- newTVarIO tempDir
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile}
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile}
where
configServers :: DefaultAgentServers
configServers =
@@ -332,7 +331,13 @@ execChatCommand s = do
u <- readTVarIO =<< asks currentUser
case parseChatCommand s of
Left e -> pure $ chatCmdError u e
Right cmd -> either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd)
Right cmd -> execChatCommand_ u cmd
execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse
execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd)
execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse
execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd)
parseChatCommand :: ByteString -> Either String ChatCommand
parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace
@@ -473,9 +478,6 @@ processChatCommand = \case
APISetXFTPConfig cfg -> do
asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg)
ok_
SetIncognito onOff -> do
asks incognitoMode >>= atomically . (`writeTVar` onOff)
ok_
APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_
ExportArchive -> do
ts <- liftIO getCurrentTime
@@ -930,10 +932,9 @@ processChatCommand = \case
pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo)
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
APIAcceptContact connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do
APIAcceptContact incognito connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do
(user, cReq) <- withStore $ \db -> getContactRequest' db connReqId
-- [incognito] generate profile to send, create connection with incognito profile
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing
ct <- acceptContactRequest user cReq incognitoProfile
pure $ CRAcceptingContactRequest user ct
@@ -1138,6 +1139,9 @@ processChatCommand = \case
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile)
APIGroupInfo gId -> withUser $ \user -> do
(g, s) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> liftIO (getGroupSummary db user gId)
pure $ CRGroupInfo user g s
APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m)
@@ -1224,6 +1228,9 @@ processChatCommand = \case
SetShowMessages cName ntfOn -> updateChatSettings cName (\cs -> cs {enableNtfs = ntfOn})
SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_})
ContactInfo cName -> withContactName cName APIContactInfo
ShowGroupInfo gName -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIGroupInfo groupId
GroupMemberInfo gName mName -> withMemberName gName mName APIGroupMemberInfo
SwitchContact cName -> withContactName cName APISwitchContact
SwitchGroupMember gName mName -> withMemberName gName mName APISwitchGroupMember
@@ -1239,32 +1246,45 @@ processChatCommand = \case
EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId
ChatHelp section -> pure $ CRChatHelp section
Welcome -> withUser $ pure . CRWelcome
APIAddContact userId -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do
APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do
-- [incognito] generate profile for connection
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing
conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile
toView $ CRNewContactConnection user conn
pure $ CRInvitation user cReq
AddContact -> withUser $ \User {userId} ->
processChatCommand $ APIAddContact userId
APIConnect userId (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do
pure $ CRInvitation user cReq conn
AddContact incognito -> withUser $ \User {userId} ->
processChatCommand $ APIAddContact userId incognito
APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do
conn'_ <- withStore $ \db -> do
conn@PendingContactConnection {pccConnStatus, customUserProfileId} <- getPendingContactConnection db userId connId
case (pccConnStatus, customUserProfileId, incognito) of
(ConnNew, Nothing, True) -> liftIO $ do
incognitoProfile <- generateRandomProfile
pId <- createIncognitoProfile db user incognitoProfile
Just <$> updatePCCIncognito db user conn (Just pId)
(ConnNew, Just pId, False) -> liftIO $ do
deletePCCIncognitoProfile db user pId
Just <$> updatePCCIncognito db user conn Nothing
_ -> pure Nothing
case conn'_ of
Just conn' -> pure $ CRConnectionIncognitoUpdated user conn'
Nothing -> throwChatError CEConnectionIncognitoChangeProhibited
APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do
-- [incognito] generate profile to send
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend
conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend
toView $ CRNewContactConnection user conn
pure $ CRSentConfirmation user
APIConnect userId (Just (ACR SCMContact cReq)) -> withUserId userId (`connectViaContact` cReq)
APIConnect _ Nothing -> throwChatError CEInvalidConnReq
Connect cReqUri -> withUser $ \User {userId} ->
processChatCommand $ APIConnect userId cReqUri
ConnectSimplex -> withUser $ \user ->
APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq
APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq
Connect incognito cReqUri -> withUser $ \User {userId} ->
processChatCommand $ APIConnect userId incognito cReqUri
ConnectSimplex incognito -> withUser $ \user ->
-- [incognito] generate profile to send
connectViaContact user adminContactReq
connectViaContact user incognito adminContactReq
DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect
ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect
APIListContacts userId -> withUserId userId $ \user ->
@@ -1308,9 +1328,9 @@ processChatCommand = \case
pure $ CRUserContactLinkUpdated user contactLink
AddressAutoAccept autoAccept_ -> withUser $ \User {userId} ->
processChatCommand $ APIAddressAutoAccept userId autoAccept_
AcceptContact cName -> withUser $ \User {userId} -> do
AcceptContact incognito cName -> withUser $ \User {userId} -> do
connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName
processChatCommand $ APIAcceptContact connReqId
processChatCommand $ APIAcceptContact incognito connReqId
RejectContact cName -> withUser $ \User {userId} -> do
connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName
processChatCommand $ APIRejectContact connReqId
@@ -1486,8 +1506,11 @@ processChatCommand = \case
ListMembers gName -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIListMembers groupId
ListGroups -> withUser $ \user ->
CRGroupsList user <$> withStore' (`getUserGroupDetails` user)
APIListGroups userId contactId_ search_ -> withUserId userId $ \user ->
CRGroupsList user <$> withStore' (\db -> getUserGroupsWithSummary db user contactId_ search_)
ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do
ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db user cName
processChatCommand $ APIListGroups userId (contactId' <$> ct_) search_
APIUpdateGroupProfile groupId p' -> withUser $ \user -> do
g <- withStore $ \db -> getGroup db user groupId
runUpdateGroupProfile user g p'
@@ -1497,6 +1520,8 @@ processChatCommand = \case
CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName)
UpdateGroupDescription gName description ->
updateGroupProfileByName gName $ \p -> p {description}
ShowGroupDescription gName -> withUser $ \user ->
CRGroupDescription user <$> withStore (\db -> getGroupInfoByName db user gName)
APICreateGroupLink groupId mRole -> withUser $ \user -> withChatLock "createGroupLink" $ do
gInfo <- withStore $ \db -> getGroupInfo db user groupId
assertUserGroupRole gInfo GRAdmin
@@ -1754,8 +1779,8 @@ processChatCommand = \case
CTDirect -> withStore $ \db -> getDirectChatItemIdByText' db user cId msg
CTGroup -> withStore $ \db -> getGroupChatItemIdByText' db user cId msg
_ -> throwChatError $ CECommandError "not supported"
connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse
connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> m ChatResponse
connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
@@ -1763,11 +1788,6 @@ processChatCommand = \case
let randomXContactId = XContactId <$> drgRandomBytes 16
xContactId <- maybe randomXContactId pure xContactId_
-- [incognito] generate profile to send
-- if user makes a contact request using main profile, then turns on incognito mode and repeats the request,
-- an incognito profile will be sent even though the address holder will have user's main profile received as well;
-- we ignore this edge case as we already allow profile updates on repeat contact requests;
-- alternatively we can re-send the main profile even if incognito mode is enabled
incognito <- readTVarIO =<< asks incognitoMode
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let profileToSend = userProfileToSend user incognitoProfile Nothing
connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId)
@@ -2534,7 +2554,7 @@ expireChatItems user@User {userId} ttl sync = do
createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs
contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") (`getUserContacts` user)
loop contacts $ processContact expirationDate
groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (`getUserGroupDetails` user)
groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (\db -> getUserGroupDetails db user Nothing Nothing)
loop groups $ processGroup expirationDate createdAtCutoff
where
loop :: [a] -> (a -> m ()) -> m ()
@@ -3436,7 +3456,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
setActive $ ActiveG g
showToast ("#" <> g) $ "member " <> c <> " is connected"
probeMatchingContacts :: Contact -> Bool -> m ()
probeMatchingContacts :: Contact -> IncognitoEnabled -> m ()
probeMatchingContacts ct connectedIncognito = do
gVar <- asks idsDrg
(probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId ct
@@ -3954,7 +3974,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
toView $ CRReceivedGroupInvitation user gInfo ct memRole
toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
whenContactNtfs user ct $
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
where
@@ -4830,13 +4850,13 @@ createInternalChatItem user cd content itemTs_ = do
ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs createdAt
toView $ CRNewChatItem user (AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci)
getCreateActiveUser :: SQLiteStore -> IO User
getCreateActiveUser st = do
getCreateActiveUser :: SQLiteStore -> Bool -> IO User
getCreateActiveUser st testView = do
user <-
withTransaction st getUsers >>= \case
[] -> newUser
users -> maybe (selectUser users) pure (find activeUser users)
putStrLn $ "Current user: " <> userStr user
unless testView $ putStrLn $ "Current user: " <> userStr user
pure user
where
newUser :: IO User
@@ -5033,7 +5053,7 @@ chatCommandP =
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
"/_delete " *> (APIDeleteChat <$> chatRefP),
"/_clear chat " *> (APIClearChat <$> chatRefP),
"/_accept " *> (APIAcceptContact <$> A.decimal),
"/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal),
"/_reject " *> (APIRejectContact <$> A.decimal),
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
"/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType),
@@ -5081,8 +5101,10 @@ chatCommandP =
"/reconnect" $> ReconnectAllServers,
"/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP),
"/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal),
"/_info #" *> (APIGroupInfo <$> A.decimal),
"/_info @" *> (APIContactInfo <$> A.decimal),
("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* char_ '@' <*> displayName),
("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayName),
("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayName),
"/_switch #" *> (APISwitchGroupMember <$> A.decimal <* A.space <*> A.decimal),
"/_switch @" *> (APISwitchContact <$> A.decimal),
@@ -5112,6 +5134,7 @@ chatCommandP =
("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups,
("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts,
("/help address" <|> "/ha") $> ChatHelp HSMyAddress,
"/help incognito" $> ChatHelp HSIncognito,
("/help messages" <|> "/hm") $> ChatHelp HSMessages,
("/help settings" <|> "/hs") $> ChatHelp HSSettings,
("/help db" <|> "/hd") $> ChatHelp HSDatabase,
@@ -5128,11 +5151,15 @@ chatCommandP =
"/clear #" *> (ClearGroup <$> displayName),
"/clear " *> char_ '@' *> (ClearContact <$> displayName),
("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayName),
("/groups" <|> "/gs") $> ListGroups,
"/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)),
("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayName) <*> optional (A.space *> stringP)),
"/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP),
("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile),
("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName),
"/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)),
"/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <* A.space <*> (Just <$> msgTextP)),
"/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> pure Nothing),
"/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayName),
"/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)),
"/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole),
"/_delete link #" *> (APIDeleteGroupLink <$> A.decimal),
@@ -5145,10 +5172,11 @@ chatCommandP =
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP),
"/_contacts " *> (APIListContacts <$> A.decimal),
"/contacts" $> ListContacts,
"/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
"/_connect " *> (APIAddContact <$> A.decimal),
("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
("/connect" <|> "/c") $> AddContact,
"/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
"/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP),
"/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP),
("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
("/connect" <|> "/c") *> (AddContact <$> incognitoP),
SendMessage <$> chatNameP <* A.space <*> msgTextP,
"/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")),
(">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv),
@@ -5174,7 +5202,7 @@ chatCommandP =
"/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal),
("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal),
("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal),
"/simplex" $> ConnectSimplex,
"/simplex" *> (ConnectSimplex <$> incognitoP),
"/_address " *> (APICreateMyAddress <$> A.decimal),
("/address" <|> "/ad") $> CreateMyAddress,
"/_delete_address " *> (APIDeleteMyAddress <$> A.decimal),
@@ -5185,7 +5213,7 @@ chatCommandP =
("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP),
"/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP),
"/auto_accept " *> (AddressAutoAccept <$> autoAcceptP),
("/accept " <|> "/ac ") *> char_ '@' *> (AcceptContact <$> displayName),
("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayName),
("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName),
("/markdown" <|> "/m") $> ChatHelp HSMarkdown,
("/welcome" <|> "/w") $> Welcome,
@@ -5207,7 +5235,7 @@ chatCommandP =
"/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)),
"/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)),
"/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))),
"/incognito " *> (SetIncognito <$> onOffP),
("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito,
("/quit" <|> "/q" <|> "/exit") $> QuitChat,
("/version" <|> "/v") $> ShowVersion,
"/debug locks" $> DebugLocks,
@@ -5216,6 +5244,8 @@ chatCommandP =
]
where
choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput)
incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False
incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False
imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,")
imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P))
chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> A.char ':' $> CTContactConnection
+25 -19
View File
@@ -9,9 +9,7 @@ module Simplex.Chat.Bot where
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Monad.Reader
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import qualified Data.Text as T
import Simplex.Chat.Controller
import Simplex.Chat.Core
@@ -19,9 +17,8 @@ import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Protocol (MsgContent (..))
import Simplex.Chat.Store
import Simplex.Chat.Types (Contact (..), IsContact (..), User (..))
import Simplex.Chat.Types (Contact (..), ContactId, IsContact (..), User (..))
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (safeDecodeUtf8)
import System.Exit (exitFailure)
chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatController -> IO ()
@@ -32,49 +29,58 @@ chatBotRepl welcome answer _user cc = do
case resp of
CRContactConnected _ contact _ -> do
contactConnected contact
void $ sendMsg contact welcome
void $ sendMessage cc contact welcome
CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) -> do
let msg = T.unpack $ ciContentToText mc
void $ sendMsg contact =<< answer contact msg
void $ sendMessage cc contact =<< answer contact msg
_ -> pure ()
where
sendMsg Contact {contactId} msg = sendChatCmd cc $ "/_send @" <> show contactId <> " text " <> msg
contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected"
initializeBotAddress :: ChatController -> IO ()
initializeBotAddress cc = do
sendChatCmd cc "/show_address" >>= \case
initializeBotAddress = initializeBotAddress' True
initializeBotAddress' :: Bool -> ChatController -> IO ()
initializeBotAddress' logAddress cc = do
sendChatCmd cc ShowMyAddress >>= \case
CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact
CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do
putStrLn "No bot address, creating..."
sendChatCmd cc "/address" >>= \case
when logAddress $ putStrLn "No bot address, creating..."
sendChatCmd cc CreateMyAddress >>= \case
CRUserContactLinkCreated _ uri -> showBotAddress uri
_ -> putStrLn "can't create bot address" >> exitFailure
_ -> putStrLn "unexpected response" >> exitFailure
where
showBotAddress uri = do
putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri)
void $ sendChatCmd cc "/auto_accept on"
when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri)
void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {acceptIncognito = False, autoReply = Nothing}
sendMessage :: ChatController -> Contact -> String -> IO ()
sendMessage cc ct = sendComposedMessage cc ct Nothing . textMsgContent
sendMessage' :: ChatController -> ContactId -> String -> IO ()
sendMessage' cc ctId = sendComposedMessage' cc ctId Nothing . textMsgContent
sendComposedMessage :: ChatController -> Contact -> Maybe ChatItemId -> MsgContent -> IO ()
sendComposedMessage cc ct quotedItemId msgContent = do
sendComposedMessage cc = sendComposedMessage' cc . contactId'
sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO ()
sendComposedMessage' cc ctId quotedItemId msgContent = do
let cm = ComposedMessage {filePath = Nothing, quotedItemId, msgContent}
sendChatCmd cc ("/_send @" <> show (contactId' ct) <> " json " <> jsonEncode cm) >>= \case
CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to " <> contactInfo ct
sendChatCmd cc (APISendMessage (ChatRef CTDirect ctId) False Nothing cm) >>= \case
CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId
r -> putStrLn $ "unexpected send message response: " <> show r
where
jsonEncode = T.unpack . safeDecodeUtf8 . LB.toStrict . J.encode
deleteMessage :: ChatController -> Contact -> ChatItemId -> IO ()
deleteMessage cc ct chatItemId = do
let cmd = "/_delete item @" <> show (contactId' ct) <> " " <> show chatItemId <> " internal"
let cmd = APIDeleteChatItem (contactRef ct) chatItemId CIDMInternal
sendChatCmd cc cmd >>= \case
CRChatItemDeleted {} -> printLog cc CLLInfo $ "deleted message from " <> contactInfo ct
r -> putStrLn $ "unexpected delete message response: " <> show r
contactRef :: Contact -> ChatRef
contactRef = ChatRef CTDirect . contactId'
textMsgContent :: String -> MsgContent
textMsgContent = MCText . T.pack
+33
View File
@@ -0,0 +1,33 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Bot.KnownContacts where
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text as T
import Options.Applicative
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Util (safeDecodeUtf8)
data KnownContact = KnownContact
{ contactId :: Int64,
localDisplayName :: Text
}
deriving (Eq)
knownContactNames :: [KnownContact] -> String
knownContactNames = T.unpack . T.intercalate ", " . map (("@" <>) . localDisplayName)
parseKnownContacts :: ReadM [KnownContact]
parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack
knownContactsP :: A.Parser [KnownContact]
knownContactsP = contactP `A.sepBy1` A.char ','
where
contactP = do
contactId <- A.decimal <* A.char ':'
localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ")
pure KnownContact {contactId, localDisplayName}
+21 -14
View File
@@ -176,7 +176,6 @@ data ChatController = ChatController
currentCalls :: TMap ContactId Call,
config :: ChatConfig,
filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps,
incognitoMode :: TVar Bool,
expireCIThreads :: TMap UserId (Maybe (Async ())),
expireCIFlags :: TMap UserId Bool,
cleanupManagerAsync :: TVar (Maybe (Async ())),
@@ -187,7 +186,7 @@ data ChatController = ChatController
logFilePath :: Maybe FilePath
}
data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSMarkdown | HSMessages | HSSettings | HSDatabase
data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase
deriving (Show, Generic)
instance ToJSON HelpSection where
@@ -223,7 +222,6 @@ data ChatCommand
| SetTempFolder FilePath
| SetFilesFolder FilePath
| APISetXFTPConfig (Maybe XFTPFileConfig)
| SetIncognito Bool
| APIExportArchive ArchiveConfig
| ExportArchive
| APIImportArchive ArchiveConfig
@@ -244,7 +242,7 @@ data ChatCommand
| APIChatUnread ChatRef Bool
| APIDeleteChat ChatRef
| APIClearChat ChatRef
| APIAcceptContact Int64
| APIAcceptContact IncognitoEnabled Int64
| APIRejectContact Int64
| APISendCallInvitation ContactId CallType
| SendCallInvitation ContactName CallType
@@ -291,6 +289,7 @@ data ChatCommand
| ReconnectAllServers
| APISetChatSettings ChatRef ChatSettings
| APIContactInfo ContactId
| APIGroupInfo GroupId
| APIGroupMemberInfo GroupId GroupMemberId
| APISwitchContact ContactId
| APISwitchGroupMember GroupId GroupMemberId
@@ -307,6 +306,7 @@ data ChatCommand
| SetShowMessages ChatName Bool
| SetSendReceipts ChatName (Maybe Bool)
| ContactInfo ContactName
| ShowGroupInfo GroupName
| GroupMemberInfo GroupName ContactName
| SwitchContact ContactName
| SwitchGroupMember GroupName ContactName
@@ -322,11 +322,12 @@ data ChatCommand
| EnableGroupMember GroupName ContactName
| ChatHelp HelpSection
| Welcome
| APIAddContact UserId
| AddContact
| APIConnect UserId (Maybe AConnectionRequestUri)
| Connect (Maybe AConnectionRequestUri)
| ConnectSimplex -- UserId (not used in UI)
| APIAddContact UserId IncognitoEnabled
| AddContact IncognitoEnabled
| APISetConnectionIncognito Int64 IncognitoEnabled
| APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri)
| Connect IncognitoEnabled (Maybe AConnectionRequestUri)
| ConnectSimplex IncognitoEnabled -- UserId (not used in UI)
| DeleteContact ContactName
| ClearContact ContactName
| APIListContacts UserId
@@ -341,7 +342,7 @@ data ChatCommand
| SetProfileAddress Bool
| APIAddressAutoAccept UserId (Maybe AutoAccept)
| AddressAutoAccept (Maybe AutoAccept)
| AcceptContact ContactName
| AcceptContact IncognitoEnabled ContactName
| RejectContact ContactName
| SendMessage ChatName Text
| SendLiveMessage ChatName Text
@@ -362,10 +363,12 @@ data ChatCommand
| DeleteGroup GroupName
| ClearGroup GroupName
| ListMembers GroupName
| ListGroups -- UserId (not used in UI)
| APIListGroups UserId (Maybe ContactId) (Maybe String)
| ListGroups (Maybe ContactName) (Maybe String)
| UpdateGroupNames GroupName GroupProfile
| ShowGroupProfile GroupName
| UpdateGroupDescription GroupName (Maybe Text)
| ShowGroupDescription GroupName
| CreateGroupLink GroupName GroupMemberRole
| GroupLinkMemberRole GroupName GroupMemberRole
| DeleteGroupLink GroupName
@@ -422,6 +425,7 @@ data ChatResponse
| CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
| CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary}
| CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats}
| CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats}
| CRGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats}
@@ -459,7 +463,7 @@ data ChatResponse
| CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRGroupsList {user :: User, groups :: [GroupInfo]}
| CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]}
| CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember}
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
| CRFileTransferStatusXFTP User AChatItem
@@ -467,7 +471,8 @@ data ChatResponse
| CRUserProfileNoChange {user :: User}
| CRUserPrivacy {user :: User, updatedUser :: User}
| CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]}
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation}
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection}
| CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection}
| CRSentConfirmation {user :: User}
| CRSentInvitation {user :: User, customUserProfile :: Maybe Profile}
| CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
@@ -518,7 +523,7 @@ data ChatResponse
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRGroupInvitation {user :: User, groupInfo :: GroupInfo}
| CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole}
| CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole}
| CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember}
| CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember}
@@ -533,6 +538,7 @@ data ChatResponse
| CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
| CRGroupProfile {user :: User, groupInfo :: GroupInfo}
| CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI
| CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole}
| CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole}
| CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo}
@@ -876,6 +882,7 @@ data ChatErrorType
| CEServerProtocol {serverProtocol :: AProtocolType}
| CEAgentCommandError {message :: String}
| CEInvalidFileDescription {message :: String}
| CEConnectionIncognitoChangeProhibited
| CEInternalError {message :: String}
| CEException {message :: String}
deriving (Show, Exception, Generic)
+7 -4
View File
@@ -15,7 +15,7 @@ import System.Exit (exitFailure)
import UnliftIO.Async
simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO ()
simplexChatCore cfg@ChatConfig {confirmMigrations} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat =
simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat =
case logAgent of
Just level -> do
setLogLevel level
@@ -27,7 +27,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations} opts@ChatOpts {coreOptions =
putStrLn $ "Error opening database: " <> show e
exitFailure
run db@ChatDatabase {chatStore} = do
u <- getCreateActiveUser chatStore
u <- getCreateActiveUser chatStore testView
cc <- newChatController db (Just u) cfg opts sendToast
runSimplexChat opts u cc chat
@@ -39,5 +39,8 @@ runSimplexChat ChatOpts {maintenance} u cc chat
a2 <- async $ chat u cc
waitEither_ a1 a2
sendChatCmd :: ChatController -> String -> IO ChatResponse
sendChatCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc
sendChatCmdStr :: ChatController -> String -> IO ChatResponse
sendChatCmdStr cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc
sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse
sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc
+31 -11
View File
@@ -8,6 +8,7 @@ module Simplex.Chat.Help
groupsHelpInfo,
contactsHelpInfo,
myAddressHelpInfo,
incognitoHelpInfo,
messagesHelpInfo,
markdownInfo,
settingsInfo,
@@ -48,7 +49,7 @@ chatWelcome user =
"Welcome " <> green userName <> "!",
"Thank you for installing SimpleX Chat!",
"",
"Connect to SimpleX Chat lead developer for any questions - just type " <> highlight "/simplex",
"Connect to SimpleX Chat developers for any questions - just type " <> highlight "/simplex",
"",
"Follow our updates:",
"> Reddit: https://www.reddit.com/r/SimpleXChat/",
@@ -213,6 +214,26 @@ myAddressHelpInfo =
"The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"]
]
incognitoHelpInfo :: [StyledString]
incognitoHelpInfo =
map
styleMarkdown
[ markdown (colored Red) "/incognito" <> " command is deprecated, use commands below instead.",
"",
"Incognito mode protects the privacy of your main profile — you can choose to create a new random profile for each new contact.",
"It allows having many anonymous connections without any shared data between them in a single chat profile.",
"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.",
"",
green "Incognito commands:",
indent <> highlight "/connect incognito " <> " - create new invitation link using incognito profile",
indent <> highlight "/connect incognito <invitation> " <> " - accept invitation using incognito profile",
indent <> highlight "/accept incognito <name> " <> " - accept contact request using incognito profile",
indent <> highlight "/simplex incognito " <> " - connect to SimpleX Chat developers using incognito profile",
"",
"The commands may be abbreviated: " <> listHighlight ["/c i", "/c i <invitation>", "/ac i <name>"],
"To find the profile used for an incognito connection, use " <> highlight "/info <contact>" <> "."
]
messagesHelpInfo :: [StyledString]
messagesHelpInfo =
map
@@ -269,7 +290,6 @@ settingsInfo =
map
styleMarkdown
[ green "Chat settings:",
indent <> highlight "/incognito on/off " <> " - enable/disable incognito mode",
indent <> highlight "/network " <> " - show / set network access options",
indent <> highlight "/smp " <> " - show / set configured SMP servers",
indent <> highlight "/xftp " <> " - show / set configured XFTP servers",
@@ -285,12 +305,12 @@ databaseHelpInfo :: [StyledString]
databaseHelpInfo =
map
styleMarkdown
[ green "Database export:",
indent <> highlight "/db export " <> " - create database export file that can be imported in mobile apps",
indent <> highlight "/files_folder <path> " <> " - set files folder path to include app files in the exported archive",
"",
green "Database encryption:",
indent <> highlight "/db encrypt <key> " <> " - encrypt chat database with key/passphrase",
indent <> highlight "/db key <current> <new>" <> " - change the key of the encrypted app database",
indent <> highlight "/db decrypt <key> " <> " - decrypt chat database"
]
[ green "Database export:",
indent <> highlight "/db export " <> " - create database export file that can be imported in mobile apps",
indent <> highlight "/files_folder <path> " <> " - set files folder path to include app files in the exported archive",
"",
green "Database encryption:",
indent <> highlight "/db encrypt <key> " <> " - encrypt chat database with key/passphrase",
indent <> highlight "/db key <current> <new>" <> " - change the key of the encrypted app database",
indent <> highlight "/db decrypt <key> " <> " - decrypt chat database"
]
+32 -1
View File
@@ -17,6 +17,7 @@ module Simplex.Chat.Store.Direct
getPendingContactConnection,
deletePendingContactConnection,
createDirectConnection,
createIncognitoProfile,
createConnReqConnection,
getProfileById,
getConnReqContactXContactId,
@@ -33,6 +34,8 @@ module Simplex.Chat.Store.Direct
updateContactUserPreferences,
updateContactAlias,
updateContactConnectionAlias,
updatePCCIncognito,
deletePCCIncognitoProfile,
updateContactUsed,
updateContactUnreadChat,
updateGroupUnreadChat,
@@ -171,6 +174,11 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile
pccConnId <- insertedRowId db
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt}
createIncognitoProfile :: DB.Connection -> User -> Profile -> IO Int64
createIncognitoProfile db User {userId} p = do
createdAt <- getCurrentTime
createIncognitoProfile_ db userId createdAt p
createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64
createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do
DB.execute
@@ -307,7 +315,30 @@ updateContactConnectionAlias db userId conn localAlias = do
WHERE user_id = ? AND connection_id = ?
|]
(localAlias, updatedAt, userId, pccConnId conn)
pure (conn :: PendingContactConnection) {localAlias}
pure (conn :: PendingContactConnection) {localAlias, updatedAt}
updatePCCIncognito :: DB.Connection -> User -> PendingContactConnection -> Maybe ProfileId -> IO PendingContactConnection
updatePCCIncognito db User {userId} conn customUserProfileId = do
updatedAt <- getCurrentTime
DB.execute
db
[sql|
UPDATE connections
SET custom_user_profile_id = ?, updated_at = ?
WHERE user_id = ? AND connection_id = ?
|]
(customUserProfileId, updatedAt, userId, pccConnId conn)
pure (conn :: PendingContactConnection) {customUserProfileId, updatedAt}
deletePCCIncognitoProfile :: DB.Connection -> User -> ProfileId -> IO ()
deletePCCIncognitoProfile db User {userId} profileId =
DB.execute
db
[sql|
DELETE FROM contact_profiles
WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1
|]
(userId, profileId)
updateContactUsed :: DB.Connection -> User -> Contact -> IO ()
updateContactUsed db User {userId} Contact {contactId} = do
+32 -3
View File
@@ -45,6 +45,8 @@ module Simplex.Chat.Store.Groups
deleteGroup,
getUserGroups,
getUserGroupDetails,
getUserGroupsWithSummary,
getGroupSummary,
getContactGroupPreferences,
checkContactHasGroups,
getGroupInvitation,
@@ -448,8 +450,8 @@ getUserGroups db user@User {userId} = do
groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId)
rights <$> mapM (runExceptT . getGroup db user) groupIds
getUserGroupDetails :: DB.Connection -> User -> IO [GroupInfo]
getUserGroupDetails db User {userId, userContactId} =
getUserGroupDetails :: DB.Connection -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo]
getUserGroupDetails db User {userId, userContactId} _contactId_ search_ =
map (toGroupInfo userContactId)
<$> DB.query
db
@@ -462,8 +464,35 @@ getUserGroupDetails db User {userId, userContactId} =
JOIN group_members mu USING (group_id)
JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id)
WHERE g.user_id = ? AND mu.contact_id = ?
AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%')
|]
(userId, userContactId)
(userId, userContactId, search, search, search)
where
search = fromMaybe "" search_
getUserGroupsWithSummary :: DB.Connection -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)]
getUserGroupsWithSummary db user _contactId_ search_ =
getUserGroupDetails db user _contactId_ search_
>>= mapM (\g@GroupInfo {groupId} -> (g,) <$> getGroupSummary db user groupId)
-- the statuses on non-current members should match memberCurrent' function
getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary
getGroupSummary db User {userId} groupId = do
currentMembers_ <- maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT count (m.group_member_id)
FROM groups g
JOIN group_members m USING (group_id)
WHERE g.user_id = ?
AND g.group_id = ?
AND m.member_status != ?
AND m.member_status != ?
AND m.member_status != ?
|]
(userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited)
pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_}
getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences]
getContactGroupPreferences db User {userId} Contact {contactId} = do
+2 -5
View File
@@ -397,14 +397,14 @@ data UserContactLink = UserContactLink
instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions
data AutoAccept = AutoAccept
{ acceptIncognito :: Bool,
{ acceptIncognito :: IncognitoEnabled,
autoReply :: Maybe MsgContent
}
deriving (Show, Generic)
instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions
toUserContactLink :: (ConnReqContact, Bool, Bool, Maybe MsgContent) -> UserContactLink
toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink
toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) =
UserContactLink connReq $
if autoAccept then Just AutoAccept {acceptIncognito, autoReply} else Nothing
@@ -452,9 +452,6 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do
Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply)
_ -> (False, False, Nothing)
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p]
getProtocolServers db User {userId} =
map toServerCfg
+2 -2
View File
@@ -203,7 +203,7 @@ createContact_ db userId connId Profile {displayName, fullName, image, contactLi
pure $ Right (ldn, contactId, profileId)
deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO ()
deleteUnusedIncognitoProfileById_ db User {userId} profile_id =
deleteUnusedIncognitoProfileById_ db User {userId} profileId =
DB.executeNamed
db
[sql|
@@ -218,7 +218,7 @@ deleteUnusedIncognitoProfileById_ db User {userId} profile_id =
WHERE user_id = :user_id AND member_profile_id = :profile_id LIMIT 1
)
|]
[":user_id" := userId, ":profile_id" := profile_id]
[":user_id" := userId, ":profile_id" := profileId]
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime)
+12 -2
View File
@@ -184,7 +184,9 @@ contactConn = activeConn
contactConnId :: Contact -> ConnId
contactConnId = aConnId . contactConn
contactConnIncognito :: Contact -> Bool
type IncognitoEnabled = Bool
contactConnIncognito :: Contact -> IncognitoEnabled
contactConnIncognito = connIncognito . contactConn
contactDirect :: Contact -> Bool
@@ -318,6 +320,13 @@ instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOption
groupName' :: GroupInfo -> GroupName
groupName' GroupInfo {localDisplayName = g} = g
data GroupSummary = GroupSummary
{ currentMembers :: Int
}
deriving (Show, Generic)
instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions
data ContactOrGroup = CGContact Contact | CGGroup Group
contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId)
@@ -595,7 +604,7 @@ memberConnId GroupMember {activeConn} = aConnId <$> activeConn
groupMemberId' :: GroupMember -> GroupMemberId
groupMemberId' GroupMember {groupMemberId} = groupMemberId
memberIncognito :: GroupMember -> Bool
memberIncognito :: GroupMember -> IncognitoEnabled
memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId
memberSecurityCode :: GroupMember -> Maybe SecurityCode
@@ -784,6 +793,7 @@ memberActive m = case memberStatus m of
memberCurrent :: GroupMember -> Bool
memberCurrent = memberCurrent' . memberStatus
-- update getGroupSummary if this is changed
memberCurrent' :: GroupMemberStatus -> Bool
memberCurrent' = \case
GSMemRemoved -> False
+28 -7
View File
@@ -79,6 +79,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl
CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile
CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s
CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats
CRContactSwitchStarted {} -> ["switch started"]
CRGroupMemberSwitchStarted {} -> ["switch started"]
@@ -115,6 +116,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
HSGroups -> groupsHelpInfo
HSContacts -> contactsHelpInfo
HSMyAddress -> myAddressHelpInfo
HSIncognito -> incognitoHelpInfo
HSMessages -> messagesHelpInfo
HSMarkdown -> markdownInfo
HSSettings -> settingsInfo
@@ -138,7 +140,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRUserProfileNoChange u -> ttyUser u ["user profile did not change"]
CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u'
CRVersionInfo info _ _ -> viewVersionInfo logLevel info
CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq
CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq
CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c
CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"]
@@ -200,7 +203,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError
(groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks
CRGroupInvitation u g -> ttyUser u [groupInvitation' g]
CRReceivedGroupInvitation u g c role -> ttyUser u $ viewReceivedGroupInvitation g c role
CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r
CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g
CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m
CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h]
@@ -217,6 +220,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"]
CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m
CRGroupProfile u g -> ttyUser u $ viewGroupProfile g
CRGroupDescription u g -> ttyUser u $ viewGroupDescription g
CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole
CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole
CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g
@@ -810,12 +814,12 @@ viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView
Nothing ->
[ttyFullContact ct <> ": contact is connected"]
viewGroupsList :: [GroupInfo] -> [StyledString]
viewGroupsList :: [(GroupInfo, GroupSummary)] -> [StyledString]
viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g <name>"]
viewGroupsList gs = map groupSS $ sortOn ldn_ gs
where
ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName)
groupSS g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings} =
ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) . fst
groupSS (g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings}, GroupSummary {currentMembers}) =
case memberStatus membership of
GSMemInvited -> groupInvitation' g
s -> membershipIncognito g <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s
@@ -825,9 +829,10 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs
GSMemLeft -> delete "you left"
GSMemGroupDeleted -> delete "group deleted"
_
| enableNtfs chatSettings -> ""
| otherwise -> " (muted, you can " <> highlight ("/unmute #" <> ldn) <> ")"
| enableNtfs chatSettings -> " (" <> memberCount <> ")"
| otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> ldn) <> ")"
delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")"
memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s"
groupInvitation' :: GroupInfo -> StyledString
groupInvitation' GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership = membership@GroupMember {memberProfile}} =
@@ -934,6 +939,12 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta
<> ["alias: " <> plain localAlias | localAlias /= ""]
<> [viewConnectionVerified (contactSecurityCode ct)]
viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString]
viewGroupInfo GroupInfo {groupId} s =
[ "group ID: " <> sShow groupId,
"current members: " <> sShow (currentMembers s)
]
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString]
viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats =
[ "group ID: " <> sShow groupId,
@@ -1135,6 +1146,10 @@ viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {description, image, g
where
pref = getGroupPreference f . mergeGroupPreferences
viewGroupDescription :: GroupInfo -> [StyledString]
viewGroupDescription GroupInfo {groupProfile = GroupProfile {description}} =
maybe ["No welcome message!"] ((bold' "Welcome message:" :) . map plain . T.lines) description
bold' :: String -> StyledString
bold' = styled Bold
@@ -1148,6 +1163,11 @@ viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias}
| localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"]
| otherwise = ["connection " <> sShow pccConnId <> " alias updated: " <> plain localAlias]
viewConnectionIncognitoUpdated :: PendingContactConnection -> [StyledString]
viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserProfileId}
| isJust customUserProfileId = ["connection " <> sShow pccConnId <> " changed to incognito"]
| otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"]
viewContactUpdated :: Contact -> Contact -> [StyledString]
viewContactUpdated
Contact {localDisplayName = n, profile = LocalProfile {fullName, contactLink}}
@@ -1539,6 +1559,7 @@ viewChatError logLevel = \case
CECommandError e -> ["bad chat command: " <> plain e]
CEAgentCommandError e -> ["agent command error: " <> plain e]
CEInvalidFileDescription e -> ["invalid file description: " <> plain e]
CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"]
CEInternalError e -> ["internal chat error: " <> plain e]
CEException e -> ["exception: " <> plain e]
-- e -> ["chat error: " <> sShow e]
+76
View File
@@ -0,0 +1,76 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Bots.BroadcastTests where
import Broadcast.Bot
import Broadcast.Options
import ChatClient
import ChatTests.Utils
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (bracket)
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Core
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..))
import Simplex.Chat.Types (Profile (..))
import System.FilePath ((</>))
import Test.Hspec
broadcastBotTests :: SpecWith FilePath
broadcastBotTests = do
it "should broadcast message" testBroadcastMessages
withBroadcastBot :: BroadcastBotOpts -> IO () -> IO ()
withBroadcastBot opts test =
bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test)
where
bot = simplexChatCore testCfg (mkChatOpts opts) Nothing $ broadcastBot opts
broadcastBotProfile :: Profile
broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing}
mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts
mkBotOpts tmp publishers =
BroadcastBotOpts
{ coreOptions = (coreOptions (testOpts :: ChatOpts)) {dbFilePrefix = tmp </> botDbPrefix},
publishers,
welcomeMessage = defaultWelcomeMessage publishers,
prohibitedMessage = defaultWelcomeMessage publishers
}
botDbPrefix :: FilePath
botDbPrefix = "broadcast_bot"
testBroadcastMessages :: HasCallStack => FilePath -> IO ()
testBroadcastMessages tmp = do
botLink <-
withNewTestChat tmp botDbPrefix broadcastBotProfile $ \bc_bot ->
withNewTestChat tmp "alice" aliceProfile $ \alice -> do
connectUsers bc_bot alice
bc_bot ##> "/ad"
getContactLink bc_bot True
let botOpts = mkBotOpts tmp [KnownContact 2 "alice"]
withBroadcastBot botOpts $
withTestChat tmp "alice" $ \alice ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
alice <## "1 contacts connected (use /cs for the list)"
bob `connectVia` botLink
bob #> "@broadcast_bot hello"
bob <# "broadcast_bot> > hello"
bob <## " Hello! I am a broadcast bot."
bob <## "I broadcast messages to all connected users from @alice."
cath `connectVia` botLink
alice #> "@broadcast_bot hello all!"
bob <# "broadcast_bot> hello all!"
cath <# "broadcast_bot> hello all!"
alice <# "broadcast_bot> > hello all!"
alice <## " Forwarded to 2 contact(s)"
where
cc `connectVia` botLink = do
cc ##> ("/c " <> botLink)
cc <## "connection request sent!"
cc <## "broadcast_bot (Broadcast Bot): contact is connected"
cc <# "broadcast_bot> Hello! I am a broadcast bot."
cc <## "I broadcast messages to all connected users from @alice."
+882
View File
@@ -0,0 +1,882 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-}
module Bots.DirectoryTests where
import ChatClient
import ChatTests.Utils
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (finally)
import Control.Monad (forM_)
import Directory.Options
import Directory.Service
import Directory.Store
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Core
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..))
import Simplex.Chat.Types (GroupMemberRole (..), Profile (..))
import System.FilePath ((</>))
import Test.Hspec
import GHC.IO.Handle (hClose)
directoryServiceTests :: SpecWith FilePath
directoryServiceTests = do
it "should register group" testDirectoryService
it "should suspend and resume group" testSuspendResume
describe "de-listing the group" $ do
it "should de-list if owner leaves the group" testDelistedOwnerLeaves
it "should de-list if owner is removed from the group" testDelistedOwnerRemoved
it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves
it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved
it "should de-list if service is removed from the group" testDelistedServiceRemoved
it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges
it "should NOT de-list if another member role changes" testNotDelistedMemberRoleChanged
it "should NOT send to approval if roles are incorrect" testNotSentApprovalBadRoles
it "should NOT allow approving if roles are incorrect" testNotApprovedBadRoles
describe "should require re-approval if profile is changed by" $ do
it "the registration owner" testRegOwnerChangedProfile
it "another owner" testAnotherOwnerChangedProfile
describe "should require profile update if group link is removed by " $ do
it "the registration owner" testRegOwnerRemovedLink
it "another owner" testAnotherOwnerRemovedLink
describe "duplicate groups (same display name and full name)" $ do
it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation
it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration
it "should prohibit confirmation if a duplicate group is listed" testDuplicateProhibitConfirmation
it "should prohibit when profile is updated and not send for approval" testDuplicateProhibitWhenUpdated
it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval
describe "list groups" $ do
it "should list user's groups" testListUserGroups
describe "store log" $ do
it "should restore directory service state" testRestoreDirectory
directoryProfile :: Profile
directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing}
mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts
mkDirectoryOpts tmp superUsers =
DirectoryOpts
{ coreOptions = (coreOptions (testOpts :: ChatOpts)) {dbFilePrefix = tmp </> serviceDbPrefix},
superUsers,
directoryLog = Just $ tmp </> "directory_service.log",
serviceName = "SimpleX-Directory",
testing = True
}
serviceDbPrefix :: FilePath
serviceDbPrefix = "directory_service"
testDirectoryService :: HasCallStack => FilePath -> IO ()
testDirectoryService tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
bob #> "@SimpleX-Directory privacy"
bob <# "SimpleX-Directory> > privacy"
bob <## " No groups found"
-- putStrLn "*** create a group"
bob ##> "/g PSA Privacy, Security & Anonymity"
bob <## "group #PSA (Privacy, Security & Anonymity) is created"
bob <## "to add members use /a PSA <name> or /create link #PSA"
bob ##> "/a PSA SimpleX-Directory member"
bob <## "invitation to join the group #PSA sent to SimpleX-Directory"
bob <# "SimpleX-Directory> You must grant directory service admin role to register the group"
bob ##> "/mr PSA SimpleX-Directory admin"
-- putStrLn "*** discover service joins group and creates the link for profile"
bob <## "#PSA: you changed the role of SimpleX-Directory from member to admin"
bob <# "SimpleX-Directory> Joining the group PSA…"
bob <## "#PSA: SimpleX-Directory joined the group"
bob <# "SimpleX-Directory> Joined the group PSA, creating the link…"
bob <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online."
bob <## ""
bob <## "Please add it to the group welcome message."
bob <## "For example, add:"
welcomeWithLink <- dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine bob
-- putStrLn "*** update profile without link"
updateGroupProfile bob "Welcome!"
bob <# "SimpleX-Directory> The profile updated for ID 1 (PSA), but the group link is not added to the welcome message."
(superUser </)
-- putStrLn "*** update profile so that it has link"
updateGroupProfile bob welcomeWithLink
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
approvalRequested superUser welcomeWithLink (1 :: Int)
-- putStrLn "*** update profile so that it still has link"
let welcomeWithLink' = "Welcome! " <> welcomeWithLink
updateGroupProfile bob welcomeWithLink'
bob <# "SimpleX-Directory> The group ID 1 (PSA) is updated!"
bob <## "It is hidden from the directory until approved."
superUser <# "SimpleX-Directory> The group ID 1 (PSA) is updated."
approvalRequested superUser welcomeWithLink' (2 :: Int)
-- putStrLn "*** try approving with the old registration code"
superUser #> "@SimpleX-Directory /approve 1:PSA 1"
superUser <# "SimpleX-Directory> > /approve 1:PSA 1"
superUser <## " Incorrect approval code"
-- putStrLn "*** update profile so that it has no link"
updateGroupProfile bob "Welcome!"
bob <# "SimpleX-Directory> The group link for ID 1 (PSA) is removed from the welcome message."
bob <## ""
bob <## "The group is hidden from the directory until the group link is added and the group is re-approved."
superUser <# "SimpleX-Directory> The group link is removed from ID 1 (PSA), de-listed."
superUser #> "@SimpleX-Directory /approve 1:PSA 2"
superUser <# "SimpleX-Directory> > /approve 1:PSA 2"
superUser <## " Error: the group ID 1 (PSA) is not pending approval."
-- putStrLn "*** update profile so that it has link again"
updateGroupProfile bob welcomeWithLink'
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
approvalRequested superUser welcomeWithLink' (1 :: Int)
superUser #> "@SimpleX-Directory /approve 1:PSA 1"
superUser <# "SimpleX-Directory> > /approve 1:PSA 1"
superUser <## " Group approved!"
bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory!"
bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved."
search bob "privacy" welcomeWithLink'
search bob "security" welcomeWithLink'
cath `connectVia` dsLink
search cath "privacy" welcomeWithLink'
where
search u s welcome = do
u #> ("@SimpleX-Directory " <> s)
u <# ("SimpleX-Directory> > " <> s)
u <## " Found 1 group(s)"
u <# "SimpleX-Directory> PSA (Privacy, Security & Anonymity)"
u <## "Welcome message:"
u <## welcome
u <## "2 members"
updateGroupProfile u welcome = do
u ##> ("/set welcome #PSA " <> welcome)
u <## "description changed to:"
u <## welcome
approvalRequested su welcome grId = do
su <# "SimpleX-Directory> bob submitted the group ID 1: PSA (Privacy, Security & Anonymity)"
su <## "Welcome message:"
su <## welcome
su <## ""
su <## "To approve send:"
su <# ("SimpleX-Directory> /approve 1:PSA " <> show grId)
testSuspendResume :: HasCallStack => FilePath -> IO ()
testSuspendResume tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
groupFound bob "privacy"
superUser #> "@SimpleX-Directory /suspend 1:privacy"
superUser <# "SimpleX-Directory> > /suspend 1:privacy"
superUser <## " Group suspended!"
bob <# "SimpleX-Directory> The group ID 1 (privacy) is suspended and hidden from directory. Please contact the administrators."
groupNotFound bob "privacy"
superUser #> "@SimpleX-Directory /resume 1:privacy"
superUser <# "SimpleX-Directory> > /resume 1:privacy"
superUser <## " Group listing resumed!"
bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!"
groupFound bob "privacy"
testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO ()
testDelistedOwnerLeaves tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
leaveGroup "privacy" bob
cath <## "#privacy: bob left the group"
bob <# "SimpleX-Directory> You left the group ID 1 (privacy)."
bob <## ""
bob <## "The group is no longer listed in the directory."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)."
groupNotFound cath "privacy"
testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO ()
testDelistedOwnerRemoved tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
removeMember "privacy" cath bob
bob <# "SimpleX-Directory> You are removed from the group ID 1 (privacy)."
bob <## ""
bob <## "The group is no longer listed in the directory."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)."
groupNotFound cath "privacy"
testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO ()
testNotDelistedMemberLeaves tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
leaveGroup "privacy" cath
bob <## "#privacy: cath left the group"
(superUser </)
groupFound cath "privacy"
testNotDelistedMemberRemoved :: HasCallStack => FilePath -> IO ()
testNotDelistedMemberRemoved tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
removeMember "privacy" bob cath
(superUser </)
groupFound cath "privacy"
testDelistedServiceRemoved :: HasCallStack => FilePath -> IO ()
testDelistedServiceRemoved tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
bob ##> "/rm #privacy SimpleX-Directory"
bob <## "#privacy: you removed SimpleX-Directory from the group"
cath <## "#privacy: bob removed SimpleX-Directory from the group"
bob <# "SimpleX-Directory> SimpleX-Directory is removed from the group ID 1 (privacy)."
bob <## ""
bob <## "The group is no longer listed in the directory."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)."
groupNotFound cath "privacy"
testDelistedRoleChanges :: HasCallStack => FilePath -> IO ()
testDelistedRoleChanges tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
groupFoundN 3 cath "privacy"
-- de-listed if service role changed
bob ##> "/mr privacy SimpleX-Directory member"
bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member"
cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member"
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member."
bob <## ""
bob <## "The group is no longer listed in the directory."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (SimpleX-Directory role is changed to member)."
groupNotFound cath "privacy"
-- re-listed if service role changed back without profile changes
cath ##> "/mr privacy SimpleX-Directory admin"
cath <## "#privacy: you changed the role of SimpleX-Directory from member to admin"
bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin"
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin."
bob <## ""
bob <## "The group is listed in the directory again."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (SimpleX-Directory role is changed to admin)."
groupFoundN 3 cath "privacy"
-- de-listed if owner role changed
cath ##> "/mr privacy bob admin"
cath <## "#privacy: you changed the role of bob from owner to admin"
bob <## "#privacy: cath changed your role from owner to admin"
bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin."
bob <## ""
bob <## "The group is no longer listed in the directory."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (user role is set to admin)."
groupNotFound cath "privacy"
-- re-listed if owner role changed back without profile changes
cath ##> "/mr privacy bob owner"
cath <## "#privacy: you changed the role of bob from admin to owner"
bob <## "#privacy: cath changed your role from admin to owner"
bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner."
bob <## ""
bob <## "The group is listed in the directory again."
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (user role is set to owner)."
groupFoundN 3 cath "privacy"
testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO ()
testNotDelistedMemberRoleChanged tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
groupFoundN 3 cath "privacy"
bob ##> "/mr privacy cath member"
bob <## "#privacy: you changed the role of cath from owner to member"
cath <## "#privacy: bob changed your role from owner to member"
groupFoundN 3 cath "privacy"
testNotSentApprovalBadRoles :: HasCallStack => FilePath -> IO ()
testNotSentApprovalBadRoles tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
cath `connectVia` dsLink
submitGroup bob "privacy" "Privacy"
welcomeWithLink <- groupAccepted bob "privacy"
bob ##> "/mr privacy SimpleX-Directory member"
bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member"
updateProfileWithLink bob "privacy" welcomeWithLink 1
bob <# "SimpleX-Directory> You must grant directory service admin role to register the group"
bob ##> "/mr privacy SimpleX-Directory admin"
bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin"
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin."
bob <## ""
bob <## "The group is submitted for approval."
notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1
groupNotFound cath "privacy"
approveRegistration superUser bob "privacy" 1
groupFound cath "privacy"
testNotApprovedBadRoles :: HasCallStack => FilePath -> IO ()
testNotApprovedBadRoles tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
cath `connectVia` dsLink
submitGroup bob "privacy" "Privacy"
welcomeWithLink <- groupAccepted bob "privacy"
updateProfileWithLink bob "privacy" welcomeWithLink 1
notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1
bob ##> "/mr privacy SimpleX-Directory member"
bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member"
let approve = "/approve 1:privacy 1"
superUser #> ("@SimpleX-Directory " <> approve)
superUser <# ("SimpleX-Directory> > " <> approve)
superUser <## " Group is not approved: user is not an owner."
groupNotFound cath "privacy"
bob ##> "/mr privacy SimpleX-Directory admin"
bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin"
bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin."
bob <## ""
bob <## "The group is submitted for approval."
notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1
approveRegistration superUser bob "privacy" 1
groupFound cath "privacy"
testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO ()
testRegOwnerChangedProfile tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
bob ##> "/gp privacy privacy Privacy and Security"
bob <## "full name changed to: Privacy and Security"
bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!"
bob <## "It is hidden from the directory until approved."
cath <## "bob updated group #privacy:"
cath <## "full name changed to: Privacy and Security"
groupNotFound cath "privacy"
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated."
reapproveGroup superUser bob
groupFoundN 3 cath "privacy"
testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO ()
testAnotherOwnerChangedProfile tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
cath ##> "/gp privacy privacy Privacy and Security"
cath <## "full name changed to: Privacy and Security"
bob <## "cath updated group #privacy:"
bob <## "full name changed to: Privacy and Security"
bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!"
bob <## "It is hidden from the directory until approved."
groupNotFound cath "privacy"
superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated."
reapproveGroup superUser bob
groupFoundN 3 cath "privacy"
testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO ()
testRegOwnerRemovedLink tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
bob ##> "/show welcome #privacy"
bob <## "Welcome message:"
welcomeWithLink <- getTermLine bob
bob ##> "/set welcome #privacy Welcome!"
bob <## "description changed to:"
bob <## "Welcome!"
bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message."
bob <## ""
bob <## "The group is hidden from the directory until the group link is added and the group is re-approved."
cath <## "bob updated group #privacy:"
cath <## "description changed to:"
cath <## "Welcome!"
superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed."
groupNotFound cath "privacy"
bob ##> ("/set welcome #privacy " <> welcomeWithLink)
bob <## "description changed to:"
bob <## welcomeWithLink
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
cath <## "bob updated group #privacy:"
cath <## "description changed to:"
cath <## welcomeWithLink
reapproveGroup superUser bob
groupFoundN 3 cath "privacy"
testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO ()
testAnotherOwnerRemovedLink tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
addCathAsOwner bob cath
bob ##> "/show welcome #privacy"
bob <## "Welcome message:"
welcomeWithLink <- getTermLine bob
cath ##> "/set welcome #privacy Welcome!"
cath <## "description changed to:"
cath <## "Welcome!"
bob <## "cath updated group #privacy:"
bob <## "description changed to:"
bob <## "Welcome!"
bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message."
bob <## ""
bob <## "The group is hidden from the directory until the group link is added and the group is re-approved."
superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed."
groupNotFound cath "privacy"
cath ##> ("/set welcome #privacy " <> welcomeWithLink)
cath <## "description changed to:"
cath <## welcomeWithLink
bob <## "cath updated group #privacy:"
bob <## "description changed to:"
bob <## welcomeWithLink
bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed."
bob <## ""
bob <## "Please update the group profile yourself."
bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!")
bob <## "description changed to:"
bob <## (welcomeWithLink <> " - welcome!")
bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message."
bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
cath <## "bob updated group #privacy:"
cath <## "description changed to:"
cath <## (welcomeWithLink <> " - welcome!")
reapproveGroup superUser bob
groupFoundN 3 cath "privacy"
testDuplicateAskConfirmation :: HasCallStack => FilePath -> IO ()
testDuplicateAskConfirmation tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
submitGroup bob "privacy" "Privacy"
_ <- groupAccepted bob "privacy"
cath `connectVia` dsLink
submitGroup cath "privacy" "Privacy"
cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory."
cath <## "To confirm the registration, please send:"
cath <# "SimpleX-Directory> /confirm 1:privacy"
cath #> "@SimpleX-Directory /confirm 1:privacy"
welcomeWithLink <- groupAccepted cath "privacy"
groupNotFound bob "privacy"
completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2
groupFound bob "privacy"
testDuplicateProhibitRegistration :: HasCallStack => FilePath -> IO ()
testDuplicateProhibitRegistration tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
cath `connectVia` dsLink
groupFound cath "privacy"
_ <- submitGroup cath "privacy" "Privacy"
cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name."
testDuplicateProhibitConfirmation :: HasCallStack => FilePath -> IO ()
testDuplicateProhibitConfirmation tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
submitGroup bob "privacy" "Privacy"
welcomeWithLink <- groupAccepted bob "privacy"
cath `connectVia` dsLink
submitGroup cath "privacy" "Privacy"
cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory."
cath <## "To confirm the registration, please send:"
cath <# "SimpleX-Directory> /confirm 1:privacy"
groupNotFound cath "privacy"
completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1
groupFound cath "privacy"
cath #> "@SimpleX-Directory /confirm 1:privacy"
cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name."
testDuplicateProhibitWhenUpdated :: HasCallStack => FilePath -> IO ()
testDuplicateProhibitWhenUpdated tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
submitGroup bob "privacy" "Privacy"
welcomeWithLink <- groupAccepted bob "privacy"
cath `connectVia` dsLink
submitGroup cath "privacy" "Privacy"
cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory."
cath <## "To confirm the registration, please send:"
cath <# "SimpleX-Directory> /confirm 1:privacy"
cath #> "@SimpleX-Directory /confirm 1:privacy"
welcomeWithLink' <- groupAccepted cath "privacy"
groupNotFound cath "privacy"
completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1
groupFound cath "privacy"
cath ##> ("/set welcome privacy " <> welcomeWithLink')
cath <## "description changed to:"
cath <## welcomeWithLink'
cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name."
cath ##> "/gp privacy security Security"
cath <## "changed to #security (Security)"
cath <# "SimpleX-Directory> Thank you! The group link for ID 2 (security) is added to the welcome message."
cath <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2
approveRegistration superUser cath "security" 2
groupFound bob "security"
groupFound cath "security"
testDuplicateProhibitApproval :: HasCallStack => FilePath -> IO ()
testDuplicateProhibitApproval tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
submitGroup bob "privacy" "Privacy"
welcomeWithLink <- groupAccepted bob "privacy"
cath `connectVia` dsLink
submitGroup cath "privacy" "Privacy"
cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory."
cath <## "To confirm the registration, please send:"
cath <# "SimpleX-Directory> /confirm 1:privacy"
cath #> "@SimpleX-Directory /confirm 1:privacy"
welcomeWithLink' <- groupAccepted cath "privacy"
updateProfileWithLink cath "privacy" welcomeWithLink' 2
notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2
groupNotFound cath "privacy"
completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1
groupFound cath "privacy"
-- fails at approval, as already listed
let approve = "/approve 2:privacy 1"
superUser #> ("@SimpleX-Directory " <> approve)
superUser <# ("SimpleX-Directory> > " <> approve)
superUser <## " The group ID 2 (privacy) is already listed in the directory."
testListUserGroups :: HasCallStack => FilePath -> IO ()
testListUserGroups tmp =
withDirectoryService tmp $ \superUser dsLink ->
withNewTestChat tmp "bob" bobProfile $ \bob ->
withNewTestChat tmp "cath" cathProfile $ \cath -> do
bob `connectVia` dsLink
cath `connectVia` dsLink
registerGroup superUser bob "privacy" "Privacy"
connectUsers bob cath
fullAddMember "privacy" "Privacy" bob cath GRMember
joinGroup "privacy" cath bob
cath <## "#privacy: member SimpleX-Directory_1 is connected"
cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory"
cath <## "use @SimpleX-Directory <message> to send messages"
registerGroupId superUser bob "security" "Security" 2 2
registerGroupId superUser cath "anonymity" "Anonymity" 3 1
cath #> "@SimpleX-Directory /list"
cath <# "SimpleX-Directory> > /list"
cath <## " 1 registered group(s)"
cath <# "SimpleX-Directory> 1. anonymity (Anonymity)"
cath <## "Welcome message:"
cath <##. "Link to join the group anonymity: "
cath <## "2 members"
cath <## "Status: active"
-- with de-listed group
groupFound cath "anonymity"
cath ##> "/mr anonymity SimpleX-Directory member"
cath <## "#anonymity: you changed the role of SimpleX-Directory from admin to member"
cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member."
cath <## ""
cath <## "The group is no longer listed in the directory."
superUser <# "SimpleX-Directory> The group ID 3 (anonymity) is de-listed (SimpleX-Directory role is changed to member)."
groupNotFound cath "anonymity"
listGroups superUser bob cath
testRestoreDirectory :: HasCallStack => FilePath -> IO ()
testRestoreDirectory tmp = do
testListUserGroups tmp
restoreDirectoryService tmp 3 3 $ \superUser _dsLink ->
withTestChat tmp "bob" $ \bob ->
withTestChat tmp "cath" $ \cath -> do
bob <## "2 contacts connected (use /cs for the list)"
bob <###
[ "#privacy (Privacy): connected to server(s)",
"#security (Security): connected to server(s)"
]
cath <## "2 contacts connected (use /cs for the list)"
cath <###
[ "#privacy (Privacy): connected to server(s)",
"#anonymity (Anonymity): connected to server(s)"
]
listGroups superUser bob cath
groupFoundN 3 bob "privacy"
groupFound bob "security"
groupFoundN 3 cath "privacy"
groupFound cath "security"
listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO ()
listGroups superUser bob cath = do
bob #> "@SimpleX-Directory /list"
bob <# "SimpleX-Directory> > /list"
bob <## " 2 registered group(s)"
bob <# "SimpleX-Directory> 1. privacy (Privacy)"
bob <## "Welcome message:"
bob <##. "Link to join the group privacy: "
bob <## "3 members"
bob <## "Status: active"
bob <# "SimpleX-Directory> 2. security (Security)"
bob <## "Welcome message:"
bob <##. "Link to join the group security: "
bob <## "2 members"
bob <## "Status: active"
cath #> "@SimpleX-Directory /list"
cath <# "SimpleX-Directory> > /list"
cath <## " 1 registered group(s)"
cath <# "SimpleX-Directory> 1. anonymity (Anonymity)"
cath <## "Welcome message:"
cath <##. "Link to join the group anonymity: "
cath <## "2 members"
cath <## "Status: suspended because roles changed"
-- superuser lists all groups
superUser #> "@SimpleX-Directory /last"
superUser <# "SimpleX-Directory> > /last"
superUser <## " 3 registered group(s)"
superUser <# "SimpleX-Directory> 1. privacy (Privacy)"
superUser <## "Welcome message:"
superUser <##. "Link to join the group privacy: "
superUser <## "Owner: bob"
superUser <## "3 members"
superUser <## "Status: active"
superUser <# "SimpleX-Directory> 2. security (Security)"
superUser <## "Welcome message:"
superUser <##. "Link to join the group security: "
superUser <## "Owner: bob"
superUser <## "2 members"
superUser <## "Status: active"
superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)"
superUser <## "Welcome message:"
superUser <##. "Link to join the group anonymity: "
superUser <## "Owner: cath"
superUser <## "2 members"
superUser <## "Status: suspended because roles changed"
-- showing last 1 group
superUser #> "@SimpleX-Directory /last 1"
superUser <# "SimpleX-Directory> > /last 1"
superUser <## " 3 registered group(s), showing the last 1"
superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)"
superUser <## "Welcome message:"
superUser <##. "Link to join the group anonymity: "
superUser <## "Owner: cath"
superUser <## "2 members"
superUser <## "Status: suspended because roles changed"
reapproveGroup :: HasCallStack => TestCC -> TestCC -> IO ()
reapproveGroup superUser bob = do
superUser <#. "SimpleX-Directory> bob submitted the group ID 1: privacy ("
superUser <## "Welcome message:"
superUser <##. "Link to join the group privacy: "
superUser <## ""
superUser <## "To approve send:"
superUser <# "SimpleX-Directory> /approve 1:privacy 1"
superUser #> "@SimpleX-Directory /approve 1:privacy 1"
superUser <# "SimpleX-Directory> > /approve 1:privacy 1"
superUser <## " Group approved!"
bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory!"
bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved."
addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO ()
addCathAsOwner bob cath = do
connectUsers bob cath
fullAddMember "privacy" "Privacy" bob cath GROwner
joinGroup "privacy" cath bob
cath <## "#privacy: member SimpleX-Directory is connected"
withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO ()
withDirectoryService tmp test = do
dsLink <-
withNewTestChat tmp serviceDbPrefix directoryProfile $ \ds ->
withNewTestChat tmp "super_user" aliceProfile $ \superUser -> do
connectUsers ds superUser
ds ##> "/ad"
getContactLink ds True
withDirectory tmp dsLink test
restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO ()
restoreDirectoryService tmp ctCount grCount test = do
dsLink <-
withTestChat tmp serviceDbPrefix $ \ds -> do
ds <## (show ctCount <> " contacts connected (use /cs for the list)")
ds <## "Your address is active! To show: /sa"
ds <## (show grCount <> " group links active")
forM_ [1..grCount] $ \_ -> ds <##. "#"
ds ##> "/sa"
dsLink <- getContactLink ds False
ds <## "auto_accept on"
pure dsLink
withDirectory tmp dsLink test
withDirectory :: HasCallStack => FilePath -> String -> (TestCC -> String -> IO ()) -> IO ()
withDirectory tmp dsLink test = do
let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"]
runDirectory opts $
withTestChat tmp "super_user" $ \superUser -> do
superUser <## "1 contacts connected (use /cs for the list)"
test superUser dsLink
runDirectory :: DirectoryOpts -> IO () -> IO ()
runDirectory opts@DirectoryOpts {directoryLog} action = do
st <- restoreDirectoryStore directoryLog
t <- forkIO $ bot st
threadDelay 500000
action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t)
where
bot st = simplexChatCore testCfg (mkChatOpts opts) Nothing $ directoryService st opts
registerGroup :: TestCC -> TestCC -> String -> String -> IO ()
registerGroup su u n fn = registerGroupId su u n fn 1 1
registerGroupId :: TestCC -> TestCC -> String -> String -> Int -> Int -> IO ()
registerGroupId su u n fn gId ugId = do
submitGroup u n fn
welcomeWithLink <- groupAccepted u n
completeRegistrationId su u n fn welcomeWithLink gId ugId
submitGroup :: TestCC -> String -> String -> IO ()
submitGroup u n fn = do
u ##> ("/g " <> n <> " " <> fn)
u <## ("group #" <> n <> " (" <> fn <> ") is created")
u <## ("to add members use /a " <> n <> " <name> or /create link #" <> n)
u ##> ("/a " <> n <> " SimpleX-Directory admin")
u <## ("invitation to join the group #" <> n <> " sent to SimpleX-Directory")
groupAccepted :: TestCC -> String -> IO String
groupAccepted u n = do
u <# ("SimpleX-Directory> Joining the group " <> n <> "")
u <## ("#" <> n <> ": SimpleX-Directory joined the group")
u <# ("SimpleX-Directory> Joined the group " <> n <> ", creating the link…")
u <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online."
u <## ""
u <## "Please add it to the group welcome message."
u <## "For example, add:"
dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine u -- welcome message with link
completeRegistration :: TestCC -> TestCC -> String -> String -> String -> Int -> IO ()
completeRegistration su u n fn welcomeWithLink gId =
completeRegistrationId su u n fn welcomeWithLink gId gId
completeRegistrationId :: TestCC -> TestCC -> String -> String -> String -> Int -> Int -> IO ()
completeRegistrationId su u n fn welcomeWithLink gId ugId = do
updateProfileWithLink u n welcomeWithLink ugId
notifySuperUser su u n fn welcomeWithLink gId
approveRegistrationId su u n gId ugId
updateProfileWithLink :: TestCC -> String -> String -> Int -> IO ()
updateProfileWithLink u n welcomeWithLink ugId = do
u ##> ("/set welcome " <> n <> " " <> welcomeWithLink)
u <## "description changed to:"
u <## welcomeWithLink
u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.")
u <## "You will be notified once the group is added to the directory - it may take up to 24 hours."
notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO ()
notifySuperUser su u n fn welcomeWithLink gId = do
uName <- userName u
su <# ("SimpleX-Directory> " <> uName <> " submitted the group ID " <> show gId <> ": " <> n <> " (" <> fn <> ")")
su <## "Welcome message:"
su <## welcomeWithLink
su <## ""
su <## "To approve send:"
let approve = "/approve " <> show gId <> ":" <> n <> " 1"
su <# ("SimpleX-Directory> " <> approve)
approveRegistration :: TestCC -> TestCC -> String -> Int -> IO ()
approveRegistration su u n gId =
approveRegistrationId su u n gId gId
approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO ()
approveRegistrationId su u n gId ugId = do
let approve = "/approve " <> show gId <> ":" <> n <> " 1"
su #> ("@SimpleX-Directory " <> approve)
su <# ("SimpleX-Directory> > " <> approve)
su <## " Group approved!"
u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory!")
u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved."
connectVia :: TestCC -> String -> IO ()
u `connectVia` dsLink = do
u ##> ("/c " <> dsLink)
u <## "connection request sent!"
u <## "SimpleX-Directory: contact is connected"
u <# "SimpleX-Directory> Welcome to SimpleX-Directory service!"
u <## "Send a search string to find groups or /help to learn how to add groups to directory."
u <## ""
u <## "For example, send privacy to find groups about privacy."
joinGroup :: String -> TestCC -> TestCC -> IO ()
joinGroup gName member host = do
let gn = "#" <> gName
memberName <- userName member
hostName <- userName host
member ##> ("/j " <> gName)
member <## (gn <> ": you joined the group")
member <#. (gn <> " " <> hostName <> "> Link to join the group " <> gName <> ": ")
host <## (gn <> ": " <> memberName <> " joined the group")
leaveGroup :: String -> TestCC -> IO ()
leaveGroup gName member = do
let gn = "#" <> gName
member ##> ("/l " <> gName)
member <## (gn <> ": you left the group")
member <## ("use /d " <> gn <> " to delete the group")
removeMember :: String -> TestCC -> TestCC -> IO ()
removeMember gName admin removed = do
let gn = "#" <> gName
adminName <- userName admin
removedName <- userName removed
admin ##> ("/rm " <> gName <> " " <> removedName)
admin <## (gn <> ": you removed " <> removedName <> " from the group")
removed <## (gn <> ": " <> adminName <> " removed you from the group")
removed <## ("use /d " <> gn <> " to delete the group")
groupFound :: TestCC -> String -> IO ()
groupFound = groupFoundN 2
groupFoundN :: Int -> TestCC -> String -> IO ()
groupFoundN count u name = do
u #> ("@SimpleX-Directory " <> name)
u <# ("SimpleX-Directory> > " <> name)
u <## " Found 1 group(s)"
u <#. ("SimpleX-Directory> " <> name <> " (")
u <## "Welcome message:"
u <##. "Link to join the group "
u <## (show count <> " members")
groupNotFound :: TestCC -> String -> IO ()
groupNotFound u s = do
u #> ("@SimpleX-Directory " <> s)
u <# ("SimpleX-Directory> > " <> s)
u <## " No groups found"

Some files were not shown because too many files have changed in this diff Show More