Merge pull request #487 from simplex-chat/master (v1.5.0)

This commit is contained in:
JRoberts
2022-04-03 14:10:02 +04:00
committed by GitHub
68 changed files with 1984 additions and 525 deletions
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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 {
+32 -1
View File
@@ -5,6 +5,10 @@
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -16,6 +20,8 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<!-- Main activity -->
<activity
android:name=".MainActivity"
android:exported="true"
@@ -27,6 +33,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- open simplex:/ connection URI -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -34,6 +41,7 @@
<data android:scheme="simplex" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="chat.simplex.app.provider"
@@ -43,6 +51,29 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application>
<!-- SimplexService foreground service -->
<service
android:name=".SimplexService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false">
</service>
<!-- SimplexService restart on reboot -->
<receiver
android:name=".SimplexService$StartReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<!-- SimplexService restart on destruction -->
<receiver
android:name=".SimplexService$AutoRestartReceiver"
android:enabled="true"
android:exported="false"/>
</application>
</manifest>
@@ -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<SimplexViewModel>()
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.ServiceStartWorker>(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)
}
}
@@ -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 {
@@ -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)
}
}
@@ -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<BGManager>()
.setInitialDelay(Duration.ofMinutes(10))
.setConstraints(constraints)
.build()
WorkManager.getInstance(appContext)
.enqueue(request)
}
}
}
@@ -29,6 +29,7 @@ class ChatModel(val controller: ChatController) {
var userSMPServers = mutableStateOf<(List<String>)?>(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf<Uri?>(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"
@@ -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<String>): 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
@@ -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)
@@ -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
)
)
)
@@ -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.*
@@ -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))
@@ -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)
)
@@ -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<ChatItem?>,
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<ChatItem>,
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>
editingItem: MutableState<ChatItem?>,
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 = { _, _ -> }
)
}
}
@@ -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
@@ -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()
)
}
@@ -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<ChatItem?>,
editingItem: MutableState<ChatItem?>,
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 = { _, _ -> }
)
}
}
@@ -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()
)
}
}
@@ -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
)
}
@@ -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 {
@@ -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()
) {
@@ -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,
@@ -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(
@@ -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)
)
@@ -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())
@@ -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<Boolean>,
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 = {}
@@ -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 {
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,7 @@
<resources>
<string name="app_name">SimpleX</string>
</resources>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title">SimpleX Chat service</string>
<string name="simplex_service_notification_text">Waiting for incoming messages</string>
</resources>
+1 -1
View File
@@ -1,6 +1,6 @@
buildscript {
ext {
compose_version = '1.1.0'
compose_version = '1.1.1'
}
repositories {
google()
+1 -1
View File
@@ -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
}
+73 -4
View File
@@ -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"
}
+60 -26
View File
@@ -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)
}
@@ -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)
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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
)
@@ -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)
+10 -4
View File
@@ -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))
}
+112 -34
View File
@@ -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)
@@ -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)
@@ -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()
@@ -10,14 +10,14 @@ import SwiftUI
import CodeScanner
struct ConnectContactView: View {
var completed: ((Error?) -> Void)
var completed: ((Result<Bool, Error>) -> 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))
}
}
}
@@ -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)
}
}
})
@@ -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
}
+40 -19
View File
@@ -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 = "<group>"; };
5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOSLaunchTests.swift; sourceTree = "<group>"; };
5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = "<group>"; };
5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a"; sourceTree = "<group>"; };
5CA14D1F27F6DE37009B11CE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CA14D2027F6DE37009B11CE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CA14D2127F6DE37009B11CE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = "<group>"; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
@@ -199,6 +216,7 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -431,6 +449,7 @@
5CE4407827ADB701007B033A /* EmojiItemView.swift */,
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
);
path = ChatItem;
sourceTree = "<group>";
@@ -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;
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+136 -43
View File
@@ -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
+14 -9
View File
@@ -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)
+15 -5
View File
@@ -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 <topic> " <> " - help on: files, groups, address, replies, smp_servers",
indent <> highlight "/help <topic> " <> " - help on: messages, files, groups, address",
indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/delete <contact>" <> " - 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) <msg> " <> " - to reply to alice's most recent message",
indent <> highlight ">> @alice (hi) <msg> " <> " - to quote user's most recent message to alice",
indent <> highlight "> #team (hi) <msg> " <> " - to quote most recent message in the group from any member",
indent <> highlight "> #team @alice (hi) <msg>" <> " - to quote alice's most recent message in the group #team"
indent <> highlight "> #team @alice (hi) <msg>" <> " - 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) <new msg> " <> " - to edit your message to alice",
indent <> highlight "! #team (hi) <new msg> " <> " - to edit your message in the group #team"
]
markdownInfo :: [StyledString]
+5 -1
View File
@@ -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
+42 -20
View File
@@ -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
+10 -3
View File
@@ -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]
+174 -53
View File
@@ -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_) =
+3 -2
View File
@@ -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
+41 -15
View File
@@ -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
+2 -3
View File
@@ -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: []
+148 -9
View File
@@ -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 $
+11
View File
@@ -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