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