diff --git a/README.md b/README.md
index d936fe1bb2..0884573f3a 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
- 🔐 Double ratchet encryption.
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220308-simplex-chat-mobile-apps.md).
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
-- 🖥 Available as a [terminal (console) app / CLI](https://github.com/simplex-chat/simplex-chat) on Linux, MacOS, Windows.
+- 🖥 Available as a [terminal (console) app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
index 4218b6eefe..5932b59f80 100644
--- a/apps/android/app/build.gradle
+++ b/apps/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
- versionCode 19
- versionName "1.4"
+ versionCode 22
+ versionName "1.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 284544089a..17c4303b20 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,10 @@
+
+
+
+
@@ -16,6 +20,8 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
+
+
+
@@ -34,6 +41,7 @@
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
index 18288bc4f1..b32b2bb276 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt
@@ -16,6 +16,7 @@ import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.AndroidViewModel
+import androidx.work.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -27,17 +28,18 @@ import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.*
+import java.util.concurrent.TimeUnit
//import kotlinx.serialization.decodeFromString
class MainActivity: ComponentActivity() {
private val vm by viewModels()
+ private val chatController by lazy { (application as SimplexApp).chatController }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
processIntent(intent, vm.chatModel)
-// vm.app.initiateBackgroundWork()
setContent {
SimpleXTheme {
Surface(
@@ -49,6 +51,25 @@ class MainActivity: ComponentActivity() {
}
}
}
+ schedulePeriodicServiceRestartWorker()
+ }
+
+ private fun schedulePeriodicServiceRestartWorker() {
+ val workerVersion = chatController.getAutoRestartWorkerVersion()
+ val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
+ Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
+ ExistingPeriodicWorkPolicy.KEEP
+ } else {
+ Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
+ chatController.setAutoRestartWorkerVersion(SimplexService.SERVICE_START_WORKER_VERSION)
+ ExistingPeriodicWorkPolicy.REPLACE
+ }
+ val work = PeriodicWorkRequestBuilder(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
+ .addTag(SimplexService.TAG)
+ .addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
+ .build()
+ Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
+ WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
index a8f0ada2be..5506c72daf 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -3,6 +3,7 @@ package chat.simplex.app
import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
+import androidx.lifecycle.*
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.withApi
import java.io.BufferedReader
@@ -24,32 +25,44 @@ external fun chatInit(path: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
external fun chatRecvMsg(ctrl: ChatCtrl) : String
-//class SimplexApp: Application(), LifecycleEventObserver {
-class SimplexApp: Application() {
- private lateinit var controller: ChatController
- lateinit var chatModel: ChatModel
- private lateinit var ntfManager: NtfManager
+class SimplexApp: Application(), LifecycleEventObserver {
+ val chatController: ChatController by lazy {
+ val ctrl = chatInit(applicationContext.filesDir.toString())
+ ChatController(ctrl, ntfManager, applicationContext)
+ }
+
+ val chatModel: ChatModel by lazy {
+ chatController.chatModel
+ }
+
+ private val ntfManager: NtfManager by lazy {
+ NtfManager(applicationContext)
+ }
override fun onCreate() {
super.onCreate()
-// ProcessLifecycleOwner.get().lifecycle.addObserver(this)
- ntfManager = NtfManager(applicationContext)
- val ctrl = chatInit(applicationContext.filesDir.toString())
- controller = ChatController(ctrl, ntfManager, applicationContext)
- chatModel = controller.chatModel
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
withApi {
- val user = controller.apiGetActiveUser()
- if (user != null) controller.startChat(user)
+ val user = chatController.apiGetActiveUser()
+ if (user != null) {
+ chatController.startChat(user)
+ SimplexService.start(applicationContext)
+ chatController.showBackgroundServiceNotice()
+ }
}
}
-// override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
-// Log.d(TAG, "onStateChanged: $event")
-// if (event == Lifecycle.Event.ON_STOP) {
-// Log.e(TAG, "BGManager schedule ${Clock.System.now()}")
-// BGManager.schedule(applicationContext)
-// }
-// }
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ Log.d(TAG, "onStateChanged: $event")
+ withApi {
+ when (event) {
+ Lifecycle.Event.ON_STOP ->
+ if (!chatController.getRunServiceInBackground()) SimplexService.stop(applicationContext)
+ Lifecycle.Event.ON_START ->
+ SimplexService.start(applicationContext)
+ }
+ }
+ }
companion object {
init {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
new file mode 100644
index 0000000000..34907f9a48
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt
@@ -0,0 +1,242 @@
+package chat.simplex.app
+
+import android.app.*
+import android.content.*
+import android.os.*
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.work.*
+import chat.simplex.app.views.helpers.withApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+// based on:
+// https://robertohuertas.com/2019/06/29/android_foreground_services/
+// https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
+
+class SimplexService: Service() {
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var isServiceStarted = false
+ private var isStartingService = false
+ private var notificationManager: NotificationManager? = null
+ private var serviceNotification: Notification? = null
+ private val chatController by lazy { (application as SimplexApp).chatController }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.d(TAG, "onStartCommand startId: $startId")
+ if (intent != null) {
+ val action = intent.action
+ Log.d(TAG, "intent action $action")
+ when (action) {
+ Action.START.name -> startService()
+ Action.STOP.name -> stopService()
+ else -> Log.e(TAG, "No action in the intent")
+ }
+ } else {
+ Log.d(TAG, "null intent. Probably restarted by the system.")
+ }
+ return START_STICKY // to restart if killed
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d(TAG, "Simplex service created")
+ val title = getString(R.string.simplex_service_notification_title)
+ val text = getString(R.string.simplex_service_notification_text)
+ notificationManager = createNotificationChannel()
+ serviceNotification = createNotification(title, text)
+
+ startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "Simplex service destroyed")
+ stopService()
+ sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
+ super.onDestroy()
+ }
+
+ private fun startService() {
+ Log.d(TAG, "SimplexService startService")
+ if (isServiceStarted || isStartingService) return
+ val self = this
+ isStartingService = true
+ withApi {
+ try {
+ val user = chatController.apiGetActiveUser()
+ if (user != null) {
+ Log.w(TAG, "Starting foreground service")
+ chatController.startChat(user)
+ chatController.startReceiver()
+ isServiceStarted = true
+ saveServiceState(self, ServiceState.STARTED)
+ wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
+ newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
+ acquire()
+ }
+ }
+ }
+ } finally {
+ isStartingService = false
+ }
+ }
+ }
+
+ private fun stopService() {
+ Log.d(TAG, "Stopping foreground service")
+ try {
+ wakeLock?.let {
+ while (it.isHeld) it.release() // release all, in case acquired more than once
+ }
+ wakeLock = null
+ stopForeground(true)
+ stopSelf()
+ } catch (e: Exception) {
+ Log.d(TAG, "Service stopped without being started: ${e.message}")
+ }
+
+ isServiceStarted = false
+ saveServiceState(this, ServiceState.STOPPED)
+ }
+
+ private fun createNotificationChannel(): NotificationManager? {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).let {
+ it.setShowBadge(false) // no long-press badge
+ it
+ }
+ notificationManager.createNotificationChannel(channel)
+ return notificationManager
+ }
+ return null
+ }
+
+ private fun createNotification(title: String, text: String): Notification {
+ val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
+ PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
+ }
+ return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ntf_icon)
+ .setColor(0x88FFFF)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setContentIntent(pendingIntent)
+ .setSound(null)
+ .setShowWhen(false) // no date/time
+ .build()
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ return null // no binding
+ }
+
+ // re-schedules the task when "Clear recent apps" is pressed
+ override fun onTaskRemoved(rootIntent: Intent) {
+ val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
+ it.setPackage(packageName)
+ };
+ val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
+ applicationContext.getSystemService(Context.ALARM_SERVICE);
+ val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
+ alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
+ }
+
+ // restart on reboot
+ class StartReceiver: BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ Log.d(TAG, "StartReceiver: onReceive called")
+ scheduleStart(context)
+ }
+ }
+
+ // restart on destruction
+ class AutoRestartReceiver: BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ Log.d(TAG, "AutoRestartReceiver: onReceive called")
+ scheduleStart(context)
+ }
+ }
+
+ class ServiceStartWorker(private val context: Context, params: WorkerParameters): CoroutineWorker(context, params) {
+ override suspend fun doWork(): Result {
+ val id = this.id
+ if (context.applicationContext !is Application) {
+ Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: $id)")
+ return Result.failure()
+ }
+ if (getServiceState(context) == ServiceState.STARTED) {
+ Log.d(TAG, "ServiceStartWorker: Starting foreground service (work ID: $id)")
+ start(context)
+ }
+ return Result.success()
+ }
+ }
+
+ enum class Action {
+ START,
+ STOP
+ }
+
+ enum class ServiceState {
+ STARTED,
+ STOPPED,
+ }
+
+ companion object {
+ const val TAG = "SIMPLEX_SERVICE"
+ const val NOTIFICATION_CHANNEL_ID = "chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION"
+ const val NOTIFICATION_CHANNEL_NAME = "SimpleX Chat service"
+ const val SIMPLEX_SERVICE_ID = 6789
+ const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
+ const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
+ const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
+
+ private const val WAKE_LOCK_TAG = "SimplexService::lock"
+ private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
+ private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
+ private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
+
+ fun scheduleStart(context: Context) {
+ Log.d(TAG, "Enqueuing work to start subscriber service")
+ val workManager = WorkManager.getInstance(context)
+ val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
+ workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
+ }
+
+ suspend fun start(context: Context) = serviceAction(context, Action.START)
+
+ suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
+
+ private suspend fun serviceAction(context: Context, action: Action) {
+ Log.d(TAG, "SimplexService serviceAction: ${action.name}")
+ withContext(Dispatchers.IO) {
+ Intent(context, SimplexService::class.java).also {
+ it.action = action.name
+ ContextCompat.startForegroundService(context, it)
+ }
+ }
+ }
+
+ fun restart(context: Context) {
+ Intent(context, SimplexService::class.java).also { intent ->
+ context.stopService(intent) // Service will auto-restart
+ }
+ }
+
+ fun saveServiceState(context: Context, state: ServiceState) {
+ getPreferences(context).edit()
+ .putString(SHARED_PREFS_SERVICE_STATE, state.name)
+ .apply()
+ }
+
+ fun getServiceState(context: Context): ServiceState {
+ val value = getPreferences(context)
+ .getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
+ return ServiceState.valueOf(value!!)
+ }
+
+ private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
+ }
+}
\ No newline at end of file
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt
deleted file mode 100644
index 9894713752..0000000000
--- a/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package chat.simplex.app.model
-
-import android.content.Context
-import android.util.Log
-import androidx.work.*
-import chat.simplex.app.TAG
-import kotlinx.datetime.Clock
-import java.time.Duration
-
-class BGManager(appContext: Context, workerParams: WorkerParameters): //, ctrl: ChatCtrl):
- Worker(appContext, workerParams) {
-// val controller = ctrl
-
- init {}
-
- override fun doWork(): Result {
- Log.e(TAG, "BGManager doWork ${Clock.System.now()}")
- schedule(applicationContext)
- getNewItems()
- return Result.success()
- }
-
- private fun getNewItems() {
- Log.e(TAG, "BGManager getNewItems")
-// val json = chatRecvMsg(controller)
-// val r = APIResponse.decodeStr(json).resp
-// Log.d(TAG, "chatRecvMsg: ${r.responseType}")
- }
-
- companion object {
- val constraints = Constraints.Builder()
- .setRequiredNetworkType(NetworkType.CONNECTED)
- .build()
-
- fun schedule(appContext: Context) {
- val request = OneTimeWorkRequestBuilder()
- .setInitialDelay(Duration.ofMinutes(10))
- .setConstraints(constraints)
- .build()
- WorkManager.getInstance(appContext)
- .enqueue(request)
- }
- }
-}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
index 7bdf80eede..3eb4ddedfa 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt
@@ -29,6 +29,7 @@ class ChatModel(val controller: ChatController) {
var userSMPServers = mutableStateOf<(List)?>(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf(null)
+ var runServiceInBackground = mutableStateOf(true)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
@@ -133,6 +134,26 @@ class ChatModel(val controller: ChatController) {
}
}
+ fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
+ // update previews
+ val i = getChatIndex(cInfo.id)
+ val chat: Chat
+ if (i >= 0) {
+ chat = chats[i]
+ val pItem = chat.chatItems.last()
+ if (pItem.id == cItem.id) {
+ chats[i] = chat.copy(chatItems = arrayListOf(cItem))
+ }
+ }
+ // remove from current chat
+ if (chatId.value == cInfo.id) {
+ val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
+ if (itemIndex >= 0) {
+ chatItems.removeAt(itemIndex)
+ }
+ }
+ }
+
fun markChatItemsRead(cInfo: ChatInfo) {
val chatIdx = getChatIndex(cInfo.id)
// update current chat
@@ -489,6 +510,20 @@ data class ChatItem (
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
else null
+ val isMsgContent: Boolean get() =
+ when (content) {
+ is CIContent.SndMsgContent -> true
+ is CIContent.RcvMsgContent -> true
+ else -> false
+ }
+
+ val isDeletedContent: Boolean get() =
+ when (content) {
+ is CIContent.SndDeleted -> true
+ is CIContent.RcvDeleted -> true
+ else -> false
+ }
+
companion object {
fun getSampleData(
id: Long = 1,
@@ -507,6 +542,20 @@ data class ChatItem (
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem
)
+
+ fun getDeletedContentSampleData(
+ id: Long = 1,
+ dir: CIDirection = CIDirection.DirectRcv(),
+ ts: Instant = Clock.System.now(),
+ text: String = "this item is deleted",
+ status: CIStatus = CIStatus.RcvRead()
+ ) =
+ ChatItem(
+ chatDir = dir,
+ meta = CIMeta.getSample(id, ts, text, status, false, false, false),
+ content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
+ quotedItem = null
+ )
}
}
@@ -597,6 +646,12 @@ sealed class CIStatus {
class RcvRead: CIStatus()
}
+@Serializable
+enum class CIDeleteMode(val deleteMode: String) {
+ @SerialName("internal") cidmInternal("internal"),
+ @SerialName("broadcast") cidmBroadcast("broadcast");
+}
+
interface ItemContent {
val text: String
}
@@ -615,6 +670,16 @@ sealed class CIContent: ItemContent {
override val text get() = msgContent.text
}
+ @Serializable @SerialName("sndDeleted")
+ class SndDeleted(val deleteMode: CIDeleteMode): CIContent() {
+ override val text get() = "deleted"
+ }
+
+ @Serializable @SerialName("rcvDeleted")
+ class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() {
+ override val text get() = "deleted"
+ }
+
@Serializable @SerialName("sndFileInvitation")
class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() {
override val text get() = "sending files is not supported yet"
diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
index c2ce6ad329..53968f7719 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt
@@ -3,8 +3,17 @@ package chat.simplex.app.model
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.content.Context
+import android.content.SharedPreferences
import android.util.Log
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
@@ -19,19 +28,25 @@ import kotlin.concurrent.thread
typealias ChatCtrl = Long
-open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context) {
+open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: NtfManager, val appContext: Context) {
var chatModel = ChatModel(this)
+ private val sharedPreferences: SharedPreferences = appContext.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
- suspend fun startChat(u: User) {
- Log.d(TAG, "user: $u")
+ init {
+ chatModel.runServiceInBackground.value = getRunServiceInBackground()
+ }
+
+ suspend fun startChat(user: User) {
+ Log.d(TAG, "user: $user")
try {
apiStartChat()
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
- chatModel.chats.addAll(apiGetChats())
- chatModel.currentUser = mutableStateOf(u)
+ val chats = apiGetChats()
+ chatModel.chats.clear()
+ chatModel.chats.addAll(chats)
+ chatModel.currentUser = mutableStateOf(user)
chatModel.userCreated.value = true
- startReceiver()
Log.d(TAG, "started chat")
} catch(e: Error) {
Log.e(TAG, "failed starting chat $e")
@@ -40,6 +55,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
}
fun startReceiver() {
+ Log.d(TAG, "ChatController startReceiver")
thread(name="receiver") {
withApi { recvMspLoop() }
}
@@ -105,7 +121,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
suspend fun apiStartChat() {
val r = sendCmd(CC.StartChat())
- if (r is CR.ChatStarted ) return
+ if (r is CR.ChatStarted || r is CR.ChatRunning) return
throw Error("failed starting chat: ${r.responseType} ${r.details}")
}
@@ -131,17 +147,17 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
return null
}
- suspend fun apiUpdateMessage(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
- val r = sendCmd(CC.ApiUpdateMessage(type, id, itemId, mc))
+ suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
+ val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc))
if (r is CR.ChatItemUpdated) return r.chatItem
- Log.e(TAG, "apiUpdateMessage bad response: ${r.responseType} ${r.details}")
+ Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
return null
}
- suspend fun apiDeleteMessage(type: ChatType, id: Long, itemId: Long, mode: MsgDeleteMode): AChatItem? {
- val r = sendCmd(CC.ApiDeleteMessage(type, id, itemId, mode))
- if (r is CR.ChatItemDeleted) return r.chatItem
- Log.e(TAG, "apiDeleteMessage bad response: ${r.responseType} ${r.details}")
+ suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): AChatItem? {
+ val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
+ if (r is CR.ChatItemDeleted) return r.toChatItem
+ Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
return null
}
@@ -160,7 +176,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
"Error saving SMP servers",
- "Make sure SMP server addresses are in correct format, line separated and are not duplicated"
+ "Make sure SMP server addresses are in correct format, line separated and are not duplicated."
)
false
}
@@ -180,7 +196,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
AlertManager.shared.showAlertMsg("Contact already exists",
- "You are already connected to ${r.contact.displayName} via this link"
+ "You are already connected to ${r.contact.displayName} via this link."
)
return false
}
@@ -207,7 +223,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
AlertManager.shared.showAlertMsg(
"Can't delete contact!",
- "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}"
+ "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}."
)
}
}
@@ -320,7 +336,11 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
is CR.ChatItemStatusUpdated -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
- if (chatModel.upsertChatItem(cInfo, cItem)) {
+ var res = false
+ if (!cItem.isDeletedContent) {
+ res = chatModel.upsertChatItem(cInfo, cItem)
+ }
+ if (res) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
@@ -332,7 +352,14 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
}
}
is CR.ChatItemDeleted -> {
- // TODO
+ val cInfo = r.toChatItem.chatInfo
+ val cItem = r.toChatItem.chatItem
+ if (cItem.meta.itemDeleted) {
+ chatModel.removeChatItem(cInfo, cItem)
+ } else {
+ // currently only broadcast deletion of rcv message can be received, and only this case should happen
+ chatModel.upsertChatItem(cInfo, cItem)
+ }
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
@@ -359,11 +386,79 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
else e.string
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err))
}
-}
-enum class MsgDeleteMode(val mode: String) {
- Broadcast("broadcast"),
- Internal("internal");
+ fun showBackgroundServiceNotice() {
+ if (!getBackgroundServiceNoticeShown()) {
+ AlertManager.shared.showAlert {
+ AlertDialog(
+ onDismissRequest = AlertManager.shared::hideAlert,
+ title = {
+ Row {
+ Icon(
+ Icons.Outlined.Bolt,
+ contentDescription = "Instant notifications",
+ )
+ Text("Private instant notifications!", fontWeight = FontWeight.Bold)
+ }
+ },
+ text = {
+ Column {
+ Text(
+ buildAnnotatedString {
+ append("To preserve your privacy, instead of push notifications the app has a ")
+ withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
+ append("SimpleX background service")
+ }
+ append(" – it uses a few percent of the battery per day.")
+ },
+ Modifier.padding(bottom = 8.dp)
+ )
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
+ append("It can be disabled via settings")
+ }
+ append(" – notifications will still be shown while the app is running.")
+ }
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = AlertManager.shared::hideAlert) { Text("Ok") }
+ }
+ )
+ }
+ setBackgroundServiceNoticeShown()
+ }
+ }
+
+ fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
+
+ fun setAutoRestartWorkerVersion(version: Int) =
+ sharedPreferences.edit()
+ .putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version)
+ .apply()
+
+ fun getRunServiceInBackground(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
+
+ fun setRunServiceInBackground(runService: Boolean) =
+ sharedPreferences.edit()
+ .putBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, runService)
+ .apply()
+
+ fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
+
+ fun setBackgroundServiceNoticeShown() =
+ sharedPreferences.edit()
+ .putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, true)
+ .apply()
+
+ companion object {
+ private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
+ private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
+ private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
+ private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
+ }
}
// ChatCommand
@@ -376,8 +471,8 @@ sealed class CC {
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
- class ApiUpdateMessage(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
- class ApiDeleteMessage(val type: ChatType, val id: Long, val itemId: Long, val mode: MsgDeleteMode): CC()
+ class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
+ class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class GetUserSMPServers(): CC()
class SetUserSMPServers(val smpServers: List): CC()
class AddContact: CC()
@@ -400,8 +495,8 @@ sealed class CC {
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}"
- is ApiUpdateMessage -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
- is ApiDeleteMessage -> "/_delete item ${chatRef(type, id)} $itemId $mode"
+ is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
+ is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is AddContact -> "/connect"
@@ -425,8 +520,8 @@ sealed class CC {
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
is ApiSendMessageQuote -> "apiSendMessageQuote"
- is ApiUpdateMessage -> "apiUpdateMessage"
- is ApiDeleteMessage -> "apiDeleteMessage"
+ is ApiUpdateChatItem -> "apiUpdateChatItem"
+ is ApiDeleteChatItem -> "apiDeleteChatItem"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is AddContact -> "addContact"
@@ -512,7 +607,7 @@ sealed class CR {
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
- @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val chatItem: AChatItem): CR()
+ @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
@Serializable @SerialName("cmdOk") class CmdOk: CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
@@ -593,7 +688,7 @@ sealed class CR {
is NewChatItem -> json.encodeToString(chatItem)
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
- is ChatItemDeleted -> json.encodeToString(chatItem)
+ is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
is CmdOk -> noDetails()
is ChatCmdError -> chatError.string
is ChatRespError -> chatError.string
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
index f2f2c0b486..3f4b1c6abf 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt
@@ -13,3 +13,5 @@ val SecretColor = Color(0x40808080)
val LightGray = Color(241, 242, 246, 255)
val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
+val ToolbarLight = Color(220, 220, 220, 20)
+val ToolbarDark = Color(80, 80, 80, 20)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
index 52b9948c13..680a6d3d89 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt
@@ -2,45 +2,54 @@ package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.font.*
import androidx.compose.ui.unit.sp
+import chat.simplex.app.R
+
+// https://github.com/rsms/inter
+val Inter = FontFamily(
+ Font(R.font.inter_regular),
+ Font(R.font.inter_italic, style = FontStyle.Italic),
+ Font(R.font.inter_bold, weight = FontWeight.Bold),
+ Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
+ Font(R.font.inter_medium, weight = FontWeight.Medium),
+)
// Set of Material typography styles to start with
val Typography = Typography(
h1 = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
+ fontFamily = Inter,
+ fontWeight = FontWeight.Bold,
fontSize = 32.sp,
),
h2 = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 24.sp
),
h3 = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 19.sp
),
body1 = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 17.sp
- ),
- body2 = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
+ body2 = TextStyle(
+ fontFamily = Inter,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp
+ ),
button = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = Inter,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
),
caption = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = Inter,
fontWeight = FontWeight.Normal,
- fontSize = 20.sp
+ fontSize = 18.sp
)
-)
\ No newline at end of file
+)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
index 3ce907afc7..98b828c42e 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt
@@ -1,6 +1,5 @@
package chat.simplex.app.views
-import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
index 3c2e33adbc..996b0bd2e1 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
+import chat.simplex.app.SimplexService
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.withApi
@@ -151,6 +152,8 @@ fun CreateProfilePanel(chatModel: ChatModel) {
Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
+ SimplexService.start(chatModel.controller.appContext)
+ chatModel.controller.showBackgroundServiceNotice()
}
},
enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName))
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
index cc272b8fb8..ed43311f96 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -61,7 +62,7 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
ChatInfoImage(chat, size = 192.dp)
val cInfo = chat.chatInfo
Text(
- cInfo.displayName, style = MaterialTheme.typography.h1,
+ cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
index a72ac162da..02b8ab1da6 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt
@@ -3,18 +3,19 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.ArrowBackIos
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
@@ -23,8 +24,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.model.*
-import chat.simplex.app.ui.theme.SimpleXTheme
+import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.ChatItemView
+import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ModalManager
import com.google.accompanist.insets.ProvideWindowInsets
@@ -62,13 +64,19 @@ fun ChatView(chatModel: ChatModel) {
ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
+ openDirectChat = { contactId ->
+ val c = chatModel.chats.firstOrNull {
+ it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
+ }
+ if (c != null) withApi { openChat(chatModel, c.chatInfo) }
+ },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val ei = editingItem.value
if (ei != null) {
- val updatedItem = chatModel.controller.apiUpdateMessage(
+ val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = ei.meta.itemId,
@@ -89,7 +97,19 @@ fun ChatView(chatModel: ChatModel) {
quotedItem.value = null
}
},
- resetMessage = { msg.value = "" }
+ resetMessage = { msg.value = "" },
+ deleteMessage = { itemId, mode ->
+ withApi {
+ val cInfo = chat.chatInfo
+ val toItem = chatModel.controller.apiDeleteChatItem(
+ type = cInfo.chatType,
+ id = cInfo.apiId,
+ itemId = itemId,
+ mode = mode
+ )
+ if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
+ }
+ }
)
}
}
@@ -104,8 +124,10 @@ fun ChatLayout(
editingItem: MutableState,
back: () -> Unit,
info: () -> Unit,
+ openDirectChat: (Long) -> Unit,
sendMessage: (String) -> Unit,
- resetMessage: () -> Unit
+ resetMessage: () -> Unit,
+ deleteMessage: (Long, CIDeleteMode) -> Unit
) {
Surface(
Modifier
@@ -119,7 +141,7 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
- ChatItemsList(user, chatItems, msg, quotedItem, editingItem)
+ ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage)
}
}
}
@@ -128,46 +150,51 @@ fun ChatLayout(
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
- Box(
- Modifier
- .height(60.dp)
- .padding(horizontal = 8.dp),
- contentAlignment = Alignment.CenterStart
- ) {
- IconButton(onClick = back) {
- Icon(
- Icons.Outlined.ArrowBack,
- "Back",
- tint = MaterialTheme.colors.primary,
- modifier = Modifier.padding(10.dp)
- )
- }
- Row(
+ Column {
+ Box(
Modifier
- .padding(horizontal = 68.dp)
.fillMaxWidth()
- .clickable(onClick = info),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically
+ .height(52.dp)
+ .background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
+ .padding(horizontal = 8.dp),
+ contentAlignment = Alignment.CenterStart,
) {
- val cInfo = chat.chatInfo
- ChatInfoImage(chat, size = 40.dp)
- Column(
- Modifier.padding(start = 8.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- cInfo.displayName, fontWeight = FontWeight.Bold,
- maxLines = 1, overflow = TextOverflow.Ellipsis
+ IconButton(onClick = back) {
+ Icon(
+ Icons.Outlined.ArrowBackIos,
+ "Back",
+ tint = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(10.dp)
)
- if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
+ }
+ Row(
+ Modifier
+ .padding(horizontal = 68.dp)
+ .fillMaxWidth()
+ .clickable(onClick = info),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val cInfo = chat.chatInfo
+ ChatInfoImage(chat, size = 40.dp)
+ Column(
+ Modifier.padding(start = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
Text(
- cInfo.fullName,
+ cInfo.displayName, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
+ if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
+ Text(
+ cInfo.fullName,
+ maxLines = 1, overflow = TextOverflow.Ellipsis
+ )
+ }
}
}
}
+ Divider()
}
}
@@ -186,10 +213,13 @@ val CIListStateSaver = run {
@Composable
fun ChatItemsList(
user: User,
+ chat: Chat,
chatItems: List,
msg: MutableState,
quotedItem: MutableState,
- editingItem: MutableState
+ editingItem: MutableState,
+ openDirectChat: (Long) -> Unit,
+ deleteMessage: (Long, CIDeleteMode) -> Unit
) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
@@ -200,8 +230,51 @@ fun ChatItemsList(
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
LazyColumn(state = listState) {
- items(chatItems) { cItem ->
- ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler)
+ itemsIndexed(chatItems) { i, cItem ->
+ if (i == 0) {
+ Spacer(Modifier.size(8.dp))
+ }
+ if (chat.chatInfo is ChatInfo.Group) {
+ if (cItem.chatDir is CIDirection.GroupRcv) {
+ val prevItem = if (i > 0) chatItems[i - 1] else null
+ val member = cItem.chatDir.groupMember
+ val showMember = showMemberImage(member, prevItem)
+ Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
+ if (showMember) {
+ val contactId = member.memberContactId
+ if (contactId == null) {
+ MemberImage(member)
+ } else {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .clickable { openDirectChat(contactId) }
+ ) {
+ MemberImage(member)
+ }
+ }
+ Spacer(Modifier.size(4.dp))
+ } else {
+ Spacer(Modifier.size(42.dp))
+ }
+ ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage)
+ }
+ } else {
+ Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
+ ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
+ }
+ }
+ } else { // direct message
+ val sent = cItem.chatDir.sent
+ Box(
+ Modifier.padding(
+ start = if (sent) 76.dp else 12.dp,
+ end = if (sent) 12.dp else 76.dp,
+ )
+ ) {
+ ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage)
+ }
+ }
}
val len = chatItems.count()
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
@@ -213,6 +286,16 @@ fun ChatItemsList(
}
}
+fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
+ return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
+ (prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
+}
+
+@Composable
+fun MemberImage(member: GroupMember) {
+ ProfileImage(38.dp, member.memberProfile.image)
+}
+
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -229,14 +312,15 @@ fun PreviewChatLayout() {
ChatItem.getSampleData(
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
),
- ChatItem.getSampleData(
- 3, CIDirection.DirectSnd(), Clock.System.now(), "hello"
- ),
+ ChatItem.getDeletedContentSampleData(3),
ChatItem.getSampleData(
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
- 5, CIDirection.DirectRcv(), Clock.System.now(), "hello"
+ 5, CIDirection.DirectSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
ChatLayout(
@@ -252,8 +336,53 @@ fun PreviewChatLayout() {
editingItem = remember { mutableStateOf(null) },
back = {},
info = {},
+ openDirectChat = {},
sendMessage = {},
- resetMessage = {}
+ resetMessage = {},
+ deleteMessage = { _, _ -> }
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewGroupChatLayout() {
+ SimpleXTheme {
+ val chatItems = listOf(
+ ChatItem.getSampleData(
+ 1, CIDirection.GroupSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
+ ),
+ ChatItem.getDeletedContentSampleData(3),
+ ChatItem.getSampleData(
+ 4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 5, CIDirection.GroupSnd(), Clock.System.now(), "hello"
+ ),
+ ChatItem.getSampleData(
+ 6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
+ )
+ )
+ ChatLayout(
+ user = User.sampleData,
+ chat = Chat(
+ chatInfo = ChatInfo.Group.sampleData,
+ chatItems = chatItems,
+ chatStats = Chat.ChatStats()
+ ),
+ chatItems = chatItems,
+ msg = remember { mutableStateOf("") },
+ quotedItem = remember { mutableStateOf(null) },
+ editingItem = remember { mutableStateOf(null) },
+ back = {},
+ info = {},
+ openDirectChat = {},
+ sendMessage = {},
+ resetMessage = {},
+ deleteMessage = { _, _ -> }
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
index 790b34924d..3cea4b1f52 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt
@@ -1,7 +1,8 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.layout.Column
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
import chat.simplex.app.model.ChatItem
// TODO ComposeState
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
index 0517b0ab10..ec00538bce 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt
@@ -22,13 +22,16 @@ fun CIMetaView(chatItem: ChatItem) {
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
- if (chatItem.meta.itemEdited) {
- Icon(
- Icons.Filled.Edit,
- modifier = Modifier.height(12.dp),
- contentDescription = "Edited",
- tint = HighOrLowlight,
- )
+ if (!chatItem.isDeletedContent) {
+ if (chatItem.meta.itemEdited) {
+ Icon(
+ Icons.Filled.Edit,
+ modifier = Modifier.height(12.dp),
+ contentDescription = "Edited",
+ tint = HighOrLowlight,
+ )
+ }
+ // TODO status
}
Text(
chatItem.timestampText,
@@ -58,3 +61,11 @@ fun PreviewCIMetaViewEdited() {
)
)
}
+
+@Preview
+@Composable
+fun PreviewCIMetaViewDeletedContent() {
+ CIMetaView(
+ chatItem = ChatItem.getDeletedContentSampleData()
+ )
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
index e5cfad3a34..dfce857f10 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.UriHandler
@@ -18,8 +19,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.helpers.copyText
-import chat.simplex.app.views.helpers.shareText
+import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
@Composable
@@ -30,7 +30,9 @@ fun ChatItemView(
quotedItem: MutableState,
editingItem: MutableState,
cxt: Context,
- uriHandler: UriHandler? = null
+ uriHandler: UriHandler? = null,
+ showMember: Boolean = false,
+ deleteMessage: (Long, CIDeleteMode) -> Unit
) {
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
@@ -38,60 +40,100 @@ fun ChatItemView(
Box(
modifier = Modifier
.padding(bottom = 4.dp)
- .fillMaxWidth()
- .padding(
- start = if (sent) 86.dp else 16.dp,
- end = if (sent) 16.dp else 86.dp,
- ),
+ .fillMaxWidth(),
contentAlignment = alignment,
) {
Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) {
- if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
- EmojiItemView(cItem)
- } else {
- FramedItemView(user, cItem, uriHandler)
+ if (cItem.isMsgContent) {
+ if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
+ EmojiItemView(cItem)
+ } else {
+ FramedItemView(user, cItem, uriHandler, showMember = showMember)
+ }
+ } else if (cItem.isDeletedContent) {
+ DeletedItemView(cItem, showMember = showMember)
}
- DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
- ItemAction("Reply", Icons.Outlined.Reply, onClick = {
- editingItem.value = null
- quotedItem.value = cItem
- showMenu = false
- })
- ItemAction("Share", Icons.Outlined.Share, onClick = {
- shareText(cxt, cItem.content.text)
- showMenu = false
- })
- ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
- copyText(cxt, cItem.content.text)
- showMenu = false
- })
-// if (cItem.chatDir.sent && cItem.meta.editable) {
-// ItemAction("Edit", Icons.Filled.Edit, onClick = {
-// quotedItem.value = null
-// editingItem.value = cItem
-// msg.value = cItem.content.text
-// showMenu = false
-// })
-// }
+ if (cItem.isMsgContent) {
+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
+ ItemAction("Reply", Icons.Outlined.Reply, onClick = {
+ editingItem.value = null
+ quotedItem.value = cItem
+ showMenu = false
+ })
+ ItemAction("Share", Icons.Outlined.Share, onClick = {
+ shareText(cxt, cItem.content.text)
+ showMenu = false
+ })
+ ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
+ copyText(cxt, cItem.content.text)
+ showMenu = false
+ })
+ if (cItem.chatDir.sent && cItem.meta.editable) {
+ ItemAction("Edit", Icons.Filled.Edit, onClick = {
+ quotedItem.value = null
+ editingItem.value = cItem
+ msg.value = cItem.content.text
+ showMenu = false
+ })
+ }
+ ItemAction(
+ "Delete",
+ Icons.Outlined.Delete,
+ onClick = {
+ showMenu = false
+ deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
+ },
+ color = Color.Red
+ )
+ }
}
}
}
}
@Composable
-private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit) {
+private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
Row {
Text(
- text, modifier = Modifier
+ text,
+ modifier = Modifier
.fillMaxWidth()
- .weight(1F)
+ .weight(1F),
+ color = color
)
- Icon(icon, text, tint = HighOrLowlight)
+ Icon(icon, text, tint = color)
}
}
}
+fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
+ AlertManager.shared.showAlertDialogButtons(
+ title = "Delete message?",
+ text = "Message will be deleted - this cannot be undone!",
+ buttons = {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Button(onClick = {
+ deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
+ AlertManager.shared.hideAlert()
+ }) { Text("For me only") }
+// if (chatItem.meta.editable) {
+// Spacer(Modifier.padding(horizontal = 4.dp))
+// Button(onClick = {
+// deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
+// AlertManager.shared.hideAlert()
+// }) { Text("For everyone") }
+// }
+ }
+ }
+ )
+}
+
@Preview
@Composable
fun PreviewChatItemView() {
@@ -104,7 +146,24 @@ fun PreviewChatItemView() {
msg = remember { mutableStateOf("") },
quotedItem = remember { mutableStateOf(null) },
editingItem = remember { mutableStateOf(null) },
- cxt = LocalContext.current
+ cxt = LocalContext.current,
+ deleteMessage = { _, _ -> }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewChatItemViewDeletedContent() {
+ SimpleXTheme {
+ ChatItemView(
+ User.sampleData,
+ ChatItem.getDeletedContentSampleData(),
+ msg = remember { mutableStateOf("") },
+ quotedItem = remember { mutableStateOf(null) },
+ editingItem = remember { mutableStateOf(null) },
+ cxt = LocalContext.current,
+ deleteMessage = { _, _ -> }
)
}
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt
new file mode 100644
index 0000000000..849550e557
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt
@@ -0,0 +1,56 @@
+package chat.simplex.app.views.chat.item
+
+import android.content.res.Configuration
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.*
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.tooling.preview.*
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.app.model.*
+import chat.simplex.app.ui.theme.HighOrLowlight
+import chat.simplex.app.ui.theme.SimpleXTheme
+
+@Composable
+fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
+ Surface(
+ shape = RoundedCornerShape(18.dp),
+ color = ReceivedColorLight,
+ ) {
+ Row(
+ Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Text(
+ buildAnnotatedString {
+ appendSender(this, if (showMember) ci.memberDisplayName else null, true)
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
+ },
+ style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ CIMetaView(ci)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ name = "Dark Mode"
+)
+@Composable
+fun PreviewDeletedItemView() {
+ SimpleXTheme {
+ DeletedItemView(
+ ChatItem.getDeletedContentSampleData()
+ )
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
index d5b2d9c24b..302f3ee1dc 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt
@@ -23,7 +23,7 @@ val SentQuoteColorLight = Color(0x2545B8FF)
val ReceivedQuoteColorLight = Color(0x25B1B0B5)
@Composable
-fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) {
+fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, showMember: Boolean = false) {
val sent = ci.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
@@ -58,7 +58,7 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) {
}
} else {
MarkdownText(
- ci.content, ci.formattedText, ci.memberDisplayName,
+ ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true
)
}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
index fa25259b37..fbd8b235a5 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt
@@ -43,7 +43,7 @@ suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = "Accept connection request?",
- text = "If you choose to reject sender will NOT be notified",
+ text = "If you choose to reject sender will NOT be notified.",
confirmText = "Accept",
onConfirm = {
withApi {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
index 65b216fdee..44017bc904 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt
@@ -1,7 +1,6 @@
package chat.simplex.app.views.chatlist
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -16,6 +15,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
+import chat.simplex.app.ui.theme.ToolbarDark
+import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.usersettings.SettingsView
@@ -76,6 +77,7 @@ fun ChatListView(chatModel: ChatModel) {
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl)
+ Divider()
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
} else {
@@ -100,7 +102,7 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.fillMaxWidth()
- .padding(8.dp)
+ .padding(16.dp)
) {
Text(
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
@@ -135,8 +137,9 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
+ .height(52.dp)
+ .background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 8.dp)
- .height(60.dp)
) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
@@ -149,7 +152,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Text(
"Your chats",
color = MaterialTheme.colors.onBackground,
- fontWeight = FontWeight.Bold,
+ fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(5.dp)
)
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
@@ -165,7 +168,6 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
@Composable
fun ChatList(chatModel: ChatModel) {
- Divider(Modifier.padding(horizontal = 8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt
index f431e68e67..a746df1925 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt
@@ -21,6 +21,22 @@ class AlertManager {
alertView.value = null
}
+ fun showAlertDialogButtons(
+ title: String,
+ text: String? = null,
+ buttons: @Composable () -> Unit,
+ ) {
+ val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
+ showAlert {
+ AlertDialog(
+ onDismissRequest = this::hideAlert,
+ title = { Text(title) },
+ text = alertText,
+ buttons = buttons
+ )
+ }
+ }
+
fun showAlertDialog(
title: String,
text: String? = null,
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
index d3a416c0d4..545bf105f4 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt
@@ -43,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
) {
Text(
"Add contact",
- style = MaterialTheme.typography.h1,
+ style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
)
Text(
"Show QR code to your contact\nto scan from the app",
- style = MaterialTheme.typography.h2.copy(fontSize = if(screenHeight > 600.dp) 26.sp else 20.sp),
+ style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center,
)
QRCode(
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
index 4abcf2bc46..06f34cb0a6 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt
@@ -76,11 +76,11 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni
) {
Text(
"Scan QR code",
- style = MaterialTheme.typography.h1,
+ style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
)
Text(
"Your chat profile will be sent\nto your contact",
- style = MaterialTheme.typography.h2,
+ style = MaterialTheme.typography.h3,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 4.dp)
)
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
index 8c593e1fb0..f9a6b4c9ce 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt
@@ -61,7 +61,7 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = "Use SimpleX Chat servers?",
- text = "Saved SMP servers will be removed",
+ text = "Saved SMP servers will be removed.",
confirmText = "Confirm",
onConfirm = {
saveSMPServers(listOf())
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index 581c9cf271..e74aa7042e 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
-import androidx.compose.runtime.Composable
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
@@ -21,6 +21,7 @@ import chat.simplex.app.BuildConfig
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
+import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.helpers.ProfileImage
@@ -32,6 +33,11 @@ fun SettingsView(chatModel: ChatModel) {
if (user != null) {
SettingsLayout(
profile = user.profile,
+ runServiceInBackground = chatModel.runServiceInBackground,
+ setRunServiceInBackground = { on ->
+ chatModel.controller.setRunServiceInBackground(on)
+ chatModel.runServiceInBackground.value = on
+ },
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
@@ -45,6 +51,8 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
+ runServiceInBackground: MutableState,
+ setRunServiceInBackground: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
@@ -63,7 +71,7 @@ fun SettingsLayout(
.padding(top = 16.dp)
) {
Text(
- "Your Settings",
+ "Your settings",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 8.dp)
)
@@ -144,6 +152,27 @@ fun SettingsLayout(
Text("SMP servers")
}
Divider(Modifier.padding(horizontal = 8.dp))
+ SettingsSectionView() {
+ Icon(
+ Icons.Outlined.Bolt,
+ contentDescription = "Private notifications",
+ )
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text("Private notifications", Modifier
+ .padding(end = 24.dp)
+ .fillMaxWidth()
+ .weight(1F))
+ Switch(
+ checked = runServiceInBackground.value,
+ onCheckedChange = { setRunServiceInBackground(it) },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colors.primary,
+ uncheckedThumbColor = HighOrLowlight
+ ),
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+ Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
@@ -169,7 +198,7 @@ fun SettingsLayout(
)
}
Divider(Modifier.padding(horizontal = 8.dp))
- SettingsSectionView(click = {}) {
+ SettingsSectionView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@@ -177,13 +206,13 @@ fun SettingsLayout(
}
@Composable
-fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
+fun SettingsSectionView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
+ val modifier = Modifier
+ .padding(start = 8.dp)
+ .fillMaxWidth()
+ .height(height)
Row(
- Modifier
- .padding(start = 8.dp)
- .fillMaxWidth()
- .clickable(onClick = click)
- .height(height),
+ if (click == null) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
content()
@@ -201,6 +230,8 @@ fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
+ runServiceInBackground = remember { mutableStateOf(true) },
+ setRunServiceInBackground = {},
showModal = {{}},
showCustomModal = {{}},
showTerminal = {}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
index 7c40fad557..486260220a 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt
@@ -33,7 +33,7 @@ fun UserAddressView(chatModel: ChatModel) {
deleteAddress = {
AlertManager.shared.showAlertMsg(
title = "Delete address?",
- text = "All your contacts will remain connected",
+ text = "All your contacts will remain connected.",
confirmText = "Delete",
onConfirm = {
withApi {
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
index f72e512bca..46ddaffe89 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt
@@ -1,12 +1,8 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
-import android.widget.ScrollView
import androidx.compose.foundation.*
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
@@ -26,7 +22,6 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
-import chat.simplex.app.views.chat.CIListState
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ModalView
import com.google.accompanist.insets.ProvideWindowInsets
diff --git a/apps/android/app/src/main/res/font/inter_bold.ttf b/apps/android/app/src/main/res/font/inter_bold.ttf
new file mode 100644
index 0000000000..7e1deec31e
Binary files /dev/null and b/apps/android/app/src/main/res/font/inter_bold.ttf differ
diff --git a/apps/android/app/src/main/res/font/inter_italic.ttf b/apps/android/app/src/main/res/font/inter_italic.ttf
new file mode 100644
index 0000000000..e1afbe7edd
Binary files /dev/null and b/apps/android/app/src/main/res/font/inter_italic.ttf differ
diff --git a/apps/android/app/src/main/res/font/inter_medium.ttf b/apps/android/app/src/main/res/font/inter_medium.ttf
new file mode 100644
index 0000000000..7e573f6498
Binary files /dev/null and b/apps/android/app/src/main/res/font/inter_medium.ttf differ
diff --git a/apps/android/app/src/main/res/font/inter_regular.ttf b/apps/android/app/src/main/res/font/inter_regular.ttf
new file mode 100644
index 0000000000..012d1b470d
Binary files /dev/null and b/apps/android/app/src/main/res/font/inter_regular.ttf differ
diff --git a/apps/android/app/src/main/res/font/inter_semi_bold.ttf b/apps/android/app/src/main/res/font/inter_semi_bold.ttf
new file mode 100644
index 0000000000..4be54399d6
Binary files /dev/null and b/apps/android/app/src/main/res/font/inter_semi_bold.ttf differ
diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml
index c8517623e9..228c03d113 100644
--- a/apps/android/app/src/main/res/values/strings.xml
+++ b/apps/android/app/src/main/res/values/strings.xml
@@ -1,3 +1,7 @@
SimpleX
-
\ No newline at end of file
+
+
+ SimpleX Chat service
+ Waiting for incoming messages
+
diff --git a/apps/android/build.gradle b/apps/android/build.gradle
index af369aaf9a..ad0b119f62 100644
--- a/apps/android/build.gradle
+++ b/apps/android/build.gradle
@@ -1,6 +1,6 @@
buildscript {
ext {
- compose_version = '1.1.0'
+ compose_version = '1.1.1'
}
repositories {
google()
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index fd9a70e0ee..b9e7fb066d 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -55,7 +55,7 @@ final class AlertManager: ObservableObject {
func showAlert(_ alert: Alert) {
logger.debug("AlertManager.showAlert")
- DispatchQueue.main.async {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.alertView = alert
self.presentAlert = true
}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index c0713686c3..1d52b2ab40 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -135,6 +135,23 @@ final class ChatModel: ObservableObject {
return res
}
}
+
+ func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
+ // update previews
+ if let chat = getChat(cInfo.id) {
+ if let pItem = chat.chatItems.last, pItem.id == cItem.id {
+ chat.chatItems = [cItem]
+ }
+ }
+ // remove from current chat
+ if chatId == cInfo.id {
+ if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
+ _ = withAnimation {
+ self.chatItems.remove(at: i)
+ }
+ }
+ }
+ }
func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview
@@ -164,6 +181,14 @@ final class ChatModel: ObservableObject {
}
}
+ func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
+ if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 {
+ return chatItems[i - 1]
+ } else {
+ return nil
+ }
+ }
+
func popChat(_ id: String) {
if let i = getChatIndex(id) {
popChat_(i)
@@ -519,6 +544,16 @@ struct GroupMember: Decodable {
var memberContactId: Int64?
// var activeConn: Connection?
+ var directChatId: ChatId? {
+ get {
+ if let chatId = memberContactId {
+ return "@\(chatId)"
+ } else {
+ return nil
+ }
+ }
+ }
+
static let sampleData = GroupMember(
groupMemberId: 1,
memberId: "abcd",
@@ -553,6 +588,22 @@ struct ChatItem: Identifiable, Decodable {
if case .rcvNew = meta.itemStatus { return true }
return false
}
+
+ func isMsgContent() -> Bool {
+ switch content {
+ case .sndMsgContent: return true
+ case .rcvMsgContent: return true
+ default: return false
+ }
+ }
+
+ func isDeletedContent() -> Bool {
+ switch content {
+ case .sndDeleted: return true
+ case .rcvDeleted: return true
+ default: return false
+ }
+ }
var memberDisplayName: String? {
get {
@@ -566,10 +617,19 @@ struct ChatItem: Identifiable, Decodable {
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem {
ChatItem(
- chatDir: dir,
- meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
- content: .sndMsgContent(msgContent: .text(text)),
- quotedItem: quotedItem
+ chatDir: dir,
+ meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
+ content: .sndMsgContent(msgContent: .text(text)),
+ quotedItem: quotedItem
+ )
+ }
+
+ static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
+ ChatItem(
+ chatDir: dir,
+ meta: CIMeta.getSample(id, ts, text, status, false, false, false),
+ content: .rcvDeleted(deleteMode: .cidmBroadcast),
+ quotedItem: nil
)
}
}
@@ -637,6 +697,11 @@ enum CIStatus: Decodable {
case rcvRead
}
+enum CIDeleteMode: String, Decodable {
+ case cidmBroadcast = "broadcast"
+ case cidmInternal = "internal"
+}
+
protocol ItemContent {
var text: String { get }
}
@@ -644,6 +709,8 @@ protocol ItemContent {
enum CIContent: Decodable, ItemContent {
case sndMsgContent(msgContent: MsgContent)
case rcvMsgContent(msgContent: MsgContent)
+ case sndDeleted(deleteMode: CIDeleteMode)
+ case rcvDeleted(deleteMode: CIDeleteMode)
case sndFileInvitation(fileId: Int64, filePath: String)
case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer)
@@ -652,6 +719,8 @@ enum CIContent: Decodable, ItemContent {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
+ case .sndDeleted: return "deleted"
+ case .rcvDeleted: return "deleted"
case .sndFileInvitation: return "sending files is not supported yet"
case .rcvFileInvitation: return "receiving files is not supported yet"
}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 0f0386bf18..68f38e423e 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -15,11 +15,6 @@ private var chatController: chat_ctrl?
private let jsonDecoder = getJSONDecoder()
private let jsonEncoder = getJSONEncoder()
-enum MsgDeleteMode: String {
- case mdBroadcast = "broadcast"
- case mdInternal = "internal"
-}
-
enum ChatCommand {
case showActiveUser
case createActiveUser(profile: Profile)
@@ -28,8 +23,8 @@ enum ChatCommand {
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
- case apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
- case apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode)
+ case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
+ case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
case getUserSMPServers
case setUserSMPServers(smpServers: [String])
case addContact
@@ -54,8 +49,8 @@ enum ChatCommand {
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)"
- case let .apiUpdateMessage(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
- case let .apiDeleteMessage(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
+ case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
+ case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case .getUserSMPServers: return "/smp_servers"
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case .addContact: return "/connect"
@@ -83,8 +78,8 @@ enum ChatCommand {
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
case .apiSendMessageQuote: return "apiSendMessageQuote"
- case .apiUpdateMessage: return "apiUpdateMessage"
- case .apiDeleteMessage: return "apiDeleteMessage"
+ case .apiUpdateChatItem: return "apiUpdateChatItem"
+ case .apiDeleteChatItem: return "apiDeleteChatItem"
case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers"
case .addContact: return "addContact"
@@ -126,6 +121,7 @@ enum ChatResponse: Decodable, Error {
case invitation(connReqInvitation: String)
case sentConfirmation
case sentInvitation
+ case contactAlreadyExists(contact: Contact)
case contactDeleted(contact: Contact)
case userProfileNoChange
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
@@ -148,7 +144,7 @@ enum ChatResponse: Decodable, Error {
case newChatItem(chatItem: AChatItem)
case chatItemStatusUpdated(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
- case chatItemDeleted(chatItem: AChatItem)
+ case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
case cmdOk
case chatCmdError(chatError: ChatError)
case chatError(chatError: ChatError)
@@ -166,6 +162,7 @@ enum ChatResponse: Decodable, Error {
case .invitation: return "invitation"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
+ case .contactAlreadyExists: return "contactAlreadyExists"
case .contactDeleted: return "contactDeleted"
case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileUpdated"
@@ -209,6 +206,7 @@ enum ChatResponse: Decodable, Error {
case let .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
+ case let .contactAlreadyExists(contact): return String(describing: contact)
case let .contactDeleted(contact): return String(describing: contact)
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
@@ -231,7 +229,7 @@ enum ChatResponse: Decodable, Error {
case let .newChatItem(chatItem): return String(describing: chatItem)
case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
- case let .chatItemDeleted(chatItem): return String(describing: chatItem)
+ case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
case .cmdOk: return noDetails
case let .chatCmdError(chatError): return String(describing: chatError)
case let .chatError(chatError): return String(describing: chatError)
@@ -410,15 +408,15 @@ func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgCon
throw r
}
-func apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
- let r = await chatSendCmd(.apiUpdateMessage(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
+func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
+ let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
throw r
}
-func apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode) async throws -> ChatItem {
- let r = await chatSendCmd(.apiDeleteMessage(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
- if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
+func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
+ let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
+ if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
throw r
}
@@ -440,11 +438,36 @@ func apiAddContact() throws -> String {
throw r
}
-func apiConnect(connReq: String) async throws {
+func apiConnect(connReq: String) async throws -> Bool {
let r = await chatSendCmd(.connect(connReq: connReq))
+ let am = AlertManager.shared
switch r {
- case .sentConfirmation: return
- case .sentInvitation: return
+ case .sentConfirmation: return true
+ case .sentInvitation: return true
+ case let .contactAlreadyExists(contact):
+ am.showAlertMsg(
+ title: "Contact already exists",
+ message: "You are already connected to \(contact.displayName) via this link."
+ )
+ return false
+ case .chatCmdError(.error(.invalidConnReq)):
+ am.showAlertMsg(
+ title: "Invalid connection link",
+ message: "Please check that you used the correct link or ask your contact to send you another one."
+ )
+ return false
+ case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
+ am.showAlertMsg(
+ title: "Connection timeout",
+ message: "Please check your network connection and try again"
+ )
+ return false
+ case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
+ am.showAlertMsg(
+ title: "Connection error",
+ message: "Please check your network connection and try again"
+ )
+ return false
default: throw r
}
}
@@ -633,7 +656,11 @@ func processReceivedMsg(_ res: ChatResponse) {
case let .chatItemStatusUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
- if chatModel.upsertChatItem(cInfo, cItem) {
+ var res = false
+ if !cItem.isDeletedContent() {
+ res = chatModel.upsertChatItem(cInfo, cItem)
+ }
+ if res {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
} else if let endTask = chatModel.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
@@ -649,9 +676,15 @@ func processReceivedMsg(_ res: ChatResponse) {
if chatModel.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
- case .chatItemDeleted(_):
- // TODO let .chatItemDeleted(aChatItem)
- return
+ case let .chatItemDeleted(_, toChatItem):
+ let cInfo = toChatItem.chatInfo
+ let cItem = toChatItem.chatItem
+ if cItem.meta.itemDeleted {
+ chatModel.removeChatItem(cInfo, cItem)
+ } else {
+ // currently only broadcast deletion of rcv message can be received, and only this case should happen
+ _ = chatModel.upsertChatItem(cInfo, cItem)
+ }
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -784,7 +817,8 @@ enum ChatErrorType: Decodable {
case fileRcvChunk(message: String)
case fileInternal(message: String)
case invalidQuote
- case invalidMessageUpdate
+ case invalidChatItemUpdate
+ case invalidChatItemDelete
case agentVersion
case commandError(message: String)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index 7e0905913f..ec52ac977a 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -13,6 +13,8 @@ struct ChatInfoView: View {
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var chat: Chat
@Binding var showChatInfo: Bool
+ @State var showDeleteAlert = false
+ @State var deletingContact: Contact?
var body: some View {
VStack{
@@ -40,7 +42,8 @@ struct ChatInfoView: View {
Spacer()
Button(role: .destructive) {
- alertManager.showAlert(deleteContactAlert(contact))
+ deletingContact = contact
+ showDeleteAlert = true
} label: {
Label("Delete contact", systemImage: "trash")
}
@@ -48,7 +51,7 @@ struct ChatInfoView: View {
}
}
}
- .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
+ .alert(isPresented: $showDeleteAlert) { deleteContactAlert(deletingContact!) }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index 80ed91e072..738e94ad1b 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -13,20 +13,22 @@ struct CIMetaView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
- if chatItem.meta.itemEdited {
- statusImage("pencil", .secondary, 9)
- }
+ if !chatItem.isDeletedContent() {
+ if chatItem.meta.itemEdited {
+ statusImage("pencil", .secondary, 9)
+ }
- switch chatItem.meta.itemStatus {
- case .sndSent:
- statusImage("checkmark", .secondary)
- case .sndErrorAuth:
- statusImage("multiply", .red)
- case .sndError:
- statusImage("exclamationmark.triangle.fill", .yellow)
- case .rcvNew:
- statusImage("circlebadge.fill", Color.accentColor)
- default: EmptyView()
+ switch chatItem.meta.itemStatus {
+ case .sndSent:
+ statusImage("checkmark", .secondary)
+ case .sndErrorAuth:
+ statusImage("multiply", .red)
+ case .sndError:
+ statusImage("exclamationmark.triangle.fill", .yellow)
+ case .rcvNew:
+ statusImage("circlebadge.fill", Color.accentColor)
+ default: EmptyView()
+ }
}
chatItem.timestampText
@@ -49,6 +51,8 @@ struct CIMetaView_Previews: PreviewProvider {
return Group {
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true))
+ CIMetaView(chatItem: ChatItem.getDeletedContentSample())
}
+ .previewLayout(.fixed(width: 360, height: 100))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
new file mode 100644
index 0000000000..ae58e84cd8
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
@@ -0,0 +1,51 @@
+//
+// FramedItemView.swift
+// SimpleX
+//
+// Created by JRoberts on 04/02/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct DeletedItemView: View {
+ @Environment(\.colorScheme) var colorScheme
+ var chatItem: ChatItem
+ var showMember = false
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 0) {
+ if showMember, let member = chatItem.memberDisplayName {
+ Text(member).fontWeight(.medium) + Text(": ")
+ }
+ Text(chatItem.content.text)
+ .foregroundColor(.secondary)
+ .italic()
+ CIMetaView(chatItem: chatItem)
+ .padding(.horizontal, 12)
+ }
+ .padding(.leading, 12)
+ .padding(.vertical, 6)
+ .background(Color(uiColor: .tertiarySystemGroupedBackground))
+ .cornerRadius(18)
+ .textSelection(.disabled)
+// .background(Color(uiColor: .systemBackground))
+// .overlay(
+// RoundedRectangle(cornerRadius: 18)
+// .stroke(.quaternary, lineWidth: 1)
+// )
+ }
+}
+
+struct DeletedItemView_Previews: PreviewProvider {
+ static var previews: some View {
+ Group {
+ DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
+ DeletedItemView(
+ chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
+ showMember: true
+ )
+ }
+ .previewLayout(.fixed(width: 360, height: 200))
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index b50abd392c..d011edc787 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -16,6 +16,7 @@ private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, o
struct FramedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
+ var showMember = false
@State var msgWidth: CGFloat = 0
var body: some View {
@@ -53,7 +54,7 @@ struct FramedItemView: View {
MsgContentView(
content: chatItem.content,
formattedText: chatItem.formattedText,
- sender: chatItem.memberDisplayName,
+ sender: showMember ? chatItem.memberDisplayName : nil,
metaText: chatItem.timestampText,
edited: chatItem.meta.itemEdited
)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 9ffea020a3..73a5b9f855 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -16,7 +16,7 @@ struct MsgContentView: View {
var formattedText: [FormattedText]? = nil
var sender: String? = nil
var metaText: Text? = nil
- var edited: Bool = false
+ var edited = false
var body: some View {
let v = messageText(content, formattedText, sender)
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index c778a6b08b..275350a852 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -10,12 +10,17 @@ import SwiftUI
struct ChatItemView: View {
var chatItem: ChatItem
+ var showMember = false
var body: some View {
- if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) {
- EmojiItemView(chatItem: chatItem)
- } else {
- FramedItemView(chatItem: chatItem)
+ if chatItem.isMsgContent() {
+ if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) {
+ EmojiItemView(chatItem: chatItem)
+ } else {
+ FramedItemView(chatItem: chatItem, showMember: showMember)
+ }
+ } else if chatItem.isDeletedContent() {
+ DeletedItemView(chatItem: chatItem, showMember: showMember)
}
}
}
@@ -28,6 +33,7 @@ struct ChatItemView_Previews: PreviewProvider {
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
+ ChatItemView(chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 70))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index c52a2e2b04..7463178efb 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -8,6 +8,8 @@
import SwiftUI
+private let memberImageSize: CGFloat = 34
+
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@@ -15,48 +17,43 @@ struct ChatView: View {
@State var message: String = ""
@State var quotedItem: ChatItem? = nil
@State var editingItem: ChatItem? = nil
+ @State var deletingItem: ChatItem? = nil
@State private var inProgress: Bool = false
@FocusState private var keyboardVisible: Bool
@State private var showChatInfo = false
+ @State private var showDeleteMessage = false
var body: some View {
let cInfo = chat.chatInfo
return VStack {
GeometryReader { g in
- let maxWidth = g.size.width * 0.78
+ let maxWidth =
+ cInfo.chatType == .group
+ ? (g.size.width - 28) * 0.84 - 42
+ : (g.size.width - 32) * 0.84
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 5) {
ForEach(chatModel.chatItems) { ci in
- let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
- ChatItemView(chatItem: ci)
- .contextMenu {
- Button {
- withAnimation {
- editingItem = nil
- quotedItem = ci
- }
- } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
- Button {
- showShareSheet(items: [ci.content.text])
- } label: { Label("Share", systemImage: "square.and.arrow.up") }
- Button {
- UIPasteboard.general.string = ci.content.text
- } label: { Label("Copy", systemImage: "doc.on.doc") }
-// if (ci.chatDir.sent && ci.meta.editable) {
-// Button {
-// withAnimation {
-// quotedItem = nil
-// editingItem = ci
-// message = ci.content.text
-// }
-// } label: { Label("Edit", systemImage: "square.and.pencil") }
-// }
+ if case let .groupRcv(member) = ci.chatDir {
+ let prevItem = chatModel.getPrevChatItem(ci)
+ HStack(alignment: .top, spacing: 0) {
+ let showMember = prevItem == nil || showMemberImage(member, prevItem)
+ if showMember {
+ ProfileImage(imageStr: member.memberProfile.image)
+ .frame(width: memberImageSize, height: memberImageSize)
+ } else {
+ Rectangle().fill(.clear)
+ .frame(width: memberImageSize, height: memberImageSize)
+ }
+ chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
}
- .padding(.horizontal)
- .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
- .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
+ .padding(.trailing)
+ .padding(.leading, 12)
+ } else {
+ chatItemWithMenu(ci, maxWidth).padding(.horizontal)
+ }
}
.onAppear {
DispatchQueue.main.async {
@@ -119,6 +116,63 @@ struct ChatView: View {
.navigationBarBackButtonHidden(true)
}
+ private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
+ let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
+ return ChatItemView(chatItem: ci, showMember: showMember)
+ .contextMenu {
+ if ci.isMsgContent() {
+ Button {
+ withAnimation {
+ editingItem = nil
+ quotedItem = ci
+ }
+ } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
+ Button {
+ showShareSheet(items: [ci.content.text])
+ } label: { Label("Share", systemImage: "square.and.arrow.up") }
+ Button {
+ UIPasteboard.general.string = ci.content.text
+ } label: { Label("Copy", systemImage: "doc.on.doc") }
+ if ci.meta.editable {
+ Button {
+ withAnimation {
+ quotedItem = nil
+ editingItem = ci
+ message = ci.content.text
+ }
+ } label: { Label("Edit", systemImage: "square.and.pencil") }
+ }
+ Button(role: .destructive) {
+ showDeleteMessage = true
+ deletingItem = ci
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
+ Button("Delete for me", role: .destructive) {
+ deleteMessage(.cidmInternal)
+ }
+// if let di = deletingItem {
+// if di.meta.editable {
+// Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast)
+// }
+// }
+// }
+ }
+ .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
+ .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
+ }
+
+ private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
+ switch (prevItem?.chatDir) {
+ case .groupSnd: return true
+ case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
+ default: return false
+ }
+ }
+
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
withAnimation(animation) { scrollToBottom_(proxy) }
}
@@ -152,7 +206,7 @@ struct ChatView: View {
logger.debug("ChatView sendMessage: in Task")
do {
if let ei = editingItem {
- let chatItem = try await apiUpdateMessage(
+ let chatItem = try await apiUpdateChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
@@ -179,6 +233,29 @@ struct ChatView: View {
}
}
}
+
+ func deleteMessage(_ mode: CIDeleteMode) {
+ logger.debug("ChatView deleteMessage")
+ Task {
+ logger.debug("ChatView deleteMessage: in Task")
+ do {
+ if let di = deletingItem {
+ let toItem = try await apiDeleteChatItem(
+ type: chat.chatInfo.chatType,
+ id: chat.chatInfo.apiId,
+ itemId: di.id,
+ mode: mode
+ )
+ DispatchQueue.main.async {
+ deletingItem = nil
+ let _ = chatModel.removeChatItem(chat.chatInfo, toItem)
+ }
+ }
+ } catch {
+ logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
+ }
+ }
+ }
}
struct ChatView_Previews: PreviewProvider {
@@ -189,11 +266,12 @@ struct ChatView_Previews: PreviewProvider {
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
- ChatItem.getSample(4, .directRcv, .now, "hello again"),
- ChatItem.getSample(5, .directSnd, .now, "hi there!!!"),
- ChatItem.getSample(6, .directSnd, .now, "how are you?"),
- ChatItem.getSample(7, .directSnd, .now, "👍👍👍👍"),
- ChatItem.getSample(8, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
+ ChatItem.getDeletedContentSample(4),
+ ChatItem.getSample(5, .directRcv, .now, "hello again"),
+ ChatItem.getSample(6, .directSnd, .now, "hi there!!!"),
+ ChatItem.getSample(7, .directSnd, .now, "how are you?"),
+ ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"),
+ ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
]
return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(chatModel)
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 766784b0e8..f74e94a957 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -89,8 +89,10 @@ struct ChatListView: View {
DispatchQueue.main.async {
Task {
do {
- try await apiConnect(connReq: link)
- connectionReqSentAlert(action == "contact" ? .contact : .invitation)
+ let ok = try await apiConnect(connReq: link)
+ if ok {
+ connectionReqSentAlert(action == "contact" ? .contact : .invitation)
+ }
} catch {
let err = error.localizedDescription
AlertManager.shared.showAlertMsg(title: "Connection error", message: err)
diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
index 8786e40da0..5bd16f693b 100644
--- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift
+++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
@@ -12,6 +12,7 @@ struct ImagePicker: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
var source: UIImagePickerController.SourceType
@Binding var image: UIImage?
+ @Binding var imageUrl: URL?
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
@@ -23,6 +24,7 @@ struct ImagePicker: UIViewControllerRepresentable {
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let uiImage = info[.originalImage] as? UIImage {
+ parent.imageUrl = info[.imageURL] as? URL
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
index b6a118b10f..2513801fcc 100644
--- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
+++ b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift
@@ -10,14 +10,14 @@ import SwiftUI
import CodeScanner
struct ConnectContactView: View {
- var completed: ((Error?) -> Void)
+ var completed: ((Result) -> Void)
var body: some View {
VStack {
Text("Scan QR code")
.font(.title)
.padding(.bottom)
- Text("Your chat profile will be sent to your contact.")
+ Text("Your chat profile will be sent to your contact")
.font(.title2)
.multilineTextAlignment(.center)
.padding()
@@ -35,16 +35,16 @@ struct ConnectContactView: View {
case let .success(r):
Task {
do {
- try await apiConnect(connReq: r.string)
- completed(nil)
+ let ok = try await apiConnect(connReq: r.string)
+ completed(.success(ok))
} catch {
logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)")
- completed(error)
+ completed(.failure(error))
}
}
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
- completed(e)
+ completed(.failure(e))
}
}
}
diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
index d984ddd0df..0fff56bf3f 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
@@ -54,10 +54,11 @@ struct NewChatButton: View {
ConnectContactView(completed: { err in
connectContact = false
DispatchQueue.global().async {
- if let error = err {
+ switch (err) {
+ case let .success(ok):
+ if ok { connectionReqSentAlert(.invitation) }
+ case let .failure(error):
connectionErrorAlert(error)
- } else {
- connectionReqSentAlert(.invitation)
}
}
})
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 7e92301383..4e5d62bf31 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -16,6 +16,7 @@ struct UserProfile: View {
@State private var showImagePicker = false
@State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
@State private var pickedImage: UIImage? = nil
+ @State private var tmpImageUrl: URL? = nil
var body: some View {
let user: User = chatModel.currentUser!
@@ -88,7 +89,7 @@ struct UserProfile: View {
}
}
.sheet(isPresented: $showImagePicker) {
- ImagePicker(source: imageSource, image: $pickedImage)
+ ImagePicker(source: imageSource, image: $pickedImage, imageUrl: $tmpImageUrl)
}
.onChange(of: pickedImage) { image in
if let image = image,
@@ -99,6 +100,13 @@ struct UserProfile: View {
} else {
logger.error("UserProfile: resized image is too big \(imageStr.count)")
}
+ if let tmpImageUrl = tmpImageUrl {
+ do {
+ try FileManager.default.removeItem(at: tmpImageUrl)
+ } catch {
+ logger.error("UserProfile: file deletion error \(error.localizedDescription)")
+ }
+ }
} else {
profile.image = nil
}
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 710cf2d87c..537fe74814 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -84,6 +84,16 @@
5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; };
5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; };
5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; };
+ 5CA14D2327F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */; };
+ 5CA14D2427F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */; };
+ 5CA14D2527F6DE37009B11CE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1F27F6DE37009B11CE /* libgmp.a */; };
+ 5CA14D2627F6DE37009B11CE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1F27F6DE37009B11CE /* libgmp.a */; };
+ 5CA14D2727F6DE37009B11CE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2027F6DE37009B11CE /* libgmpxx.a */; };
+ 5CA14D2827F6DE37009B11CE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2027F6DE37009B11CE /* libgmpxx.a */; };
+ 5CA14D2927F6DE37009B11CE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2127F6DE37009B11CE /* libffi.a */; };
+ 5CA14D2A27F6DE37009B11CE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2127F6DE37009B11CE /* libffi.a */; };
+ 5CA14D2B27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */; };
+ 5CA14D2C27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */; };
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; };
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; };
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; };
@@ -116,6 +126,8 @@
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
+ 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
+ 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -183,6 +195,11 @@
5CA059E7279559F40002BEB4 /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; };
5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOSLaunchTests.swift; sourceTree = ""; };
5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; };
+ 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a"; sourceTree = ""; };
+ 5CA14D1F27F6DE37009B11CE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 5CA14D2027F6DE37009B11CE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 5CA14D2127F6DE37009B11CE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a"; sourceTree = ""; };
5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; };
@@ -199,6 +216,7 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; };
+ 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -206,14 +224,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5C36026E27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */,
- 5C36026C27F44386009F19D9 /* libgmpxx.a in Frameworks */,
- 5C36027027F44386009F19D9 /* libgmp.a in Frameworks */,
+ 5CA14D2727F6DE37009B11CE /* libgmpxx.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
+ 5CA14D2527F6DE37009B11CE /* libgmp.a in Frameworks */,
+ 5CA14D2B27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
+ 5CA14D2927F6DE37009B11CE /* libffi.a in Frameworks */,
+ 5CA14D2327F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
- 5C36026827F44386009F19D9 /* libffi.a in Frameworks */,
- 5C36026A27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -221,12 +239,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5C36026F27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */,
- 5C36027127F44386009F19D9 /* libgmp.a in Frameworks */,
+ 5CA14D2C27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */,
+ 5CA14D2427F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */,
+ 5CA14D2A27F6DE37009B11CE /* libffi.a in Frameworks */,
+ 5CA14D2827F6DE37009B11CE /* libgmpxx.a in Frameworks */,
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */,
- 5C36026D27F44386009F19D9 /* libgmpxx.a in Frameworks */,
- 5C36026B27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */,
- 5C36026927F44386009F19D9 /* libffi.a in Frameworks */,
+ 5CA14D2627F6DE37009B11CE /* libgmp.a in Frameworks */,
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -279,11 +297,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5C36026327F44385009F19D9 /* libffi.a */,
- 5C36026727F44386009F19D9 /* libgmp.a */,
- 5C36026527F44386009F19D9 /* libgmpxx.a */,
- 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */,
- 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */,
+ 5CA14D2127F6DE37009B11CE /* libffi.a */,
+ 5CA14D1F27F6DE37009B11CE /* libgmp.a */,
+ 5CA14D2027F6DE37009B11CE /* libgmpxx.a */,
+ 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */,
+ 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */,
);
path = Libraries;
sourceTree = "";
@@ -431,6 +449,7 @@
5CE4407827ADB701007B033A /* EmojiItemView.swift */,
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
+ 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
);
path = ChatItem;
sourceTree = "";
@@ -653,6 +672,7 @@
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
+ 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
@@ -704,6 +724,7 @@
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */,
5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */,
+ 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */,
@@ -863,7 +884,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -883,7 +904,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 1.4;
+ MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -903,7 +924,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -923,7 +944,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
- MARKETING_VERSION = 1.4;
+ MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
diff --git a/cabal.project b/cabal.project
index e877b40192..b30676122e 100644
--- a/cabal.project
+++ b/cabal.project
@@ -3,7 +3,7 @@ packages: .
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
- tag: b6e87e4a3e4d8d6f0d4b41ec13b3787f8d1c5189
+ tag: 3ba1926b1e5ab32451a2239831d614492d40c9be
source-repository-package
type: git
diff --git a/package.yaml b/package.yaml
index bbbd45a0c3..7fe6db1b38 100644
--- a/package.yaml
+++ b/package.yaml
@@ -1,5 +1,5 @@
name: simplex-chat
-version: 1.4.0
+version: 1.5.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
diff --git a/sha256map.nix b/sha256map.nix
index b496917510..77f2287dd2 100644
--- a/sha256map.nix
+++ b/sha256map.nix
@@ -1,5 +1,5 @@
{
- "https://github.com/simplex-chat/simplexmq.git"."b6e87e4a3e4d8d6f0d4b41ec13b3787f8d1c5189" = "1qn6r598qj6xa7bjkrqa8zdj12mrvvwb6kmcjrwya4n8s9gjb3lh";
+ "https://github.com/simplex-chat/simplexmq.git"."3ba1926b1e5ab32451a2239831d614492d40c9be" = "1rgadib3xjzi81i1xda55gv1mdaq8vvyyh65qg482rnyh6kq9f6g";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 34b6e02f15..f85424e044 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 1.4.0
+version: 1.5.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index 458a34c460..bbe08d5934 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -38,7 +38,7 @@ import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, getCurrentTime)
-import Data.Time.LocalTime (getCurrentTimeZone)
+import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime)
import Data.Word (Word32)
import Simplex.Chat.Controller
import Simplex.Chat.Markdown
@@ -215,7 +215,7 @@ processChatCommand = \case
msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId}
in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem)
CTContactRequest -> pure $ chatCmdError "not supported"
- APIUpdateMessage cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
+ APIUpdateChatItem cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
CTDirect -> do
(ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId
case ci of
@@ -226,8 +226,8 @@ processChatCommand = \case
updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) msgId
setActive $ ActiveC c
pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi
- _ -> throwChatError CEInvalidMessageUpdate
- CChatItem SMDRcv _ -> throwChatError CEInvalidMessageUpdate
+ _ -> throwChatError CEInvalidChatItemUpdate
+ CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
CTGroup -> do
Group gInfo@GroupInfo {groupId, localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
@@ -240,12 +240,36 @@ processChatCommand = \case
updCi <- withStore $ \st -> updateGroupChatItem st user groupId itemId (CISndMsgContent mc) msgId
setActive $ ActiveG gName
pure . CRChatItemUpdated $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) updCi
- _ -> throwChatError CEInvalidMessageUpdate
- CChatItem SMDRcv _ -> throwChatError CEInvalidMessageUpdate
+ _ -> throwChatError CEInvalidChatItemUpdate
+ CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
CTContactRequest -> pure $ chatCmdError "not supported"
- APIDeleteMessage cType _chatId _itemId _mode -> withUser $ \_user -> withChatLock $ case cType of
- CTDirect -> pure CRCmdOk
- CTGroup -> pure CRCmdOk
+ APIDeleteChatItem cType chatId itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of
+ CTDirect -> do
+ (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId
+ case (mode, msgDir, itemSharedMsgId) of
+ (CIDMInternal, _, _) -> do
+ toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId
+ pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi
+ (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do
+ SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId)
+ toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId
+ setActive $ ActiveC c
+ pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi
+ (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete
+ CTGroup -> do
+ Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId
+ unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
+ CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}} <- withStore $ \st -> getGroupChatItem st user chatId itemId
+ case (mode, msgDir, itemSharedMsgId) of
+ (CIDMInternal, _, _) -> do
+ toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId
+ pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi
+ (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do
+ SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId)
+ toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId
+ setActive $ ActiveG gName
+ pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi
+ (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete
CTContactRequest -> pure $ chatCmdError "not supported"
APIChatRead cType chatId fromToIds -> withChatLock $ case cType of
CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk
@@ -295,7 +319,7 @@ processChatCommand = \case
Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} ->
connectViaContact userId cReq profile
Connect Nothing -> throwChatError CEInvalidConnReq
- ConnectAdmin -> withUser $ \User {userId, profile} ->
+ ConnectSimplex -> withUser $ \User {userId, profile} ->
connectViaContact userId adminContactReq profile
DeleteContact cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
@@ -326,11 +350,29 @@ processChatCommand = \case
contactId <- withStore $ \st -> getContactIdByName st userId cName
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessage CTDirect contactId mc
+ SendMessageBroadcast msg -> withUser $ \user -> do
+ contacts <- withStore (`getUserContacts` user)
+ withChatLock . procCmd $ do
+ let mc = MCText $ safeDecodeUtf8 msg
+ cts = filter isReady contacts
+ forM_ cts $ \ct ->
+ void (sendDirectChatItem user ct (XMsgNew $ MCSimple mc) (CISndMsgContent mc) Nothing)
+ `catchError` (toView . CRChatError)
+ CRBroadcastSent mc (length cts) <$> liftIO getZonedTime
SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg)
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId mc
+ DeleteMessage cName deletedMsg -> withUser $ \User {userId} -> do
+ contactId <- withStore $ \st -> getContactIdByName st userId cName
+ deletedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 deletedMsg)
+ processChatCommand $ APIDeleteChatItem CTDirect contactId deletedItemId CIDMBroadcast
+ EditMessage cName editedMsg msg -> withUser $ \User {userId} -> do
+ contactId <- withStore $ \st -> getContactIdByName st userId cName
+ editedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 editedMsg)
+ let mc = MCText $ safeDecodeUtf8 msg
+ processChatCommand $ APIUpdateChatItem CTDirect contactId editedItemId mc
NewGroup gProfile -> withUser $ \user -> do
gVar <- asks idsDrg
CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile)
@@ -411,6 +453,15 @@ processChatCommand = \case
quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg)
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId mc
+ DeleteGroupMessage gName deletedMsg -> withUser $ \user@User {localDisplayName} -> do
+ groupId <- withStore $ \st -> getGroupIdByName st user gName
+ deletedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 deletedMsg)
+ processChatCommand $ APIDeleteChatItem CTGroup groupId deletedItemId CIDMBroadcast
+ EditGroupMessage gName editedMsg msg -> withUser $ \user@User {localDisplayName} -> do
+ groupId <- withStore $ \st -> getGroupIdByName st user gName
+ editedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 editedMsg)
+ let mc = MCText $ safeDecodeUtf8 msg
+ processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc
SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do
(fileSize, chSize) <- checkSndFile f
contact <- withStore $ \st -> getContactByName st userId cName
@@ -518,20 +569,21 @@ processChatCommand = \case
unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f
(,) <$> getFileSize f <*> asks (fileChunkSize . config)
updateProfile :: User -> Profile -> m ChatResponse
- updateProfile user@User {profile = p} p'@Profile {displayName} = do
- if p' == p
- then pure CRUserProfileNoChange
- else do
- withStore $ \st -> updateUserProfile st user p'
- let user' = (user :: User) {localDisplayName = displayName, profile = p'}
- asks currentUser >>= atomically . (`writeTVar` Just user')
- contacts <- withStore (`getUserContacts` user)
- withChatLock . procCmd $ do
- forM_ contacts $ \ct ->
- let s = connStatus $ activeConn (ct :: Contact)
- in when (s == ConnReady || s == ConnSndReady) $
- void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError)
- pure $ CRUserProfileUpdated p p'
+ updateProfile user@User {profile = p} p'@Profile {displayName}
+ | p' == p = pure CRUserProfileNoChange
+ | otherwise = do
+ withStore $ \st -> updateUserProfile st user p'
+ let user' = (user :: User) {localDisplayName = displayName, profile = p'}
+ asks currentUser >>= atomically . (`writeTVar` Just user')
+ contacts <- filter isReady <$> withStore (`getUserContacts` user)
+ withChatLock . procCmd $ do
+ forM_ contacts $ \ct ->
+ void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError)
+ pure $ CRUserProfileUpdated p p'
+ isReady :: Contact -> Bool
+ isReady ct =
+ let s = connStatus $ activeConn (ct :: Contact)
+ in s == ConnReady || s == ConnSndReady
getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath
getRcvFilePath fileId filePath fileName = case filePath of
Nothing -> do
@@ -717,6 +769,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
case chatMsgEvent of
XMsgNew mc -> newContentMessage ct mc msg msgMeta
XMsgUpdate sharedMsgId mContent -> messageUpdate ct sharedMsgId mContent msg msgMeta
+ XMsgDel sharedMsgId -> messageDelete ct sharedMsgId msg msgMeta
XFile fInv -> processFileInvitation ct fInv msg msgMeta
XInfo p -> xInfo ct p
XGrpInv gInv -> processGroupInvitation ct gInv
@@ -856,7 +909,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta
- XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo sharedMsgId mContent msg
+ XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo m sharedMsgId mContent msg
+ XMsgDel sharedMsgId -> groupMessageDelete gInfo m sharedMsgId msg
XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta
XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo
XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo
@@ -1036,11 +1090,28 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
setActive $ ActiveC c
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m ()
- messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc RcvMessage {msgId} msgMeta = do
- updCi <- withStore $ \st -> updateDirectChatItemByMsgId st userId contactId sharedMsgId (CIRcvMsgContent mc) msgId
- toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
- setActive $ ActiveC c
+ messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do
+ CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
+ case msgDir of
+ SMDRcv -> do
+ updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CIRcvMsgContent mc) msgId
+ toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ SMDSnd -> do
+ messageError "x.msg.update: contact attempted invalid message update"
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+
+ messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m ()
+ messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do
+ CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
+ case msgDir of
+ SMDRcv -> do
+ toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId
+ toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ SMDSnd -> do
+ messageError "x.msg.del: contact attempted invalid message delete"
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do
@@ -1051,12 +1122,29 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
setActive $ ActiveG g
- groupMessageUpdate :: GroupInfo -> SharedMsgId -> MsgContent -> RcvMessage -> m ()
- groupMessageUpdate gInfo@GroupInfo {groupId} sharedMsgId mc RcvMessage {msgId} = do
- updCi <- withStore $ \st -> updateGroupChatItemByMsgId st user groupId sharedMsgId (CIRcvMsgContent mc) msgId
- toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) updCi
- let g = groupName' gInfo
- setActive $ ActiveG g
+ groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> m ()
+ groupMessageUpdate gInfo@GroupInfo {groupId} GroupMember {memberId} sharedMsgId mc RcvMessage {msgId} = do
+ CChatItem msgDir ChatItem {chatDir, meta = CIMeta {itemId}} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId
+ case (msgDir, chatDir) of
+ (SMDRcv, CIGroupRcv m) ->
+ if sameMemberId memberId m
+ then do
+ updCi <- withStore $ \st -> updateGroupChatItem st user groupId itemId (CIRcvMsgContent mc) msgId
+ toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) updCi
+ else messageError "x.msg.update: group member attempted to update a message of another member"
+ (SMDSnd, _) -> messageError "x.msg.update: group member attempted invalid message update"
+
+ groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> RcvMessage -> m ()
+ groupMessageDelete gInfo@GroupInfo {groupId} GroupMember {memberId} sharedMsgId RcvMessage {msgId} = do
+ CChatItem msgDir deletedItem@ChatItem {chatDir, meta = CIMeta {itemId}} <- withStore $ \st -> getGroupChatItemBySharedMsgId st user groupId sharedMsgId
+ case (msgDir, chatDir) of
+ (SMDRcv, CIGroupRcv m) ->
+ if sameMemberId memberId m
+ then do
+ toCi <- withStore $ \st -> deleteGroupChatItemRcvBroadcast st user gInfo itemId msgId
+ toView $ CRChatItemDeleted (AChatItem SCTGroup SMDRcv (GroupChat gInfo) deletedItem) toCi
+ else messageError "x.msg.del: group member attempted to delete a message of another member"
+ (SMDSnd, _) -> messageError "x.msg.del: group member attempted invalid message delete"
processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do
@@ -1172,7 +1260,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
GCInviteeMember -> do
members <- withStore $ \st -> getGroupMembers st user gInfo
case find (sameMemberId memId) members of
- Nothing -> messageError "x.grp.mem.inv error: referenced member does not exists"
+ Nothing -> messageError "x.grp.mem.inv error: referenced member does not exist"
Just reMember -> do
GroupMemberIntro {introId} <- withStore $ \st -> saveIntroInvitation st reMember m introInv
void $ sendXGrpMemInv gInfo reMember (XGrpMemFwd (memberInfo m) introInv) introId
@@ -1447,7 +1535,7 @@ mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do
tz <- getCurrentTimeZone
currentTs <- liftIO getCurrentTime
let itemText = ciContentToText content
- meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt
+ meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt
pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem}
allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m ()
@@ -1567,8 +1655,8 @@ chatCommandP =
<|> "/_get items count=" *> (APIGetChatItems <$> A.decimal)
<|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP)
<|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP)
- <|> "/_update item " *> (APIUpdateMessage <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP)
- <|> "/_delete item " *> (APIDeleteMessage <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgDeleteMode)
+ <|> "/_update item " *> (APIUpdateChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP)
+ <|> "/_delete item " *> (APIDeleteChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> ciDeleteMode)
<|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))
<|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal)
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
@@ -1580,7 +1668,7 @@ chatCommandP =
<|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles
<|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups
<|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress
- <|> ("/help replies" <|> "/hr") $> ChatHelp HSQuotes
+ <|> ("/help messages" <|> "/hm") $> ChatHelp HSMessages
<|> ("/help" <|> "/h") $> ChatHelp HSMain
<|> ("/group #" <|> "/group " <|> "/g #" <|> "/g ") *> (NewGroup <$> groupProfile)
<|> ("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole)
@@ -1593,6 +1681,8 @@ chatCommandP =
<|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString)
<|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString)
<|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString)
+ <|> ("\\#" <|> "\\ #") *> (DeleteGroupMessage <$> displayName <* A.space <*> A.takeByteString)
+ <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString)
<|> ("/contacts" <|> "/cs") $> ListContacts
<|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing))
<|> ("/connect" <|> "/c") $> AddContact
@@ -1600,12 +1690,15 @@ chatCommandP =
<|> A.char '@' *> (SendMessage <$> displayName <* A.space <*> A.takeByteString)
<|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv)
<|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd)
+ <|> ("\\@" <|> "\\ @") *> (DeleteMessage <$> displayName <* A.space <*> A.takeByteString)
+ <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString)
+ <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString)
<|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath)
<|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath)
<|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath))
<|> ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal)
<|> ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal)
- <|> "/simplex" $> ConnectAdmin
+ <|> "/simplex" $> ConnectSimplex
<|> ("/address" <|> "/ad") $> CreateMyAddress
<|> ("/delete_address" <|> "/da") $> DeleteMyAddress
<|> ("/show_address" <|> "/sa") $> ShowMyAddress
@@ -1631,7 +1724,7 @@ chatCommandP =
msgContentP =
"text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString)
<|> "json " *> jsonP
- msgDeleteMode = "broadcast" $> MDBroadcast <|> "internal" $> MDInternal
+ ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString
quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index d2bb4bfeb4..132cc5ef5e 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -20,6 +20,7 @@ import Data.ByteString.Char8 (ByteString)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Data.Text (Text)
+import Data.Time (ZonedTime)
import Data.Version (showVersion)
import GHC.Generics (Generic)
import Numeric.Natural
@@ -78,10 +79,7 @@ data ChatController = ChatController
config :: ChatConfig
}
-data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSQuotes
- deriving (Show, Generic)
-
-data MsgDeleteMode = MDBroadcast | MDInternal
+data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages
deriving (Show, Generic)
instance ToJSON HelpSection where
@@ -97,8 +95,8 @@ data ChatCommand
| APIGetChatItems Int
| APISendMessage ChatType Int64 MsgContent
| APISendMessageQuote ChatType Int64 ChatItemId MsgContent
- | APIUpdateMessage ChatType Int64 ChatItemId MsgContent
- | APIDeleteMessage ChatType Int64 ChatItemId MsgDeleteMode
+ | APIUpdateChatItem ChatType Int64 ChatItemId MsgContent
+ | APIDeleteChatItem ChatType Int64 ChatItemId CIDeleteMode
| APIChatRead ChatType Int64 (ChatItemId, ChatItemId)
| APIDeleteChat ChatType Int64
| APIAcceptContact Int64
@@ -110,7 +108,7 @@ data ChatCommand
| Welcome
| AddContact
| Connect (Maybe AConnectionRequestUri)
- | ConnectAdmin
+ | ConnectSimplex
| DeleteContact ContactName
| ListContacts
| CreateMyAddress
@@ -121,6 +119,9 @@ data ChatCommand
| RejectContact ContactName
| SendMessage ContactName ByteString
| SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString}
+ | SendMessageBroadcast ByteString
+ | DeleteMessage ContactName ByteString
+ | EditMessage {contactName :: ContactName, editedMsg :: ByteString, message :: ByteString}
| NewGroup GroupProfile
| AddMember GroupName ContactName GroupMemberRole
| JoinGroup GroupName
@@ -132,6 +133,8 @@ data ChatCommand
| ListGroups
| SendGroupMessage GroupName ByteString
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
+ | DeleteGroupMessage GroupName ByteString
+ | EditGroupMessage {groupName :: ContactName, editedMsg :: ByteString, message :: ByteString}
| SendFile ContactName FilePath
| SendGroupFile GroupName FilePath
| ReceiveFile FileTransferId (Maybe FilePath)
@@ -154,7 +157,8 @@ data ChatResponse
| CRNewChatItem {chatItem :: AChatItem}
| CRChatItemStatusUpdated {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem}
- | CRChatItemDeleted {chatItem :: AChatItem}
+ | CRChatItemDeleted {deletedChatItem :: AChatItem, toChatItem :: AChatItem}
+ | CRBroadcastSent MsgContent Int ZonedTime
| CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile
| CRCmdAccepted {corr :: CorrId}
| CRCmdOk
@@ -303,7 +307,8 @@ data ChatErrorType
| CEFileRcvChunk {message :: String}
| CEFileInternal {message :: String}
| CEInvalidQuote
- | CEInvalidMessageUpdate
+ | CEInvalidChatItemUpdate
+ | CEInvalidChatItemDelete
| CEAgentVersion
| CECommandError {message :: String}
deriving (Show, Exception, Generic)
diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs
index 58c8fbb3e3..b3d1784eb3 100644
--- a/src/Simplex/Chat/Help.hs
+++ b/src/Simplex/Chat/Help.hs
@@ -7,7 +7,7 @@ module Simplex.Chat.Help
filesHelpInfo,
groupsHelpInfo,
myAddressHelpInfo,
- quotesHelpInfo,
+ messagesHelpInfo,
markdownInfo,
)
where
@@ -83,7 +83,7 @@ chatHelpInfo =
green "Create your address: " <> highlight "/address",
"",
green "Other commands:",
- indent <> highlight "/help " <> " - help on: files, groups, address, replies, smp_servers",
+ indent <> highlight "/help " <> " - help on: messages, files, groups, address",
indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/delete " <> " - delete contact and all messages with them",
indent <> highlight "/contacts " <> " - list contacts",
@@ -143,8 +143,8 @@ myAddressHelpInfo =
"The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"]
]
-quotesHelpInfo :: [StyledString]
-quotesHelpInfo =
+messagesHelpInfo :: [StyledString]
+messagesHelpInfo =
map
styleMarkdown
[ green "Sending replies to messages",
@@ -152,7 +152,17 @@ quotesHelpInfo =
indent <> highlight "> @alice (hi) " <> " - to reply to alice's most recent message",
indent <> highlight ">> @alice (hi) " <> " - to quote user's most recent message to alice",
indent <> highlight "> #team (hi) " <> " - to quote most recent message in the group from any member",
- indent <> highlight "> #team @alice (hi) " <> " - to quote alice's most recent message in the group #team"
+ indent <> highlight "> #team @alice (hi) " <> " - to quote alice's most recent message in the group #team",
+ "",
+ green "Deleting sent messages (for everyone)",
+ "To delete a message that starts with \"hi\":",
+ indent <> highlight "\\ @alice hi " <> " - to delete your message to alice",
+ indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team",
+ "",
+ green "Editing sent messages",
+ "To edit a message that starts with \"hi\":",
+ indent <> highlight "! @alice (hi) " <> " - to edit your message to alice",
+ indent <> highlight "! #team (hi) " <> " - to edit your message in the group #team"
]
markdownInfo :: [StyledString]
diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs
index fe5d0e0d38..8242d46510 100644
--- a/src/Simplex/Chat/Markdown.hs
+++ b/src/Simplex/Chat/Markdown.hs
@@ -12,6 +12,7 @@ import qualified Data.Attoparsec.Text as A
import Data.Char (isDigit)
import Data.Either (fromRight)
import Data.Functor (($>))
+import Data.List (intercalate)
import Data.Maybe (fromMaybe, isNothing)
import Data.String
import Data.Text (Text)
@@ -83,6 +84,9 @@ data FormattedText = FormattedText {format :: Maybe Format, text :: Text}
instance ToJSON FormattedText where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+instance IsString FormattedText where
+ fromString = FormattedText Nothing . T.pack
+
type MarkdownList = [FormattedText]
unmarked :: Text -> Markdown
@@ -90,7 +94,7 @@ unmarked = Markdown Nothing
parseMaybeMarkdownList :: Text -> Maybe MarkdownList
parseMaybeMarkdownList s =
- let m = markdownToList $ parseMarkdown s
+ let m = intercalate ["\n"] . map (markdownToList . parseMarkdown) $ T.lines s
in if all (isNothing . format) m then Nothing else Just m
parseMarkdownList :: Text -> MarkdownList
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index 011b3c338f..1a430ed831 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -214,10 +214,12 @@ data CIMeta (d :: MsgDirection) = CIMeta
}
deriving (Show, Generic)
-mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d
-mkCIMeta itemId itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt =
+mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d
+mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt =
let localItemTs = utcToZonedTime tz itemTs
- editable = diffUTCTime currentTs itemTs < nominalDay
+ editable = case itemContent of
+ CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay
+ _ -> False
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt}
instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions
@@ -336,19 +338,34 @@ jsonCIStatus = \case
type ChatItemId = Int64
+type ChatItemTs = UTCTime
+
data ChatPagination
= CPLast Int
| CPAfter ChatItemId Int
| CPBefore ChatItemId Int
deriving (Show)
-type ChatItemTs = UTCTime
+data CIDeleteMode = CIDMBroadcast | CIDMInternal
+ deriving (Show, Generic)
+
+instance ToJSON CIDeleteMode where
+ toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIDM"
+ toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIDM"
+
+instance FromJSON CIDeleteMode where
+ parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIDM"
+
+ciDeleteModeToText :: CIDeleteMode -> Text
+ciDeleteModeToText = \case
+ CIDMBroadcast -> "this item is deleted (broadcast)"
+ CIDMInternal -> "this item is deleted (internal)"
data CIContent (d :: MsgDirection) where
CISndMsgContent :: MsgContent -> CIContent 'MDSnd
CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv
- CISndMsgDeleted :: MsgContent -> CIContent 'MDSnd
- CIRcvMsgDeleted :: MsgContent -> CIContent 'MDRcv
+ CISndDeleted :: CIDeleteMode -> CIContent 'MDSnd
+ CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv
CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd
CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv
@@ -358,11 +375,16 @@ ciContentToText :: CIContent d -> Text
ciContentToText = \case
CISndMsgContent mc -> msgContentText mc
CIRcvMsgContent mc -> msgContentText mc
- CISndMsgDeleted _ -> "this message is deleted"
- CIRcvMsgDeleted _ -> "this message is deleted"
+ CISndDeleted cidm -> ciDeleteModeToText cidm
+ CIRcvDeleted cidm -> ciDeleteModeToText cidm
CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath
CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName
+msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d
+msgDirToDeletedContent_ msgDir mode = case msgDir of
+ SMDRcv -> CIRcvDeleted mode
+ SMDSnd -> CISndDeleted mode
+
-- platform independent
instance ToField (CIContent d) where
toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode . dbJsonCIContent
@@ -387,8 +409,8 @@ instance FromField ACIContent where fromField = fromTextField_ $ fmap aciContent
data JSONCIContent
= JCISndMsgContent {msgContent :: MsgContent}
| JCIRcvMsgContent {msgContent :: MsgContent}
- | JCISndMsgDeleted {msgContent :: MsgContent}
- | JCIRcvMsgDeleted {msgContent :: MsgContent}
+ | JCISndDeleted {deleteMode :: CIDeleteMode}
+ | JCIRcvDeleted {deleteMode :: CIDeleteMode}
| JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath}
| JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer}
deriving (Generic)
@@ -404,8 +426,8 @@ jsonCIContent :: CIContent d -> JSONCIContent
jsonCIContent = \case
CISndMsgContent mc -> JCISndMsgContent mc
CIRcvMsgContent mc -> JCIRcvMsgContent mc
- CISndMsgDeleted mc -> JCISndMsgDeleted mc
- CIRcvMsgDeleted mc -> JCIRcvMsgDeleted mc
+ CISndDeleted cidm -> JCISndDeleted cidm
+ CIRcvDeleted cidm -> JCIRcvDeleted cidm
CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath
CIRcvFileInvitation ft -> JCIRcvFileInvitation ft
@@ -413,8 +435,8 @@ aciContentJSON :: JSONCIContent -> ACIContent
aciContentJSON = \case
JCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc
JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
- JCISndMsgDeleted mc -> ACIContent SMDSnd $ CISndMsgDeleted mc
- JCIRcvMsgDeleted mc -> ACIContent SMDRcv $ CIRcvMsgDeleted mc
+ JCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm
+ JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm
JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath
JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft
@@ -422,8 +444,8 @@ aciContentJSON = \case
data DBJSONCIContent
= DBJCISndMsgContent {msgContent :: MsgContent}
| DBJCIRcvMsgContent {msgContent :: MsgContent}
- | DBJCISndMsgDeleted {msgContent :: MsgContent}
- | DBJCIRcvMsgDeleted {msgContent :: MsgContent}
+ | DBJCISndDeleted {deleteMode :: CIDeleteMode}
+ | DBJCIRcvDeleted {deleteMode :: CIDeleteMode}
| DBJCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath}
| DBJCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer}
deriving (Generic)
@@ -439,8 +461,8 @@ dbJsonCIContent :: CIContent d -> DBJSONCIContent
dbJsonCIContent = \case
CISndMsgContent mc -> DBJCISndMsgContent mc
CIRcvMsgContent mc -> DBJCIRcvMsgContent mc
- CISndMsgDeleted mc -> DBJCISndMsgDeleted mc
- CIRcvMsgDeleted mc -> DBJCIRcvMsgDeleted mc
+ CISndDeleted cidm -> DBJCISndDeleted cidm
+ CIRcvDeleted cidm -> DBJCIRcvDeleted cidm
CISndFileInvitation fId fPath -> DBJCISndFileInvitation fId fPath
CIRcvFileInvitation ft -> DBJCIRcvFileInvitation ft
@@ -448,8 +470,8 @@ aciContentDBJSON :: DBJSONCIContent -> ACIContent
aciContentDBJSON = \case
DBJCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc
DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
- DBJCISndMsgDeleted ciId -> ACIContent SMDSnd $ CISndMsgDeleted ciId
- DBJCIRcvMsgDeleted ciId -> ACIContent SMDRcv $ CIRcvMsgDeleted ciId
+ DBJCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm
+ DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm
DBJCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath
DBJCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft
diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs
index ff011ea8a3..04df7a9a5c 100644
--- a/src/Simplex/Chat/Protocol.hs
+++ b/src/Simplex/Chat/Protocol.hs
@@ -111,6 +111,7 @@ data ChatMsgEvent
= XMsgNew MsgContainer
| XMsgUpdate SharedMsgId MsgContent
| XMsgDel SharedMsgId
+ | XMsgDeleted
| XFile FileInvitation
| XFileAcpt String
| XInfo Profile
@@ -236,6 +237,7 @@ data CMEventTag
= XMsgNew_
| XMsgUpdate_
| XMsgDel_
+ | XMsgDeleted_
| XFile_
| XFileAcpt_
| XInfo_
@@ -264,6 +266,7 @@ instance StrEncoding CMEventTag where
XMsgNew_ -> "x.msg.new"
XMsgUpdate_ -> "x.msg.update"
XMsgDel_ -> "x.msg.del"
+ XMsgDeleted_ -> "x.msg.deleted"
XFile_ -> "x.file"
XFileAcpt_ -> "x.file.acpt"
XInfo_ -> "x.info"
@@ -289,6 +292,7 @@ instance StrEncoding CMEventTag where
"x.msg.new" -> Right XMsgNew_
"x.msg.update" -> Right XMsgUpdate_
"x.msg.del" -> Right XMsgDel_
+ "x.msg.deleted" -> Right XMsgDeleted_
"x.file" -> Right XFile_
"x.file.acpt" -> Right XFileAcpt_
"x.info" -> Right XInfo_
@@ -317,6 +321,7 @@ toCMEventTag = \case
XMsgNew _ -> XMsgNew_
XMsgUpdate _ _ -> XMsgUpdate_
XMsgDel _ -> XMsgDel_
+ XMsgDeleted -> XMsgDeleted_
XFile _ -> XFile_
XFileAcpt _ -> XFileAcpt_
XInfo _ -> XInfo_
@@ -360,9 +365,10 @@ appToChatMessage AppMessage {msgId, event, params} = do
opt :: FromJSON a => J.Key -> Either String (Maybe a)
opt key = JT.parseEither (.:? key) params
msg = \case
- XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params
+ XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params
XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content"
XMsgDel_ -> XMsgDel <$> p "msgId"
+ XMsgDeleted_ -> pure XMsgDeleted
XFile_ -> XFile <$> p "file"
XFileAcpt_ -> XFileAcpt <$> p "fileName"
XInfo_ -> XInfo <$> p "profile"
@@ -394,8 +400,9 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
key .=? value = maybe id ((:) . (key .=)) value
params = case chatMsgEvent of
XMsgNew container -> msgContainerJSON container
- XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content]
- XMsgDel msgId' -> o ["msgId" .= msgId']
+ XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content]
+ XMsgDel msgId' -> o ["msgId" .= msgId']
+ XMsgDeleted -> JM.empty
XFile fileInv -> o ["file" .= fileInv]
XFileAcpt fileName -> o ["fileName" .= fileName]
XInfo profile -> o ["profile" .= profile]
diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs
index 1ccc0c9ef6..ed38209ed1 100644
--- a/src/Simplex/Chat/Store.hs
+++ b/src/Simplex/Chat/Store.hs
@@ -120,15 +120,21 @@ module Simplex.Chat.Store
getGroupChat,
getChatItemIdByAgentMsgId,
getDirectChatItem,
+ getDirectChatItemBySharedMsgId,
getGroupChatItem,
+ getGroupChatItemBySharedMsgId,
getDirectChatItemIdByText,
getGroupChatItemIdByText,
updateDirectChatItemStatus,
updateDirectChatItem,
- updateDirectChatItemByMsgId,
- updateDirectChatItemsRead,
+ deleteDirectChatItemInternal,
+ deleteDirectChatItemRcvBroadcast,
+ deleteDirectChatItemSndBroadcast,
updateGroupChatItem,
- updateGroupChatItemByMsgId,
+ deleteGroupChatItemInternal,
+ deleteGroupChatItemRcvBroadcast,
+ deleteGroupChatItemSndBroadcast,
+ updateDirectChatItemsRead,
updateGroupChatItemsRead,
getSMPServers,
overwriteSMPServers,
@@ -152,7 +158,7 @@ import Data.Function (on)
import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (find, sortBy, sortOn)
-import Data.Maybe (fromMaybe, isJust, listToMaybe)
+import Data.Maybe (fromMaybe, listToMaybe)
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as T
@@ -180,8 +186,8 @@ import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, AgentMsgId, Conn
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C
+import Simplex.Messaging.Encoding.String (StrEncoding (strEncode))
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
-import Simplex.Messaging.Protocol (MsgBody)
import Simplex.Messaging.Util (liftIOEither, (<$$>))
import System.FilePath (takeFileName)
import UnliftIO.STM
@@ -2041,21 +2047,18 @@ createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGr
createNewSndMessage st gVar connOrGroupId mkMessage =
liftIOEither . withTransaction st $ \db ->
createWithRandomId gVar $ \sharedMsgId -> do
+ let NewMessage {chatMsgEvent, msgBody} = mkMessage $ SharedMsgId sharedMsgId
createdAt <- getCurrentTime
- DB.execute
- db
- "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, shared_msg_id, shared_msg_id_user, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
- (MDSnd, XUnknown_ "", "" :: MsgBody, sharedMsgId, Just True, createdAt, createdAt)
- msgId <- insertedRowId db
- let NewMessage {chatMsgEvent, msgBody} = mkMessage $ SharedMsgId sharedMsgId
DB.execute
db
[sql|
- UPDATE messages
- SET msg_sent = ?, chat_msg_event = ?, msg_body = ?, connection_id = ?, group_id = ?
- WHERE message_id = ?
+ INSERT INTO messages (
+ msg_sent, chat_msg_event, msg_body, connection_id, group_id,
+ shared_msg_id, shared_msg_id_user, created_at, updated_at
+ ) VALUES (?,?,?,?,?,?,?,?,?)
|]
- (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, msgId)
+ (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, sharedMsgId, Just True, createdAt, createdAt)
+ msgId <- insertedRowId db
pure SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody}
where
(connId_, groupId_) = case connOrGroupId of
@@ -2214,7 +2217,7 @@ createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent, shar
(Just $ Just userMemberId == memberId, memberId)
createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> UTCTime -> UTCTime -> IO ChatItemId
-createNewChatItem_ db User {userId} chatDirection msgId sharedMsgId ciContent quoteRow itemTs createdAt = do
+createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemTs createdAt = do
DB.execute
db
[sql|
@@ -2227,10 +2230,11 @@ createNewChatItem_ db User {userId} chatDirection msgId sharedMsgId ciContent qu
quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
- ((userId, msgId) :. idsRow :. itemRow :. quoteRow)
+ ((userId, msgId_) :. idsRow :. itemRow :. quoteRow)
ciId <- insertedRowId db
- when (isJust msgId) $
- DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, createdAt, createdAt)
+ case msgId_ of
+ Just msgId -> insertChatItemMessage_ db ciId msgId createdAt
+ Nothing -> pure ()
pure ciId
where
itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, UTCTime, UTCTime)
@@ -2242,6 +2246,9 @@ createNewChatItem_ db User {userId} chatDirection msgId sharedMsgId ciContent qu
CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId)
CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing)
+insertChatItemMessage_ :: DB.Connection -> ChatItemId -> MessageId -> UTCTime -> IO ()
+insertChatItemMessage_ db ciId msgId ts = DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, ts, ts)
+
getChatItemQuote_ :: DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c)
getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} =
case chatDirection of
@@ -2500,7 +2507,7 @@ getDirectChatLast_ db User {userId} contactId count = do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
- WHERE i.user_id = ? AND i.contact_id = ?
+ WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1
ORDER BY i.chat_item_id DESC
LIMIT ?
|]
@@ -2528,7 +2535,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
- WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id > ?
+ WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id > ? AND i.item_deleted != 1
ORDER BY i.chat_item_id ASC
LIMIT ?
|]
@@ -2556,7 +2563,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
- WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id < ?
+ WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id < ? AND i.item_deleted != 1
ORDER BY i.chat_item_id DESC
LIMIT ?
|]
@@ -2570,7 +2577,7 @@ getDirectChatStats_ db userId contactId =
[sql|
SELECT COUNT(1), MIN(chat_item_id)
FROM chat_items
- WHERE user_id = ? AND contact_id = ? AND item_status = ?
+ WHERE user_id = ? AND contact_id = ? AND item_status = ? AND item_deleted != 1
GROUP BY contact_id
|]
(userId, contactId, CISRcvNew)
@@ -2668,7 +2675,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id
- WHERE i.user_id = ? AND i.group_id = ?
+ WHERE i.user_id = ? AND i.group_id = ? AND i.item_deleted != 1
ORDER BY i.item_ts DESC, i.chat_item_id DESC
LIMIT ?
|]
@@ -2708,7 +2715,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id
- WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id > ?
+ WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id > ? AND i.item_deleted != 1
ORDER BY i.item_ts ASC, i.chat_item_id ASC
LIMIT ?
|]
@@ -2748,7 +2755,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id
LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id
- WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id < ?
+ WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id < ? AND i.item_deleted != 1
ORDER BY i.item_ts DESC, i.chat_item_id DESC
LIMIT ?
|]
@@ -2762,7 +2769,7 @@ getGroupChatStats_ db userId groupId =
[sql|
SELECT COUNT(1), MIN(chat_item_id)
FROM chat_items
- WHERE user_id = ? AND group_id = ? AND item_status = ?
+ WHERE user_id = ? AND group_id = ? AND item_status = ? AND item_deleted != 1
GROUP BY group_id
|]
(userId, groupId, CISRcvNew)
@@ -2844,26 +2851,101 @@ updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $
ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId
currentTs <- liftIO getCurrentTime
let newText = ciContentToText newContent
- liftIO $
+ liftIO $ do
DB.execute
db
[sql|
UPDATE chat_items
- SET item_content = ?, item_text = ?, item_edited = 1, updated_at = ?
+ SET item_content = ?, item_text = ?, item_deleted = 0, item_edited = 1, updated_at = ?
WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
|]
(newContent, newText, currentTs, userId, contactId, itemId)
- liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs)
+ insertChatItemMessage_ db itemId msgId currentTs
pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText}
where
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
-updateDirectChatItemByMsgId :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d)
-updateDirectChatItemByMsgId st userId contactId sharedMsgId newContent msgId =
+deleteDirectChatItemInternal :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> m AChatItem
+deleteDirectChatItemInternal st userId ct itemId =
+ liftIOEither . withTransaction st $ \db -> do
+ currentTs <- liftIO getCurrentTime
+ ci <- deleteDirectChatItem_ db userId ct itemId CIDMInternal True currentTs
+ setChatItemMessagesDeleted_ db itemId
+ pure ci
+
+setChatItemMessagesDeleted_ :: DB.Connection -> ChatItemId -> IO ()
+setChatItemMessagesDeleted_ db itemId =
+ DB.execute
+ db
+ [sql|
+ UPDATE messages
+ SET chat_msg_event = ?, msg_body = ?
+ WHERE message_id IN (
+ SELECT message_id
+ FROM chat_item_messages
+ WHERE chat_item_id = ?
+ )
+ |]
+ (XMsgDeleted_, xMsgDeletedBody, itemId)
+ where
+ xMsgDeletedBody = strEncode ChatMessage {msgId = Nothing, chatMsgEvent = XMsgDeleted}
+
+deleteDirectChatItemRcvBroadcast :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> MessageId -> m AChatItem
+deleteDirectChatItemRcvBroadcast st userId ct itemId msgId =
+ liftIOEither . withTransaction st $ \db -> deleteDirectChatItemBroadcast_ db userId ct itemId False msgId
+
+deleteDirectChatItemSndBroadcast :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> MessageId -> m AChatItem
+deleteDirectChatItemSndBroadcast st userId ct itemId msgId =
+ liftIOEither . withTransaction st $ \db -> do
+ ci <- deleteDirectChatItemBroadcast_ db userId ct itemId True msgId
+ setChatItemMessagesDeleted_ db itemId
+ pure ci
+
+deleteDirectChatItemBroadcast_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> Bool -> MessageId -> IO (Either StoreError AChatItem)
+deleteDirectChatItemBroadcast_ db userId ct itemId itemDeleted msgId = do
+ currentTs <- liftIO getCurrentTime
+ insertChatItemMessage_ db itemId msgId currentTs
+ deleteDirectChatItem_ db userId ct itemId CIDMBroadcast itemDeleted currentTs
+
+deleteDirectChatItem_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> Bool -> UTCTime -> IO (Either StoreError AChatItem)
+deleteDirectChatItem_ db userId ct@Contact {contactId} itemId mode itemDeleted currentTs = runExceptT $ do
+ (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId
+ let toContent = msgDirToDeletedContent_ msgDir mode
+ liftIO $ do
+ DB.execute
+ db
+ [sql|
+ UPDATE chat_items
+ SET item_content = ?, item_text = ?, item_deleted = ?, updated_at = ?
+ WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
+ |]
+ (toContent, toText, itemDeleted, currentTs, userId, contactId, itemId)
+ when itemDeleted $ deleteQuote_ db itemId
+ pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted}, formattedText = Nothing})
+ where
+ toText = ciDeleteModeToText mode
+
+deleteQuote_ :: DB.Connection -> ChatItemId -> IO ()
+deleteQuote_ db itemId =
+ DB.execute
+ db
+ [sql|
+ UPDATE chat_items
+ SET quoted_shared_msg_id = NULL, quoted_sent_at = NULL, quoted_content = NULL, quoted_sent = NULL, quoted_member_id = NULL
+ WHERE chat_item_id = ?
+ |]
+ (Only itemId)
+
+getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect)
+getDirectChatItem st userId contactId itemId =
+ liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId
+
+getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m (CChatItem 'CTDirect)
+getDirectChatItemBySharedMsgId st userId contactId sharedMsgId =
liftIOEither . withTransaction st $ \db -> runExceptT $ do
itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId
- liftIOEither $ updateDirectChatItem_ db userId contactId itemId newContent msgId
+ liftIOEither $ getDirectChatItem_ db userId contactId itemId
getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64)
getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId =
@@ -2879,10 +2961,6 @@ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId =
|]
(userId, contactId, sharedMsgId)
-getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect)
-getDirectChatItem st userId contactId itemId =
- liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId
-
getDirectChatItem_ :: DB.Connection -> UserId -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTDirect))
getDirectChatItem_ db userId contactId itemId = do
tz <- getCurrentTimeZone
@@ -2928,26 +3006,73 @@ updateGroupChatItem_ db user@User {userId} groupId itemId newContent msgId = run
ci <- ExceptT $ (correctDir =<<) <$> getGroupChatItem_ db user groupId itemId
currentTs <- liftIO getCurrentTime
let newText = ciContentToText newContent
- liftIO $
+ liftIO $ do
DB.execute
db
[sql|
UPDATE chat_items
- SET item_content = ?, item_text = ?, item_edited = 1, updated_at = ?
+ SET item_content = ?, item_text = ?, item_deleted = 0, item_edited = 1, updated_at = ?
WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
|]
(newContent, newText, currentTs, userId, groupId, itemId)
- liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs)
+ insertChatItemMessage_ db itemId msgId currentTs
pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText}
where
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
-updateGroupChatItemByMsgId :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> User -> Int64 -> SharedMsgId -> CIContent d -> MessageId -> m (ChatItem 'CTGroup d)
-updateGroupChatItemByMsgId st user groupId sharedMsgId newContent msgId =
+deleteGroupChatItemInternal :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> m AChatItem
+deleteGroupChatItemInternal st user gInfo itemId =
+ liftIOEither . withTransaction st $ \db -> do
+ currentTs <- liftIO getCurrentTime
+ ci <- deleteGroupChatItem_ db user gInfo itemId CIDMInternal True currentTs
+ setChatItemMessagesDeleted_ db itemId
+ pure ci
+
+deleteGroupChatItemRcvBroadcast :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> MessageId -> m AChatItem
+deleteGroupChatItemRcvBroadcast st user gInfo itemId msgId =
+ liftIOEither . withTransaction st $ \db -> deleteGroupChatItemBroadcast_ db user gInfo itemId False msgId
+
+deleteGroupChatItemSndBroadcast :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> MessageId -> m AChatItem
+deleteGroupChatItemSndBroadcast st user gInfo itemId msgId =
+ liftIOEither . withTransaction st $ \db -> do
+ ci <- deleteGroupChatItemBroadcast_ db user gInfo itemId True msgId
+ setChatItemMessagesDeleted_ db itemId
+ pure ci
+
+deleteGroupChatItemBroadcast_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Bool -> MessageId -> IO (Either StoreError AChatItem)
+deleteGroupChatItemBroadcast_ db user gInfo itemId itemDeleted msgId = do
+ currentTs <- liftIO getCurrentTime
+ insertChatItemMessage_ db itemId msgId currentTs
+ deleteGroupChatItem_ db user gInfo itemId CIDMBroadcast itemDeleted currentTs
+
+deleteGroupChatItem_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> CIDeleteMode -> Bool -> UTCTime -> IO (Either StoreError AChatItem)
+deleteGroupChatItem_ db user@User {userId} gInfo@GroupInfo {groupId} itemId mode itemDeleted currentTs = runExceptT $ do
+ (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId
+ let toContent = msgDirToDeletedContent_ msgDir mode
+ liftIO $ do
+ DB.execute
+ db
+ [sql|
+ UPDATE chat_items
+ SET item_content = ?, item_text = ?, item_deleted = ?, updated_at = ?
+ WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
+ |]
+ (toContent, toText, itemDeleted, currentTs, userId, groupId, itemId)
+ when itemDeleted $ deleteQuote_ db itemId
+ pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted}, formattedText = Nothing})
+ where
+ toText = ciDeleteModeToText mode
+
+getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup)
+getGroupChatItem st user groupId itemId =
+ liftIOEither . withTransaction st $ \db -> getGroupChatItem_ db user groupId itemId
+
+getGroupChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> User -> Int64 -> SharedMsgId -> m (CChatItem 'CTGroup)
+getGroupChatItemBySharedMsgId st user groupId sharedMsgId =
liftIOEither . withTransaction st $ \db -> runExceptT $ do
itemId <- ExceptT $ getGroupChatItemIdBySharedMsgId_ db user groupId sharedMsgId
- liftIOEither $ updateGroupChatItem_ db user groupId itemId newContent msgId
+ liftIOEither $ getGroupChatItem_ db user groupId itemId
getGroupChatItemIdBySharedMsgId_ :: DB.Connection -> User -> Int64 -> SharedMsgId -> IO (Either StoreError Int64)
getGroupChatItemIdBySharedMsgId_ db User {userId} groupId sharedMsgId =
@@ -2963,10 +3088,6 @@ getGroupChatItemIdBySharedMsgId_ db User {userId} groupId sharedMsgId =
|]
(userId, groupId, sharedMsgId)
-getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup)
-getGroupChatItem st user groupId itemId =
- liftIOEither . withTransaction st $ \db -> getGroupChatItem_ db user groupId itemId
-
getGroupChatItem_ :: DB.Connection -> User -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTGroup))
getGroupChatItem_ db User {userId, userContactId} groupId itemId = do
tz <- getCurrentTimeZone
@@ -3106,10 +3227,10 @@ toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatu
where
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> CChatItem 'CTDirect
cItem d chatDir ciStatus content =
- CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow}
+ CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow}
badItem = Left $ SEBadChatItem itemId
- ciMeta :: CIStatus d -> CIMeta d
- ciMeta status = mkCIMeta itemId itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
+ ciMeta :: CIContent d -> CIStatus d -> CIMeta d
+ ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect]
toDirectChatItemList tz currentTs ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. quoteRow) =
@@ -3139,10 +3260,10 @@ toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemTe
where
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> CChatItem 'CTGroup
cItem d chatDir ciStatus content quotedMember_ =
- CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_}
+ CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_}
badItem = Left $ SEBadChatItem itemId
- ciMeta :: CIStatus d -> CIMeta d
- ciMeta status = mkCIMeta itemId itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
+ ciMeta :: CIContent d -> CIStatus d -> CIMeta d
+ ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup]
toGroupChatItemList tz currentTs userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) =
diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs
index 7a1a3017d4..cc7aa47cf5 100644
--- a/src/Simplex/Chat/Terminal/Input.hs
+++ b/src/Simplex/Chat/Terminal/Input.hs
@@ -50,6 +50,7 @@ runInputLoop ct cc = forever $ do
Right SendGroupFile {} -> True
Right SendMessageQuote {} -> True
Right SendGroupMessageQuote {} -> True
+ Right SendMessageBroadcast {} -> True
_ -> False
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
@@ -100,9 +101,9 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition
_ -> ts
where
insertCharsWithContact cs
- | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" =
+ | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" =
insertChars $ contactPrefix <> cs
- | s == ">" && cs == " " =
+ | (s == ">" || s == "\\" || s == "!") && cs == " " =
insertChars $ cs <> contactPrefix
| otherwise = insertChars cs
insertChars = ts' . if p >= length s then append else insert
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index 61e514b325..f6cd89f824 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -49,8 +49,9 @@ responseToView testView = \case
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item
CRChatItemStatusUpdated _ -> []
- CRChatItemUpdated (AChatItem _ _ chat item) -> viewMessageUpdate chat item
- CRChatItemDeleted _ -> [] -- TODO
+ CRChatItemUpdated (AChatItem _ _ chat item) -> viewItemUpdate chat item
+ CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> viewItemDelete chat deletedItem toItem
+ CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
CRCmdAccepted _ -> []
CRCmdOk -> ["ok"]
@@ -59,7 +60,7 @@ responseToView testView = \case
HSFiles -> filesHelpInfo
HSGroups -> groupsHelpInfo
HSMyAddress -> myAddressHelpInfo
- HSQuotes -> quotesHelpInfo
+ HSMessages -> messagesHelpInfo
HSMarkdown -> markdownInfo
CRWelcome user -> chatWelcome user
CRContactsList cs -> viewContactsList cs
@@ -168,13 +169,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
DirectChat c -> case chatDir of
CIDirectSnd -> case content of
CISndMsgContent mc -> viewSentMessage to quote mc meta
- CISndMsgDeleted _mc -> []
+ CISndDeleted _ -> []
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToContact' c
CIDirectRcv -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
- CIRcvMsgDeleted _mc -> []
+ CIRcvDeleted _ -> []
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft
where
from = ttyFromContact' c
@@ -183,13 +184,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
GroupChat g -> case chatDir of
CIGroupSnd -> case content of
CISndMsgContent mc -> viewSentMessage to quote mc meta
- CISndMsgDeleted _mc -> []
+ CISndDeleted _ -> []
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToGroup g
CIGroupRcv m -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
- CIRcvMsgDeleted _mc -> []
+ CIRcvDeleted _ -> []
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft
where
from = ttyFromGroup' g m
@@ -197,8 +198,8 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
quote = maybe [] (groupQuote g) quotedItem
_ -> []
-viewMessageUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
-viewMessageUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
+viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
+viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
DirectChat Contact {localDisplayName = c} -> case chatDir of
CIDirectRcv -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
@@ -206,7 +207,7 @@ viewMessageUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat
where
from = ttyFromContactEdited c
quote = maybe [] (directQuote chatDir) quotedItem
- CIDirectSnd -> []
+ CIDirectSnd -> ["message updated"]
GroupChat g -> case chatDir of
CIGroupRcv GroupMember {localDisplayName = m} -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
@@ -214,8 +215,23 @@ viewMessageUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat
where
from = ttyFromGroupEdited g m
quote = maybe [] (groupQuote g) quotedItem
- CIGroupSnd -> []
- where
+ CIGroupSnd -> ["message updated"]
+ _ -> []
+
+viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> [StyledString]
+viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} = case chat of
+ DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of
+ (CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
+ CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] meta mc
+ CIDMInternal -> ["message deleted"]
+ (CIDirectSnd, _, _) -> ["message deleted"]
+ _ -> []
+ GroupChat g -> case (chatDir, deletedContent, toContent) of
+ (CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
+ CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] meta mc
+ CIDMInternal -> ["message deleted"]
+ (CIGroupSnd, _, _) -> ["message deleted"]
+ _ -> []
_ -> []
directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString]
@@ -237,8 +253,8 @@ msgPreview :: MsgContent -> [StyledString]
msgPreview = msgPlain . preview . msgContentText
where
preview t
- | T.length t <= 60 = t
- | otherwise = t <> "..."
+ | T.length t <= 120 = t
+ | otherwise = T.take 120 t <> "..."
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
viewMsgIntegrityError err = msgError $ case err of
@@ -441,6 +457,9 @@ viewSentMessage to quote mc = sentWithTime_ . prependFirst to $ quote <> prepend
where
indent = if null quote then "" else " "
+viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString]
+viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc)
+
viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString]
viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath
@@ -585,7 +604,8 @@ viewChatError = \case
CEFileRcvChunk e -> ["error receiving file: " <> plain e]
CEFileInternal e -> ["file error: " <> plain e]
CEInvalidQuote -> ["cannot reply to this message"]
- CEInvalidMessageUpdate -> ["cannot update this message"]
+ CEInvalidChatItemUpdate -> ["cannot update this item"]
+ CEInvalidChatItemDelete -> ["cannot delete this item"]
CEAgentVersion -> ["unsupported agent version"]
CECommandError e -> ["bad chat command: " <> plain e]
-- e -> ["chat error: " <> sShow e]
@@ -639,6 +659,9 @@ ttyFromContact c = ttyFrom $ c <> "> "
ttyFromContactEdited :: ContactName -> StyledString
ttyFromContactEdited c = ttyFrom $ c <> "> [edited] "
+ttyFromContactDeleted :: ContactName -> StyledString
+ttyFromContactDeleted c = ttyFrom $ c <> "> [deleted] "
+
ttyToContact' :: Contact -> StyledString
ttyToContact' Contact {localDisplayName = c} = ttyToContact c
@@ -673,6 +696,9 @@ ttyFromGroup GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c
ttyFromGroupEdited :: GroupInfo -> ContactName -> StyledString
ttyFromGroupEdited GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> [edited] "
+ttyFromGroupDeleted :: GroupInfo -> ContactName -> StyledString
+ttyFromGroupDeleted GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> [deleted] "
+
ttyFrom :: Text -> StyledString
ttyFrom = styled $ colored Yellow
diff --git a/stack.yaml b/stack.yaml
index fbb69616ba..b9c297539d 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -39,7 +39,7 @@ extra-deps:
- network-3.1.2.7@sha256:e3d78b13db9512aeb106e44a334ab42b7aa48d26c097299084084cb8be5c5568,4888
- simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079
- tls-1.5.7@sha256:1cc30253a9696b65a9cafc0317fbf09f7dcea15e3a145ed6c9c0e28c632fa23a,6991
- # below hackage dependancies are to update Aeson to 2.0.3
+ # below hackage dependencies are to update Aeson to 2.0.3
- OneTuple-0.3.1@sha256:a848c096c9d29e82ffdd30a9998aa2931cbccb3a1bc137539d80f6174d31603e,2262
- attoparsec-0.14.4@sha256:79584bdada8b730cb5138fca8c35c76fbef75fc1d1e01e6b1d815a5ee9843191,5810
- hashable-1.4.0.2@sha256:0cddd0229d1aac305ea0404409c0bbfab81f075817bd74b8b2929eff58333e55,5005
@@ -49,13 +49,12 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
- commit: b6e87e4a3e4d8d6f0d4b41ec13b3787f8d1c5189
+ commit: 3ba1926b1e5ab32451a2239831d614492d40c9be
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
- github: simplex-chat/haskell-terminal
commit: f708b00009b54890172068f168bf98508ffcd495
-
#
# extra-deps: []
diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs
index 19b28a7cbb..ad0e238e04 100644
--- a/tests/ChatTests.hs
+++ b/tests/ChatTests.hs
@@ -36,6 +36,7 @@ chatTests = do
it "add contact and send/receive message" testAddContact
it "direct message quoted replies" testDirectMessageQuotedReply
it "direct message update" testDirectMessageUpdate
+ it "direct message delete" testDirectMessageDelete
describe "chat groups" $ do
it "add contacts, create group and send/receive messages" testGroup
it "create and join group with 4 members" testGroup2
@@ -46,6 +47,7 @@ chatTests = do
it "list groups containing group invitations" testGroupList
it "group message quoted replies" testGroupMessageQuotedReply
it "group message update" testGroupMessageUpdate
+ it "group message delete" testGroupMessageDelete
describe "user profiles" $ do
it "update user profiles and notify contacts" testUpdateProfile
it "update user profile with image" testUpdateProfileImage
@@ -128,7 +130,7 @@ testAddContact =
bob #$> ("/_read chat @2 from=1 to=100", id, "ok")
testDirectMessageQuotedReply :: IO ()
-testDirectMessageQuotedReply = do
+testDirectMessageQuotedReply =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
@@ -153,7 +155,7 @@ testDirectMessageQuotedReply = do
alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))])
testDirectMessageUpdate :: IO ()
-testDirectMessageUpdate = do
+testDirectMessageUpdate =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
@@ -172,7 +174,7 @@ testDirectMessageUpdate = do
alice #$> ("/_get chat @2 count=100", chat', [((1, "hello 🙂"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))])
bob #$> ("/_get chat @2 count=100", chat', [((0, "hello 🙂"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))])
- alice ##> "/_update item @2 1 text hey 👋"
+ alice #$> ("/_update item @2 1 text hey 👋", id, "message updated")
bob <# "alice> [edited] hey 👋"
alice #$> ("/_get chat @2 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))])
@@ -188,23 +190,75 @@ testDirectMessageUpdate = do
alice #$> ("/_get chat @2 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂")), ((0, "hey alice"), Just (1, "hey 👋"))])
bob #$> ("/_get chat @2 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂")), ((1, "hey alice"), Just (0, "hey 👋"))])
- alice ##> "/_update item @2 1 text greetings 🤝"
+ alice #$> ("/_update item @2 1 text greetings 🤝", id, "message updated")
bob <# "alice> [edited] greetings 🤝"
+ alice #$> ("/_update item @2 2 text updating bob's message", id, "cannot update this item")
+
alice #$> ("/_get chat @2 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂")), ((0, "hey alice"), Just (1, "hey 👋"))])
bob #$> ("/_get chat @2 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂")), ((1, "hey alice"), Just (0, "hey 👋"))])
- bob ##> "/_update item @2 2 text hey Alice"
+ bob #$> ("/_update item @2 2 text hey Alice", id, "message updated")
alice <# "bob> [edited] > hello 🙂"
alice <## " hey Alice"
- bob ##> "/_update item @2 3 text greetings Alice"
+ bob #$> ("/_update item @2 3 text greetings Alice", id, "message updated")
alice <# "bob> [edited] > hey 👋"
alice <## " greetings Alice"
alice #$> ("/_get chat @2 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hey Alice"), Just (1, "hello 🙂")), ((0, "greetings Alice"), Just (1, "hey 👋"))])
bob #$> ("/_get chat @2 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hey Alice"), Just (0, "hello 🙂")), ((1, "greetings Alice"), Just (0, "hey 👋"))])
+testDirectMessageDelete :: IO ()
+testDirectMessageDelete =
+ testChat2 aliceProfile bobProfile $
+ \alice bob -> do
+ connectUsers alice bob
+
+ -- msg id 1
+ alice #> "@bob hello 🙂"
+ bob <# "alice> hello 🙂"
+
+ -- msg id 2
+ bob `send` "> @alice (hello) hey alic"
+ bob <# "@alice > hello 🙂"
+ bob <## " hey alic"
+ alice <# "bob> > hello 🙂"
+ alice <## " hey alic"
+
+ alice #$> ("/_delete item @2 1 internal", id, "message deleted")
+ alice #$> ("/_delete item @2 2 internal", id, "message deleted")
+
+ alice #$$> ("/_get chats", [("@bob", "")])
+ alice #$> ("/_get chat @2 count=100", chat, [])
+
+ alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item")
+ alice #$> ("/_send_quote @2 1 text quoting deleted message", id, "cannot reply to this message")
+
+ bob #$> ("/_update item @2 2 text hey alice", id, "message updated")
+ alice <# "bob> [edited] hey alice"
+
+ alice #$$> ("/_get chats", [("@bob", "hey alice")])
+ alice #$> ("/_get chat @2 count=100", chat, [(0, "hey alice")])
+
+ -- msg id 3
+ bob #> "@alice how are you?"
+ alice <# "bob> how are you?"
+
+ bob #$> ("/_delete item @2 3 broadcast", id, "message deleted")
+ alice <# "bob> [deleted] how are you?"
+
+ alice #$> ("/_delete item @2 1 broadcast", id, "message deleted")
+ bob <# "alice> [deleted] hello 🙂"
+
+ alice #$> ("/_delete item @2 2 broadcast", id, "cannot delete this item")
+ alice #$> ("/_delete item @2 2 internal", id, "message deleted")
+
+ alice #$$> ("/_get chats", [("@bob", "this item is deleted (broadcast)")])
+ alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")])
+ bob #$$> ("/_get chats", [("@alice", "hey alice")])
+ bob #$> ("/_get chat @2 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hey alice"), (Just (0, "hello 🙂")))])
+
testGroup :: IO ()
testGroup =
testChat3 aliceProfile bobProfile cathProfile $
@@ -688,16 +742,17 @@ testGroupMessageQuotedReply =
)
testGroupMessageUpdate :: IO ()
-testGroupMessageUpdate = do
+testGroupMessageUpdate =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
+ -- msg id 1
alice #> "#team hello!"
concurrently_
(bob <# "#team alice> hello!")
(cath <# "#team alice> hello!")
- alice ##> "/_update item #1 1 text hey 👋"
+ alice #$> ("/_update item #1 1 text hey 👋", id, "message updated")
concurrently_
(bob <# "#team alice> [edited] hey 👋")
(cath <# "#team alice> [edited] hey 👋")
@@ -707,6 +762,7 @@ testGroupMessageUpdate = do
cath #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing)])
threadDelay 1000000
+ -- msg id 2
bob `send` "> #team @alice (hey) hi alice"
bob <# "#team > alice hey 👋"
bob <## " hi alice"
@@ -724,11 +780,13 @@ testGroupMessageUpdate = do
bob #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hey 👋"))])
cath #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing), ((0, "hi alice"), Just (0, "hey 👋"))])
- alice ##> "/_update item #1 1 text greetings 🤝"
+ alice #$> ("/_update item #1 1 text greetings 🤝", id, "message updated")
concurrently_
(bob <# "#team alice> [edited] greetings 🤝")
(cath <# "#team alice> [edited] greetings 🤝")
+ alice #$> ("/_update item #1 2 text updating bob's message", id, "cannot update this item")
+
threadDelay 1000000
cath `send` "> #team @alice (greetings) greetings!"
cath <# "#team > alice greetings 🤝"
@@ -747,6 +805,87 @@ testGroupMessageUpdate = do
bob #$> ("/_get chat #1 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hey 👋")), ((0, "greetings!"), Just (0, "greetings 🤝"))])
cath #$> ("/_get chat #1 count=100", chat', [((0, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (0, "hey 👋")), ((1, "greetings!"), Just (0, "greetings 🤝"))])
+testGroupMessageDelete :: IO ()
+testGroupMessageDelete =
+ testChat3 aliceProfile bobProfile cathProfile $
+ \alice bob cath -> do
+ createGroup3 "team" alice bob cath
+ -- msg id 1
+ alice #> "#team hello!"
+ concurrently_
+ (bob <# "#team alice> hello!")
+ (cath <# "#team alice> hello!")
+
+ alice #$> ("/_delete item #1 1 internal", id, "message deleted")
+
+ alice #$> ("/_get chat #1 count=100", chat, [])
+ bob #$> ("/_get chat #1 count=100", chat, [(0, "hello!")])
+ cath #$> ("/_get chat #1 count=100", chat, [(0, "hello!")])
+
+ alice #$> ("/_update item #1 1 text updating deleted message", id, "cannot update this item")
+ alice #$> ("/_send_quote #1 1 text quoting deleted message", id, "cannot reply to this message")
+
+ threadDelay 1000000
+ -- msg id 2
+ bob `send` "> #team @alice (hello) hi alic"
+ bob <# "#team > alice hello!"
+ bob <## " hi alic"
+ concurrently_
+ ( do
+ alice <# "#team bob> > alice hello!"
+ alice <## " hi alic"
+ )
+ ( do
+ cath <# "#team bob> > alice hello!"
+ cath <## " hi alic"
+ )
+
+ alice #$> ("/_get chat #1 count=100", chat', [((0, "hi alic"), Just (1, "hello!"))])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "hello!"), Nothing), ((1, "hi alic"), Just (0, "hello!"))])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "hello!"), Nothing), ((0, "hi alic"), Just (0, "hello!"))])
+
+ alice #$> ("/_delete item #1 1 broadcast", id, "message deleted")
+ concurrently_
+ (bob <# "#team alice> [deleted] hello!")
+ (cath <# "#team alice> [deleted] hello!")
+
+ alice #$> ("/_delete item #1 2 internal", id, "message deleted")
+
+ alice #$> ("/_get chat #1 count=100", chat', [])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hi alic"), Just (0, "hello!"))])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((0, "hi alic"), Just (0, "hello!"))])
+
+ bob #$> ("/_update item #1 2 text hi alice", id, "message updated")
+ concurrently_
+ (alice <# "#team bob> [edited] hi alice")
+ ( do
+ cath <# "#team bob> [edited] > alice hello!"
+ cath <## " hi alice"
+ )
+
+ alice #$> ("/_get chat #1 count=100", chat', [((0, "hi alice"), Nothing)])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hi alice"), Just (0, "hello!"))])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((0, "hi alice"), Just (0, "hello!"))])
+
+ threadDelay 1000000
+ -- msg id 3
+ cath #> "#team how are you?"
+ concurrently_
+ (alice <# "#team cath> how are you?")
+ (bob <# "#team cath> how are you?")
+
+ cath #$> ("/_delete item #1 3 broadcast", id, "message deleted")
+ concurrently_
+ (alice <# "#team cath> [deleted] how are you?")
+ (bob <# "#team cath> [deleted] how are you?")
+
+ alice #$> ("/_delete item #1 2 broadcast", id, "cannot delete this item")
+ alice #$> ("/_delete item #1 2 internal", id, "message deleted")
+
+ alice #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing)])
+ bob #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hi alice"), Just (0, "hello!")), ((0, "this item is deleted (broadcast)"), Nothing)])
+ cath #$> ("/_get chat #1 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((0, "hi alice"), Just (0, "hello!"))])
+
testUpdateProfile :: IO ()
testUpdateProfile =
testChat3 aliceProfile bobProfile cathProfile $
diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs
index 168c1aeb75..1782d74a3f 100644
--- a/tests/MarkdownTests.hs
+++ b/tests/MarkdownTests.hs
@@ -16,6 +16,7 @@ markdownTests = do
textWithUri
textWithEmail
textWithPhone
+ multilineMarkdownList
textFormat :: Spec
textFormat = describe "text format (bold)" do
@@ -180,3 +181,13 @@ textWithPhone = describe "text with Phone" do
parseMarkdown "test 077777 test" `shouldBe` "test 077777 test"
it "ignored as markdown (double spaces)" $
parseMarkdown "test 07777 777 777 test" `shouldBe` "test 07777 777 777 test"
+
+uri' :: Text -> FormattedText
+uri' = FormattedText $ Just Uri
+
+multilineMarkdownList :: Spec
+multilineMarkdownList = describe "multiline markdown" do
+ it "correct markdown" do
+ parseMaybeMarkdownList "http://simplex.chat\nhttp://app.simplex.chat" `shouldBe` Just [uri' "http://simplex.chat", "\n", uri' "http://app.simplex.chat"]
+ it "no markdown" do
+ parseMaybeMarkdownList "not a\nmarkdown" `shouldBe` Nothing