Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 138dc7fe8f | |||
| 0535d84719 | |||
| f4447ffe89 | |||
| 146d5f99bc | |||
| 73e5fff8f5 | |||
| 33e7538172 | |||
| 49c9c501aa | |||
| a177dc5a13 | |||
| a4f207875f | |||
| bcca0998d5 | |||
| 95cc9e1e55 | |||
| ab5ae2d2cb | |||
| 40a91a7273 | |||
| 1240b31df8 | |||
| ff14730738 | |||
| 0cba3a4bb3 | |||
| 208f8a3346 | |||
| caa3efb9ed | |||
| 4beb916754 | |||
| c1ee04eed1 | |||
| 0ad3bc9993 | |||
| 9893aa665a | |||
| fda8836ab8 | |||
| 05fdd07409 | |||
| fb8f5facd0 | |||
| 8bdb784a14 | |||
| 5d785aad2e | |||
| ce11d58a76 | |||
| 887b374bfc | |||
| 94dc967197 | |||
| 4319a581ca | |||
| fb05218558 | |||
| edf2d02a0d | |||
| 87ba429dfd | |||
| 7af1a7cf76 | |||
| df619acdd4 | |||
| 503d0cd451 | |||
| 1294a00ee7 | |||
| 0a8069ada2 | |||
| c167f594b9 | |||
| ce5124594d |
@@ -5,8 +5,9 @@
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
[](https://mastodon.social/@simplex)
|
||||
[](https://twitter.com/SimpleXChat)
|
||||
|
||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||
|
||||
@@ -85,16 +86,14 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration.](./blog/20221206-simplex-chat-v4.3-voice-messages.md)
|
||||
|
||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
|
||||
|
||||
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
|
||||
|
||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
|
||||
|
||||
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## Make a private connection
|
||||
@@ -103,7 +102,7 @@ You need to share a link or scan a QR code (in person or during a video call) to
|
||||
|
||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
|
||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||
|
||||
## :zap: Quick installation of a terminal app
|
||||
|
||||
@@ -187,19 +186,20 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Chat database encryption.
|
||||
- ✅ Automatic chat history deletion.
|
||||
- ✅ Links to join groups and improve groups stability.
|
||||
- ✅ Voice messages (with recipient opt-out per contact).
|
||||
- ✅ Basic authentication for SMP servers (to authorize creating new queues).
|
||||
- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
||||
- ✅ Block screenshots and view in recent apps.
|
||||
- ✅ Advanced server configuration.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Voice messages (with recipient opt-out per contact).
|
||||
- 🏗 Basic authentication for SMP servers (to authorize creating new queues).
|
||||
- View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
||||
- Block screenshots and view in recent apps.
|
||||
- 🏗 Contact verification via a separate out-of-band channel.
|
||||
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Optionally avoid re-using the same TCP session for multiple connections.
|
||||
- Access password/pin (with optional alternative access password).
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Video messages.
|
||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- Multiple user profiles in the same chat database.
|
||||
- Advanced server configuration.
|
||||
- Feeds/broadcasts.
|
||||
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
@@ -246,8 +246,10 @@ It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
||||
|
||||
Thank you,
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 75
|
||||
versionName "4.3"
|
||||
versionCode 78
|
||||
versionName "4.3.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
|
||||
@@ -11,8 +11,7 @@ import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -173,7 +172,18 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
val server = LocalServerSocket(socketName)
|
||||
var server: LocalServerSocket? = null
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
server = LocalServerSocket(socketName + i)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
if (server == null) {
|
||||
throw Error("Unable to setup local server socket. Contact developers")
|
||||
}
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
|
||||
@@ -101,7 +101,7 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
|
||||
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact && !contact.viaGroupLink)
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directContact)
|
||||
|
||||
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
|
||||
|
||||
@@ -215,6 +215,9 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
|
||||
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (cItem.isRcvNew) {
|
||||
decreaseCounterInChat(cInfo.id)
|
||||
}
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
@@ -222,7 +225,7 @@ class ChatModel(val controller: ChatController) {
|
||||
chat = chats[i]
|
||||
val pItem = chat.chatItems.lastOrNull()
|
||||
if (pItem?.id == cItem.id) {
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
||||
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
@@ -551,6 +554,7 @@ data class Contact(
|
||||
val profile: LocalProfile,
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val contactUsed: Boolean,
|
||||
val chatSettings: ChatSettings,
|
||||
val userPreferences: ChatPreferences,
|
||||
val mergedPreferences: ContactUserPreferences,
|
||||
@@ -571,11 +575,8 @@ data class Contact(
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = profile.localAlias
|
||||
|
||||
val isIndirectContact: Boolean get() =
|
||||
activeConn.connLevel > 0 || viaGroup != null
|
||||
|
||||
val viaGroupLink: Boolean get() =
|
||||
activeConn.viaGroupLink
|
||||
val directContact: Boolean get() =
|
||||
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
|
||||
|
||||
val contactConnIncognito =
|
||||
activeConn.customUserProfileId != null
|
||||
@@ -586,6 +587,7 @@ data class Contact(
|
||||
localDisplayName = "alice",
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
contactUsed = true,
|
||||
chatSettings = ChatSettings(true),
|
||||
userPreferences = ChatPreferences.sampleData,
|
||||
mergedPreferences = ContactUserPreferences.sampleData,
|
||||
@@ -1202,6 +1204,26 @@ data class ChatItem (
|
||||
file = null
|
||||
)
|
||||
}
|
||||
|
||||
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||
|
||||
val deletedItemDummy: ChatItem
|
||||
get() = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta(
|
||||
itemId = TEMP_DELETED_CHAT_ITEM_ID,
|
||||
itemTs = Clock.System.now(),
|
||||
itemText = generalGetString(R.string.deleted_description),
|
||||
itemStatus = CIStatus.RcvRead(),
|
||||
createdAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemEdited = false,
|
||||
editable = false
|
||||
),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||
|
||||
// DO NOT change notification channel settings / names
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
|
||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||
const val CallNotificationId: Int = -1
|
||||
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
@@ -39,24 +39,29 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
|
||||
init {
|
||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
|
||||
manager.createNotificationChannel(callNotificationChannel())
|
||||
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||
// Remove old channels since they can't be edited
|
||||
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
ACCEPT_CONTACT_REQUEST
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(): NotificationChannel {
|
||||
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
|
||||
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||
callChannel.setSound(soundUri, attrs)
|
||||
callChannel.enableVibration(true)
|
||||
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
||||
// (wait, vibration duration, wait till off, wait till on again = ringtone mp3 duration - vibration duration - ~50ms lost somewhere)
|
||||
callChannel.vibrationPattern = longArrayOf(250, 250, 0, 2600)
|
||||
return callChannel
|
||||
}
|
||||
|
||||
@@ -153,13 +158,14 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||
if (SimplexApp.context.isAppOnForeground) return
|
||||
val keyguardManager = getKeyguardManager(context)
|
||||
Log.d(TAG,
|
||||
"notifyCallInvitation pre-requests: device locked ${keyguardManager.isDeviceLocked}, " +
|
||||
"notifyCallInvitation pre-requests: " +
|
||||
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}"
|
||||
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||
)
|
||||
if (SimplexApp.context.isAppOnForeground) return
|
||||
val contactId = invitation.contact.id
|
||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||
val image = invitation.contact.image
|
||||
@@ -169,15 +175,17 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, LockScreenCallChannel)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSilent(false)
|
||||
} else {
|
||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
NotificationCompat.Builder(context, CallChannel)
|
||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, contactId, true))
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setSound(soundUri)
|
||||
}
|
||||
val text = generalGetString(
|
||||
@@ -206,8 +214,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
.setLargeIcon(largeIcon)
|
||||
.setColor(0x88FFFF)
|
||||
.setAutoCancel(true)
|
||||
val notification = ntfBuilder.build()
|
||||
// This makes notification sound and vibration repeat endlessly
|
||||
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
notify(CallNotificationId, ntfBuilder.build())
|
||||
notify(CallNotificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +226,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
manager.cancel(CallNotificationId)
|
||||
}
|
||||
|
||||
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md != null) {
|
||||
@@ -228,16 +241,20 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
var intent = Intent(context, MainActivity::class.java)
|
||||
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.setAction(intentAction)
|
||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
return if (!broadcast) {
|
||||
TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent)
|
||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
} else {
|
||||
PendingIntent.getBroadcast(SimplexApp.context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +272,12 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||
}
|
||||
RejectCallAction -> {
|
||||
val invitation = SimplexApp.context.chatModel.callInvitations[chatId]
|
||||
if (invitation != null) {
|
||||
SimplexApp.context.chatModel.callManager.endCall(invitation = invitation)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ class AppPreferences(val context: Context) {
|
||||
},
|
||||
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
|
||||
)
|
||||
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
|
||||
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false)
|
||||
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
|
||||
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
|
||||
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
|
||||
@@ -196,7 +196,7 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
|
||||
internal val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
|
||||
internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
|
||||
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
|
||||
@@ -993,7 +993,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.removeChat(r.connection.id)
|
||||
}
|
||||
is CR.ContactConnected -> {
|
||||
if (!r.contact.viaGroupLink) {
|
||||
if (r.contact.directContact) {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.dismissConnReqView(r.contact.activeConn.id)
|
||||
chatModel.removeChat(r.contact.activeConn.id)
|
||||
@@ -1002,7 +1002,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
is CR.ContactConnecting -> {
|
||||
if (!r.contact.viaGroupLink) {
|
||||
if (r.contact.directContact) {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.dismissConnReqView(r.contact.activeConn.id)
|
||||
chatModel.removeChat(r.contact.activeConn.id)
|
||||
@@ -1047,10 +1047,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
val cItem = r.chatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
val file = cItem.file
|
||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) }
|
||||
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
|
||||
val mc = cItem.content.msgContent
|
||||
if (file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) {
|
||||
val acceptImages = appPrefs.privacyAcceptImages.get()
|
||||
if ((mc is MsgContent.MCImage && acceptImages)
|
||||
|| (mc is MsgContent.MCVoice && ((file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && acceptImages) || cInfo is ChatInfo.Group))) {
|
||||
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
|
||||
}
|
||||
}
|
||||
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
@@ -1070,11 +1073,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
is CR.ChatItemUpdated ->
|
||||
chatItemSimpleUpdate(r.chatItem)
|
||||
is CR.ChatItemDeleted -> {
|
||||
AudioPlayer.stop(r.deletedChatItem.chatItem)
|
||||
val cInfo = r.deletedChatItem.chatInfo
|
||||
val cItem = r.deletedChatItem.chatItem
|
||||
AudioPlayer.stop(cItem)
|
||||
val isLastChatItem = chatModel.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
|
||||
if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
|
||||
ntfManager.cancelNotificationsForChat(cInfo.id)
|
||||
ntfManager.notifyMessageReceived(
|
||||
cInfo.id,
|
||||
cInfo.displayName,
|
||||
generalGetString(if (r.toChatItem != null) R.string.marked_deleted_description else R.string.deleted_description)
|
||||
)
|
||||
}
|
||||
if (r.toChatItem == null) {
|
||||
chatModel.removeChatItem(r.deletedChatItem.chatInfo, r.deletedChatItem.chatItem)
|
||||
chatModel.removeChatItem(cInfo, cItem)
|
||||
} else {
|
||||
chatModel.upsertChatItem(r.toChatItem.chatInfo, r.toChatItem.chatItem)
|
||||
chatModel.upsertChatItem(cInfo, r.toChatItem.chatItem)
|
||||
}
|
||||
}
|
||||
is CR.ReceivedGroupInvitation -> {
|
||||
@@ -2202,6 +2216,7 @@ sealed class ContactFeatureAllowed {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ContactFeaturesAllowed(
|
||||
val fullDelete: ContactFeatureAllowed,
|
||||
val voice: ContactFeatureAllowed
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.call
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -43,8 +44,7 @@ class IncomingCallActivity: ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val activity = this
|
||||
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
||||
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
|
||||
@@ -83,11 +83,12 @@ fun getKeyguardManager(context: Context): KeyguardManager =
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
@Composable
|
||||
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||
fun IncomingCallActivityView(m: ChatModel) {
|
||||
val switchingCall = m.switchingCall.value
|
||||
val invitation = m.activeCallInvitation.value
|
||||
val call = m.activeCall.value
|
||||
val showCallView = m.showCallView.value
|
||||
val activity = LocalContext.current as Activity
|
||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||
@@ -105,20 +106,23 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
IncomingCallLockScreenAlert(invitation, m, activity)
|
||||
IncomingCallLockScreenAlert(invitation, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||
val cm = chatModel.callManager
|
||||
val cxt = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
invitation,
|
||||
callOnLockScreen,
|
||||
@@ -129,15 +133,14 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
SoundPlayer.shared.stop()
|
||||
var intent = Intent(activity, MainActivity::class.java)
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(OpenChatAction)
|
||||
.putExtra("chatId", invitation.contact.id)
|
||||
activity.startActivity(intent)
|
||||
activity.finish()
|
||||
context.startActivity(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
||||
getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
|
||||
}
|
||||
(context as Activity).finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,8 +63,11 @@ fun ChatInfoView(
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
ContactPreferencesView(chatModel, chatModel.currentUser.value ?: return@showModal, contact.contactId)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -25,33 +26,45 @@ fun ContactPreferencesView(
|
||||
m: ChatModel,
|
||||
user: User,
|
||||
contactId: Long,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||
val ct = contact.value ?: return
|
||||
var featuresAllowed by remember(ct) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||
var currentFeaturesAllowed by remember(ct) { mutableStateOf(featuresAllowed) }
|
||||
ContactPreferencesLayout(
|
||||
featuresAllowed,
|
||||
currentFeaturesAllowed,
|
||||
user,
|
||||
ct,
|
||||
applyPrefs = { prefs ->
|
||||
featuresAllowed = prefs
|
||||
},
|
||||
reset = {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
},
|
||||
savePrefs = {
|
||||
withApi {
|
||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
m.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
m.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (featuresAllowed == currentFeaturesAllowed) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
)
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ContactPreferencesLayout(
|
||||
featuresAllowed,
|
||||
currentFeaturesAllowed,
|
||||
user,
|
||||
ct,
|
||||
applyPrefs = { prefs ->
|
||||
featuresAllowed = prefs
|
||||
},
|
||||
reset = {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
},
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -139,3 +152,13 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
|
||||
selectedRole = selectedRole,
|
||||
allowModifyMembers = allowModifyMembers,
|
||||
openPreferences = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
GroupPreferencesView(chatModel, groupInfo.id)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(chatModel, groupInfo.id, close)
|
||||
}
|
||||
},
|
||||
inviteMembers = {
|
||||
|
||||
@@ -65,10 +65,11 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showModal(true) {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
GroupPreferencesView(
|
||||
chatModel,
|
||||
chat.id
|
||||
chat.id,
|
||||
close
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,20 +49,26 @@ fun GroupMemberInfoView(
|
||||
connStats,
|
||||
newRole,
|
||||
developerTools,
|
||||
openDirectChat = {
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
knownDirectChat = {
|
||||
withApi {
|
||||
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
|
||||
if (oldChat != null) {
|
||||
openChat(oldChat.chatInfo, chatModel)
|
||||
} else {
|
||||
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(it.chatItems)
|
||||
chatModel.chatId.value = it.chatInfo.id
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
newDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
chatModel.addChat(newChat)
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = newChat.id
|
||||
closeAll()
|
||||
}
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||
@@ -114,7 +120,9 @@ fun GroupMemberInfoLayout(
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
openDirectChat: () -> Unit,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
knownDirectChat: (Chat) -> Unit,
|
||||
newDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
@@ -133,11 +141,20 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (member.memberContactId != null && groupInfo.fullGroupPreferences.directMessages.enable == GroupFeatureEnabled.ON) {
|
||||
SectionView {
|
||||
OpenChatButton(openDirectChat)
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
val chat = getContactChat(contactId)
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directContact) {
|
||||
SectionView {
|
||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||
}
|
||||
SectionSpacer()
|
||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
SectionView {
|
||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||
@@ -302,7 +319,9 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
connStats = null,
|
||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||
developerTools = false,
|
||||
openDirectChat = {},
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
|
||||
@@ -11,43 +11,53 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimplexGreen
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupPreferencesView(m: ChatModel, chatId: String) {
|
||||
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
|
||||
val gInfo = groupInfo.value ?: return
|
||||
var preferences by remember(gInfo) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||
var currentPreferences by remember(gInfo) { mutableStateOf(preferences) }
|
||||
GroupPreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
gInfo,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = {
|
||||
withApi {
|
||||
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||
if (gInfo != null) {
|
||||
m.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||
if (gInfo != null) {
|
||||
m.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (preferences == currentPreferences) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
)
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupPreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
gInfo,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -130,3 +140,13 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
@@ -93,6 +94,41 @@ class AlertManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogStacked(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||
@@ -128,4 +164,4 @@ class AlertManager {
|
||||
companion object {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.app.model.json
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -462,3 +465,9 @@ val LongRange.Companion.saver
|
||||
save = { it.value.first to it.value.last },
|
||||
restore = { mutableStateOf(it.first..it.second) }
|
||||
)
|
||||
|
||||
/* Make sure that T class has @Serializable annotation */
|
||||
inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||
save = { json.encodeToString(it) },
|
||||
restore = { json.decodeFromString(it) }
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -19,33 +20,40 @@ import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PreferencesView(m: ChatModel, user: User) {
|
||||
var preferences by remember { mutableStateOf(user.fullPreferences) }
|
||||
var currentPreferences by remember { mutableStateOf(preferences) }
|
||||
PreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
applyPrefs = { prefs ->
|
||||
preferences = prefs
|
||||
},
|
||||
reset = {
|
||||
preferences = currentPreferences
|
||||
},
|
||||
savePrefs = {
|
||||
withApi {
|
||||
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
|
||||
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
|
||||
if (updatedProfile != null) {
|
||||
val updatedUser = user.copy(
|
||||
profile = updatedProfile.toLocalProfile(user.profile.profileId),
|
||||
fullPreferences = preferences
|
||||
)
|
||||
currentPreferences = preferences
|
||||
m.currentUser.value = updatedUser
|
||||
}
|
||||
fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) {
|
||||
var preferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(user.fullPreferences) }
|
||||
var currentPreferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||
|
||||
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
|
||||
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
|
||||
if (updatedProfile != null) {
|
||||
val updatedUser = user.copy(
|
||||
profile = updatedProfile.toLocalProfile(user.profile.profileId),
|
||||
fullPreferences = preferences
|
||||
)
|
||||
currentPreferences = preferences
|
||||
m.currentUser.value = updatedUser
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
ModalView(
|
||||
close = {
|
||||
if (preferences == currentPreferences) close()
|
||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||
},
|
||||
)
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
PreferencesLayout(
|
||||
preferences,
|
||||
currentPreferences,
|
||||
applyPrefs = { preferences = it },
|
||||
reset = { preferences = currentPreferences },
|
||||
savePrefs = ::savePrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -107,3 +115,13 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
confirmText = generalGetString(R.string.save_and_notify_contacts),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ fun SettingsLayout(
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
ChatPreferencesItem(showSettingsModal)
|
||||
ChatPreferencesItem(showCustomModal)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
@@ -239,14 +239,14 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatPreferencesItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
|
||||
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.chat_preferences),
|
||||
click = {
|
||||
withApi {
|
||||
showSettingsModal {
|
||||
PreferencesView(it, it.currentUser.value ?: return@showSettingsModal)
|
||||
showCustomModal { m, close ->
|
||||
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
<!-- Notification channels -->
|
||||
<string name="ntf_channel_messages">SimpleX Chat Nachrichten</string>
|
||||
<string name="ntf_channel_calls">SimpleX Chat Anrufe</string>
|
||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat Anrufe (Sperrbildschirm)</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="settings_notifications_mode_title">Benachrichtigungsdienst</string>
|
||||
@@ -476,9 +475,11 @@
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\n<xliff:g id="appName">SimpleX</xliff:g>-Server können Ihr Profil nicht sehen.</string>
|
||||
<string name="edit_image">Bild bearbeiten</string>
|
||||
<string name="delete_image">Bild löschen</string>
|
||||
<string name="save_and_notify_contact">Speichern (und Kontakt benachrichtigen)</string>
|
||||
<string name="save_and_notify_contacts">Speichern (und Kontakte benachrichtigen)</string>
|
||||
<string name="save_and_notify_group_members">Speichern (und Gruppenmitglieder benachrichtigen)</string>
|
||||
<string name="save_preferences_question">Präferenzen speichern?</string>
|
||||
<string name="save_and_notify_contact">Speichern und Kontakt benachrichtigen</string>
|
||||
<string name="save_and_notify_contacts">Speichern und Kontakte benachrichtigen</string>
|
||||
<string name="save_and_notify_group_members">Speichern und Gruppenmitglieder benachrichtigen</string>
|
||||
<string name="exit_without_saving">Beenden ohne Speichern</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
<!-- Notification channels -->
|
||||
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
|
||||
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
|
||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat звонки (экран блокировки)</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
|
||||
@@ -473,9 +472,11 @@
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
|
||||
<string name="edit_image">Поменять аватар</string>
|
||||
<string name="delete_image">Удалить аватар</string>
|
||||
<string name="save_and_notify_contact">Сохранить (и уведомить контакт)</string>
|
||||
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
|
||||
<string name="save_and_notify_group_members">Сохранить (и уведомить членов группы)</string>
|
||||
<string name="save_preferences_question">Сохранить предпочтения?</string>
|
||||
<string name="save_and_notify_contact">Сохранить и уведомить контакт</string>
|
||||
<string name="save_and_notify_contacts">Сохранить и уведомить контакты</string>
|
||||
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
|
||||
<string name="exit_without_saving">Выйти без сохранения</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
<!-- Notification channels -->
|
||||
<string name="ntf_channel_messages">SimpleX Chat messages</string>
|
||||
<string name="ntf_channel_calls">SimpleX Chat calls</string>
|
||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat calls (lock screen)</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="settings_notifications_mode_title">Notification service</string>
|
||||
@@ -476,9 +475,11 @@
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
|
||||
<string name="edit_image">Edit image</string>
|
||||
<string name="delete_image">Delete image</string>
|
||||
<string name="save_and_notify_contact">Save (and notify contact)</string>
|
||||
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
|
||||
<string name="save_and_notify_group_members">Save (and notify group members)</string>
|
||||
<string name="save_preferences_question">Save preferences?</string>
|
||||
<string name="save_and_notify_contact">Save and notify contact</string>
|
||||
<string name="save_and_notify_contacts">Save and notify contacts</string>
|
||||
<string name="save_and_notify_group_members">Save and notify group members</string>
|
||||
<string name="exit_without_saving">Exit without saving</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">You control your chat!</string>
|
||||
|
||||
@@ -101,7 +101,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateContact(_ contact: Contact) {
|
||||
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact && !contact.viaGroupLink)
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directContact)
|
||||
}
|
||||
|
||||
func updateGroup(_ groupInfo: GroupInfo) {
|
||||
@@ -236,16 +236,19 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
decreaseUnreadCounter(cInfo)
|
||||
}
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
|
||||
chat.chatItems = [cItem]
|
||||
chat.chatItems = [ChatItem.deletedItemDummy()]
|
||||
}
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if reversedChatItems[i].isRcvNew() == true {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
NtfManager.shared.decNtfBadgeCount()
|
||||
}
|
||||
_ = withAnimation {
|
||||
@@ -342,9 +345,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update preview
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
@@ -352,6 +353,12 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
}
|
||||
|
||||
func totalUnreadCount() -> Int {
|
||||
chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount })
|
||||
}
|
||||
@@ -418,7 +425,7 @@ final class ChatModel: ObservableObject {
|
||||
var unreadBelow = 0
|
||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
||||
totalBelow += 1
|
||||
if reversedChatItems[i].isRcvNew() {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
unreadBelow += 1
|
||||
}
|
||||
i += 1
|
||||
|
||||
@@ -917,7 +917,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .contactConnectionDeleted(connection):
|
||||
m.removeChat(connection.id)
|
||||
case let .contactConnected(contact, _):
|
||||
if !contact.viaGroupLink {
|
||||
if contact.directContact {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
@@ -925,7 +925,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
NtfManager.shared.notifyContactConnected(contact)
|
||||
}
|
||||
case let .contactConnecting(contact):
|
||||
if !contact.viaGroupLink {
|
||||
if contact.directContact {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
@@ -972,20 +972,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
m.addChatItem(cInfo, cItem)
|
||||
if case .image = cItem.content.msgContent,
|
||||
let file = cItem.file,
|
||||
file.fileSize <= MAX_IMAGE_SIZE,
|
||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
} else if case .voice = cItem.content.msgContent, // TODO check inlineFileMode != IFMSent
|
||||
let file = cItem.file,
|
||||
file.fileSize <= MAX_IMAGE_SIZE,
|
||||
file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND,
|
||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
if let file = cItem.file,
|
||||
let mc = cItem.content.msgContent,
|
||||
file.fileSize <= MAX_IMAGE_SIZE {
|
||||
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
if (mc.isImage && acceptImages)
|
||||
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
|
||||
Task {
|
||||
await receiveFile(fileId: file.fileId) // TODO check inlineFileMode != IFMSent
|
||||
}
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
|
||||
@@ -221,7 +221,7 @@ struct ChatView: View {
|
||||
.onAppear {
|
||||
itemsInView.insert(ci.viewId)
|
||||
loadChatItems(cInfo, ci, proxy)
|
||||
if ci.isRcvNew() {
|
||||
if ci.isRcvNew {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
|
||||
Task {
|
||||
|
||||
@@ -42,9 +42,16 @@ struct GroupMemberInfoView: View {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if let contactId = member.memberContactId, groupInfo.fullGroupPreferences.directMessages.enable == .on {
|
||||
Section {
|
||||
openDirectChatButton(contactId)
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directContact ?? false {
|
||||
Section {
|
||||
knownDirectChatButton(chat)
|
||||
}
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
Section {
|
||||
newDirectChatButton(contactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,26 +119,30 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func openDirectChatButton(_ contactId: Int64) -> some View {
|
||||
func knownDirectChatButton(_ chat: Chat) -> some View {
|
||||
Button {
|
||||
var chat = chatModel.getContactChat(contactId)
|
||||
if chat == nil {
|
||||
do {
|
||||
chat = try apiGetChat(type: .direct, id: contactId)
|
||||
if let chat = chat {
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
chat.serverInfo = Chat.ServerInfo(networkStatus: .connected)
|
||||
chatModel.addChat(chat)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
}
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.chatId = chat.id
|
||||
}
|
||||
if let chat = chat {
|
||||
} label: {
|
||||
Label("Send direct message", systemImage: "message")
|
||||
}
|
||||
}
|
||||
|
||||
func newDirectChatButton(_ contactId: Int64) -> some View {
|
||||
Button {
|
||||
do {
|
||||
let chat = try apiGetChat(type: .direct, id: contactId)
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
chat.serverInfo = Chat.ServerInfo(networkStatus: .connected)
|
||||
chatModel.addChat(chat)
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.chatId = chat.id
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
}
|
||||
} label: {
|
||||
Label("Send direct message", systemImage: "message")
|
||||
|
||||
@@ -137,11 +137,11 @@
|
||||
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; };
|
||||
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; };
|
||||
644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */; };
|
||||
644EFFEC293B5B1800525D5B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFE7293B5B1700525D5B /* libgmpxx.a */; };
|
||||
644EFFED293B5B1800525D5B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFE8293B5B1700525D5B /* libgmp.a */; };
|
||||
644EFFEE293B5B1800525D5B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFE9293B5B1800525D5B /* libffi.a */; };
|
||||
644EFFEF293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFEA293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X-ghc8.10.7.a */; };
|
||||
644EFFF0293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFEB293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X.a */; };
|
||||
644EFFF62941BD6900525D5B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF12941BD6800525D5B /* libffi.a */; };
|
||||
644EFFF72941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF22941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a */; };
|
||||
644EFFF82941BD6900525D5B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF32941BD6800525D5B /* libgmp.a */; };
|
||||
644EFFF92941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF42941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a */; };
|
||||
644EFFFA2941BD6900525D5B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF52941BD6900525D5B /* libgmpxx.a */; };
|
||||
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
|
||||
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
|
||||
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
|
||||
@@ -348,11 +348,11 @@
|
||||
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; };
|
||||
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
|
||||
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedDeletedItemView.swift; sourceTree = "<group>"; };
|
||||
644EFFE7293B5B1700525D5B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
644EFFE8293B5B1700525D5B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
644EFFE9293B5B1800525D5B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
644EFFEA293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
644EFFEB293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X.a"; sourceTree = "<group>"; };
|
||||
644EFFF12941BD6800525D5B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
644EFFF22941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
644EFFF32941BD6800525D5B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
644EFFF42941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a"; sourceTree = "<group>"; };
|
||||
644EFFF52941BD6900525D5B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
|
||||
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
|
||||
@@ -398,13 +398,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
644EFFEC293B5B1800525D5B /* libgmpxx.a in Frameworks */,
|
||||
644EFFF72941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a in Frameworks */,
|
||||
644EFFF92941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a in Frameworks */,
|
||||
644EFFF82941BD6900525D5B /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
644EFFED293B5B1800525D5B /* libgmp.a in Frameworks */,
|
||||
644EFFF0293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X.a in Frameworks */,
|
||||
644EFFEE293B5B1800525D5B /* libffi.a in Frameworks */,
|
||||
644EFFF62941BD6900525D5B /* libffi.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
644EFFEF293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X-ghc8.10.7.a in Frameworks */,
|
||||
644EFFFA2941BD6900525D5B /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -460,11 +460,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
644EFFE9293B5B1800525D5B /* libffi.a */,
|
||||
644EFFE8293B5B1700525D5B /* libgmp.a */,
|
||||
644EFFE7293B5B1700525D5B /* libgmpxx.a */,
|
||||
644EFFEA293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X-ghc8.10.7.a */,
|
||||
644EFFEB293B5B1800525D5B /* libHSsimplex-chat-4.3.0-GPZqasc9wbbBzOZyUtfq0X.a */,
|
||||
644EFFF12941BD6800525D5B /* libffi.a */,
|
||||
644EFFF32941BD6800525D5B /* libgmp.a */,
|
||||
644EFFF52941BD6900525D5B /* libgmpxx.a */,
|
||||
644EFFF22941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a */,
|
||||
644EFFF42941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -1259,7 +1259,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1280,7 +1280,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.3;
|
||||
MARKETING_VERSION = 4.3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1301,7 +1301,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1322,7 +1322,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.3;
|
||||
MARKETING_VERSION = 4.3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1380,7 +1380,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1393,7 +1393,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.3;
|
||||
MARKETING_VERSION = 4.3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1410,7 +1410,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
CURRENT_PROJECT_VERSION = 101;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1423,7 +1423,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 4.3;
|
||||
MARKETING_VERSION = 4.3.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -802,6 +802,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var profile: LocalProfile
|
||||
public var activeConn: Connection
|
||||
public var viaGroup: Int64?
|
||||
public var contactUsed: Bool
|
||||
public var chatSettings: ChatSettings
|
||||
public var userPreferences: Preferences
|
||||
public var mergedPreferences: ContactUserPreferences
|
||||
@@ -817,12 +818,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var image: String? { get { profile.image } }
|
||||
public var localAlias: String { profile.localAlias }
|
||||
|
||||
public var isIndirectContact: Bool {
|
||||
activeConn.connLevel > 0 || viaGroup != nil
|
||||
}
|
||||
|
||||
public var viaGroupLink: Bool {
|
||||
activeConn.viaGroupLink
|
||||
public var directContact: Bool {
|
||||
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
|
||||
}
|
||||
|
||||
public var contactConnIncognito: Bool {
|
||||
@@ -834,6 +831,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
localDisplayName: "alice",
|
||||
profile: LocalProfile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
contactUsed: true,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
userPreferences: Preferences.sampleData,
|
||||
mergedPreferences: ContactUserPreferences.sampleData,
|
||||
@@ -1382,7 +1380,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public func isRcvNew() -> Bool {
|
||||
public var isRcvNew: Bool {
|
||||
if case .rcvNew = meta.itemStatus { return true }
|
||||
return false
|
||||
}
|
||||
@@ -1460,7 +1458,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .sndMsgContent(msgContent: .text(text)),
|
||||
quotedItem: quotedItem,
|
||||
file: file
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getVoiceMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "voice.m4a", fileSize: Int64 = 65536, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
|
||||
@@ -1470,7 +1468,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .rcvMsgContent(msgContent: .voice(text: text, duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
|
||||
@@ -1480,7 +1478,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .rcvMsgContent(msgContent: .file(text)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
|
||||
@@ -1490,7 +1488,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .rcvDeleted(deleteMode: .cidmBroadcast),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getIntegrityErrorSample (_ status: CIStatus = .rcvRead, fromMsgId: Int64 = 1, toMsgId: Int64 = 2) -> ChatItem {
|
||||
@@ -1500,7 +1498,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: fromMsgId, toMsgId: toMsgId)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getGroupInvitationSample (_ status: CIGroupInvitationStatus = .pending) -> ChatItem {
|
||||
@@ -1510,7 +1508,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: status), memberRole: .admin),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getGroupEventSample () -> ChatItem {
|
||||
@@ -1520,7 +1518,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func getChatFeatureSample(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> ChatItem {
|
||||
@@ -1531,7 +1529,27 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
content: content,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static func deletedItemDummy() -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: CIDirection.directRcv,
|
||||
meta: CIMeta(
|
||||
itemId: -1,
|
||||
itemTs: .now,
|
||||
itemText: NSLocalizedString("deleted", comment: "deleted chat item"),
|
||||
itemStatus: .rcvRead,
|
||||
createdAt: .now,
|
||||
updatedAt: .now,
|
||||
itemDeleted: false,
|
||||
itemEdited: false,
|
||||
editable: false
|
||||
),
|
||||
content: .rcvDeleted(deleteMode: .cidmBroadcast),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1788,6 +1806,13 @@ public enum MsgContent {
|
||||
}
|
||||
}
|
||||
|
||||
public var isImage: Bool {
|
||||
switch self {
|
||||
case .image: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .text(text): return "text \(text)"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
layout: layouts/article.html
|
||||
title: "SimpleX Chat reviews and v4.3 released – with instant voice messages, irreversible deletion of sent messages and improved server configuration."
|
||||
date: 2022-12-06
|
||||
image: images/20221206-voice.png
|
||||
imageBottom: true
|
||||
previewBody: blog_previews/20221206.html
|
||||
permalink: "/blog/20221206-simplex-chat-v4.3-voice-messages.html"
|
||||
---
|
||||
|
||||
# SimpleX Chat reviews and v4.3 released – with instant voice messages, irreversible deletion of sent messages and improved server configuration.
|
||||
|
||||
**Published:** Dec 6, 2022
|
||||
|
||||
## SimpleX Chat reviews
|
||||
|
||||
Since we published [the security assessment of SimpleX Chat](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) completed by Trail of Bits in November, several sites published the reviews and included it in their recommendations:
|
||||
|
||||
- Privacy Guides added SimpleX Chat to [the recommended private and secure messengers](https://www.privacyguides.org/real-time-communication/#simplex-chat).
|
||||
- Mike Kuketz – a well-known security expert – published [the review of SimpleX Chat](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) and added it to [the messenger matrix](https://www.messenger-matrix.de).
|
||||
- Supernova published [the review](https://supernova.tilde.team/detailed_reviews.html#simplex) and increased [SimpleX Chat recommendation ratings](https://supernova.tilde.team/messengers.html).
|
||||
|
||||
## What's new in v4.3
|
||||
|
||||
- [instant voice messages!](#instant-voice-messages)
|
||||
- [irreversible deletion of sent messages for all recipients](#irreversible-message-deletion)
|
||||
- [improved SMP server configuration and support for server passwords](#smp-servers-configuration-and-password)
|
||||
- [privacy and security improvements](#privacy-and-security-improvements):
|
||||
- protect app screen in recent apps and prevent screenshots
|
||||
- improved privacy and security of SimpleX invitation links in the app
|
||||
- optional Android app data backup
|
||||
- optionally allow direct messages between group members
|
||||
|
||||
### Instant voice messages
|
||||
|
||||
<img src="./images/20221206-voice.png" width="288">
|
||||
|
||||
Voice messages, unlike normal files, are sent instantly, in the existing connection with your contact and without acceptance from the recipient. For this reason we limited the size of voice messages to ~92.5kb (an equivalent of 6 messages), that limits the duration to 30 seconds on iOS and to ~42 seconds on Android (the size is different because of different encoders), with an average sound quality. The voice messages are sent in MP4AAC format that is natively supported both on iOS and on Android, and you can play voice message files outside of SimpleX Chat app.
|
||||
|
||||
Users who do not want to receive voice messages can disable them, either globally, for all contacts, or for each contact independently. Please note that the global preference change will only affect the contacts where you shared your main profile (not incognito contacts) and where you didn't change the preference for the particular contact. Groups have a separate policy that allows disabling voice messages for all members (they are allowed by default). The owner can set this policy when creating a group or later, via Group preferences page.
|
||||
|
||||
### Irreversible message deletion
|
||||
|
||||
<img src="./images/20221206-deleted1.png" width="288"> <img src="./images/20221206-deleted2.png" width="288">
|
||||
|
||||
When you receive email, you have full confidence that the sender cannot delete their email from your mailbox after you received it. And it seems correct – in the end, this is your device, and nobody should be able to delete any data from it.
|
||||
|
||||
Most existing messengers made an opposite decision – the senders can irreversibly delete their messages from the recipients' devices after they were delivered, whether recipients agree to that or not. And it seems correct too - this is your message, you should be able to delete it, at least for a limited time; that the message is on the recipient device doesn't change your ownership of this message.
|
||||
|
||||
While both these statements appear correct, at least to some people, they simply cannot both be correct at the same time, as they contradict each other - either one or both of them must be wrong. This appears to be a very polarising subject, and [the polls](https://mastodon.social/@simplex/109461879089268041) [I made](https://www.reddit.com/r/SimpleXChat/comments/zdam11/poll_irreversible_message_deletion_by_sender_what/) [yesterday](https://twitter.com/epoberezkin/status/1599797374389727233) [show it](https://www.linkedin.com/feed/update/urn:li:activity:7005564342502842368/) - the votes are split evenly.
|
||||
|
||||
You may want to be able to delete your messages even after they are received to protect your privacy and security, and you want the communication product you use to enforce it. But you may also have many reason to disagree to the deletion of messages on your device for several different reasons:
|
||||
|
||||
- it may be a business context, and either your organisation policy or a compliance requirement is that every message you receive must be preserved for some time.
|
||||
- these messages may contain threat or abuse and you want to keep them as a proof.
|
||||
- you may have paid for the the message (e.g., it can be a consulting report), and you don't want it to suddenly disappear before you had a chance to store it outside of the conversation.
|
||||
|
||||
Instead of taking any side in this choice, we decided to allow to change this behaviour either globally or separately for each contact or group. That makes SimpleX Chat unique, being suitable both for the communication contexts where email is traditionally used and in informal or privacy sensitive contexts, that would allow the senders to delete messages irreversibly, provided that the recipients agree to that.
|
||||
|
||||
In any case, the senders can never be 100% certain that the message is deleted from the recipient's device - recipient can be running a modified client that does not honour the conversation setting, and there is no way to ascertain which code your contact runs on their device.
|
||||
|
||||
If irreversible message deletion is not allowed in the conversation, the senders can still mark their messages as deleted, and it would show "mark deleted" placeholder in the conversation. The recipients can then both reveal the content of the original message and fully delete it on their devices.
|
||||
|
||||
### SMP servers configuration and password
|
||||
|
||||
<img src="./images/20221206-server1.png" width="288"> <img src="./images/20221206-server2.png" width="288"> <img src="./images/20221206-server3.png" width="288">
|
||||
|
||||
When you self-host your own SMP server you may want to make it public so that anybody can use it to receive messages. But many users want to host their private servers, so that only they and their friends can use them to receive the messages.
|
||||
|
||||
v4.0 of SMP server and the new version of the apps adds support for server passwords. It is chosen randomly when you initialize the new server, and if you already have a server you can change it. Anybody can still message you, it doesn't require knowing the password, and the links you share do not include it, but to be able to receive the messages you need to know a server address that includes the password. In a way, it is similar to how basic authentication works in HTTP, and how browsers support the URIs with included credentials.
|
||||
|
||||
The new server configuration section now allows to test your servers before you start using them, and you can also share your server address via QR code, so that your friends or your team can use them too, without the need to copy paste the addresses.
|
||||
|
||||
You can read how to install and configure SMP servers in [this guide](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md).
|
||||
|
||||
### Privacy and security improvements
|
||||
|
||||
#### Protect app screen
|
||||
|
||||
<img src="./images/20221206-protect.png" width="330">
|
||||
|
||||
It is now enabled by default, but you can disable it via settings.
|
||||
|
||||
iOS app only hides the app screen in the recent apps, Android app in addition to that also prevents the screenshots.
|
||||
|
||||
This is not the security measure for the senders, and we made it optional, as the recipient could circumvent it anyway – this is for you to protect your app screen when you give your phone to somebody.
|
||||
|
||||
#### Privacy and security of SimpleX invitation links
|
||||
|
||||
Previously, when you sent somebody an invitation link, a contact address or a group link, they would take half a screen in the chat and they could open in the browser in some cases. Also, as these links are quire large, it is not easy to see if the page domain is maliciously replaced, what SMP server the connection would go through or what kind of link it is.
|
||||
|
||||
This version instead of showing the full link shows a short description, and it replaces a public web address with an internal URI scheme that the app uses (simplex:/) – such links open directly in the app. There is an option to show the full link, if you need it, and even to open it in the browser from the app, but in this case if this link is not using https://simplex.chat website it will show as red to highlight it.
|
||||
|
||||
### Optional Android app data backup
|
||||
|
||||
The previous version always backed up app data in the way it was configured by the system. Now you can override it from inside the app, preventing the backup even if it's enabled by the system settings. This version requires disabling it manually, we will make it disabled by default in the next release (v4.3.1).
|
||||
|
||||
### Direct messages between group members
|
||||
|
||||
The new version does not allow them by default, but it can be enabled by group owners in the group settings when the group is created or at any later moment.
|
||||
|
||||
## SimpleX platform
|
||||
|
||||
Some links to answer the most common questions:
|
||||
|
||||
[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers).
|
||||
|
||||
[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users).
|
||||
|
||||
[Technical details and limitations](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md#privacy-technical-details-and-limitations).
|
||||
|
||||
[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions).
|
||||
|
||||
Please also see the information on our [new website](https://simplex.chat) - it also answers all these questions.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, makes a big difference for us.
|
||||
|
||||
It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in many crypto-currencies.
|
||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- please let us know, via GitHub issue or chat, if you want to make a donation in some other cryptocurrency - we will add the address to the list.
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
@@ -1,5 +1,21 @@
|
||||
# Blog
|
||||
|
||||
Dec 12, 2022 [SimpleX Chat reviews and v4.3 released]
|
||||
|
||||
November reviews:
|
||||
|
||||
- [Privacy Guides](https://www.privacyguides.org/real-time-communication/#simplex-chat) recommendations.
|
||||
- [Review by Mike Kuketz](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/).
|
||||
- [The messenger matrix](https://www.messenger-matrix.de).
|
||||
- [Supernova review](https://supernova.tilde.team/detailed_reviews.html#simplex) and [messenger ratings](https://supernova.tilde.team/messengers.html).
|
||||
|
||||
v4.3 is released:
|
||||
|
||||
- instant voice messages!
|
||||
- irreversible deletion of sent messages for all recipients
|
||||
- improved SMP server configuration and support for server passwords
|
||||
- privacy and security improvements: protect app screen, SimpleX links security, etc.
|
||||
|
||||
Nov 8, 2022 [Security audit by Trail of Bits, the new website and v4.2 released](./20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||
|
||||
_"Have you been audited or should we just ignore you?"_
|
||||
|
||||
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: e4842f4f47fb60ef8843dbce6fd43dec96f157d2
|
||||
tag: fb21d9836e07706c7498baa967f932cb11b818e5
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
<h1>SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!</h1>
|
||||
|
||||
<p><strong>Full privacy of your identity, profile, contacts and metadata</strong>: unlike any other existing messaging platform, SimpleX uses no phone numbers or any other identifiers assigned to the users - not even random numbers. This protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers.</p>
|
||||
|
||||
<p><strong>Complete protection against spam and abuse</strong>: as you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Read more.</p>
|
||||
|
||||
<p><strong>Full ownership, control and security of your data</strong>: SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received.</p>
|
||||
|
||||
<p><strong>Decentralized network</strong>: you can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers.</p>
|
||||
|
||||
<p><a href="https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html" target="_blank">Security assessment</a> was done by Trail of Bits in November 2022.</p>
|
||||
|
||||
<p>SimpleX Chat features:</p>
|
||||
|
||||
<ul>
|
||||
<li>end-to-end encrypted messages, with editing, replies and deletion of messages.</li>
|
||||
<li>sending end-to-end encrypted images and files.</li>
|
||||
<li>single-use and long-term user addresses.</li>
|
||||
<li>secret chat groups - only group members know it exists and who is the member.</li>
|
||||
<li>end-to-end encrypted audio and video calls.</li>
|
||||
<li>private instant notifications.</li>
|
||||
<li>portable chat profile - you can transfer your chat contacts and history to another device (terminal or mobile).</li>
|
||||
</ul>
|
||||
|
||||
<p>SimpleX Chat advantages:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Full privacy of your identity, profile, contacts and metadata</strong>: unlike any other existing messaging platform, SimpleX uses no phone numbers or any other identifiers assigned to the users - not even random numbers. This protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers.</li>
|
||||
<li><strong>Complete protection against spam and abuse</strong>: as you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address.</li>
|
||||
<li><strong>Full ownership, control and security of your data</strong>: SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received.</li>
|
||||
<li><strong>Decentralized network</strong>: you can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers.</li>
|
||||
</ul>
|
||||
|
||||
<p>You can connect to anybody you know via link or scan QR code (in the video call or in person) and start sending messages instantly - no emails, phone numbers or passwords needed.</p>
|
||||
|
||||
<p>Your profile and contacts are only stored in the app on your device - our servers do not have access to this information.</p>
|
||||
|
||||
<p>All messages are end-to-end encrypted using open-source double-ratchet protocol; the messages are routed via our servers using open-source SimpleX Messaging Protocol.</p>
|
||||
|
||||
<p>The app sends local notifications only when messages or connection requests arrive - the app checks for the new messages every 10-15 min, but if you stop using the app it may stop checking for the new messages.</p>
|
||||
<p>Please send us any questions via the app (connect to the team via settings!), <a href="mailto:chat@simplex.chat?subject=SimpleX Chat on F-Droid">email us</a> or submit an <a href="https://github.com/simplex-chat/simplex-chat/issues" target="_blank">issue on GitHub</a>.</p>
|
||||
|
||||
<p>Please send us any questions via the app (connect to the team via settings!), email chat@simplex.chat or submit issues on GitHub (https://github.com/simplex-chat/simplex-chat/issues)</p>
|
||||
|
||||
<p>Source code: https://github.com/simplex-chat/simplex-chat</p>
|
||||
|
||||
<p>Follow us on Twitter (@SimpleXChat) and Reddit (r/SimpleXChat/) for the latest updates.</p>
|
||||
<p>Follow us on Mastodon (<a href="https://mastodon.social/@simplex" target="_blank">@simplex@mastodon.social</a>), Twitter (<a href="https://twitter.com/SimpleXChat" target="_blank">@SimpleXChat</a>) and Reddit (<a href="https://www.reddit.com/r/SimpleXChat/" target="_blank">r/SimpleXChat</a>) for the latest updates.</p>
|
||||
|
||||
<p>Once you install SimpleX Chat, join the group of users via <a href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" target="_blank">this link</a> to share your ideas and feedback.</p>
|
||||
|
||||
<p>Source code: <a href="https://github.com/simplex-chat/simplex-chat#readme" target="_blank">https://github.com/simplex-chat/simplex-chat</a></p>
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 955 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 924 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 804 KiB |
|
After Width: | Height: | Size: 915 KiB |
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 4.3.0
|
||||
version: 4.3.2
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
@@ -4,8 +4,7 @@ set -eu
|
||||
|
||||
u="$USER"
|
||||
tmp=$(mktemp -d -t)
|
||||
source="github:simplex-chat/simplex-chat"
|
||||
commit="$1"
|
||||
folder="$tmp/simplex-chat"
|
||||
commands="nix git curl gradle zip unzip zipalign"
|
||||
|
||||
nix_install() {
|
||||
@@ -13,9 +12,9 @@ nix_install() {
|
||||
[ ! -d /nix ] && sudo sh -c "mkdir -p /nix && chown -R $u /nix"
|
||||
|
||||
# Install nix
|
||||
nix_ver="nix-2.10.3"
|
||||
nix_ver="nix-2.11.1"
|
||||
nix_url="https://releases.nixos.org/nix/$nix_ver/install"
|
||||
nix_hash="2e96a9c4abb5648a805480e8679de3d9fecff30453603f11c26bb4e7176c7ebe"
|
||||
nix_hash="4569a01dc5f62056f29f3195673bc3242fc70bf2474927fb5d8549c4d997402d"
|
||||
|
||||
curl -sSf "$nix_url" -o "$tmp/nix-install"
|
||||
printf "%s %s" "$nix_hash" "$tmp/nix-install" | sha256sum -c
|
||||
@@ -30,14 +29,11 @@ nix_setup() {
|
||||
}
|
||||
|
||||
git_setup() {
|
||||
# Clone simplex
|
||||
git clone https://github.com/simplex-chat/simplex-chat "$tmp/simplex-chat"
|
||||
|
||||
# Switch to nix-android branch
|
||||
git -C "$tmp/simplex-chat" checkout "$commit"
|
||||
git -C "$folder" checkout "$commit"
|
||||
|
||||
# Create missing folders
|
||||
mkdir -p "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
mkdir -p "$folder/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
}
|
||||
|
||||
checks() {
|
||||
@@ -59,6 +55,10 @@ checks() {
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$folder" != "." ] && {
|
||||
git clone https://github.com/simplex-chat/simplex-chat "$folder"
|
||||
}
|
||||
|
||||
if [ -n "$commands_failed" ]; then
|
||||
commands_failed=${commands_failed% *}
|
||||
printf "%s is not found in your \$PATH. Please install them and re-run the script.\n" "$commands_failed"
|
||||
@@ -70,19 +70,19 @@ checks() {
|
||||
|
||||
build() {
|
||||
# Build simplex lib
|
||||
nix build "$source/$commit#hydraJobs.aarch64-android:lib:simplex-chat.x86_64-linux"
|
||||
unzip -o "$PWD/result/pkg-aarch64-android-libsimplex.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
nix build "$folder#hydraJobs.aarch64-android:lib:simplex-chat.x86_64-linux"
|
||||
unzip -o "$PWD/result/pkg-aarch64-android-libsimplex.zip" -d "$folder/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
|
||||
# Build android suppprt lib
|
||||
nix build "$source/$commit#hydraJobs.aarch64-android:lib:support.x86_64-linux"
|
||||
unzip -o "$PWD/result/pkg-aarch64-android-libsupport.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
nix build "$folder#hydraJobs.aarch64-android:lib:support.x86_64-linux"
|
||||
unzip -o "$PWD/result/pkg-aarch64-android-libsupport.zip" -d "$folder/apps/android/app/src/main/cpp/libs/arm64-v8a"
|
||||
|
||||
sed -i.bak 's/${extract_native_libs}/true/' "$tmp/simplex-chat/apps/android/app/src/main/AndroidManifest.xml"
|
||||
sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/android/app/src/main/AndroidManifest.xml"
|
||||
|
||||
gradle -p "$tmp/simplex-chat/apps/android/" clean build assembleRelease
|
||||
gradle -p "$folder/apps/android/" clean build assembleRelease
|
||||
|
||||
mkdir -p "$tmp/android"
|
||||
unzip -oqd "$tmp/android/" "$tmp/simplex-chat/apps/android/app/build/outputs/apk/release/app-release-unsigned.apk"
|
||||
unzip -oqd "$tmp/android/" "$folder/apps/android/app/build/outputs/apk/release/app-release-unsigned.apk"
|
||||
|
||||
(cd "$tmp/android" && zip -rq5 "$tmp/simplex-chat.apk" . && zip -rq0 "$tmp/simplex-chat.apk" resources.arsc res)
|
||||
|
||||
@@ -94,10 +94,18 @@ final() {
|
||||
}
|
||||
|
||||
main() {
|
||||
while getopts ":s" opt; do
|
||||
case $opt in
|
||||
s) folder="." ;;
|
||||
*) printf "Flag '-%s' doesn't exist.\n" "$OPTARG"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
shift $(( $OPTIND - 1 ))
|
||||
commit="$1"; shift 1
|
||||
checks
|
||||
git_setup
|
||||
build
|
||||
final
|
||||
}
|
||||
|
||||
main
|
||||
main "$@"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."e4842f4f47fb60ef8843dbce6fd43dec96f157d2" = "191h2v5jcn51haj0mi5y5pm8x8fi9pz49ydxwwzqr3m6zjp21ngg";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."fb21d9836e07706c7498baa967f932cb11b818e5" = "0dl08ag38d1azzil1xxi6xrzqwfcv550wi5kjdmxn4h820icl2ja";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
|
||||
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
|
||||
|
||||
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 4.3.0
|
||||
version: 4.3.2
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -65,6 +65,9 @@ library
|
||||
Simplex.Chat.Migrations.M20221115_server_cfg
|
||||
Simplex.Chat.Migrations.M20221129_delete_group_feature_items
|
||||
Simplex.Chat.Migrations.M20221130_delete_item_deleted
|
||||
Simplex.Chat.Migrations.M20221209_verified_connection
|
||||
Simplex.Chat.Migrations.M20221210_idxs
|
||||
Simplex.Chat.Migrations.M20221211_group_description
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Options
|
||||
Simplex.Chat.ProfileGenerator
|
||||
|
||||
@@ -280,8 +280,7 @@ processChatCommand = \case
|
||||
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
|
||||
-- TODO optimize queries calculating ChatStats, currently they're disabled
|
||||
CTDirect -> do
|
||||
directChat@Chat {chatInfo} <- withStore (\db -> getDirectChat db user cId pagination search)
|
||||
case chatInfo of DirectChat ct@Contact {contactUsed} -> unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
|
||||
directChat <- withStore (\db -> getDirectChat db user cId pagination search)
|
||||
pure . CRApiChat $ AChat SCTDirect directChat
|
||||
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search)
|
||||
CTContactRequest -> pure $ chatCmdError "not implemented"
|
||||
@@ -345,7 +344,7 @@ processChatCommand = \case
|
||||
if isVoice mc && not (groupFeatureAllowed GFVoice gInfo)
|
||||
then pure $ chatCmdError $ "feature not allowed " <> T.unpack (groupFeatureToText GFVoice)
|
||||
else do
|
||||
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo (length ms)
|
||||
(fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo (length $ filter memberCurrent ms)
|
||||
(msgContainer, quotedItem_) <- prepareMsg fileInvitation_ membership
|
||||
msg@SndMessage {sharedMsgId} <- sendGroupMessage gInfo ms (XMsgNew msgContainer)
|
||||
mapM_ (sendGroupFileInline ms sharedMsgId) ft_
|
||||
@@ -525,7 +524,19 @@ processChatCommand = \case
|
||||
withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members
|
||||
withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members
|
||||
withStore' $ \db -> deleteGroup db user gInfo
|
||||
let contactIds = mapMaybe memberContactId members
|
||||
forM_ contactIds $ \ctId ->
|
||||
deleteUnusedContact ctId `catchError` (toView . CRChatError)
|
||||
pure $ CRGroupDeletedUser gInfo
|
||||
where
|
||||
deleteUnusedContact contactId = do
|
||||
ct <- withStore $ \db -> getContact db user contactId
|
||||
unless (directContact ct) $ do
|
||||
ctGroupId <- withStore' $ \db -> checkContactHasGroups db user ct
|
||||
when (isNothing ctGroupId) $ do
|
||||
conns <- withStore $ \db -> getContactConnections db userId ct
|
||||
forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure ()
|
||||
withStore' $ \db -> deleteContactWithoutGroups db user ct
|
||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||
APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of
|
||||
CTDirect -> do
|
||||
@@ -747,24 +758,53 @@ processChatCommand = \case
|
||||
case memberConnId m of
|
||||
Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) $> CRCmdOk
|
||||
_ -> throwChatError CEGroupMemberNotActive
|
||||
APIGetContactCode contactId -> withUser $ \user -> do
|
||||
ct@Contact {activeConn = conn@Connection {connId}} <- withStore $ \db -> getContact db user contactId
|
||||
code <- getConnectionCode (contactConnId ct)
|
||||
ct' <- case contactSecurityCode ct of
|
||||
Just SecurityCode {securityCode}
|
||||
| sameVerificationCode code securityCode -> pure ct
|
||||
| otherwise -> do
|
||||
withStore' $ \db -> setConnectionVerified db user connId Nothing
|
||||
pure (ct :: Contact) {activeConn = conn {connectionCode = Nothing}}
|
||||
_ -> pure ct
|
||||
pure $ CRContactCode ct' code
|
||||
APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do
|
||||
(g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
|
||||
case activeConn of
|
||||
Just conn@Connection {connId} -> do
|
||||
code <- getConnectionCode $ aConnId conn
|
||||
m' <- case memberSecurityCode m of
|
||||
Just SecurityCode {securityCode}
|
||||
| sameVerificationCode code securityCode -> pure m
|
||||
| otherwise -> do
|
||||
withStore' $ \db -> setConnectionVerified db user connId Nothing
|
||||
pure (m :: GroupMember) {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}}
|
||||
_ -> pure m
|
||||
pure $ CRGroupMemberCode g m' code
|
||||
_ -> throwChatError CEGroupMemberNotActive
|
||||
APIVerifyContact contactId code -> withUser $ \user -> do
|
||||
Contact {activeConn} <- withStore $ \db -> getContact db user contactId
|
||||
verifyConnectionCode user activeConn code
|
||||
APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do
|
||||
GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId
|
||||
case activeConn of
|
||||
Just conn -> verifyConnectionCode user conn code
|
||||
_ -> throwChatError CEGroupMemberNotActive
|
||||
ShowMessages (ChatName cType name) ntfOn -> withUser $ \user -> do
|
||||
chatId <- case cType of
|
||||
CTDirect -> withStore $ \db -> getContactIdByName db user name
|
||||
CTGroup -> withStore $ \db -> getGroupIdByName db user name
|
||||
_ -> throwChatError $ CECommandError "not supported"
|
||||
processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ ChatSettings ntfOn
|
||||
ContactInfo cName -> withUser $ \user -> do
|
||||
contactId <- withStore $ \db -> getContactIdByName db user cName
|
||||
processChatCommand $ APIContactInfo contactId
|
||||
GroupMemberInfo gName mName -> withUser $ \user -> do
|
||||
(gId, mId) <- withStore $ \db -> getGroupIdByName db user gName >>= \gId -> (gId,) <$> getGroupMemberIdByName db user gId mName
|
||||
processChatCommand $ APIGroupMemberInfo gId mId
|
||||
SwitchContact cName -> withUser $ \user -> do
|
||||
contactId <- withStore $ \db -> getContactIdByName db user cName
|
||||
processChatCommand $ APISwitchContact contactId
|
||||
SwitchGroupMember gName mName -> withUser $ \user -> do
|
||||
(gId, mId) <- withStore $ \db -> getGroupIdByName db user gName >>= \gId -> (gId,) <$> getGroupMemberIdByName db user gId mName
|
||||
processChatCommand $ APISwitchGroupMember gId mId
|
||||
ContactInfo cName -> withContactName cName APIContactInfo
|
||||
GroupMemberInfo gName mName -> withMemberName gName mName APIGroupMemberInfo
|
||||
SwitchContact cName -> withContactName cName APISwitchContact
|
||||
SwitchGroupMember gName mName -> withMemberName gName mName APISwitchGroupMember
|
||||
GetContactCode cName -> withContactName cName APIGetContactCode
|
||||
GetGroupMemberCode gName mName -> withMemberName gName mName APIGetGroupMemberCode
|
||||
VerifyContact cName code -> withContactName cName (`APIVerifyContact` code)
|
||||
VerifyGroupMember gName mName code -> withMemberName gName mName $ \gId mId -> APIVerifyGroupMember gId mId code
|
||||
ChatHelp section -> pure $ CRChatHelp section
|
||||
Welcome -> withUser $ pure . CRWelcome
|
||||
AddContact -> withUser $ \User {userId} -> withChatLock "addContact" . procCmd $ do
|
||||
@@ -791,12 +831,8 @@ processChatCommand = \case
|
||||
ConnectSimplex -> withUser $ \user ->
|
||||
-- [incognito] generate profile to send
|
||||
connectViaContact user adminContactReq
|
||||
DeleteContact cName -> withUser $ \user -> do
|
||||
contactId <- withStore $ \db -> getContactIdByName db user cName
|
||||
processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId)
|
||||
ClearContact cName -> withUser $ \user -> do
|
||||
contactId <- withStore $ \db -> getContactIdByName db user cName
|
||||
processChatCommand $ APIClearChat (ChatRef CTDirect contactId)
|
||||
DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect
|
||||
ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect
|
||||
ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user)
|
||||
CreateMyAddress -> withUser $ \User {userId} -> withChatLock "createMyAddress" . procCmd $ do
|
||||
(connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact Nothing
|
||||
@@ -926,7 +962,7 @@ processChatCommand = \case
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) Nothing Nothing
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
|
||||
pure CRMemberRoleUser {groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole}
|
||||
APIRemoveMember groupId memberId -> withUser $ \user@User {userId} -> do
|
||||
APIRemoveMember groupId memberId -> withUser $ \user -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId
|
||||
case find ((== memberId) . groupMemberId') members of
|
||||
Nothing -> throwChatError CEGroupMemberNotFound
|
||||
@@ -944,10 +980,8 @@ processChatCommand = \case
|
||||
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) Nothing Nothing
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
|
||||
deleteMemberConnection user m
|
||||
withStore' $ \db ->
|
||||
checkGroupMemberHasItems db user m >>= \case
|
||||
Just _ -> updateGroupMemberStatus db userId m GSMemRemoved
|
||||
Nothing -> deleteGroupMember db user m
|
||||
-- undeleted "member connected" chat item will prevent deletion of member record
|
||||
deleteOrUpdateMemberRecord user m
|
||||
pure $ CRUserDeletedMember gInfo m {memberStatus = GSMemRemoved}
|
||||
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId
|
||||
@@ -957,6 +991,7 @@ processChatCommand = \case
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
|
||||
-- TODO delete direct connections that were unused
|
||||
deleteGroupLink' user gInfo `catchError` \_ -> pure ()
|
||||
-- member records are not deleted to keep history
|
||||
forM_ members $ deleteMemberConnection user
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft
|
||||
pure $ CRLeftMemberUser gInfo {membership = membership {memberStatus = GSMemLeft}}
|
||||
@@ -967,12 +1002,8 @@ processChatCommand = \case
|
||||
JoinGroup gName -> withUser $ \user -> do
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APIJoinGroup groupId
|
||||
MemberRole gName groupMemberName memRole -> do
|
||||
(groupId, groupMemberId) <- getGroupAndMemberId gName groupMemberName
|
||||
processChatCommand $ APIMemberRole groupId groupMemberId memRole
|
||||
RemoveMember gName groupMemberName -> do
|
||||
(groupId, groupMemberId) <- getGroupAndMemberId gName groupMemberName
|
||||
processChatCommand $ APIRemoveMember groupId groupMemberId
|
||||
MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole
|
||||
RemoveMember gName gMemberName -> withMemberName gName gMemberName APIRemoveMember
|
||||
LeaveGroup gName -> withUser $ \user -> do
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APILeaveGroup groupId
|
||||
@@ -989,9 +1020,12 @@ processChatCommand = \case
|
||||
APIUpdateGroupProfile groupId p' -> withUser $ \user -> do
|
||||
g <- withStore $ \db -> getGroup db user groupId
|
||||
runUpdateGroupProfile user g p'
|
||||
UpdateGroupProfile gName profile -> withUser $ \user -> do
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APIUpdateGroupProfile groupId profile
|
||||
UpdateGroupNames gName GroupProfile {displayName, fullName} ->
|
||||
updateGroupProfileByName gName $ \p -> p {displayName, fullName}
|
||||
ShowGroupProfile gName -> withUser $ \user ->
|
||||
CRGroupProfile <$> withStore (\db -> getGroupInfoByName db user gName)
|
||||
UpdateGroupDescription gName description ->
|
||||
updateGroupProfileByName gName $ \p -> p {description}
|
||||
APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do
|
||||
gInfo@GroupInfo {membership = membership@GroupMember {memberRole = userRole}} <- withStore $ \db -> getGroupInfo db user groupId
|
||||
when (userRole < GRAdmin) $ throwChatError CEGroupUserRole
|
||||
@@ -1086,10 +1120,9 @@ processChatCommand = \case
|
||||
ct@Contact {userPreferences} <- withStore $ \db -> getContactByName db user cName
|
||||
let prefs' = setPreference f allowed_ $ Just userPreferences
|
||||
updateContactPrefs user ct prefs'
|
||||
SetGroupFeature f gName enabled -> withUser $ \user -> do
|
||||
g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> getGroup db user =<< getGroupIdByName db user gName
|
||||
let p' = p {groupPreferences = Just . setGroupPreference f enabled $ groupPreferences p}
|
||||
runUpdateGroupProfile user g p'
|
||||
SetGroupFeature f gName enabled ->
|
||||
updateGroupProfileByName gName $ \p ->
|
||||
p {groupPreferences = Just . setGroupPreference f enabled $ groupPreferences p}
|
||||
QuitChat -> liftIO exitSuccess
|
||||
ShowVersion -> pure $ CRVersionInfo versionNumber
|
||||
DebugLocks -> do
|
||||
@@ -1125,6 +1158,24 @@ processChatCommand = \case
|
||||
withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk
|
||||
checkStoreNotChanged :: m ChatResponse -> m ChatResponse
|
||||
checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged)
|
||||
withContactName :: ContactName -> (ContactId -> ChatCommand) -> m ChatResponse
|
||||
withContactName cName cmd = withUser $ \user ->
|
||||
withStore (\db -> getContactIdByName db user cName) >>= processChatCommand . cmd
|
||||
withMemberName :: GroupName -> ContactName -> (GroupId -> GroupMemberId -> ChatCommand) -> m ChatResponse
|
||||
withMemberName gName mName cmd = withUser $ \user ->
|
||||
getGroupAndMemberId user gName mName >>= processChatCommand . uncurry cmd
|
||||
getConnectionCode :: ConnId -> m Text
|
||||
getConnectionCode connId = verificationCode <$> withAgent (`getConnectionRatchetAdHash` connId)
|
||||
verifyConnectionCode :: User -> Connection -> Maybe Text -> m ChatResponse
|
||||
verifyConnectionCode user conn@Connection {connId} (Just code) = do
|
||||
code' <- getConnectionCode $ aConnId conn
|
||||
let verified = sameVerificationCode code code'
|
||||
when verified . withStore' $ \db -> setConnectionVerified db user connId $ Just code'
|
||||
pure $ CRConnectionVerified verified code'
|
||||
verifyConnectionCode user conn@Connection {connId} _ = do
|
||||
code' <- getConnectionCode $ aConnId conn
|
||||
withStore' $ \db -> setConnectionVerified db user connId Nothing
|
||||
pure $ CRConnectionVerified False code'
|
||||
getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64
|
||||
getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of
|
||||
CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd (safeDecodeUtf8 msg)
|
||||
@@ -1212,6 +1263,11 @@ processChatCommand = \case
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat g') ci
|
||||
createGroupFeatureChangedItems user cd CISndGroupFeature p p'
|
||||
pure $ CRGroupUpdated g g' Nothing
|
||||
updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> m ChatResponse
|
||||
updateGroupProfileByName gName update = withUser $ \user -> do
|
||||
g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db ->
|
||||
getGroupIdByName db user gName >>= getGroup db user
|
||||
runUpdateGroupProfile user g $ update p
|
||||
isReady :: Contact -> Bool
|
||||
isReady ct =
|
||||
let s = connStatus $ activeConn (ct :: Contact)
|
||||
@@ -1243,8 +1299,8 @@ processChatCommand = \case
|
||||
_ -> throwChatError CEFileNotReceived {fileId}
|
||||
where
|
||||
forward = processChatCommand . sendCommand chatName
|
||||
getGroupAndMemberId :: GroupName -> ContactName -> m (GroupId, GroupMemberId)
|
||||
getGroupAndMemberId gName groupMemberName = withUser $ \user -> do
|
||||
getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId)
|
||||
getGroupAndMemberId user gName groupMemberName =
|
||||
withStore $ \db -> do
|
||||
groupId <- getGroupIdByName db user gName
|
||||
groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName
|
||||
@@ -1912,7 +1968,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
GCHostMember -> do
|
||||
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected}
|
||||
createGroupFeatureItems gInfo m
|
||||
let GroupInfo {groupProfile = GroupProfile {description}} = gInfo
|
||||
memberConnectedChatItem gInfo m
|
||||
forM_ description $ groupDescriptionChatItem gInfo m
|
||||
setActive $ ActiveG gName
|
||||
showToast ("#" <> gName) "you are connected to group"
|
||||
GCInviteeMember -> do
|
||||
@@ -2188,6 +2246,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
-- ts should be broker ts but we don't have it for CON
|
||||
createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing
|
||||
|
||||
groupDescriptionChatItem :: GroupInfo -> GroupMember -> Text -> m ()
|
||||
groupDescriptionChatItem gInfo m descr =
|
||||
createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing
|
||||
|
||||
notifyMemberConnected :: GroupInfo -> GroupMember -> m ()
|
||||
notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do
|
||||
memberConnectedChatItem gInfo m
|
||||
@@ -2225,12 +2287,15 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
|
||||
let ExtMsgContent content fileInvitation_ = mcExtMsgContent mc
|
||||
if isVoice content && not (featureAllowed CFVoice forContact ct)
|
||||
then void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing
|
||||
then do
|
||||
void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing
|
||||
setActive $ ActiveC c
|
||||
else do
|
||||
ciFile_ <- processFileInvitation fileInvitation_ content $ \db -> createRcvFileTransfer db userId ct
|
||||
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_
|
||||
when (enableNtfs chatSettings) $ showMsgToast (c <> "> ") content formattedText
|
||||
setActive $ ActiveC c
|
||||
when (enableNtfs chatSettings) $ do
|
||||
showMsgToast (c <> "> ") content formattedText
|
||||
setActive $ ActiveC c
|
||||
where
|
||||
newChatItem ciContent ciFile_ = do
|
||||
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta ciContent ciFile_
|
||||
@@ -2296,8 +2361,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
ciFile_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m
|
||||
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_
|
||||
let g = groupName' gInfo
|
||||
when (enableNtfs chatSettings) $ showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
|
||||
setActive $ ActiveG g
|
||||
when (enableNtfs chatSettings) $ do
|
||||
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
|
||||
setActive $ ActiveG g
|
||||
where
|
||||
newChatItem ciContent ciFile_ = do
|
||||
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta ciContent ciFile_
|
||||
@@ -2792,23 +2858,26 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
if memberId (membership :: GroupMember) == memId
|
||||
then checkRole membership $ do
|
||||
deleteGroupLink' user gInfo `catchError` \_ -> pure ()
|
||||
-- member records are not deleted to keep history
|
||||
forM_ members $ deleteMemberConnection user
|
||||
deleteMember membership RGEUserDeleted
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved
|
||||
deleteMemberItem RGEUserDeleted
|
||||
toView $ CRDeletedMemberUser gInfo {membership = membership {memberStatus = GSMemRemoved}} m
|
||||
else case find (sameMemberId memId) members of
|
||||
Nothing -> messageError "x.grp.mem.del with unknown member ID"
|
||||
Just member@GroupMember {groupMemberId, memberProfile} ->
|
||||
checkRole member $ do
|
||||
deleteMemberConnection user member
|
||||
deleteMember member $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)
|
||||
-- undeleted "member connected" chat item will prevent deletion of member record
|
||||
deleteOrUpdateMemberRecord user member
|
||||
deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)
|
||||
toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved}
|
||||
where
|
||||
checkRole GroupMember {memberRole} a
|
||||
| senderRole < GRAdmin || senderRole < memberRole =
|
||||
messageError "x.grp.mem.del with insufficient member permissions"
|
||||
| otherwise = a
|
||||
deleteMember member gEvent = do
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId member GSMemRemoved
|
||||
deleteMemberItem gEvent = do
|
||||
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) Nothing
|
||||
groupMsgToView gInfo m ci msgMeta
|
||||
|
||||
@@ -2818,6 +2887,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m ()
|
||||
xGrpLeave gInfo m msg msgMeta = do
|
||||
deleteMemberConnection user m
|
||||
-- member record is not deleted to allow creation of "member left" chat item
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft
|
||||
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEMemberLeft) Nothing
|
||||
groupMsgToView gInfo m ci msgMeta
|
||||
@@ -2830,6 +2900,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage =
|
||||
members <- getGroupMembers db user gInfo
|
||||
updateGroupMemberStatus db userId membership GSMemGroupDeleted
|
||||
pure members
|
||||
-- member records are not deleted to keep history
|
||||
forM_ ms $ deleteMemberConnection user
|
||||
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted) Nothing
|
||||
groupMsgToView gInfo m ci msgMeta
|
||||
@@ -2987,6 +3058,13 @@ deleteMemberConnection user GroupMember {activeConn} = do
|
||||
deleteAgentConnectionAsync user conn `catchError` \_ -> pure ()
|
||||
withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
|
||||
|
||||
deleteOrUpdateMemberRecord :: ChatMonad m => User -> GroupMember -> m ()
|
||||
deleteOrUpdateMemberRecord user@User {userId} member =
|
||||
withStore' $ \db ->
|
||||
checkGroupMemberHasItems db user member >>= \case
|
||||
Just _ -> updateGroupMemberStatus db userId member GSMemRemoved
|
||||
Nothing -> deleteGroupMember db user member
|
||||
|
||||
sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64)
|
||||
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent = do
|
||||
if connStatus == ConnReady || connStatus == ConnSndReady
|
||||
@@ -3317,7 +3395,7 @@ chatCommandP =
|
||||
"/_accept " *> (APIAcceptContact <$> A.decimal),
|
||||
"/_reject " *> (APIRejectContact <$> A.decimal),
|
||||
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
|
||||
("/call @" <|> "/call ") *> (SendCallInvitation <$> displayName <*> pure defaultCallType),
|
||||
"/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType),
|
||||
"/_call reject @" *> (APIRejectCall <$> A.decimal),
|
||||
"/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP),
|
||||
@@ -3359,34 +3437,43 @@ chatCommandP =
|
||||
"/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP),
|
||||
"/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_info @" *> (APIContactInfo <$> A.decimal),
|
||||
("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* optional (A.char '@') <*> displayName),
|
||||
("/info @" <|> "/info " <|> "/i @" <|> "/i ") *> (ContactInfo <$> displayName),
|
||||
("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* char_ '@' <*> displayName),
|
||||
("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayName),
|
||||
"/_switch #" *> (APISwitchGroupMember <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_switch @" *> (APISwitchContact <$> A.decimal),
|
||||
"/switch #" *> (SwitchGroupMember <$> displayName <* A.space <* optional (A.char '@') <*> displayName),
|
||||
("/switch @" <|> "/switch ") *> (SwitchContact <$> displayName),
|
||||
"/switch #" *> (SwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName),
|
||||
"/switch " *> char_ '@' *> (SwitchContact <$> displayName),
|
||||
"/_get code @" *> (APIGetContactCode <$> A.decimal),
|
||||
"/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> textP)),
|
||||
"/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> textP)),
|
||||
"/code " *> char_ '@' *> (GetContactCode <$> displayName),
|
||||
"/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName),
|
||||
"/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> textP)),
|
||||
"/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> textP)),
|
||||
("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles,
|
||||
("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups,
|
||||
("/help address" <|> "/ha") $> ChatHelp HSMyAddress,
|
||||
("/help messages" <|> "/hm") $> ChatHelp HSMessages,
|
||||
("/help settings" <|> "/hs") $> ChatHelp HSSettings,
|
||||
("/help" <|> "/h") $> ChatHelp HSMain,
|
||||
("/group #" <|> "/group " <|> "/g #" <|> "/g ") *> (NewGroup <$> groupProfile),
|
||||
("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile),
|
||||
"/_group " *> (NewGroup <$> jsonP),
|
||||
("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <* optional (A.char '@') <*> displayName <*> memberRole),
|
||||
("/join #" <|> "/join " <|> "/j #" <|> "/j ") *> (JoinGroup <$> displayName),
|
||||
("/member role #" <|> "/member role " <|> "/mr #" <|> "/mr ") *> (MemberRole <$> displayName <* A.space <* optional (A.char '@') <*> displayName <*> memberRole),
|
||||
("/remove #" <|> "/remove " <|> "/rm #" <|> "/rm ") *> (RemoveMember <$> displayName <* A.space <* optional (A.char '@') <*> displayName),
|
||||
("/leave #" <|> "/leave " <|> "/l #" <|> "/l ") *> (LeaveGroup <$> displayName),
|
||||
("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole),
|
||||
("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName),
|
||||
("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole),
|
||||
("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName),
|
||||
("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayName),
|
||||
("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName),
|
||||
("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName),
|
||||
("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayName),
|
||||
"/clear #" *> (ClearGroup <$> displayName),
|
||||
("/clear @" <|> "/clear ") *> (ClearContact <$> displayName),
|
||||
("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName),
|
||||
"/clear " *> char_ '@' *> (ClearContact <$> displayName),
|
||||
("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayName),
|
||||
("/groups" <|> "/gs") $> ListGroups,
|
||||
"/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP),
|
||||
-- TODO group profile update via terminal should not reset image and preferences to Nothing (now it does)
|
||||
("/group_profile #" <|> "/gp #" <|> "/group_profile " <|> "/gp ") *> (UpdateGroupProfile <$> displayName <* A.space <*> groupProfile),
|
||||
("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile),
|
||||
("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName),
|
||||
"/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> (jsonP <|> textP))),
|
||||
"/_create link #" *> (APICreateGroupLink <$> A.decimal),
|
||||
"/_delete link #" *> (APIDeleteGroupLink <$> A.decimal),
|
||||
"/_get link #" *> (APIGetGroupLink <$> A.decimal),
|
||||
@@ -3394,7 +3481,7 @@ chatCommandP =
|
||||
"/delete link #" *> (DeleteGroupLink <$> displayName),
|
||||
"/show link #" *> (ShowGroupLink <$> displayName),
|
||||
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString),
|
||||
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString),
|
||||
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString),
|
||||
("/contacts" <|> "/cs") $> ListContacts,
|
||||
("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)),
|
||||
("/connect" <|> "/c") $> AddContact,
|
||||
@@ -3418,8 +3505,8 @@ chatCommandP =
|
||||
("/delete_address" <|> "/da") $> DeleteMyAddress,
|
||||
("/show_address" <|> "/sa") $> ShowMyAddress,
|
||||
"/auto_accept " *> (AddressAutoAccept <$> autoAcceptP),
|
||||
("/accept @" <|> "/accept " <|> "/ac @" <|> "/ac ") *> (AcceptContact <$> displayName),
|
||||
("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName),
|
||||
("/accept " <|> "/ac ") *> char_ '@' *> (AcceptContact <$> displayName),
|
||||
("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName),
|
||||
("/markdown" <|> "/m") $> ChatHelp HSMarkdown,
|
||||
("/welcome" <|> "/w") $> Welcome,
|
||||
"/profile_image " *> (UpdateProfileImage . Just . ImageData <$> imageP),
|
||||
@@ -3468,7 +3555,7 @@ chatCommandP =
|
||||
gName <- displayName
|
||||
fullName <- fullNameP gName
|
||||
let groupPreferences = Just (emptyGroupPrefs :: GroupPreferences) {directMessages = Just GroupPreference {enable = FEOn}}
|
||||
pure GroupProfile {displayName = gName, fullName, image = Nothing, groupPreferences}
|
||||
pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences}
|
||||
fullNameP name = do
|
||||
n <- (A.space *> A.takeByteString) <|> pure ""
|
||||
pure $ if B.null n then name else safeDecodeUtf8 n
|
||||
@@ -3506,6 +3593,7 @@ chatCommandP =
|
||||
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
|
||||
(pure Nothing)
|
||||
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
|
||||
char_ = optional . A.char
|
||||
|
||||
adminContactReq :: ConnReqContact
|
||||
adminContactReq =
|
||||
|
||||
@@ -200,11 +200,19 @@ data ChatCommand
|
||||
| APIGroupMemberInfo GroupId GroupMemberId
|
||||
| APISwitchContact ContactId
|
||||
| APISwitchGroupMember GroupId GroupMemberId
|
||||
| APIGetContactCode ContactId
|
||||
| APIGetGroupMemberCode GroupId GroupMemberId
|
||||
| APIVerifyContact ContactId (Maybe Text)
|
||||
| APIVerifyGroupMember GroupId GroupMemberId (Maybe Text)
|
||||
| ShowMessages ChatName Bool
|
||||
| ContactInfo ContactName
|
||||
| GroupMemberInfo GroupName ContactName
|
||||
| SwitchContact ContactName
|
||||
| SwitchGroupMember GroupName ContactName
|
||||
| GetContactCode ContactName
|
||||
| GetGroupMemberCode GroupName ContactName
|
||||
| VerifyContact ContactName (Maybe Text)
|
||||
| VerifyGroupMember GroupName ContactName (Maybe Text)
|
||||
| ChatHelp HelpSection
|
||||
| Welcome
|
||||
| AddContact
|
||||
@@ -234,7 +242,9 @@ data ChatCommand
|
||||
| ClearGroup GroupName
|
||||
| ListMembers GroupName
|
||||
| ListGroups
|
||||
| UpdateGroupProfile GroupName GroupProfile
|
||||
| UpdateGroupNames GroupName GroupProfile
|
||||
| ShowGroupProfile GroupName
|
||||
| UpdateGroupDescription GroupName (Maybe Text)
|
||||
| CreateGroupLink GroupName
|
||||
| DeleteGroupLink GroupName
|
||||
| ShowGroupLink GroupName
|
||||
@@ -276,6 +286,9 @@ data ChatResponse
|
||||
| CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats}
|
||||
| CRContactSwitch {contact :: Contact, switchProgress :: SwitchProgress}
|
||||
| CRGroupMemberSwitch {groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress}
|
||||
| CRContactCode {contact :: Contact, connectionCode :: Text}
|
||||
| CRGroupMemberCode {groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text}
|
||||
| CRConnectionVerified {verified :: Bool, expectedCode :: Text}
|
||||
| CRNewChatItem {chatItem :: AChatItem}
|
||||
| CRChatItemStatusUpdated {chatItem :: AChatItem}
|
||||
| CRChatItemUpdated {chatItem :: AChatItem}
|
||||
@@ -357,6 +370,7 @@ data ChatResponse
|
||||
| CRGroupRemoved {groupInfo :: GroupInfo}
|
||||
| CRGroupDeleted {groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRGroupUpdated {fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
|
||||
| CRGroupProfile {groupInfo :: GroupInfo}
|
||||
| CRGroupLinkCreated {groupInfo :: GroupInfo, connReqContact :: ConnReqContact}
|
||||
| CRGroupLink {groupInfo :: GroupInfo, connReqContact :: ConnReqContact}
|
||||
| CRGroupLinkDeleted {groupInfo :: GroupInfo}
|
||||
|
||||
@@ -123,9 +123,12 @@ groupsHelpInfo =
|
||||
indent <> highlight "/leave <group> " <> " - leave group",
|
||||
indent <> highlight "/delete <group> " <> " - delete group",
|
||||
indent <> highlight "/members <group> " <> " - list group members",
|
||||
indent <> highlight "/gp <group> " <> " - view group profile",
|
||||
indent <> highlight "/gp <group> <new_name> [<full_name>] " <> " - update group profile",
|
||||
indent <> highlight "/group_descr <group> [<descr>] " <> " - update/remove group description",
|
||||
indent <> highlight "/groups " <> " - list groups",
|
||||
indent <> highlight "#<group> <message> " <> " - send message to group",
|
||||
indent <> highlight "/create link #<group> " <> " - create public group link",
|
||||
"",
|
||||
"The commands may be abbreviated: " <> listHighlight ["/g", "/a", "/j", "/rm", "/l", "/d", "/ms", "/gs"]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20221209_verified_connection where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20221209_verified_connection :: Query
|
||||
m20221209_verified_connection =
|
||||
[sql|
|
||||
ALTER TABLE connections ADD COLUMN security_code TEXT NULL;
|
||||
ALTER TABLE connections ADD COLUMN security_code_verified_at TEXT NULL;
|
||||
|]
|
||||
@@ -0,0 +1,16 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20221210_idxs where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20221210_idxs :: Query
|
||||
m20221210_idxs =
|
||||
[sql|
|
||||
CREATE INDEX idx_messages_connection_id ON messages(connection_id);
|
||||
|
||||
CREATE INDEX idx_chat_items_group_member_id ON chat_items(group_member_id);
|
||||
|
||||
CREATE INDEX idx_chat_items_contact_id ON chat_items(contact_id);
|
||||
|]
|
||||
@@ -0,0 +1,12 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20221211_group_description where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20221211_group_description :: Query
|
||||
m20221211_group_description =
|
||||
[sql|
|
||||
ALTER TABLE group_profiles ADD COLUMN description TEXT NULL;
|
||||
|]
|
||||
@@ -116,7 +116,8 @@ CREATE TABLE group_profiles(
|
||||
updated_at TEXT CHECK(updated_at NOT NULL),
|
||||
image TEXT,
|
||||
user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE,
|
||||
preferences TEXT
|
||||
preferences TEXT,
|
||||
description TEXT NULL
|
||||
);
|
||||
CREATE TABLE groups(
|
||||
group_id INTEGER PRIMARY KEY, -- local group ID
|
||||
@@ -258,6 +259,8 @@ CREATE TABLE connections(
|
||||
local_alias DEFAULT '' CHECK(local_alias NOT NULL),
|
||||
via_group_link INTEGER DEFAULT 0 CHECK(via_group_link NOT NULL),
|
||||
group_link_id BLOB,
|
||||
security_code TEXT NULL,
|
||||
security_code_verified_at TEXT NULL,
|
||||
FOREIGN KEY(snd_file_id, connection_id)
|
||||
REFERENCES snd_files(file_id, connection_id)
|
||||
ON DELETE CASCADE
|
||||
@@ -450,3 +453,6 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON user_contact_links(
|
||||
CREATE UNIQUE INDEX idx_snd_files_last_inline_msg_delivery_id ON snd_files(
|
||||
last_inline_msg_delivery_id
|
||||
);
|
||||
CREATE INDEX idx_messages_connection_id ON messages(connection_id);
|
||||
CREATE INDEX idx_chat_items_group_member_id ON chat_items(group_member_id);
|
||||
CREATE INDEX idx_chat_items_contact_id ON chat_items(contact_id);
|
||||
|
||||
@@ -35,6 +35,7 @@ module Simplex.Chat.Store
|
||||
createDirectContact,
|
||||
deleteContactConnectionsAndFiles,
|
||||
deleteContact,
|
||||
deleteContactWithoutGroups,
|
||||
getContactByName,
|
||||
getContact,
|
||||
getContactIdByName,
|
||||
@@ -46,7 +47,9 @@ module Simplex.Chat.Store
|
||||
updateContactUsed,
|
||||
updateContactUnreadChat,
|
||||
updateGroupUnreadChat,
|
||||
setConnectionVerified,
|
||||
getUserContacts,
|
||||
getUserContactProfiles,
|
||||
createUserContactLink,
|
||||
getUserAddressConnections,
|
||||
getUserContactLinks,
|
||||
@@ -92,6 +95,7 @@ module Simplex.Chat.Store
|
||||
getUserGroups,
|
||||
getUserGroupDetails,
|
||||
getContactGroupPreferences,
|
||||
checkContactHasGroups,
|
||||
getGroupInvitation,
|
||||
createNewContactMember,
|
||||
createNewContactMemberAsync,
|
||||
@@ -301,6 +305,9 @@ import Simplex.Chat.Migrations.M20221112_server_password
|
||||
import Simplex.Chat.Migrations.M20221115_server_cfg
|
||||
import Simplex.Chat.Migrations.M20221129_delete_group_feature_items
|
||||
import Simplex.Chat.Migrations.M20221130_delete_item_deleted
|
||||
import Simplex.Chat.Migrations.M20221209_verified_connection
|
||||
import Simplex.Chat.Migrations.M20221210_idxs
|
||||
import Simplex.Chat.Migrations.M20221211_group_description
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..))
|
||||
@@ -351,7 +358,10 @@ schemaMigrations =
|
||||
("20221112_server_password", m20221112_server_password),
|
||||
("20221115_server_cfg", m20221115_server_cfg),
|
||||
("20221129_delete_group_feature_items", m20221129_delete_group_feature_items),
|
||||
("20221130_delete_item_deleted", m20221130_delete_item_deleted)
|
||||
("20221130_delete_item_deleted", m20221130_delete_item_deleted),
|
||||
("20221209_verified_connection", m20221209_verified_connection),
|
||||
("20221210_idxs", m20221210_idxs),
|
||||
("20221211_group_description", m20221211_group_description)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
@@ -462,7 +472,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.contact_used, ct.enable_ntfs, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id
|
||||
JOIN connections c ON c.contact_id = ct.contact_id
|
||||
@@ -537,7 +547,7 @@ createConnection_ db userId connType entityId acId viaContact viaUserContactLink
|
||||
:. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs)
|
||||
)
|
||||
connId <- insertedRowId db
|
||||
pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs}
|
||||
pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing}
|
||||
where
|
||||
ent ct = if connType == ct then entityId else Nothing
|
||||
|
||||
@@ -595,6 +605,15 @@ deleteContact db user@User {userId} Contact {contactId, localDisplayName, active
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId
|
||||
|
||||
-- should only be used if contact is not member of any groups
|
||||
deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO ()
|
||||
deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn = Connection {customUserProfileId}} = do
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
deleteContactProfile_ db userId contactId
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
|
||||
forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId
|
||||
|
||||
deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO ()
|
||||
deleteUnusedIncognitoProfileById_ db User {userId} profile_id =
|
||||
DB.executeNamed
|
||||
@@ -710,6 +729,11 @@ updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do
|
||||
updatedAt <- getCurrentTime
|
||||
DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (unreadChat, updatedAt, userId, groupId)
|
||||
|
||||
setConnectionVerified :: DB.Connection -> User -> Int64 -> Maybe Text -> IO ()
|
||||
setConnectionVerified db User {userId} connId code = do
|
||||
updatedAt <- getCurrentTime
|
||||
DB.execute db "UPDATE connections SET security_code = ?, security_code_verified_at = ?, updated_at = ? WHERE user_id = ? AND connection_id = ?" (code, code $> updatedAt, updatedAt, userId, connId)
|
||||
|
||||
updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO ()
|
||||
updateContactProfile_ db userId profileId profile = do
|
||||
currentTs <- getCurrentTime
|
||||
@@ -770,6 +794,22 @@ getUserContacts db user@User {userId} = do
|
||||
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId)
|
||||
rights <$> mapM (runExceptT . getContact db user) contactIds
|
||||
|
||||
-- only used in tests
|
||||
getUserContactProfiles :: DB.Connection -> User -> IO [Profile]
|
||||
getUserContactProfiles db User {userId} =
|
||||
map toContactProfile
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT display_name, full_name, image, preferences
|
||||
FROM contact_profiles
|
||||
WHERE user_id = ?
|
||||
|]
|
||||
(Only userId)
|
||||
where
|
||||
toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe Preferences) -> (Profile)
|
||||
toContactProfile (displayName, fullName, image, preferences) = Profile {displayName, fullName, image, preferences}
|
||||
|
||||
createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO ()
|
||||
createUserContactLink db userId agentConnId cReq =
|
||||
checkConstraint SEDuplicateContactLink . liftIO $ do
|
||||
@@ -793,7 +833,7 @@ getUserAddressConnections db User {userId} = do
|
||||
db
|
||||
[sql|
|
||||
SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM connections c
|
||||
JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id
|
||||
WHERE c.user_id = ? AND uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL
|
||||
@@ -807,7 +847,7 @@ getUserContactLinks db User {userId} =
|
||||
db
|
||||
[sql|
|
||||
SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at,
|
||||
uc.user_contact_link_id, uc.conn_req_contact, uc.group_id
|
||||
FROM connections c
|
||||
JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id
|
||||
@@ -940,7 +980,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} =
|
||||
db
|
||||
[sql|
|
||||
SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM connections c
|
||||
JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id
|
||||
WHERE c.user_id = ? AND uc.user_id = ? AND uc.group_id = ?
|
||||
@@ -1043,7 +1083,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.contact_used, ct.enable_ntfs, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id
|
||||
LEFT JOIN connections c ON c.contact_id = ct.contact_id
|
||||
@@ -1238,7 +1278,7 @@ getContactConnections db userId Contact {contactId} =
|
||||
db
|
||||
[sql|
|
||||
SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM connections c
|
||||
JOIN contacts ct ON ct.contact_id = c.contact_id
|
||||
WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ?
|
||||
@@ -1249,14 +1289,15 @@ getContactConnections db userId Contact {contactId} =
|
||||
|
||||
type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64)
|
||||
|
||||
type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. Only UTCTime
|
||||
type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime)
|
||||
|
||||
type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. Only (Maybe UTCTime)
|
||||
type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime)
|
||||
|
||||
toConnection :: ConnectionRow -> Connection
|
||||
toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) =
|
||||
toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_)) =
|
||||
let entityId = entityId_ connType
|
||||
in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, createdAt}
|
||||
connectionCode = SecurityCode <$> code_ <*> verifiedAt_
|
||||
in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, createdAt}
|
||||
where
|
||||
entityId_ :: ConnType -> Maybe Int64
|
||||
entityId_ ConnContact = contactId
|
||||
@@ -1266,8 +1307,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup
|
||||
entityId_ ConnUserContact = userContactLinkId
|
||||
|
||||
toMaybeConnection :: MaybeConnectionRow -> Maybe Connection
|
||||
toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only (Just createdAt)) =
|
||||
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt)
|
||||
toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_)) =
|
||||
Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_))
|
||||
toMaybeConnection _ = Nothing
|
||||
|
||||
getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact]
|
||||
@@ -1438,7 +1479,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|
||||
db
|
||||
[sql|
|
||||
SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id,
|
||||
conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at
|
||||
conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at
|
||||
FROM connections
|
||||
WHERE user_id = ? AND agent_conn_id = ?
|
||||
|]
|
||||
@@ -1470,7 +1511,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
-- GroupInfo {membership}
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -1536,7 +1577,7 @@ getConnectionById db User {userId} connId = ExceptT $ do
|
||||
db
|
||||
[sql|
|
||||
SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id,
|
||||
conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at
|
||||
conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at
|
||||
FROM connections
|
||||
WHERE user_id = ? AND connection_id = ?
|
||||
|]
|
||||
@@ -1571,7 +1612,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId =
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
-- GroupInfo {membership}
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -1581,7 +1622,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId =
|
||||
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
|
||||
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, p.preferences,
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM group_members m
|
||||
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
JOIN groups g ON g.group_id = m.group_id
|
||||
@@ -1611,15 +1652,15 @@ updateConnectionStatus db Connection {connId} connStatus = do
|
||||
-- | creates completely new group with a single member - the current user
|
||||
createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||
createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do
|
||||
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
||||
let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
currentTs <- getCurrentTime
|
||||
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
|
||||
groupId <- liftIO $ do
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
|
||||
(displayName, fullName, image, userId, groupPreferences, currentTs, currentTs)
|
||||
"INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
|
||||
(displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs)
|
||||
profileId <- insertedRowId db
|
||||
DB.execute
|
||||
db
|
||||
@@ -1655,7 +1696,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
|
||||
DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId)
|
||||
createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||
createGroupInvitation_ = do
|
||||
let GroupProfile {displayName, fullName, image, groupPreferences} = groupProfile
|
||||
let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
ExceptT $
|
||||
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
|
||||
@@ -1663,8 +1704,8 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo
|
||||
groupId <- liftIO $ do
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
|
||||
(displayName, fullName, image, userId, groupPreferences, currentTs, currentTs)
|
||||
"INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
|
||||
(displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs)
|
||||
profileId <- insertedRowId db
|
||||
DB.execute
|
||||
db
|
||||
@@ -1759,7 +1800,9 @@ deleteGroupItemsAndMembers db user@User {userId} GroupInfo {groupId} members = d
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId)
|
||||
void $ runExceptT cleanupHostGroupLinkConn_ -- to allow repeat connection via the same group link if one was used
|
||||
DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_id = ?" (userId, groupId)
|
||||
forM_ members $ \m -> cleanupMemberContactAndProfile_ db user m
|
||||
forM_ members $ \m@GroupMember {memberProfile = LocalProfile {profileId}} -> do
|
||||
cleanupMemberProfileAndName_ db user m
|
||||
when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user profileId
|
||||
where
|
||||
cleanupHostGroupLinkConn_ = do
|
||||
hostId <- getHostMemberId_ db user groupId
|
||||
@@ -1777,10 +1820,11 @@ deleteGroupItemsAndMembers db user@User {userId} GroupInfo {groupId} members = d
|
||||
(userId, userId, hostId)
|
||||
|
||||
deleteGroup :: DB.Connection -> User -> GroupInfo -> IO ()
|
||||
deleteGroup db User {userId} GroupInfo {groupId, localDisplayName} = do
|
||||
deleteGroup db user@User {userId} GroupInfo {groupId, localDisplayName, membership = membership@GroupMember {memberProfile = LocalProfile {profileId}}} = do
|
||||
deleteGroupProfile_ db userId groupId
|
||||
DB.execute db "DELETE FROM groups WHERE user_id = ? AND group_id = ?" (userId, groupId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
when (memberIncognito membership) $ deleteUnusedIncognitoProfileById_ db user profileId
|
||||
|
||||
deleteGroupProfile_ :: DB.Connection -> UserId -> GroupId -> IO ()
|
||||
deleteGroupProfile_ db userId groupId =
|
||||
@@ -1807,7 +1851,7 @@ getUserGroupDetails db User {userId, userContactId} =
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status,
|
||||
mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.local_alias, pu.preferences
|
||||
FROM groups g
|
||||
@@ -1832,19 +1876,24 @@ getContactGroupPreferences db User {userId} Contact {contactId} = do
|
||||
|]
|
||||
(userId, contactId)
|
||||
|
||||
checkContactHasGroups :: DB.Connection -> User -> Contact -> IO (Maybe GroupId)
|
||||
checkContactHasGroups db User {userId} Contact {contactId} =
|
||||
maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId)
|
||||
|
||||
getGroupInfoByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupInfo
|
||||
getGroupInfoByName db user gName = do
|
||||
gId <- getGroupIdByName db user gName
|
||||
getGroupInfo db user gId
|
||||
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe GroupPreferences, UTCTime, UTCTime) :. GroupMemberRow
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe GroupPreferences, UTCTime, UTCTime) :. GroupMemberRow
|
||||
|
||||
toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
|
||||
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
|
||||
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, groupPreferences, createdAt, updatedAt) :. userMemberRow) =
|
||||
let membership = toGroupMember userContactId userMemberRow
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_}
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image, groupPreferences}, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
|
||||
groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences}
|
||||
in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt}
|
||||
|
||||
getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember
|
||||
getGroupMember db user@User {userId} groupId groupMemberId =
|
||||
@@ -1856,7 +1905,7 @@ getGroupMember db user@User {userId} groupId groupMemberId =
|
||||
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
|
||||
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, p.preferences,
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM group_members m
|
||||
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
LEFT JOIN connections c ON c.connection_id = (
|
||||
@@ -1878,7 +1927,7 @@ getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do
|
||||
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
|
||||
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, p.preferences,
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM group_members m
|
||||
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
LEFT JOIN connections c ON c.connection_id = (
|
||||
@@ -1900,7 +1949,7 @@ getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {gro
|
||||
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
|
||||
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias,
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM group_members m
|
||||
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
LEFT JOIN connections c ON c.connection_id = (
|
||||
@@ -2025,7 +2074,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.contact_used, ct.enable_ntfs, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id
|
||||
JOIN connections c ON c.connection_id = (
|
||||
@@ -2130,27 +2179,22 @@ checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} =
|
||||
maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? LIMIT 1" (userId, groupId, groupMemberId)
|
||||
|
||||
deleteGroupMember :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId, groupId} = do
|
||||
deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId, groupId, memberProfile = LocalProfile {profileId}} = do
|
||||
deleteGroupMemberConnection db user m
|
||||
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ?" (userId, groupId, groupMemberId)
|
||||
DB.execute db "DELETE FROM group_members WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId)
|
||||
cleanupMemberContactAndProfile_ db user m
|
||||
cleanupMemberProfileAndName_ db user m
|
||||
when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user profileId
|
||||
|
||||
-- it's important this function is used in transaction after the actual group_members record is deleted, see checkIncognitoProfileInUse_
|
||||
cleanupMemberContactAndProfile_ :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
cleanupMemberContactAndProfile_ db user@User {userId} m@GroupMember {groupMemberId, localDisplayName, memberContactId, memberContactProfileId, memberProfile = LocalProfile {profileId}} =
|
||||
case memberContactId of
|
||||
Just contactId ->
|
||||
runExceptT (getContact db user contactId) >>= \case
|
||||
Right ct@Contact {activeConn = Connection {connLevel, viaGroupLink}, contactUsed} ->
|
||||
unless ((connLevel == 0 && not viaGroupLink) || contactUsed) $ deleteContact db user ct
|
||||
_ -> pure ()
|
||||
Nothing -> do
|
||||
sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId)
|
||||
unless (isJust sameProfileMember) $ do
|
||||
DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
when (memberIncognito m) $ deleteUnusedIncognitoProfileById_ db user profileId
|
||||
cleanupMemberProfileAndName_ :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
cleanupMemberProfileAndName_ db User {userId} GroupMember {groupMemberId, memberContactId, memberContactProfileId, localDisplayName} =
|
||||
-- check record has no memberContactId (contact_id) - it means contact has been deleted and doesn't use profile & ldn
|
||||
when (isNothing memberContactId) $ do
|
||||
-- check other group member records don't use profile & ldn
|
||||
sameProfileMember :: (Maybe GroupMemberId) <- maybeFirstRow fromOnly $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1" (userId, memberContactProfileId, groupMemberId)
|
||||
when (isNothing sameProfileMember) $ do
|
||||
DB.execute db "DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ?" (userId, memberContactProfileId)
|
||||
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
|
||||
|
||||
deleteGroupMemberConnection :: DB.Connection -> User -> GroupMember -> IO ()
|
||||
deleteGroupMemberConnection db User {userId} GroupMember {groupMemberId} =
|
||||
@@ -2325,7 +2369,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
-- GroupInfo {membership}
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -2335,7 +2379,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} =
|
||||
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
|
||||
m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, p.preferences,
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM group_members m
|
||||
JOIN contacts ct ON ct.contact_id = m.contact_id
|
||||
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
@@ -2367,7 +2411,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
|
||||
SELECT
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at,
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
|
||||
JOIN connections c ON c.connection_id = (
|
||||
@@ -3246,7 +3290,7 @@ getDirectChatPreviews_ db user@User {userId} = do
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.contact_used, ct.enable_ntfs, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at,
|
||||
-- ChatStats
|
||||
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat,
|
||||
-- ChatItem
|
||||
@@ -3307,7 +3351,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
-- GroupMember - membership
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -3568,7 +3612,7 @@ getContact db user@User {userId} contactId =
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.contact_used, ct.enable_ntfs, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id
|
||||
LEFT JOIN connections c ON c.contact_id = ct.contact_id
|
||||
@@ -3673,7 +3717,7 @@ getGroupInfo db User {userId, userContactId} groupId =
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, gp.preferences, g.created_at, g.updated_at,
|
||||
-- GroupMember - membership
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -3687,7 +3731,7 @@ getGroupInfo db User {userId, userContactId} groupId =
|
||||
(groupId, userId, userContactId)
|
||||
|
||||
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
|
||||
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, image, groupPreferences}
|
||||
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
|
||||
| displayName == newName = liftIO $ do
|
||||
currentTs <- getCurrentTime
|
||||
updateGroupProfile_ currentTs $> (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
|
||||
@@ -3704,14 +3748,14 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou
|
||||
db
|
||||
[sql|
|
||||
UPDATE group_profiles
|
||||
SET display_name = ?, full_name = ?, image = ?, preferences = ?, updated_at = ?
|
||||
SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, updated_at = ?
|
||||
WHERE group_profile_id IN (
|
||||
SELECT group_profile_id
|
||||
FROM groups
|
||||
WHERE user_id = ? AND group_id = ?
|
||||
)
|
||||
|]
|
||||
(newName, fullName, image, groupPreferences, currentTs, userId, groupId)
|
||||
(newName, fullName, description, image, groupPreferences, currentTs, userId, groupId)
|
||||
updateGroup_ ldn currentTs = do
|
||||
DB.execute
|
||||
db
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
module Simplex.Chat.Types where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Crypto.Number.Serialize (os2ip)
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.Encoding as JE
|
||||
@@ -126,6 +127,9 @@ directContact Contact {contactUsed, activeConn = Connection {connLevel, viaGroup
|
||||
anyDirectContact :: Contact -> Bool
|
||||
anyDirectContact Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed
|
||||
|
||||
contactSecurityCode :: Contact -> Maybe SecurityCode
|
||||
contactSecurityCode Contact {activeConn} = connectionCode activeConn
|
||||
|
||||
data ContactRef = ContactRef
|
||||
{ contactId :: ContactId,
|
||||
localDisplayName :: ContactName
|
||||
@@ -720,6 +724,7 @@ fromLocalProfile LocalProfile {displayName, fullName, image, preferences} =
|
||||
data GroupProfile = GroupProfile
|
||||
{ displayName :: GroupName,
|
||||
fullName :: Text,
|
||||
description :: Maybe Text,
|
||||
image :: Maybe ImageData,
|
||||
groupPreferences :: Maybe GroupPreferences
|
||||
}
|
||||
@@ -866,6 +871,9 @@ groupMemberId' GroupMember {groupMemberId} = groupMemberId
|
||||
memberIncognito :: GroupMember -> Bool
|
||||
memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId
|
||||
|
||||
memberSecurityCode :: GroupMember -> Maybe SecurityCode
|
||||
memberSecurityCode GroupMember {activeConn} = connectionCode =<< activeConn
|
||||
|
||||
data NewGroupMember = NewGroupMember
|
||||
{ memInfo :: MemberInfo,
|
||||
memCategory :: GroupMemberCategory,
|
||||
@@ -1302,10 +1310,29 @@ data Connection = Connection
|
||||
connStatus :: ConnStatus,
|
||||
localAlias :: Text,
|
||||
entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID
|
||||
connectionCode :: Maybe SecurityCode,
|
||||
createdAt :: UTCTime
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
data SecurityCode = SecurityCode {securityCode :: Text, verifiedAt :: UTCTime}
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON SecurityCode where
|
||||
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
verificationCode :: ByteString -> Text
|
||||
verificationCode = T.pack . unwords . chunks 5 . show . os2ip
|
||||
where
|
||||
chunks _ [] = []
|
||||
chunks n xs = let (h, t) = splitAt n xs in h : chunks n t
|
||||
|
||||
sameVerificationCode :: Text -> Text -> Bool
|
||||
sameVerificationCode c1 c2 = noSpaces c1 == noSpaces c2
|
||||
where
|
||||
noSpaces = T.filter (/= ' ')
|
||||
|
||||
aConnId :: Connection -> ConnId
|
||||
aConnId Connection {agentConnId = AgentConnId cId} = cId
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ responseToView user_ testView ts = \case
|
||||
CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats
|
||||
CRContactSwitch ct progress -> viewContactSwitch ct progress
|
||||
CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress
|
||||
CRConnectionVerified verified code -> [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code]
|
||||
CRContactCode ct code -> viewContactCode ct code testView
|
||||
CRGroupMemberCode g m code -> viewGroupMemberCode g m code testView
|
||||
CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts
|
||||
CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
|
||||
CRChatItemStatusUpdated _ -> []
|
||||
@@ -181,6 +184,7 @@ responseToView user_ testView ts = \case
|
||||
CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"]
|
||||
CRGroupDeleted g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"]
|
||||
CRGroupUpdated g g' m -> viewGroupUpdated g g' m
|
||||
CRGroupProfile g -> viewGroupProfile g
|
||||
CRGroupLinkCreated g cReq -> groupLink_ "Group link is created!" g cReq
|
||||
CRGroupLink g cReq -> groupLink_ "Group link:" g cReq
|
||||
CRGroupLinkDeleted g -> viewGroupLinkDeleted g
|
||||
@@ -485,9 +489,9 @@ viewReceivedContactRequest c Profile {fullName} =
|
||||
]
|
||||
|
||||
viewGroupCreated :: GroupInfo -> [StyledString]
|
||||
viewGroupCreated g@GroupInfo {localDisplayName} =
|
||||
viewGroupCreated g@GroupInfo {localDisplayName = n} =
|
||||
[ "group " <> ttyFullGroup g <> " is created",
|
||||
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
|
||||
"to add members use " <> highlight ("/a " <> n <> " <name>") <> " or " <> highlight ("/create link #" <> n)
|
||||
]
|
||||
|
||||
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
|
||||
@@ -497,8 +501,8 @@ viewCannotResendInvitation GroupInfo {localDisplayName = gn} c =
|
||||
]
|
||||
|
||||
viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString]
|
||||
viewDirectMessagesProhibited MDSnd c = [ "direct messages to indirect contact " <> ttyContact' c <> " are prohibited"]
|
||||
viewDirectMessagesProhibited MDRcv c = [ "received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"]
|
||||
viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"]
|
||||
viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"]
|
||||
|
||||
viewUserJoinedGroup :: GroupInfo -> [StyledString]
|
||||
viewUserJoinedGroup g@GroupInfo {membership = membership@GroupMember {memberProfile}} =
|
||||
@@ -578,7 +582,7 @@ viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView
|
||||
where
|
||||
message =
|
||||
[ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile,
|
||||
"use " <> highlight ("/info " <> localDisplayName) <> " to print out this incognito profile again"
|
||||
"use " <> highlight ("/i " <> localDisplayName) <> " to print out this incognito profile again"
|
||||
]
|
||||
Nothing ->
|
||||
[ttyFullContact ct <> ": contact is connected"]
|
||||
@@ -681,21 +685,27 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
|
||||
]
|
||||
|
||||
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
|
||||
viewContactInfo Contact {contactId, profile = LocalProfile {localAlias}} stats incognitoProfile =
|
||||
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias}} stats incognitoProfile =
|
||||
["contact ID: " <> sShow contactId] <> viewConnectionStats stats
|
||||
<> maybe
|
||||
["you've shared main profile with this contact"]
|
||||
(\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p])
|
||||
incognitoProfile
|
||||
<> if localAlias /= "" then ["alias: " <> plain localAlias] else ["alias not set"]
|
||||
<> ["alias: " <> plain localAlias | localAlias /= ""]
|
||||
<> [viewConnectionVerified (contactSecurityCode ct)]
|
||||
|
||||
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString]
|
||||
viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats =
|
||||
viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats =
|
||||
[ "group ID: " <> sShow groupId,
|
||||
"member ID: " <> sShow groupMemberId
|
||||
]
|
||||
<> maybe ["member not connected"] viewConnectionStats stats
|
||||
<> if localAlias /= "" then ["alias: " <> plain localAlias] else ["no alias for contact"]
|
||||
<> ["alias: " <> plain localAlias | localAlias /= ""]
|
||||
<> [viewConnectionVerified (memberSecurityCode m) | isJust stats]
|
||||
|
||||
viewConnectionVerified :: Maybe SecurityCode -> StyledString
|
||||
viewConnectionVerified (Just _) = "connection verified" -- TODO show verification time?
|
||||
viewConnectionVerified _ = "connection not verified, use " <> highlight' "/code" <> " command to see security code"
|
||||
|
||||
viewConnectionStats :: ConnectionStats -> [StyledString]
|
||||
viewConnectionStats ConnectionStats {rcvServers, sndServers} =
|
||||
@@ -720,6 +730,17 @@ viewGroupMemberSwitch g m (SwitchProgress qd phase _) = case qd of
|
||||
QDRcv -> [ttyGroup' g <> ": you " <> viewSwitchPhase phase <> " for " <> ttyMember m]
|
||||
QDSnd -> [ttyGroup' g <> ": " <> ttyMember m <> " " <> viewSwitchPhase phase <> " for you"]
|
||||
|
||||
viewContactCode :: Contact -> Text -> Bool -> [StyledString]
|
||||
viewContactCode ct@Contact {localDisplayName = c} = viewSecurityCode (ttyContact' ct) ("/verify " <> c <> " <code from your contact>")
|
||||
|
||||
viewGroupMemberCode :: GroupInfo -> GroupMember -> Text -> Bool -> [StyledString]
|
||||
viewGroupMemberCode g m@GroupMember {localDisplayName = n} = viewSecurityCode (ttyGroup' g <> " " <> ttyMember m) ("/verify #" <> groupName' g <> " " <> n <> " <code from your contact>")
|
||||
|
||||
viewSecurityCode :: StyledString -> Text -> Text -> Bool -> [StyledString]
|
||||
viewSecurityCode name cmd code testView
|
||||
| testView = [plain code]
|
||||
| otherwise = [name <> " security code:", plain code, "pass this code to your contact and use " <> highlight cmd <> " to verify"]
|
||||
|
||||
viewSwitchPhase :: SwitchPhase -> StyledString
|
||||
viewSwitchPhase SPCompleted = "changed address"
|
||||
viewSwitchPhase phase = plain (strEncode phase) <> " changing address"
|
||||
@@ -789,8 +810,8 @@ viewCountactUserPref = \case
|
||||
|
||||
viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString]
|
||||
viewGroupUpdated
|
||||
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, image, groupPreferences = gps}}
|
||||
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', image = image', groupPreferences = gps'}}
|
||||
GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, description, image, groupPreferences = gps}}
|
||||
g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', description = description', image = image', groupPreferences = gps'}}
|
||||
m = do
|
||||
let update = groupProfileUpdated <> groupPrefsUpdated
|
||||
if null update
|
||||
@@ -798,21 +819,35 @@ viewGroupUpdated
|
||||
else memberUpdated <> update
|
||||
where
|
||||
memberUpdated = maybe [] (\m' -> [ttyMember m' <> " updated group " <> ttyGroup n <> ":"]) m
|
||||
groupProfileUpdated
|
||||
| n == n' && fullName == fullName' && image == image' = []
|
||||
| n == n' && fullName == fullName' = ["profile image " <> (if isNothing image' then "removed" else "updated")]
|
||||
| n == n' = ["full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName']
|
||||
| otherwise = ["changed to " <> ttyFullGroup g']
|
||||
groupProfileUpdated =
|
||||
["changed to " <> ttyFullGroup g' | n /= n']
|
||||
<> ["full name " <> if T.null fullName' || fullName' == n' then "removed" else "changed to: " <> plain fullName' | n == n' && fullName /= fullName']
|
||||
<> ["profile image " <> maybe "removed" (const "updated") image' | image /= image']
|
||||
<> (if description == description' then [] else maybe ["description removed"] ((bold' "description changed to:" :) . map plain . T.lines) description')
|
||||
groupPrefsUpdated
|
||||
| null prefs = []
|
||||
| otherwise = "updated group preferences:" : prefs
|
||||
| otherwise = bold' "updated group preferences:" : prefs
|
||||
where
|
||||
prefs = mapMaybe viewPref allGroupFeatures
|
||||
viewPref pt
|
||||
| pref gps == pref gps' = Nothing
|
||||
| otherwise = Just $ plain (groupFeatureToText pt) <> " enabled: " <> plain (groupPrefToText $ pref gps')
|
||||
where
|
||||
pref pss = getGroupPreference pt $ mergeGroupPreferences pss
|
||||
pref = getGroupPreference pt . mergeGroupPreferences
|
||||
|
||||
viewGroupProfile :: GroupInfo -> [StyledString]
|
||||
viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {description, image, groupPreferences = gps}} =
|
||||
[ttyFullGroup g]
|
||||
<> maybe [] (const ["has profile image"]) image
|
||||
<> maybe [] ((bold' "description:" :) . map plain . T.lines) description
|
||||
<> (bold' "group preferences:" : map viewPref allGroupFeatures)
|
||||
where
|
||||
viewPref pt = plain (groupFeatureToText pt) <> " enabled: " <> plain (groupPrefToText $ pref gps)
|
||||
where
|
||||
pref = getGroupPreference pt . mergeGroupPreferences
|
||||
|
||||
bold' :: String -> StyledString
|
||||
bold' = styled Bold
|
||||
|
||||
viewContactAliasUpdated :: Contact -> [StyledString]
|
||||
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}}
|
||||
|
||||
@@ -49,7 +49,7 @@ extra-deps:
|
||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||
# - ../simplexmq
|
||||
- github: simplex-chat/simplexmq
|
||||
commit: e4842f4f47fb60ef8843dbce6fd43dec96f157d2
|
||||
commit: fb21d9836e07706c7498baa967f932cb11b818e5
|
||||
# - ../direct-sqlcipher
|
||||
- github: simplex-chat/direct-sqlcipher
|
||||
commit: 34309410eb2069b029b8fc1872deb1e0db123294
|
||||
|
||||
@@ -215,7 +215,7 @@ getTermLine :: TestCC -> IO String
|
||||
getTermLine cc =
|
||||
5000000 `timeout` atomically (readTQueue $ termQ cc) >>= \case
|
||||
Just s -> do
|
||||
-- uncomment code below to echo virtual terminal
|
||||
-- uncomment 2 lines below to echo virtual terminal
|
||||
-- name <- userName cc
|
||||
-- putStrLn $ name <> ": " <> s
|
||||
pure s
|
||||
|
||||
@@ -24,7 +24,10 @@ import qualified Data.Text as T
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig)
|
||||
import Simplex.Chat.Options (ChatOpts (..))
|
||||
import Simplex.Chat.Store (getUserContactProfiles)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Store.SQLite (withTransaction)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Util (unlessM)
|
||||
import System.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
|
||||
@@ -50,6 +53,7 @@ chatTests :: Spec
|
||||
chatTests = do
|
||||
describe "direct messages" $ do
|
||||
describe "add contact and send/receive message" testAddContact
|
||||
it "deleting contact deletes profile" testDeleteContactDeletesProfile
|
||||
it "direct message quoted replies" testDirectMessageQuotedReply
|
||||
it "direct message update" testDirectMessageUpdate
|
||||
it "direct message delete" testDirectMessageDelete
|
||||
@@ -71,6 +75,8 @@ chatTests = do
|
||||
it "group message delete" testGroupMessageDelete
|
||||
it "update group profile" testUpdateGroupProfile
|
||||
it "update member role" testUpdateMemberRole
|
||||
it "unused contacts are deleted after all their groups are deleted" testGroupDeleteUnusedContacts
|
||||
it "group description is shown as the first message to new members" testGroupDescription
|
||||
describe "async group connections" $ do
|
||||
xit "create and join group when clients go offline" testGroupAsync
|
||||
describe "user profiles" $ do
|
||||
@@ -116,6 +122,15 @@ chatTests = do
|
||||
it "join group incognito" testJoinGroupIncognito
|
||||
it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito
|
||||
it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito
|
||||
it "deleting contact first, group second deletes incognito profile" testDeleteContactThenGroupDeletesIncognitoProfile
|
||||
it "deleting group first, contact second deletes incognito profile" testDeleteGroupThenContactDeletesIncognitoProfile
|
||||
describe "group links" $ do
|
||||
it "create group link, join via group link" testGroupLink
|
||||
it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin
|
||||
it "sending message to contact created via group link marks it used" testGroupLinkContactUsed
|
||||
it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership
|
||||
it "unused host contact is deleted after all groups with it are deleted" testGroupLinkUnusedHostContactDeleted
|
||||
it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted
|
||||
describe "contact aliases" $ do
|
||||
it "set contact alias" testSetAlias
|
||||
it "set connection alias" testSetConnectionAlias
|
||||
@@ -152,14 +167,16 @@ chatTests = do
|
||||
it "mute/unmute group" testMuteGroup
|
||||
describe "chat item expiration" $ do
|
||||
it "set chat item TTL" testSetChatItemTTL
|
||||
describe "group links" $ do
|
||||
it "create group link, join via group link" testGroupLink
|
||||
it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin
|
||||
it "sending message to contact created via group link marks it used" testGroupLinkContactUsed
|
||||
it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership
|
||||
describe "queue rotation" $ do
|
||||
it "switch contact to a different queue" testSwitchContact
|
||||
it "switch group member to a different queue" testSwitchGroupMember
|
||||
describe "connection verification code" $ do
|
||||
it "verificationCode function converts ByteString to series of digits" $
|
||||
verificationCode (C.sha256Hash "abcd") `shouldBe` "61889 38426 63934 09576 96390 79389 84124 85253 63658 69469 70853 37788 95900 68296 20156 25"
|
||||
it "sameVerificationCode function should ignore spaces" $
|
||||
sameVerificationCode "123 456 789" "12345 6789" `shouldBe` True
|
||||
it "mark contact verified" testMarkContactVerified
|
||||
it "mark group member verified" testMarkGroupMemberVerified
|
||||
|
||||
versionTestMatrix2 :: (TestCC -> TestCC -> IO ()) -> Spec
|
||||
versionTestMatrix2 runTest = do
|
||||
@@ -260,7 +277,9 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
alice ##> "@bob_1 hey"
|
||||
alice <## "no contact bob_1"
|
||||
alice @@@ [("@bob", "how are you?")]
|
||||
alice `hasContactProfiles` ["alice", "bob"]
|
||||
bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")]
|
||||
bob `hasContactProfiles` ["alice", "alice", "bob"]
|
||||
-- test clearing chat
|
||||
alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY")
|
||||
alice #$> ("/_get chat @2 count=100", chat, [])
|
||||
@@ -292,6 +311,25 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
alice #$> ("/_read chat @2", id, "ok")
|
||||
bob #$> ("/_read chat @2", id, "ok")
|
||||
|
||||
testDeleteContactDeletesProfile :: IO ()
|
||||
testDeleteContactDeletesProfile =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
connectUsers alice bob
|
||||
alice <##> bob
|
||||
-- alice deletes contact, profile is deleted
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
alice ##> "/cs"
|
||||
(alice </)
|
||||
alice `hasContactProfiles` ["alice"]
|
||||
-- bob deletes contact, profile is deleted
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
testDirectMessageQuotedReply :: IO ()
|
||||
testDirectMessageQuotedReply =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
@@ -461,7 +499,7 @@ testGroupShared alice bob cath checkMessages = do
|
||||
connectUsers alice cath
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -596,7 +634,7 @@ testGroup2 =
|
||||
connectUsers alice dan
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "use /a club <name> to add members"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/a club bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #club sent to bob",
|
||||
@@ -814,10 +852,10 @@ testGroupSameName =
|
||||
\alice _ -> do
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team_1 (team) is created"
|
||||
alice <## "use /a team_1 <name> to add members"
|
||||
alice <## "to add members use /a team_1 <name> or /create link #team_1"
|
||||
|
||||
testGroupDeleteWhenInvited :: IO ()
|
||||
testGroupDeleteWhenInvited =
|
||||
@@ -826,7 +864,7 @@ testGroupDeleteWhenInvited =
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -853,7 +891,7 @@ testGroupReAddInvited =
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -888,7 +926,7 @@ testGroupReAddInvitedChangeRole =
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -928,7 +966,7 @@ testGroupDeleteInvitedContact =
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -959,7 +997,7 @@ testDeleteGroupMemberProfileKept =
|
||||
-- group 1
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -978,7 +1016,7 @@ testDeleteGroupMemberProfileKept =
|
||||
-- group 2
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "use /a club <name> to add members"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/a club bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #club sent to bob",
|
||||
@@ -1071,7 +1109,7 @@ testGroupList =
|
||||
createGroup2 "team" alice bob
|
||||
alice ##> "/g tennis"
|
||||
alice <## "group #tennis is created"
|
||||
alice <## "use /a tennis <name> to add members"
|
||||
alice <## "to add members use /a tennis <name> or /create link #tennis"
|
||||
alice ##> "/a tennis bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #tennis sent to bob",
|
||||
@@ -1340,7 +1378,7 @@ testUpdateMemberRole =
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
addMember "team" alice bob GRAdmin
|
||||
alice ##> "/mr team bob member"
|
||||
alice <## "#team: you changed the role of bob from admin to member"
|
||||
@@ -1379,6 +1417,154 @@ testUpdateMemberRole =
|
||||
alice ##> "/d #team"
|
||||
alice <## "you have insufficient permissions for this group command"
|
||||
|
||||
testGroupDeleteUnusedContacts :: IO ()
|
||||
testGroupDeleteUnusedContacts =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
-- create group 1
|
||||
createGroup3 "team" alice bob cath
|
||||
-- create group 2
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/a club bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #club sent to bob",
|
||||
do
|
||||
bob <## "#club: alice invites you to join the group as admin"
|
||||
bob <## "use /j club to accept"
|
||||
]
|
||||
bob ##> "/j club"
|
||||
concurrently_
|
||||
(alice <## "#club: bob joined the group")
|
||||
(bob <## "#club: you joined the group")
|
||||
alice ##> "/a club cath"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #club sent to cath",
|
||||
do
|
||||
cath <## "#club: alice invites you to join the group as admin"
|
||||
cath <## "use /j club to accept"
|
||||
]
|
||||
cath ##> "/j club"
|
||||
concurrentlyN_
|
||||
[ alice <## "#club: cath joined the group",
|
||||
do
|
||||
cath <## "#club: you joined the group"
|
||||
cath <## "#club: member bob_1 (Bob) is connected"
|
||||
cath <## "contact bob_1 is merged into bob"
|
||||
cath <## "use @bob <message> to send messages",
|
||||
do
|
||||
bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)"
|
||||
bob <## "#club: new member cath_1 is connected"
|
||||
bob <## "contact cath_1 is merged into cath"
|
||||
bob <## "use @cath <message> to send messages"
|
||||
]
|
||||
-- list contacts
|
||||
bob ##> "/cs"
|
||||
bob <## "alice (Alice)"
|
||||
bob <## "cath (Catherine)"
|
||||
cath ##> "/cs"
|
||||
cath <## "alice (Alice)"
|
||||
cath <## "bob (Bob)"
|
||||
-- delete group 1, contacts and profiles are kept
|
||||
deleteGroup alice bob cath "team"
|
||||
bob ##> "/cs"
|
||||
bob <## "alice (Alice)"
|
||||
bob <## "cath (Catherine)"
|
||||
bob `hasContactProfiles` ["alice", "bob", "cath"]
|
||||
cath ##> "/cs"
|
||||
cath <## "alice (Alice)"
|
||||
cath <## "bob (Bob)"
|
||||
cath `hasContactProfiles` ["alice", "bob", "cath"]
|
||||
-- delete group 2, unused contacts and profiles are deleted
|
||||
deleteGroup alice bob cath "club"
|
||||
bob ##> "/cs"
|
||||
bob <## "alice (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "bob"]
|
||||
cath ##> "/cs"
|
||||
cath <## "alice (Alice)"
|
||||
cath `hasContactProfiles` ["alice", "cath"]
|
||||
where
|
||||
deleteGroup alice bob cath group = do
|
||||
alice ##> ("/d #" <> group)
|
||||
concurrentlyN_
|
||||
[ alice <## ("#" <> group <> ": you deleted the group"),
|
||||
do
|
||||
bob <## ("#" <> group <> ": alice deleted the group")
|
||||
bob <## ("use /d #" <> group <> " to delete the local copy of the group"),
|
||||
do
|
||||
cath <## ("#" <> group <> ": alice deleted the group")
|
||||
cath <## ("use /d #" <> group <> " to delete the local copy of the group")
|
||||
]
|
||||
bob ##> ("/d #" <> group)
|
||||
bob <## ("#" <> group <> ": you deleted the group")
|
||||
cath ##> ("/d #" <> group)
|
||||
cath <## ("#" <> group <> ": you deleted the group")
|
||||
|
||||
testGroupDescription :: IO ()
|
||||
testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
addMember "team" alice bob GRAdmin
|
||||
bob ##> "/j team"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: bob joined the group",
|
||||
bob <## "#team: you joined the group"
|
||||
]
|
||||
alice ##> "/group_profile team"
|
||||
alice <## "#team"
|
||||
groupInfo alice
|
||||
alice ##> "/group_descr team Welcome to the team!"
|
||||
alice <## "description changed to:"
|
||||
alice <## "Welcome to the team!"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "description changed to:"
|
||||
bob <## "Welcome to the team!"
|
||||
alice ##> "/group_profile team"
|
||||
alice <## "#team"
|
||||
alice <## "description:"
|
||||
alice <## "Welcome to the team!"
|
||||
groupInfo alice
|
||||
connectUsers alice cath
|
||||
addMember "team" alice cath GRMember
|
||||
cath ##> "/j team"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: cath joined the group",
|
||||
do
|
||||
cath <## "#team: you joined the group"
|
||||
cath <# "#team alice> Welcome to the team!"
|
||||
cath <## "#team: member bob (Bob) is connected",
|
||||
do
|
||||
bob <## "#team: alice added cath (Catherine) to the group (connecting...)"
|
||||
bob <## "#team: new member cath is connected"
|
||||
]
|
||||
connectUsers bob dan
|
||||
addMember "team" bob dan GRMember
|
||||
dan ##> "/j team"
|
||||
concurrentlyN_
|
||||
[ bob <## "#team: dan joined the group",
|
||||
do
|
||||
dan <## "#team: you joined the group"
|
||||
dan <# "#team bob> Welcome to the team!"
|
||||
dan
|
||||
<### [ "#team: member alice (Alice) is connected",
|
||||
"#team: member cath (Catherine) is connected"
|
||||
],
|
||||
bobAddedDan alice,
|
||||
bobAddedDan cath
|
||||
]
|
||||
where
|
||||
groupInfo alice = do
|
||||
alice <## "group preferences:"
|
||||
alice <## "Direct messages enabled: on"
|
||||
alice <## "Full deletion enabled: off"
|
||||
alice <## "Voice messages enabled: on"
|
||||
bobAddedDan cc = do
|
||||
cc <## "#team: bob added dan (Daniel) to the group (connecting...)"
|
||||
cc <## "#team: new member dan is connected"
|
||||
|
||||
testGroupAsync :: IO ()
|
||||
testGroupAsync = withTmpFiles $ do
|
||||
print (0 :: Integer)
|
||||
@@ -1387,7 +1573,7 @@ testGroupAsync = withTmpFiles $ do
|
||||
connectUsers alice bob
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/a team bob"
|
||||
concurrentlyN_
|
||||
[ alice <## "invitation to join the group #team sent to bob",
|
||||
@@ -2570,7 +2756,7 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $
|
||||
do
|
||||
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice
|
||||
<### [ "use /info bob to print out this incognito profile again",
|
||||
<### [ "use /i bob to print out this incognito profile again",
|
||||
WithTime "i @bob hello!"
|
||||
]
|
||||
]
|
||||
@@ -2589,10 +2775,10 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## (aliceIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## ("use /info " <> aliceIncognito <> " to print out this incognito profile again"),
|
||||
bob <## ("use /i " <> aliceIncognito <> " to print out this incognito profile again"),
|
||||
do
|
||||
alice <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## ("use /info " <> bobIncognito <> " to print out this incognito profile again")
|
||||
alice <## ("use /i " <> bobIncognito <> " to print out this incognito profile again")
|
||||
]
|
||||
-- after turning incognito mode off conversation is incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
@@ -2630,6 +2816,28 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi
|
||||
alice <## "Full deletion: off (you allow: no, contact allows: no)"
|
||||
bob <## (aliceIncognito <> " updated preferences for you:")
|
||||
bob <## "Full deletion: off (you allow: no, contact allows: no)"
|
||||
-- list contacts
|
||||
alice ##> "/cs"
|
||||
alice
|
||||
<### [ ConsoleString $ "i " <> bobIncognito,
|
||||
"cath (Catherine)"
|
||||
]
|
||||
alice `hasContactProfiles` ["alice", T.pack aliceIncognito, T.pack bobIncognito, "cath"]
|
||||
bob ##> "/cs"
|
||||
bob <## ("i " <> aliceIncognito)
|
||||
bob `hasContactProfiles` ["bob", T.pack aliceIncognito, T.pack bobIncognito]
|
||||
-- alice deletes contact, incognito profile is deleted
|
||||
alice ##> ("/d " <> bobIncognito)
|
||||
alice <## (bobIncognito <> ": contact is deleted")
|
||||
alice ##> "/cs"
|
||||
alice <## "cath (Catherine)"
|
||||
alice `hasContactProfiles` ["alice", "cath"]
|
||||
-- bob deletes contact, incognito profile is deleted
|
||||
bob ##> ("/d " <> aliceIncognito)
|
||||
bob <## (aliceIncognito <> ": contact is deleted")
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
testConnectIncognitoContactAddress :: IO ()
|
||||
testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
|
||||
@@ -2649,7 +2857,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /info alice to print out this incognito profile again",
|
||||
bob <## "use /i alice to print out this incognito profile again",
|
||||
alice <## (bobIncognito <> ": contact is connected")
|
||||
]
|
||||
-- after turning incognito mode off conversation is incognito
|
||||
@@ -2659,6 +2867,16 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
|
||||
bob ?<# "alice> who are you?"
|
||||
bob ?#> "@alice I'm Batman"
|
||||
alice <# (bobIncognito <> "> I'm Batman")
|
||||
-- list contacts
|
||||
bob ##> "/cs"
|
||||
bob <## "i alice (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
-- delete contact, incognito profile is deleted
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
testAcceptContactRequestIncognito :: IO ()
|
||||
testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $
|
||||
@@ -2675,7 +2893,7 @@ testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $
|
||||
[ bob <## (aliceIncognito <> ": contact is connected"),
|
||||
do
|
||||
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## "use /info bob to print out this incognito profile again"
|
||||
alice <## "use /i bob to print out this incognito profile again"
|
||||
]
|
||||
-- after turning incognito mode off conversation is incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
@@ -2684,6 +2902,16 @@ testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $
|
||||
bob <# (aliceIncognito <> "> my profile is totally inconspicuous")
|
||||
bob #> ("@" <> aliceIncognito <> " I know!")
|
||||
alice ?<# "bob> I know!"
|
||||
-- list contacts
|
||||
alice ##> "/cs"
|
||||
alice <## "i bob (Bob)"
|
||||
alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito]
|
||||
-- delete contact, incognito profile is deleted
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
alice ##> "/cs"
|
||||
(alice </)
|
||||
alice `hasContactProfiles` ["alice"]
|
||||
|
||||
testJoinGroupIncognito :: IO ()
|
||||
testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $
|
||||
@@ -2704,13 +2932,13 @@ testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfil
|
||||
concurrentlyN_
|
||||
[ do
|
||||
cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito)
|
||||
cath <## "use /info alice to print out this incognito profile again",
|
||||
cath <## "use /i alice to print out this incognito profile again",
|
||||
alice <## (cathIncognito <> ": contact is connected")
|
||||
]
|
||||
-- alice creates group
|
||||
alice ##> "/g secret_club"
|
||||
alice <## "group #secret_club is created"
|
||||
alice <## "use /a secret_club <name> to add members"
|
||||
alice <## "to add members use /a secret_club <name> or /create link #secret_club"
|
||||
-- alice invites bob
|
||||
alice ##> "/a secret_club bob"
|
||||
concurrentlyN_
|
||||
@@ -2890,13 +3118,13 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $
|
||||
[ bob <## (aliceIncognito <> ": contact is connected"),
|
||||
do
|
||||
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## "use /info bob to print out this incognito profile again"
|
||||
alice <## "use /i bob to print out this incognito profile again"
|
||||
]
|
||||
-- alice creates group non incognito
|
||||
alice #$> ("/incognito off", id, "ok")
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "use /a club <name> to add members"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/a club bob"
|
||||
alice <## "you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"
|
||||
-- bob doesn't receive invitation
|
||||
@@ -2920,7 +3148,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr
|
||||
[ bob <## (aliceIncognito <> ": contact is connected"),
|
||||
do
|
||||
alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito)
|
||||
alice <## "use /info bob to print out this incognito profile again",
|
||||
alice <## "use /i bob to print out this incognito profile again",
|
||||
do
|
||||
cath <## "alice (Alice): contact is connected"
|
||||
]
|
||||
@@ -2955,6 +3183,110 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr
|
||||
cath <## "alice updated preferences for you:"
|
||||
cath <## "Full deletion: off (you allow: default (no), contact allows: yes)"
|
||||
|
||||
testDeleteContactThenGroupDeletesIncognitoProfile :: IO ()
|
||||
testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
-- bob connects incognito to alice
|
||||
alice ##> "/c"
|
||||
inv <- getInvitation alice
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
bob ##> ("/c " <> inv)
|
||||
bob <## "confirmation sent!"
|
||||
bobIncognito <- getTermLine bob
|
||||
concurrentlyN_
|
||||
[ alice <## (bobIncognito <> ": contact is connected"),
|
||||
do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /i alice to print out this incognito profile again"
|
||||
]
|
||||
-- bob joins group using incognito profile
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> ("/a team " <> bobIncognito)
|
||||
concurrentlyN_
|
||||
[ alice <## ("invitation to join the group #team sent to " <> bobIncognito),
|
||||
do
|
||||
bob <## "#team: alice invites you to join the group as admin"
|
||||
bob <## ("use /j team to join incognito as " <> bobIncognito)
|
||||
]
|
||||
bob ##> "/j team"
|
||||
concurrently_
|
||||
(alice <## ("#team: " <> bobIncognito <> " joined the group"))
|
||||
(bob <## ("#team: you joined the group incognito as " <> bobIncognito))
|
||||
bob ##> "/cs"
|
||||
bob <## "i alice (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
-- delete contact
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
-- delete group
|
||||
bob ##> "/l team"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## "#team: you left the group"
|
||||
bob <## "use /d #team to delete the group",
|
||||
alice <## ("#team: " <> bobIncognito <> " left the group")
|
||||
]
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted the group"
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
testDeleteGroupThenContactDeletesIncognitoProfile :: IO ()
|
||||
testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
-- bob connects incognito to alice
|
||||
alice ##> "/c"
|
||||
inv <- getInvitation alice
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
bob ##> ("/c " <> inv)
|
||||
bob <## "confirmation sent!"
|
||||
bobIncognito <- getTermLine bob
|
||||
concurrentlyN_
|
||||
[ alice <## (bobIncognito <> ": contact is connected"),
|
||||
do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /i alice to print out this incognito profile again"
|
||||
]
|
||||
-- bob joins group using incognito profile
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> ("/a team " <> bobIncognito)
|
||||
concurrentlyN_
|
||||
[ alice <## ("invitation to join the group #team sent to " <> bobIncognito),
|
||||
do
|
||||
bob <## "#team: alice invites you to join the group as admin"
|
||||
bob <## ("use /j team to join incognito as " <> bobIncognito)
|
||||
]
|
||||
bob ##> "/j team"
|
||||
concurrently_
|
||||
(alice <## ("#team: " <> bobIncognito <> " joined the group"))
|
||||
(bob <## ("#team: you joined the group incognito as " <> bobIncognito))
|
||||
bob ##> "/cs"
|
||||
bob <## "i alice (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
-- delete group
|
||||
bob ##> "/l team"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## "#team: you left the group"
|
||||
bob <## "use /d #team to delete the group",
|
||||
alice <## ("#team: " <> bobIncognito <> " left the group")
|
||||
]
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted the group"
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
-- delete contact
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
testSetAlias :: IO ()
|
||||
testSetAlias = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
@@ -3182,19 +3514,19 @@ testProhibitDirectMessages =
|
||||
addMember "team" cath dan GRMember
|
||||
dan ##> "/j #team"
|
||||
concurrentlyN_
|
||||
[ cath <## ("#team: dan joined the group"),
|
||||
[ cath <## "#team: dan joined the group",
|
||||
do
|
||||
dan <## ("#team: you joined the group")
|
||||
dan <###
|
||||
[ "#team: member alice (Alice) is connected",
|
||||
"#team: member bob (Bob) is connected"
|
||||
],
|
||||
dan <## "#team: you joined the group"
|
||||
dan
|
||||
<### [ "#team: member alice (Alice) is connected",
|
||||
"#team: member bob (Bob) is connected"
|
||||
],
|
||||
do
|
||||
alice <## ("#team: cath added dan (Daniel) to the group (connecting...)")
|
||||
alice <## ("#team: new member dan is connected"),
|
||||
alice <## "#team: cath added dan (Daniel) to the group (connecting...)"
|
||||
alice <## "#team: new member dan is connected",
|
||||
do
|
||||
bob <## ("#team: cath added dan (Daniel) to the group (connecting...)")
|
||||
bob <## ("#team: new member dan is connected")
|
||||
bob <## "#team: cath added dan (Daniel) to the group (connecting...)"
|
||||
bob <## "#team: new member dan is connected"
|
||||
]
|
||||
alice ##> "@dan hi"
|
||||
alice <## "direct messages to indirect contact dan are prohibited"
|
||||
@@ -3241,7 +3573,7 @@ testTestSMPServerConnection =
|
||||
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001"
|
||||
alice <## "SMP server test passed"
|
||||
alice ##> "/smp test smp://LcJU@localhost:5001"
|
||||
alice <## ("SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:5001 NETWORK")
|
||||
alice <## "SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:5001 NETWORK"
|
||||
alice <## "Possibly, certificate fingerprint in server address is incorrect"
|
||||
|
||||
testAsyncInitiatingOffline :: IO ()
|
||||
@@ -3834,7 +4166,7 @@ testGroupLink =
|
||||
\alice bob cath -> do
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/show link #team"
|
||||
alice <## "no group link, to create: /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
@@ -3864,9 +4196,6 @@ testGroupLink =
|
||||
-- contacts connected via group link are not in chat previews
|
||||
alice @@@ [("#team", "connected")]
|
||||
bob @@@ [("#team", "connected")]
|
||||
-- calling /_get chat api marks it as used and adds it to chat previews
|
||||
alice #$> ("/_get chat @2 count=100", chat, [])
|
||||
alice @@@ [("@bob", ""), ("#team", "connected")]
|
||||
alice <##> bob
|
||||
alice @@@ [("@bob", "hey"), ("#team", "connected")]
|
||||
|
||||
@@ -3937,7 +4266,7 @@ testGroupLinkDeleteGroupRejoin =
|
||||
\alice bob -> do
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
gLink <- getGroupLink alice "team" True
|
||||
bob ##> ("/c " <> gLink)
|
||||
@@ -3993,7 +4322,7 @@ testGroupLinkContactUsed =
|
||||
\alice bob -> do
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
gLink <- getGroupLink alice "team" True
|
||||
bob ##> ("/c " <> gLink)
|
||||
@@ -4032,14 +4361,14 @@ testGroupLinkIncognitoMembership =
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /info alice to print out this incognito profile again",
|
||||
bob <## "use /i alice to print out this incognito profile again",
|
||||
alice <## (bobIncognito <> ": contact is connected")
|
||||
]
|
||||
bob #$> ("/incognito off", id, "ok")
|
||||
-- alice creates group
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "use /a team <name> to add members"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
-- alice invites bob
|
||||
alice ##> ("/a team " <> bobIncognito)
|
||||
concurrentlyN_
|
||||
@@ -4062,7 +4391,7 @@ testGroupLinkIncognitoMembership =
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## "use /info cath to print out this incognito profile again"
|
||||
bob <## "use /i cath to print out this incognito profile again"
|
||||
bob <## "cath invited to group #team via your group link"
|
||||
bob <## "#team: cath joined the group",
|
||||
do
|
||||
@@ -4088,12 +4417,12 @@ testGroupLinkIncognitoMembership =
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## (danIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## ("use /info " <> danIncognito <> " to print out this incognito profile again")
|
||||
bob <## ("use /i " <> danIncognito <> " to print out this incognito profile again")
|
||||
bob <## (danIncognito <> " invited to group #team via your group link")
|
||||
bob <## ("#team: " <> danIncognito <> " joined the group"),
|
||||
do
|
||||
dan <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> danIncognito)
|
||||
dan <## ("use /info " <> bobIncognito <> " to print out this incognito profile again")
|
||||
dan <## ("use /i " <> bobIncognito <> " to print out this incognito profile again")
|
||||
dan <## ("#team: you joined the group incognito as " <> danIncognito)
|
||||
dan
|
||||
<### [ "#team: member alice (Alice) is connected",
|
||||
@@ -4136,6 +4465,134 @@ testGroupLinkIncognitoMembership =
|
||||
cath <# ("#team " <> danIncognito <> "> how is it going?")
|
||||
]
|
||||
|
||||
testGroupLinkUnusedHostContactDeleted :: IO ()
|
||||
testGroupLinkUnusedHostContactDeleted =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
-- create group 1
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
gLinkTeam <- getGroupLink alice "team" True
|
||||
bob ##> ("/c " <> gLinkTeam)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob (Bob): accepting request to join group #team..."
|
||||
concurrentlyN_
|
||||
[ do
|
||||
alice <## "bob (Bob): contact is connected"
|
||||
alice <## "bob invited to group #team via your group link"
|
||||
alice <## "#team: bob joined the group",
|
||||
do
|
||||
bob <## "alice (Alice): contact is connected"
|
||||
bob <## "#team: you joined the group"
|
||||
]
|
||||
-- create group 2
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/create link #club"
|
||||
gLinkClub <- getGroupLink alice "club" True
|
||||
bob ##> ("/c " <> gLinkClub)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob_1 (Bob): accepting request to join group #club..."
|
||||
concurrentlyN_
|
||||
[ alice
|
||||
<### [ "bob_1 (Bob): contact is connected",
|
||||
"contact bob_1 is merged into bob",
|
||||
"use @bob <message> to send messages",
|
||||
EndsWith "invited to group #club via your group link",
|
||||
EndsWith "joined the group"
|
||||
],
|
||||
bob
|
||||
<### [ "alice_1 (Alice): contact is connected",
|
||||
"contact alice_1 is merged into alice",
|
||||
"use @alice <message> to send messages",
|
||||
"#club: you joined the group"
|
||||
]
|
||||
]
|
||||
-- list contacts
|
||||
bob ##> "/cs"
|
||||
bob <## "alice (Alice)"
|
||||
-- delete group 1, host contact and profile are kept
|
||||
bobLeaveDeleteGroup alice bob "team"
|
||||
bob ##> "/cs"
|
||||
bob <## "alice (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "bob"]
|
||||
-- delete group 2, unused host contact and profile are deleted
|
||||
bobLeaveDeleteGroup alice bob "club"
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
where
|
||||
bobLeaveDeleteGroup alice bob group = do
|
||||
bob ##> ("/l " <> group)
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("#" <> group <> ": you left the group")
|
||||
bob <## ("use /d #" <> group <> " to delete the group"),
|
||||
alice <## ("#" <> group <> ": bob left the group")
|
||||
]
|
||||
bob ##> ("/d #" <> group)
|
||||
bob <## ("#" <> group <> ": you deleted the group")
|
||||
|
||||
testGroupLinkIncognitoUnusedHostContactsDeleted :: IO ()
|
||||
testGroupLinkIncognitoUnusedHostContactsDeleted =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
bob #$> ("/incognito on", id, "ok")
|
||||
bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice"
|
||||
bobIncognitoClub <- createGroupBobIncognito alice bob "club" "alice_1"
|
||||
bobIncognitoTeam `shouldNotBe` bobIncognitoClub
|
||||
-- list contacts
|
||||
bob ##> "/cs"
|
||||
bob <## "i alice (Alice)"
|
||||
bob <## "i alice_1 (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "alice", "bob", T.pack bobIncognitoTeam, T.pack bobIncognitoClub]
|
||||
-- delete group 1, unused host contact and profile are deleted
|
||||
bobLeaveDeleteGroup alice bob "team" bobIncognitoTeam
|
||||
bob ##> "/cs"
|
||||
bob <## "i alice_1 (Alice)"
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognitoClub]
|
||||
-- delete group 2, unused host contact and profile are deleted
|
||||
bobLeaveDeleteGroup alice bob "club" bobIncognitoClub
|
||||
bob ##> "/cs"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
where
|
||||
createGroupBobIncognito alice bob group bobsAliceContact = do
|
||||
alice ##> ("/g " <> group)
|
||||
alice <## ("group #" <> group <> " is created")
|
||||
alice <## ("to add members use /a " <> group <> " <name> or /create link #" <> group)
|
||||
alice ##> ("/create link #" <> group)
|
||||
gLinkTeam <- getGroupLink alice group True
|
||||
bob ##> ("/c " <> gLinkTeam)
|
||||
bobIncognito <- getTermLine bob
|
||||
bob <## "connection request sent incognito!"
|
||||
alice <## (bobIncognito <> ": accepting request to join group #" <> group <> "...")
|
||||
_ <- getTermLine bob
|
||||
concurrentlyN_
|
||||
[ do
|
||||
alice <## (bobIncognito <> ": contact is connected")
|
||||
alice <## (bobIncognito <> " invited to group #" <> group <> " via your group link")
|
||||
alice <## ("#" <> group <> ": " <> bobIncognito <> " joined the group"),
|
||||
do
|
||||
bob <## (bobsAliceContact <> " (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito)
|
||||
bob <## ("use /i " <> bobsAliceContact <> " to print out this incognito profile again")
|
||||
bob <## ("#" <> group <> ": you joined the group incognito as " <> bobIncognito)
|
||||
]
|
||||
pure bobIncognito
|
||||
bobLeaveDeleteGroup alice bob group bobIncognito = do
|
||||
bob ##> ("/l " <> group)
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## ("#" <> group <> ": you left the group")
|
||||
bob <## ("use /d #" <> group <> " to delete the group"),
|
||||
alice <## ("#" <> group <> ": " <> bobIncognito <> " left the group")
|
||||
]
|
||||
bob ##> ("/d #" <> group)
|
||||
bob <## ("#" <> group <> ": you deleted the group")
|
||||
|
||||
testSwitchContact :: IO ()
|
||||
testSwitchContact =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
@@ -4167,6 +4624,68 @@ testSwitchGroupMember =
|
||||
bob #> "#team hi"
|
||||
alice <# "#team bob> hi"
|
||||
|
||||
testMarkContactVerified :: IO ()
|
||||
testMarkContactVerified =
|
||||
testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
connectUsers alice bob
|
||||
alice ##> "/i bob"
|
||||
bobInfo alice
|
||||
alice <## "connection not verified, use /code command to see security code"
|
||||
alice ##> "/code bob"
|
||||
bCode <- getTermLine alice
|
||||
bob ##> "/code alice"
|
||||
aCode <- getTermLine bob
|
||||
bCode `shouldBe` aCode
|
||||
alice ##> "/verify bob 123"
|
||||
alice <##. "connection not verified, current code is "
|
||||
alice ##> ("/verify bob " <> aCode)
|
||||
alice <## "connection verified"
|
||||
alice ##> "/i bob"
|
||||
bobInfo alice
|
||||
alice <## "connection verified"
|
||||
alice ##> "/verify bob"
|
||||
alice <##. "connection not verified, current code is "
|
||||
alice ##> "/i bob"
|
||||
bobInfo alice
|
||||
alice <## "connection not verified, use /code command to see security code"
|
||||
where
|
||||
bobInfo alice = do
|
||||
alice <## "contact ID: 2"
|
||||
alice <## "receiving messages via: localhost"
|
||||
alice <## "sending messages via: localhost"
|
||||
alice <## "you've shared main profile with this contact"
|
||||
|
||||
testMarkGroupMemberVerified :: IO ()
|
||||
testMarkGroupMemberVerified =
|
||||
testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
alice ##> "/i #team bob"
|
||||
bobInfo alice
|
||||
alice <## "connection not verified, use /code command to see security code"
|
||||
alice ##> "/code #team bob"
|
||||
bCode <- getTermLine alice
|
||||
bob ##> "/code #team alice"
|
||||
aCode <- getTermLine bob
|
||||
bCode `shouldBe` aCode
|
||||
alice ##> "/verify #team bob 123"
|
||||
alice <##. "connection not verified, current code is "
|
||||
alice ##> ("/verify #team bob " <> aCode)
|
||||
alice <## "connection verified"
|
||||
alice ##> "/i #team bob"
|
||||
bobInfo alice
|
||||
alice <## "connection verified"
|
||||
alice ##> "/verify #team bob"
|
||||
alice <##. "connection not verified, current code is "
|
||||
alice ##> "/i #team bob"
|
||||
bobInfo alice
|
||||
alice <## "connection not verified, use /code command to see security code"
|
||||
where
|
||||
bobInfo alice = do
|
||||
alice <## "group ID: 1"
|
||||
alice <## "member ID: 2"
|
||||
alice <## "receiving messages via: localhost"
|
||||
alice <## "sending messages via: localhost"
|
||||
|
||||
withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a
|
||||
withTestChatContactConnected dbPrefix action =
|
||||
withTestChat dbPrefix $ \cc -> do
|
||||
@@ -4257,7 +4776,7 @@ createGroup2 gName cc1 cc2 = do
|
||||
name2 <- userName cc2
|
||||
cc1 ##> ("/g " <> gName)
|
||||
cc1 <## ("group #" <> gName <> " is created")
|
||||
cc1 <## ("use /a " <> gName <> " <name> to add members")
|
||||
cc1 <## ("to add members use /a " <> gName <> " <name> or /create link #" <> gName)
|
||||
addMember gName cc1 cc2 GRAdmin
|
||||
cc2 ##> ("/j " <> gName)
|
||||
concurrently_
|
||||
@@ -4482,3 +5001,16 @@ getGroupLink cc gName created = do
|
||||
cc <## ("to show it again: /show link #" <> gName)
|
||||
cc <## ("to delete it: /delete link #" <> gName <> " (joined members will remain connected to you)")
|
||||
pure link
|
||||
|
||||
hasContactProfiles :: TestCC -> [ContactName] -> Expectation
|
||||
hasContactProfiles cc names =
|
||||
getContactProfiles cc >>= \ps -> ps `shouldMatchList` names
|
||||
|
||||
getContactProfiles :: TestCC -> IO [ContactName]
|
||||
getContactProfiles cc = do
|
||||
user_ <- readTVarIO (currentUser $ chatController cc)
|
||||
case user_ of
|
||||
Nothing -> pure []
|
||||
Just user -> do
|
||||
profiles <- withTransaction (chatStore $ chatController cc) $ \db -> getUserContactProfiles db user
|
||||
pure $ map (\Profile {displayName} -> displayName) profiles
|
||||
|
||||
@@ -89,7 +89,7 @@ testProfile :: Profile
|
||||
testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), preferences = testChatPreferences}
|
||||
|
||||
testGroupProfile :: GroupProfile
|
||||
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing, groupPreferences = testGroupPreferences}
|
||||
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, image = Nothing, groupPreferences = testGroupPreferences}
|
||||
|
||||
decodeChatMessageTest :: Spec
|
||||
decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
|
||||
@@ -26,6 +26,7 @@ module.exports = function (ty) {
|
||||
ty.addPassthroughCopy("src/blog/images")
|
||||
ty.addPassthroughCopy("src/images")
|
||||
ty.addPassthroughCopy("src/CNAME")
|
||||
ty.addPassthroughCopy("src/.well-known")
|
||||
|
||||
ty.addCollection('blogs', function (collection) {
|
||||
return collection.getFilteredByGlob('src/blog/*.md').reverse()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# .well-known
|
||||
|
||||
This website files allow opening SimpleX Chat links (1-time invitations, contact addresses and groups) directly in the app.
|
||||
|
||||
## Android
|
||||
|
||||
File `assetlinks.json` includes certificate hashes for:
|
||||
|
||||
- Play Store (5E:3E:DC:C2:00:FB:A8:D5:F4:88:F3:CA:4C:32:5B:05:78:C5:6A:9C:03:A1:CC:B5:92:9C:D7:5C:7E:57:E2:4D)
|
||||
- APK in GitHub releases (3C:52:C4:FD:3C:AD:1C:07:C9:B0:0A:70:80:E3:58:FA:B9:FE:FC:B8:AF:5A:EC:14:77:65:F1:6D:0F:21:AD:85)
|
||||
- F-Droid (AE:C1:95:DC:FD:46:14:BD:3A:91:EC:26:D1:D5:14:C8:75:71:C5:CC:8D:CF:48:08:3F:92:83:14:3C:A2:B9:A6)
|
||||
|
||||
## iOS
|
||||
|
||||
`apple-app-site-association` currently does not work, as it needs to be served with `Content-type: application/json; charset=utf-8` and GitHub pages do not support adding this header to files without JSON extension.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"applinks": {
|
||||
"details": [
|
||||
{
|
||||
"appIDs": [
|
||||
"5NN7GUYB6.chat.simplex.app"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"/": "/contact/*"
|
||||
},
|
||||
{
|
||||
"/": "/contact"
|
||||
},
|
||||
{
|
||||
"/": "/invitation/*"
|
||||
},
|
||||
{
|
||||
"/": "/invitation"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "chat.simplex.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"5E:3E:DC:C2:00:FB:A8:D5:F4:88:F3:CA:4C:32:5B:05:78:C5:6A:9C:03:A1:CC:B5:92:9C:D7:5C:7E:57:E2:4D",
|
||||
"3C:52:C4:FD:3C:AD:1C:07:C9:B0:0A:70:80:E3:58:FA:B9:FE:FC:B8:AF:5A:EC:14:77:65:F1:6D:0F:21:AD:85",
|
||||
"AE:C1:95:DC:FD:46:14:BD:3A:91:EC:26:D1:D5:14:C8:75:71:C5:CC:8D:CF:48:08:3F:92:83:14:3C:A2:B9:A6"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
<p>SimpleX Chat November reviews:</p>
|
||||
|
||||
<p><a href="https://www.privacyguides.org/real-time-communication/#simplex-chat" target="_blank">Privacy Guides</a> recommendations.</p>
|
||||
<p><a href="https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/" target="_blank">Review by Mike Kuketz</a>.</p>
|
||||
<p><a href="https://www.messenger-matrix.de" target="_blank">The messenger matrix</a>.</p>
|
||||
<p class="mb-[12px]"><a href="https://supernova.tilde.team/detailed_reviews.html#simplex" target="_blank">Supernova review</a> and <a href="https://supernova.tilde.team/messengers.html" target="_blank">messenger ratings</a>.</p>
|
||||
|
||||
<p>v4.3 is released:</p>
|
||||
|
||||
<ul>
|
||||
<li>instant voice messages!</li>
|
||||
<li>irreversible deletion of sent messages for all recipients</li>
|
||||
<li>improved SMP server configuration and support for server passwords</li>
|
||||
<li>privacy and security improvements: protect app screen, SimpleX links security, etc.</li>
|
||||
</ul>
|
||||