Merge pull request #685 from simplex-chat/master (version 2.1.0)

This commit is contained in:
JRoberts
2022-05-21 21:44:33 +04:00
committed by GitHub
82 changed files with 5014 additions and 1978 deletions
-1
View File
@@ -13,7 +13,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 31
versionName "2.0.1"
versionCode 32
versionName "2.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="chat.simplex.app">
<uses-feature android:name="android.hardware.camera" />
@@ -15,7 +16,9 @@
<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" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name="SimplexApp"
+15 -2
View File
@@ -1,11 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link href="./style.css" rel="stylesheet" />
<script src="./lz-string.min.js"></script>
</head>
<body>
<video id="remote-video-stream" autoplay playsinline></video>
<video id="local-video-stream" muted autoplay playsinline></video>
<video
id="remote-video-stream"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
<video
id="local-video-stream"
muted
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
</body>
<footer>
<script src="./call.js"></script>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);
@@ -21,6 +21,6 @@ body {
object-fit: cover;
margin: 16px;
border-radius: 16px;
bottom: 0;
top: 0;
right: 0;
}
@@ -21,6 +21,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
import chat.simplex.app.views.call.ActiveCallView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
@@ -90,7 +91,8 @@ fun MainPage(chatModel: ChatModel) {
when {
onboarding == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated ->
if (chatModel.chatId.value == null) ChatListView(chatModel)
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {
@@ -111,7 +113,7 @@ fun processIntent(intent: Intent?, chatModel: ChatModel) {
if (chatId != null) {
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null) withApi { openChat(chatModel, cInfo) }
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
}
}
NtfManager.ShowChatsAction -> {
@@ -52,7 +52,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
} else {
chatController.startChat(user)
SimplexService.start(applicationContext)
chatController.showBackgroundServiceNotice()
chatController.showBackgroundServiceNoticeIfNeeded()
}
}
}
@@ -65,6 +65,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
if (!chatController.getRunServiceInBackground()) SimplexService.stop(applicationContext)
Lifecycle.Event.ON_START ->
SimplexService.start(applicationContext)
Lifecycle.Event.ON_RESUME ->
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
}
}
}
@@ -45,6 +45,7 @@ class ChatModel(val controller: ChatController) {
val activeCallInvitation = mutableStateOf<ContactRef?>(null)
val activeCall = mutableStateOf<Call?>(null)
val callCommand = mutableStateOf<WCallCommand?>(null)
val showCallView = mutableStateOf(false)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
@@ -75,6 +76,20 @@ class ChatModel(val controller: ChatController) {
}
}
fun updateChats(newChats: List<Chat>) {
val mergedChats = arrayListOf<Chat>()
for (newChat in newChats) {
val i = getChatIndex(newChat.chatInfo.id)
if (i >= 0) {
mergedChats.add(newChat.copy(serverInfo = chats[i].serverInfo))
} else {
mergedChats.add(newChat)
}
}
chats.clear()
chats.addAll(mergedChats)
}
fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) {
val i = getChatIndex(id)
if (i >= 0) {
@@ -172,8 +187,28 @@ class ChatModel(val controller: ChatController) {
}
}
fun clearChat(cInfo: ChatInfo) {
// clear preview
val i = getChatIndex(cInfo.id)
if (i >= 0) {
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
}
// clear current chat
if (chatId.value == cInfo.id) {
chatItems.clear()
}
}
fun markChatItemsRead(cInfo: ChatInfo) {
// update preview
val chatIdx = getChatIndex(cInfo.id)
if (chatIdx >= 0) {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
if (lastId != null) {
chats[chatIdx] = chat.copy(chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = lastId + 1))
}
}
// update current chat
if (chatId.value == cInfo.id) {
var i = 0
@@ -184,12 +219,6 @@ class ChatModel(val controller: ChatController) {
}
i += 1
}
val chat = chats[chatIdx]
val pItem = chat.chatItems.lastOrNull()
chats[chatIdx] = chat.copy(
chatItems = if (pItem == null) arrayListOf() else arrayListOf(pItem.withStatus(CIStatus.RcvRead())),
chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1)
)
}
}
@@ -256,6 +285,7 @@ interface SomeChat {
val apiId: Long
val ready: Boolean
val createdAt: Instant
val updatedAt: Instant
}
@Serializable
@@ -307,6 +337,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val createdAt get() = contact.createdAt
override val updatedAt get() = contact.updatedAt
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
override val image get() = contact.image
@@ -324,6 +355,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val createdAt get() = groupInfo.createdAt
override val updatedAt get() = groupInfo.updatedAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
override val image get() = groupInfo.image
@@ -341,6 +373,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val createdAt get() = contactRequest.createdAt
override val updatedAt get() = contactRequest.updatedAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
override val image get() = contactRequest.image
@@ -358,6 +391,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val createdAt get() = contactConnection.createdAt
override val updatedAt get() = contactConnection.updatedAt
override val displayName get() = contactConnection.displayName
override val fullName get() = contactConnection.fullName
override val image get() = contactConnection.image
@@ -376,7 +410,8 @@ class Contact(
val profile: Profile,
val activeConn: Connection,
val viaGroup: Long? = null,
override val createdAt: Instant
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
override val id get() = "@$contactId"
@@ -392,7 +427,8 @@ class Contact(
localDisplayName = "alice",
profile = Profile.sampleData,
activeConn = Connection.sampleData,
createdAt = Clock.System.now()
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
}
}
@@ -438,7 +474,8 @@ class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
override val createdAt: Instant
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
@@ -453,7 +490,8 @@ class GroupInfo (
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
createdAt = Clock.System.now()
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
}
}
@@ -525,7 +563,7 @@ class UserContactRequest (
override val localDisplayName: String,
val profile: Profile,
override val createdAt: Instant,
val updatedAt: Instant
override val updatedAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
@@ -553,7 +591,7 @@ class PendingContactConnection(
val pccConnStatus: ConnStatus,
val viaContactUri: Boolean,
override val createdAt: Instant,
val updatedAt: Instant
override val updatedAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.ContactConnection
override val id get () = ":$pccConnId"
@@ -7,6 +7,8 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
import chat.simplex.app.views.call.CallInvitation
import chat.simplex.app.views.call.CallMediaType
import kotlinx.datetime.Clock
class NtfManager(val context: Context) {
@@ -56,7 +58,7 @@ class NtfManager(val context: Context) {
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(cInfo))
.setContentIntent(getMsgPendingIntent(cInfo.id))
.setSilent(recentNotification)
.build()
@@ -76,6 +78,37 @@ class NtfManager(val context: Context) {
}
}
fun notifyCallInvitation(contact: Contact, invitation: CallInvitation) {
Log.d(TAG, "notifyCallInvitationReceived ${contact.id}")
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(contact.displayName)
.setContentText("Incoming ${invitation.peerMedia} call (${if (invitation.sharedKey == null) "not e2e encrypted" else "e2e encrypted"})")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(contact.id))
.setSilent(false)
.build()
// val summary = NotificationCompat.Builder(context, MessageChannel)
// .setSmallIcon(R.drawable.ntf_icon)
// .setColor(0x88FFFF)
// .setGroup(MessageGroup)
// .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
// .setGroupSummary(true)
// .setContentIntent(getSummaryNtfIntent())
// .build()
with(NotificationManagerCompat.from(context)) {
notify(0, notification)
// notify(0, summary)
}
}
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
@@ -93,12 +126,12 @@ class NtfManager(val context: Context) {
}
}
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
private fun getMsgPendingIntent(chatId: String) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent $chatId")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
val intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra("chatId", cInfo.id)
.putExtra("chatId", chatId)
.setAction(OpenChatAction)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
@@ -1,9 +1,14 @@
package chat.simplex.app.model
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.content.Context
import android.content.SharedPreferences
import android.app.Application
import android.content.*
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -40,18 +45,22 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
suspend fun startChat(user: User) {
Log.d(TAG, "user: $user")
try {
apiStartChat()
val chatStarted = apiStartChat()
apiSetFilesFolder(getAppFilesDirectory(appContext))
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
val chats = apiGetChats()
chatModel.chats.clear()
chatModel.chats.addAll(chats)
if (chatStarted) {
chatModel.chats.clear()
chatModel.chats.addAll(chats)
} else {
chatModel.updateChats(chats)
}
chatModel.currentUser.value = user
chatModel.userCreated.value = true
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
Log.d(TAG, "started chat")
} catch(e: Error) {
Log.d(TAG, "chat started")
} catch (e: Error) {
Log.e(TAG, "failed starting chat $e")
throw e
}
@@ -130,10 +139,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
throw Error("user not created ${r.responseType} ${r.details}")
}
suspend fun apiStartChat() {
suspend fun apiStartChat(): Boolean {
val r = sendCmd(CC.StartChat())
if (r is CR.ChatStarted || r is CR.ChatRunning) return
throw Error("failed starting chat: ${r.responseType} ${r.details}")
when (r) {
is CR.ChatStarted -> return true
is CR.ChatRunning -> return false
else -> throw Error("failed starting chat: ${r.responseType} ${r.details}")
}
}
suspend fun apiSetFilesFolder(filesFolder: String) {
@@ -268,6 +280,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return false
}
suspend fun apiClearChat(type: ChatType, id: Long): ChatInfo? {
val r = sendCmd(CC.ApiClearChat(type, id))
if (r is CR.ChatCleared) return r.chatInfo
Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
val r = sendCmd(CC.ApiUpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
@@ -332,20 +351,20 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return r is CR.CmdOk
}
suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: List<String>, media: CallMediaType, capabilities: CallCapabilities): Boolean {
suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean {
val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates)
val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession)
val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer))
return r is CR.CmdOk
}
suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: List<String>): Boolean {
suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean {
val answer = WebRTCSession(rtcSession, rtcIceCandidates)
val r = sendCmd(CC.ApiSendCallAnswer(contact, answer))
return r is CR.CmdOk
}
suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: List<String>): Boolean {
suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean {
val extraInfo = WebRTCExtraInfo(rtcIceCandidates)
val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo))
return r is CR.CmdOk
@@ -356,15 +375,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return r is CR.CmdOk
}
suspend fun apiCallStatus(contact: Contact, status: String): Boolean {
try {
val callStatus = WebRTCCallStatus.valueOf(status)
val r = sendCmd(CC.ApiCallStatus(contact, callStatus))
return r is CR.CmdOk
} catch (e: Error) {
Log.d(TAG,"apiCallStatus: call status $status not used")
return false
}
suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
val r = sendCmd(CC.ApiCallStatus(contact, status))
return r is CR.CmdOk
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
@@ -491,11 +504,82 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
removeFile(appContext, fileName)
}
}
is CR.CallInvitation -> {
val invitation = CallInvitation(r.callType.media, r.sharedKey)
chatModel.callInvitations[r.contact.id] = invitation
if (chatModel.activeCallInvitation.value == null) {
chatModel.activeCallInvitation.value = ContactRef(r.contact.apiId, r.contact.localDisplayName)
}
ntfManager.notifyCallInvitation(r.contact, invitation)
AlertManager.shared.showAlertDialog(
title = invitation.callTitle,
text = String.format(generalGetString(R.string.contact_wants_to_connect_via_call), r.contact.displayName) + " " + invitation.callTypeText + ".",
confirmText = generalGetString(R.string.answer),
onConfirm = {
if (chatModel.activeCallInvitation.value == null) {
AlertManager.shared.hideAlert()
withApi { AlertManager.shared.showAlertMsg(generalGetString(R.string.call_already_ended)) }
} else {
chatModel.activeCallInvitation.value = null
chatModel.activeCall.value = Call(
contact = r.contact,
callState = CallState.InvitationReceived,
localMedia = invitation.peerMedia,
sharedKey = invitation.sharedKey
)
chatModel.callCommand.value = WCallCommand.Start(media = invitation.peerMedia, aesKey = invitation.sharedKey)
chatModel.showCallView.value = true
}
},
onDismiss = {
chatModel.activeCallInvitation.value = null
}
)
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey)
}
}
is CR.CallAnswer -> {
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
}
}
is CR.CallExtraInfo -> {
withCall(r, r.contact) { _ ->
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
}
}
is CR.CallEnded -> {
withCall(r, r.contact) { _ ->
chatModel.callCommand.value = WCallCommand.End
withApi {
chatModel.activeCall.value = null
chatModel.callCommand.value = null
}
}
chatModel.activeCallInvitation.value = null
chatModel.showCallView.value = false
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
}
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
val call = chatModel.activeCall.value
if (call != null && call.contact.apiId == contact.apiId) {
perform(call)
} else {
Log.d(TAG, "processReceivedMsg: ignoring ${r.responseType}, not in call with the contact ${contact.id}")
}
}
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
@@ -526,38 +610,118 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err))
}
fun showBackgroundServiceNotice() {
fun showBackgroundServiceNoticeIfNeeded() {
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
if (!getBackgroundServiceNoticeShown()) {
AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
)
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown))
}
},
confirmButton = {
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
// the branch for the new users who has never seen service notice
if (isIgnoringBatteryOptimizations(appContext)) {
showBGServiceNotice()
} else {
showBGServiceNoticeIgnoreOptimization()
}
// set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice
setBackgroundServiceNoticeShown(true)
setBackgroundServiceBatteryNoticeShown(true)
} else if (!isIgnoringBatteryOptimizations(appContext) && getRunServiceInBackground()) {
// the branch for users who have app installed, and have seen the service notice,
// but the battery optimization for the app is on (Android 12) AND the service is running
if (getBackgroundServiceBatteryNoticeShown()) {
// users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service
showDisablingServiceNotice()
setRunServiceInBackground(false)
chatModel.runServiceInBackground.value = false
} else {
// show battery optimization notice
showBGServiceNoticeIgnoreOptimization()
setBackgroundServiceBatteryNoticeShown(true)
}
setBackgroundServiceNoticeShown()
}
}
private fun showBGServiceNotice() = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
)
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown))
}
},
confirmButton = {
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
private fun showBGServiceNoticeIgnoreOptimization() = AlertManager.shared.showAlert {
val ignoreOptimization = {
AlertManager.shared.hideAlert()
askAboutIgnoringBatteryOptimization(appContext)
}
AlertDialog(
onDismissRequest = ignoreOptimization,
title = {
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
)
Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery),
Modifier.padding(bottom = 8.dp)
)
Text(annotatedStringResource(R.string.turn_off_battery_optimization))
}
},
confirmButton = {
Button(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
}
)
}
private fun showDisablingServiceNotice() = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row {
Icon(
Icons.Outlined.Bolt,
contentDescription = stringResource(R.string.icon_descr_instant_notifications),
)
Text(stringResource(R.string.private_instant_notifications_disabled), fontWeight = FontWeight.Bold)
}
},
text = {
Column {
Text(
annotatedStringResource(R.string.turning_off_background_service),
Modifier.padding(bottom = 8.dp)
)
}
},
confirmButton = {
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
}
)
}
fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
fun setAutoRestartWorkerVersion(version: Int) =
@@ -572,18 +736,45 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
.putBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, runService)
.apply()
fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
private fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
fun setBackgroundServiceNoticeShown() =
fun setBackgroundServiceNoticeShown(shown: Boolean) =
sharedPreferences.edit()
.putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, true)
.putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, shown)
.apply()
private fun getBackgroundServiceBatteryNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
fun setBackgroundServiceBatteryNoticeShown(shown: Boolean) =
sharedPreferences.edit()
.putBoolean(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, shown)
.apply()
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val powerManager = context.getSystemService(Application.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
}
private fun askAboutIgnoringBatteryOptimization(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
Intent().apply {
@SuppressLint("BatteryLife")
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
// This flag is needed when you start a new activity from non-Activity context
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(this)
}
}
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"
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
}
}
@@ -604,6 +795,7 @@ sealed class CC {
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiUpdateProfile(val profile: Profile): CC()
class ApiParseMarkdown(val text: String): CC()
class CreateMyAddress: CC()
@@ -637,6 +829,7 @@ sealed class CC {
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is ApiParseMarkdown -> "/_parse $text"
is CreateMyAddress -> "/address"
@@ -650,7 +843,7 @@ sealed class CC {
is ApiSendCallAnswer -> "/_call answer @${contact.apiId} ${json.encodeToString(answer)}"
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
is ApiEndCall -> "/_call end @${contact.apiId}"
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus}"
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ReceiveFile -> "/freceive $fileId"
}
@@ -671,6 +864,7 @@ sealed class CC {
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
is ApiClearChat -> "apiClearChat"
is ApiUpdateProfile -> "updateProfile"
is ApiParseMarkdown -> "apiParseMarkdown"
is CreateMyAddress -> "createMyAddress"
@@ -742,6 +936,7 @@ sealed class CR {
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
@Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@@ -774,8 +969,8 @@ sealed class CR {
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
@@ -799,6 +994,7 @@ sealed class CR {
is SentInvitation -> "sentInvitation"
is ContactAlreadyExists -> "contactAlreadyExists"
is ContactDeleted -> "contactDeleted"
is ChatCleared -> "chatCleared"
is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated"
is ParsedMarkdown -> "apiParsedMarkdown"
@@ -857,6 +1053,7 @@ sealed class CR {
is SentInvitation -> noDetails()
is ContactAlreadyExists -> json.encodeToString(contact)
is ContactDeleted -> json.encodeToString(contact)
is ChatCleared -> json.encodeToString(chatInfo)
is UserProfileNoChange -> noDetails()
is UserProfileUpdated -> json.encodeToString(toProfile)
is ParsedMarkdown -> json.encodeToString(formattedText)
@@ -115,8 +115,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
)
chatModel.controller.startChat(user)
SimplexService.start(chatModel.controller.appContext)
// TODO show it later?
chatModel.controller.showBackgroundServiceNotice()
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
}
}
@@ -1,21 +1,35 @@
package chat.simplex.app.views.call
import android.Manifest
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.graphics.fonts.FontStyle
import android.os.Build
import android.service.controls.templates.ControlButton
import android.util.Log
import android.view.ViewGroup
import android.webkit.*
import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.magnifier
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
@@ -23,17 +37,246 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.views.helpers.TextEditor
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ChatInfoLayout
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun VideoCallView(close: () -> Unit) {
BackHandler(onBack = close)
lateinit var wv: WebView
val context = LocalContext.current
val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java)
fun ActiveCallView(chatModel: ChatModel) {
val endCall = {
Log.d(TAG, "ActiveCallView: endCall")
chatModel.activeCall.value = null
chatModel.activeCallInvitation.value = null
chatModel.callCommand.value = null
chatModel.showCallView.value = false
}
BackHandler(onBack = endCall)
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
val call = chatModel.activeCall.value
if (call != null) {
Log.d(TAG, "has active call $call")
when (val r = apiMsg.resp) {
is WCallResponse.Capabilities -> withApi {
val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(call.contact, callType)
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
}
is WCallResponse.Offer -> withApi {
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
}
is WCallResponse.Answer -> withApi {
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
}
is WCallResponse.Ice -> withApi {
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
}
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
} catch (e: Error) {
Log.d(TAG,"call status ${r.state.connectionState} not used")
}
is WCallResponse.Connected -> {
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
}
is WCallResponse.Ended -> endCall()
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Media -> {
when (cmd.media) {
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
}
}
is WCallCommand.Camera -> chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
is WCallCommand.End -> endCall()
else -> {}
}
is WCallResponse.Error -> {
Log.e(TAG, "ActiveCallView: command error ${r.message}")
}
}
}
}
val call = chatModel.activeCall.value
if (call != null) ActiveCallOverlay(call, chatModel, endCall)
}
}
@Composable
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, endCall: () -> Unit) {
ActiveCallOverlayLayout(
call = call,
dismiss = {
chatModel.callCommand.value = WCallCommand.End
withApi {
chatModel.controller.apiEndCall(call.contact)
endCall()
}
},
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
)
}
@Composable
private fun ActiveCallOverlayLayout(
call: Call,
dismiss: () -> Unit,
toggleAudio: () -> Unit,
toggleVideo: () -> Unit,
flipCamera: () -> Unit
) {
Column(Modifier.padding(16.dp)) {
when (call.peerMedia ?: call.localMedia) {
CallMediaType.Video -> {
CallInfoView(call, alignment = Alignment.Start)
Spacer(Modifier.fillMaxHeight().weight(1f))
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, toggleAudio)
Spacer(Modifier.size(40.dp))
IconButton(onClick = dismiss) {
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
if (call.videoEnabled) {
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
} else {
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
}
}
}
CallMediaType.Audio -> {
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
CallInfoView(call, alignment = Alignment.CenterHorizontally)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss) {
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
}
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, toggleAudio)
}
}
}
}
}
}
@Composable
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
if (call.hasMedia) {
IconButton(onClick = action) {
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
}
} else {
Spacer(Modifier.size(40.dp))
}
}
@Composable
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
if (call.audioEnabled) {
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_video_off, toggleAudio)
} else {
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
}
}
@Composable
private fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
Text(text, color = Color(0xFFFFFFD8), style = style)
Column(horizontalAlignment = alignment) {
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
InfoText(call.callState.text)
val connInfo =
if (call.connectionInfo == null) ""
else " (${call.connectionInfo.text})"
InfoText(call.encryptionStatus + connInfo)
}
}
//@Composable
//fun CallViewDebug(close: () -> Unit) {
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
//
// BackHandler(onBack = close)
// Column(
// horizontalAlignment = Alignment.CenterHorizontally,
// verticalArrangement = Arrangement.spacedBy(12.dp),
// modifier = Modifier
// .background(MaterialTheme.colors.background)
// .fillMaxSize()
// ) {
// WebRTCView(callCommand) { apiMsg ->
// // for debugging
// // commandText.value = apiMsg
// commandText.value = json.encodeToString(apiMsg)
// }
//
// TextEditor(Modifier.height(180.dp), text = commandText)
//
// Row(
// Modifier
// .fillMaxWidth()
// .padding(bottom = 6.dp),
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Button(onClick = {
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
// clipboard?.setPrimaryClip(clip)
// }) { Text("Copy") }
// Button(onClick = {
// try {
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
// commandText.value = ""
// println("sending: ${commandText.value}")
// callCommand.value = apiCall.command
// } catch(e: Error) {
// println("error parsing command: ${commandText.value}")
// println(e)
// }
// }) { Text("Send") }
// Button(onClick = {
// commandText.value = ""
// }) { Text("Clear") }
// }
// }
//}
@Composable
// for debugging
// fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) {
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
@@ -42,6 +285,10 @@ fun VideoCallView(close: () -> Unit) {
Manifest.permission.INTERNET
)
)
fun processCommand(wv: WebView, cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd)
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
@@ -50,98 +297,85 @@ fun VideoCallView(close: () -> Unit) {
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
wv.evaluateJavascript("endCall()", null)
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val localContext = LocalContext.current
val commandToShow = remember { mutableStateOf("processCommand({command: {type: 'start', media: 'video'}})") } //, aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") }
LaunchedEffect(callCommand.value, webView.value) {
val cmd = callCommand.value
val wv = webView.value
if (cmd != null && wv != null) {
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
processCommand(wv, cmd)
callCommand.value = null
}
}
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext))
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
.build()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
if (permissionsState.allPermissionsGranted) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) {
AndroidView(
factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
this.webChromeClient = object: WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (request.origin.toString().startsWith("file:/")) {
request.grant(request.resources)
} else {
Log.d(TAG, "Permission request from webview denied.")
request.deny()
}
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
val rtnValue = super.onConsoleMessage(consoleMessage)
val msg = consoleMessage?.message() as String
if (msg.startsWith("{")) {
commandToShow.value = "processCommand($msg)"
}
return rtnValue
if (permissionsState.allPermissionsGranted) {
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
this.webChromeClient = object: WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (request.origin.toString().startsWith("file:/")) {
request.grant(request.resources)
} else {
Log.d(TAG, "Permission request from webview denied.")
request.deny()
}
}
this.webViewClient = LocalContentWebViewClient(assetLoader)
this.clearHistory()
this.clearCache(true)
// this.addJavascriptInterface(JavascriptInterface(), "Android")
val webViewSettings = this.settings
webViewSettings.allowFileAccess = true
webViewSettings.allowContentAccess = true
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html")
}
this.webViewClient = LocalContentWebViewClient(assetLoader)
this.clearHistory()
this.clearCache(true)
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
val webViewSettings = this.settings
webViewSettings.allowFileAccess = true
webViewSettings.allowContentAccess = true
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html")
}
) {
wv = it
}
) { wv ->
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
withApi {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv
}
}
} else {
Text("NEED PERMISSIONS")
}
} else {
Text("NEED PERMISSIONS")
}
}
TextEditor(Modifier.height(180.dp), text = commandToShow)
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button( onClick = {
val clip: ClipData = ClipData.newPlainText("js command", commandToShow.value)
clipboard?.setPrimaryClip(clip)
}) {Text("Copy")}
Button( onClick = {
println("sending: ${commandToShow.value}")
wv.evaluateJavascript(commandToShow.value, null)
commandToShow.value = ""
}) {Text("Send")}
Button( onClick = {
commandToShow.value = ""
}) {Text("Clear")}
// for debugging
// class WebRTCInterface(private val onResponse: (String) -> Unit) {
class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
@JavascriptInterface
fun postMessage(message: String) {
Log.d(TAG, "WebRTCInterface.postMessage")
try {
// for debugging
// onResponse(message)
onResponse(json.decodeFromString(message))
} catch (e: Error) {
Log.e(TAG, "failed parsing WebView message: $message")
}
}
}
@@ -154,3 +388,43 @@ private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoa
return assetLoader.shouldInterceptRequest(request.url)
}
}
@Preview
@Composable
fun PreviewActiveCallOverlayVideo() {
SimpleXTheme {
ActiveCallOverlayLayout(
call = Call(
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Video,
peerMedia = CallMediaType.Video,
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
),
dismiss = {},
toggleAudio = {},
toggleVideo = {},
flipCamera = {}
)
}
}
@Preview
@Composable
fun PreviewActiveCallOverlayAudio() {
SimpleXTheme {
ActiveCallOverlayLayout(
call = Call(
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Audio,
peerMedia = CallMediaType.Audio,
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
),
dismiss = {},
toggleAudio = {},
toggleVideo = {},
flipCamera = {}
)
}
}
@@ -1,12 +1,14 @@
package chat.simplex.app.views.call
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.Contact
import chat.simplex.app.views.helpers.generalGetString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class Call(
data class Call(
val contact: Contact,
val callState: CallState,
val localMedia: CallMediaType,
@@ -14,9 +16,21 @@ class Call(
val peerMedia: CallMediaType? = null,
val sharedKey: String? = null,
val audioEnabled: Boolean = true,
val videoEnabled: Boolean = localMedia == CallMediaType.Video
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null
) {
val encrypted: Boolean get() = (localCapabilities?.encryption ?: false) && sharedKey != null
val encrypted: Boolean get() = localEncrypted && sharedKey != null
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
val encryptionStatus: String @Composable get() = when(callState) {
CallState.WaitCapabilities -> ""
CallState.InvitationSent -> stringResource(if (localEncrypted) R.string.status_e2e_encrypted else R.string.status_no_e2e_encryption)
CallState.InvitationReceived -> stringResource(if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_contact_has_e2e_encryption)
else -> stringResource(if (!localEncrypted) R.string.status_no_e2e_encryption else if (sharedKey == null) R.string.status_contact_has_no_e2e_encryption else R.string.status_e2e_encrypted)
}
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
}
enum class CallState {
@@ -28,63 +42,103 @@ enum class CallState {
Negotiated,
Connected;
val text: String get() = when(this) {
WaitCapabilities -> generalGetString(R.string.callstate_starting)
InvitationSent -> generalGetString(R.string.callstate_waiting_for_answer)
InvitationReceived -> generalGetString(R.string.callstate_starting)
OfferSent -> generalGetString(R.string.callstate_waiting_for_confirmation)
OfferReceived -> generalGetString(R.string.callstate_received_answer)
Negotiated -> generalGetString(R.string.callstate_connecting)
Connected -> generalGetString(R.string.callstate_connected)
val text: String @Composable get() = when(this) {
WaitCapabilities -> stringResource(R.string.callstate_starting)
InvitationSent -> stringResource(R.string.callstate_waiting_for_answer)
InvitationReceived -> stringResource(R.string.callstate_starting)
OfferSent -> stringResource(R.string.callstate_waiting_for_confirmation)
OfferReceived -> stringResource(R.string.callstate_received_answer)
Negotiated -> stringResource(R.string.callstate_connecting)
Connected -> stringResource(R.string.callstate_connected)
}
}
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand?)
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
@Serializable
sealed class WCallCommand {
@Serializable @SerialName("capabilities") class Capabilities(): WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List<String>, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List<String>): WCallCommand()
@Serializable @SerialName("ice") class Ice(val iceCandidates: List<String>): WCallCommand()
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("end") class End(): WCallCommand()
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand()
}
@Serializable
sealed class WCallResponse {
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: List<String>): WCallResponse()
// TODO remove accept, it is needed for debugging
@Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List<String>, val media: CallMediaType, val aesKey: String? = null): WCallResponse()
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: List<String>): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: List<String>): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("ended") class Ended(): WCallResponse()
@Serializable @SerialName("ok") class Ok(): WCallResponse()
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
@Serializable @SerialName("ended") object Ended: WCallResponse()
@Serializable @SerialName("ok") object Ok: WCallResponse()
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
@Serializable class Invalid(val str: String): WCallResponse()
}
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List<String>)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: List<String>)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?)
@Serializable class CallInvitation(val peerMedia: CallMediaType, val sharedKey: String?) {
val callTypeText: String get() = generalGetString(when(peerMedia) {
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
})
val callTitle: String get() = generalGetString(when(peerMedia) {
CallMediaType.Video -> R.string.incoming_video_call
CallMediaType.Audio -> R.string.incoming_audio_call
})
}
@Serializable class CallCapabilities(val encryption: Boolean)
@Serializable class ConnectionInfo(val localCandidate: RTCIceCandidate?, val remoteCandidate: RTCIceCandidate) {
val text: String @Composable get() = when {
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
stringResource(R.string.call_connection_peer_to_peer)
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
stringResource(R.string.call_connection_via_relay)
else ->
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
enum class WebRTCCallStatus(val status: String) {
Connected("connected"),
Disconnected("disconnected"),
Failed("failed")
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
@Serializable
enum class RTCIceCandidateType(val value: String) {
@SerialName("host") Host("host"),
@SerialName("srflx") ServerReflexive("srflx"),
@SerialName("prflx") PeerReflexive("prflx"),
@SerialName("relay") Relay("relay")
}
@Serializable
enum class CallMediaType(val media: String) {
Video("video"),
Audio("audio")
enum class WebRTCCallStatus(val value: String) {
@SerialName("connected") Connected("connected"),
@SerialName("connecting") Connecting("connecting"),
@SerialName("disconnected") Disconnected("disconnected"),
@SerialName("failed") Failed("failed")
}
@Serializable
enum class CallMediaType {
@SerialName("video") Video,
@SerialName("audio") Audio
}
@Serializable
enum class VideoCamera {
@SerialName("user") User,
@SerialName("environment") Environment;
val flipped: VideoCamera get() = if (this == User) Environment else User
}
@Serializable
@@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -27,36 +26,62 @@ fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
ChatInfoLayout(chat,
ChatInfoLayout(
chat,
close = close,
deleteContact = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact__question),
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
val cInfo = chat.chatInfo
withApi {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
chatModel.chatId.value = null
close()
}
}
}
)
}
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
)
}
}
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: () -> Unit) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact__question),
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
close()
}
}
}
)
}
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: () -> Unit) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.clear_chat_question),
text = generalGetString(R.string.clear_chat_warning),
confirmText = generalGetString(R.string.clear_verb),
onConfirm = {
withApi {
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
if (updatedChatInfo != null) {
chatModel.clearChat(updatedChatInfo)
close()
}
}
}
)
}
@Composable
fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Column(Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
fun ChatInfoLayout(
chat: Chat,
close: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit
) {
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
@@ -66,7 +91,9 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
modifier = Modifier
.padding(top = 32.dp)
.padding(bottom = 8.dp)
)
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
@@ -90,13 +117,27 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 16.dp)
)
}
Spacer(Modifier.weight(1F))
Box(Modifier.padding(48.dp)) {
Box(Modifier.padding(4.dp)) {
SimpleButton(
stringResource(R.string.clear_chat_button),
icon = Icons.Outlined.Restore,
color = WarningOrange,
click = clearChat
)
}
Box(
Modifier
.padding(4.dp)
.padding(bottom = 32.dp)
) {
SimpleButton(
stringResource(R.string.button_delete_contact),
icon = Icons.Outlined.Delete,
@@ -104,22 +145,35 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
click = deleteContact
)
}
} else if (cInfo is ChatInfo.Group) {
Spacer(Modifier.weight(1F))
Box(
Modifier
.padding(4.dp)
.padding(bottom = 32.dp)
) {
SimpleButton(
stringResource(R.string.clear_chat_button),
icon = Icons.Outlined.Restore,
color = WarningOrange,
click = clearChat
)
}
}
}
}
@Composable
fun ServerImage(chat: Chat) {
val status = chat.serverInfo.networkStatus
when {
status is Chat.NetworkStatus.Connected ->
when (chat.serverInfo.networkStatus) {
is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
status is Chat.NetworkStatus.Disconnected ->
is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
status is Chat.NetworkStatus.Error ->
is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
else ->
Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
}
}
@@ -133,7 +187,7 @@ fun PreviewChatInfoLayout() {
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}
close = {}, deleteContact = {}, clearChat = {}
)
}
}
@@ -1,10 +1,9 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -12,7 +11,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBackIos
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
@@ -20,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@@ -28,10 +28,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp.Companion.context
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
@@ -45,10 +45,9 @@ fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
val user = chatModel.currentUser.value
val composeState = remember { mutableStateOf(ComposeState()) }
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
if (chat == null || user == null) {
chatModel.chatId.value = null
@@ -57,8 +56,8 @@ fun ChatView(chatModel: ChatModel) {
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
delay(1000L)
if (chat.chatItems.count() > 0) {
delay(750L)
if (chat.chatItems.isNotEmpty()) {
chatModel.markChatItemsRead(chat.chatInfo)
chatModel.controller.cancelNotificationsForChat(chat.id)
withApi {
@@ -76,15 +75,11 @@ fun ChatView(chatModel: ChatModel) {
composeState,
composeView = {
ComposeView(
chatModel,
chat,
composeState,
chosenImage,
chosenFile,
showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } })
chatModel, chat, composeState, attachmentOption,
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
)
},
chosenImage,
chosenFile,
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
@@ -94,7 +89,7 @@ fun ChatView(chatModel: ChatModel) {
val c = chatModel.chats.firstOrNull {
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
}
if (c != null) withApi { openChat(chatModel, c.chatInfo) }
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
},
deleteMessage = { itemId, mode ->
withApi {
@@ -117,6 +112,30 @@ fun ChatView(chatModel: ChatModel) {
chatModel.upsertChatItem(cInfo, cItem)
}
}
},
startCall = { media ->
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities
}
},
acceptCall = { contact ->
val invitation = chatModel.callInvitations.remove(contact.id)
if (invitation == null) {
AlertManager.shared.showAlertMsg("Call already ended!")
} else {
chatModel.activeCallInvitation.value = null
chatModel.activeCall.value = Call(
contact = contact,
callState = CallState.InvitationReceived,
localMedia = invitation.peerMedia,
sharedKey = invitation.sharedKey
)
chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Start(media = invitation.peerMedia, aesKey = invitation.sharedKey)
}
}
)
}
@@ -128,8 +147,7 @@ fun ChatLayout(
chat: Chat,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
chosenImage: MutableState<Bitmap?>,
chosenFile: MutableState<Uri?>,
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
@@ -137,19 +155,10 @@ fun ChatLayout(
info: () -> Unit,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit
) {
fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
fun onFileChange(uri: Uri) {
val fileName = getFileName(context, uri)
if (fileName != null) {
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
}
}
Surface(
Modifier
.fillMaxWidth()
@@ -160,25 +169,21 @@ fun ChatLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
::onImageChange,
chosenFile,
::onFileChange,
hideBottomSheet = {
scope.launch { attachmentBottomSheetState.hide() }
})
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile)
ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage, receiveFile, acceptCall)
}
}
}
@@ -187,33 +192,43 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
IconButton(onClick, modifier = modifier) {
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
}
}
Column {
Box(
Modifier
.fillMaxWidth()
.height(52.dp)
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(horizontal = 8.dp),
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
IconButton(onClick = back) {
Icon(
Icons.Outlined.ArrowBackIos,
stringResource(R.string.back),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
val cInfo = chat.chatInfo
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
// if (cInfo is ChatInfo.Direct) {
// Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
// Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
// toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
// startCall(CallMediaType.Audio)
// }
// }
// toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
// startCall(CallMediaType.Video)
// }
// }
// }
Row(
Modifier
.padding(horizontal = 68.dp)
.padding(horizontal = 80.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 40.dp)
Column(
Modifier.padding(start = 8.dp),
@@ -256,7 +271,8 @@ fun ChatItemsList(
chatItems: List<ChatItem>,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
val keyboardState by getKeyboardState()
@@ -294,11 +310,11 @@ fun ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
}
}
} else { // direct message
@@ -309,7 +325,7 @@ fun ChatItemsList(
end = if (sent) 12.dp else 76.dp,
)
) {
ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
}
}
}
@@ -369,8 +385,7 @@ fun PreviewChatLayout() {
),
composeState = remember { mutableStateOf(ComposeState()) },
composeView = {},
chosenFile = remember { mutableStateOf(null) },
chosenImage = remember { mutableStateOf(null) },
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
@@ -378,7 +393,9 @@ fun PreviewChatLayout() {
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
startCall = {},
acceptCall = { _ -> }
)
}
}
@@ -414,8 +431,7 @@ fun PreviewGroupChatLayout() {
),
composeState = remember { mutableStateOf(ComposeState()) },
composeView = {},
chosenImage = remember { mutableStateOf(null) },
chosenFile = remember { mutableStateOf(null) },
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
@@ -423,7 +439,9 @@ fun PreviewGroupChatLayout() {
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
startCall = {},
acceptCall = { _ -> }
)
}
}
@@ -2,8 +2,20 @@ package chat.simplex.app.views.chat
import ComposeFileView
import ComposeImageView
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.CallSuper
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -12,7 +24,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -21,12 +33,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import java.io.File
sealed class ComposePreview {
object NoPreview: ComposePreview()
@@ -101,9 +116,8 @@ fun ComposeView(
chatModel: ChatModel,
chat: Chat,
composeState: MutableState<ComposeState>,
chosenImage: MutableState<Bitmap?>,
chosenFile: MutableState<Uri?>,
showAttachmentBottomSheet: () -> Unit
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
) {
val context = LocalContext.current
val linkUrl = remember { mutableStateOf<String?>(null) }
@@ -112,6 +126,107 @@ fun ComposeView(
val cancelledLinks = remember { mutableSetOf<String>() }
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val chosenFile = remember { mutableStateOf<Uri?>(null) }
val photoUri = remember { mutableStateOf<Uri?>(null) }
val photoTmpFile = remember { mutableStateOf<File?>(null) }
class ComposeTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
photoTmpFile.value = File.createTempFile("image", ".bmp", SimplexApp.context.filesDir)
photoUri.value = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", photoTmpFile.value!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, photoUri.value)
}
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
val photoUriVal = photoUri.value
val photoTmpFileVal = photoTmpFile.value
return if (resultCode == Activity.RESULT_OK && photoUriVal != null && photoTmpFileVal != null) {
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, photoUriVal)
val bitmap = ImageDecoder.decodeBitmap(source)
photoTmpFileVal.delete()
bitmap
} else {
Log.e(TAG, "Getting image from camera cancelled or failed.")
photoTmpFile.value?.delete()
null
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(contract = ComposeTakePicturePreview()) { bitmap: Bitmap? ->
if (bitmap != null) {
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
chosenImage.value = bitmap
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
}
}
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
val fileName = getFileName(SimplexApp.context, uri)
if (fileName != null) {
chosenFile.value = uri
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
}
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
}
}
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
attachmentOption.value = null
}
AttachmentOption.PickImage -> {
galleryLauncher.launch("image/*")
attachmentOption.value = null
}
AttachmentOption.PickFile -> {
filesLauncher.launch("*/*")
attachmentOption.value = null
}
else -> {}
}
}
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
@@ -195,15 +310,15 @@ fun ComposeView(
}
fun sendMessage() {
withApi {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
composeState.value = composeState.value.copy(inProgress = true)
val cInfo = chat.chatInfo
val cs = composeState.value
when (val contextItem = cs.contextItem) {
is ComposeContextItem.EditingItem -> {
val ei = contextItem.chatItem
val oldMsgContent = ei.content.msgContent
if (oldMsgContent != null) {
withApi {
val updatedItem = chatModel.controller.apiUpdateChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -213,37 +328,39 @@ fun ComposeView(
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
}
}
else -> {
var mc: MsgContent? = null
var file: String? = null
when (val preview = cs.preview) {
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
is ComposePreview.ImagePreview -> {
val chosenImageVal = chosenImage.value
if (chosenImageVal != null) {
file = saveImage(context, chosenImageVal)
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
}
}
}
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
}
}
else -> {
var mc: MsgContent? = null
var file: String? = null
when (val preview = cs.preview) {
ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message)
is ComposePreview.CLinkPreview -> mc = checkLinkPreview()
is ComposePreview.ImagePreview -> {
val chosenImageVal = chosenImage.value
if (chosenImageVal != null) {
file = saveImage(context, chosenImageVal)
if (file != null) {
mc = MsgContent.MCImage(cs.message, preview.image)
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value
if (chosenFileVal != null) {
file = saveFileFromUri(context, chosenFileVal)
if (file != null) {
mc = MsgContent.MCFile(cs.message)
}
}
}
}
val quotedItemId: Long? = when (contextItem) {
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
else -> null
}
if (mc != null) {
if (mc != null) {
withApi {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -255,8 +372,8 @@ fun ComposeView(
}
}
}
clearState()
}
clearState()
}
fun onMessageChange(s: String) {
@@ -343,7 +460,7 @@ fun ComposeView(
.clip(CircleShape)
.clickable {
if (attachEnabled) {
showAttachmentBottomSheet()
showChooseAttachment()
}
}
)
@@ -0,0 +1,157 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material.icons.outlined.*
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleButton
@Composable
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
val sent = cItem.chatDir.sent
Column(
Modifier
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
when (status) {
CICallStatus.Pending -> if (sent) {
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
} else {
AcceptCallButton(cInfo, acceptCall)
}
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = HighOrLowlight)
CICallStatus.Accepted -> ConnectingCallIcon()
CICallStatus.Negotiated -> ConnectingCallIcon()
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = Color.Green)
CICallStatus.Ended -> Row {
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(status.duration(duration), color = HighOrLowlight)
}
}
Text(
cItem.timestampText,
color = HighOrLowlight,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
@Composable
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
if (cInfo is ChatInfo.Direct) {
SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
} else {
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
}
// if case let .direct(contact) = chatInfo {
// Button {
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
// m.activeCallInvitation = nil
// m.activeCall = Call(
// contact: contact,
// callState: .invitationReceived,
// localMedia: invitation.peerMedia,
// sharedKey: invitation.sharedKey
// )
// m.showCallView = true
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
// } else {
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
// }
// } label: {
// Label("Answer call", systemImage: "phone.arrow.down.left")
// }
// } else {
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
// }
}
//struct CICallItemView: View {
// @EnvironmentObject var m: ChatModel
// var chatInfo: ChatInfo
// var chatItem: ChatItem
// var status: CICallStatus
// var duration: Int
//
// var body: some View {
// switch status {
// case .pending:
// if sent {
// Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
// } else {
// acceptCallButton()
// }
// case .missed: missedCallIcon(sent).foregroundColor(.red)
// case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary)
// case .accepted: connectingCallIcon()
// case .negotiated: connectingCallIcon()
// case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
// case .ended: endedCallIcon(sent)
// case .error: missedCallIcon(sent).foregroundColor(.orange)
// }
//
// chatItem.timestampText
// .font(.caption)
// .foregroundColor(.secondary)
// .padding(.bottom, 8)
// .padding(.horizontal, 12)
// }
// }
//
// private func missedCallIcon(_ sent: Bool) -> some View {
// Image(systemName: sent ? "phone.arrow.up.right" : "phone.arrow.down.left")
// }
//
// private func connectingCallIcon() -> some View {
// Image(systemName: "phone.connection").foregroundColor(.green)
// }
//
// @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
// HStack {
// Image(systemName: "phone.down")
// Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
// }
// }
//
//
// @ViewBuilder private func acceptCallButton() -> some View {
// if case let .direct(contact) = chatInfo {
// Button {
// if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
// m.activeCallInvitation = nil
// m.activeCall = Call(
// contact: contact,
// callState: .invitationReceived,
// localMedia: invitation.peerMedia,
// sharedKey: invitation.sharedKey
// )
// m.showCallView = true
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
// } else {
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
// }
// } label: {
// Label("Answer call", systemImage: "phone.arrow.down.left")
// }
// } else {
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
// }
// }
//}
@@ -203,7 +203,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
@Preview
@Composable
fun PreviewTextItemViewSnd(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(User.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
@@ -30,13 +30,15 @@ import kotlinx.datetime.Clock
@Composable
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
showMember: Boolean = false,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val context = LocalContext.current
val sent = cItem.chatDir.sent
@@ -54,18 +56,12 @@ fun ChatItemView(
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
) {
if (cItem.isMsgContent) {
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
} else if (cItem.isDeletedContent) {
DeletedItemView(cItem, showMember = showMember)
} else if (cItem.isCall) {
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
}
if (cItem.isMsgContent) {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
@@ -100,7 +96,7 @@ fun ChatItemView(
})
}
}
if (cItem.chatDir.sent && cItem.meta.editable) {
if (cItem.meta.editable) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem)
showMenu.value = false
@@ -117,6 +113,38 @@ fun ChatItemView(
)
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, showMember = showMember)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
}
}
}
}
@@ -170,13 +198,15 @@ fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
acceptCall = { _ -> }
)
}
}
@@ -187,11 +217,13 @@ fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
composeState = remember { mutableStateOf(ComposeState()) },
cxt = LocalContext.current,
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
acceptCall = { _ -> }
)
}
}
@@ -5,90 +5,242 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ChatListNavLinkLayout(
chat = chat,
click = {
when (chat.chatInfo) {
is ChatInfo.ContactRequest -> contactRequestAlertDialog(chat.chatInfo, chatModel)
is ChatInfo.ContactConnection -> contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel)
else ->
if (chat.chatInfo.ready) {
withApi { openChat(chatModel, chat.chatInfo) }
} else {
pendingContactAlertDialog(chat.chatInfo, chatModel)
}
}
},
deleteContact = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact__question),
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
val cInfo = chat.chatInfo
withApi {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
chatModel.chatId.value = null
}
}
}
val showMenu = remember { mutableStateOf(false) }
var showMarkRead by remember { mutableStateOf(false) }
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
showMenu.value = false
launch {
delay(500L)
showMarkRead = chat.chatStats.unreadCount > 0
}
}
when (chat.chatInfo) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu
)
},
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu
)
}
}
suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
} else {
pendingContactAlertDialog(chatInfo, chatModel)
}
}
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
chatModel.chatItems.clear()
chatModel.chatItems.addAll(chat.chatItems)
chatModel.chatId.value = cInfo.id
chatModel.chatId.value = chatInfo.id
}
}
@Composable
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
showMenu.value = false
}
)
}
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
}
)
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContactDialog(chat.chatInfo as ChatInfo.Direct, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
showMenu.value = false
}
)
}
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
}
)
}
@Composable
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.accept_contact_button),
Icons.Outlined.Check,
onClick = {
acceptContactRequest(chatInfo, chatModel)
showMenu.value = false
}
)
ItemAction(
stringResource(R.string.reject_contact_button),
Icons.Outlined.Close,
onClick = {
rejectContactRequest(chatInfo, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
fun markChatRead(chat: Chat, chatModel: ChatModel) {
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
}
}
fun deleteContactDialog(contact: ChatInfo.Direct, chatModel: ChatModel) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_contact__question),
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteChat(contact.chatType, contact.apiId)
if (r) {
chatModel.removeChat(contact.id)
chatModel.chatId.value = null
}
}
}
)
}
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.clear_chat_question),
text = generalGetString(R.string.clear_chat_warning),
confirmText = generalGetString(R.string.clear_verb),
onConfirm = {
withApi {
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
if (updatedChatInfo != null) {
chatModel.clearChat(updatedChatInfo)
}
}
}
)
}
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.accept_connection_request__question),
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
confirmText = generalGetString(R.string.accept_contact_button),
onConfirm = {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
}
}
},
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
dismissText = generalGetString(R.string.reject_contact_button),
onDismiss = {
withApi {
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
}
}
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
)
}
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
}
}
}
fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
withApi {
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
}
}
fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(
@@ -159,14 +311,18 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
}
@Composable
fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Unit) {
val showMenu = remember { mutableStateOf(false) }
fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = click,
onLongClick = { if (chat.chatInfo is ChatInfo.Direct) showMenu.value = true }
onLongClick = { showMenu.value = true }
)
.height(88.dp)
) {
@@ -178,28 +334,17 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Un
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
when (chat.chatInfo) {
is ChatInfo.Direct -> ChatPreviewView(chat)
is ChatInfo.Group -> ChatPreviewView(chat)
is ChatInfo.ContactRequest -> ContactRequestView(chat.chatInfo)
is ChatInfo.ContactConnection -> ContactConnectionView(chat.chatInfo.contactConnection)
}
chatLinkPreview()
}
if (chat.chatInfo is ChatInfo.Direct) {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
deleteContact()
showMenu.value = false
},
color = Color.Red
)
if (dropdownMenuItems != null) {
Box(Modifier.padding(horizontal = 16.dp)) {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
dropdownMenuItems()
}
}
}
}
@@ -216,20 +361,25 @@ fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit, deleteContact: () -> Un
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.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."
chatLinkPreview = {
ChatPreviewView(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.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."
)
),
chatStats = Chat.ChatStats()
)
),
chatStats = Chat.ChatStats()
),
)
},
click = {},
deleteContact = {}
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }
)
}
}
@@ -244,20 +394,25 @@ fun PreviewChatListNavLinkDirect() {
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.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."
chatLinkPreview = {
ChatPreviewView(
Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.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."
)
),
chatStats = Chat.ChatStats()
)
),
chatStats = Chat.ChatStats()
),
)
},
click = {},
deleteContact = {}
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }
)
}
}
@@ -272,13 +427,12 @@ fun PreviewChatListNavLinkGroup() {
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.ContactRequest.sampleData,
chatItems = listOf(),
chatStats = Chat.ChatStats()
),
chatLinkPreview = {
ContactRequestView(ChatInfo.ContactRequest.sampleData)
},
click = {},
deleteContact = {}
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }
)
}
}
@@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -20,8 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
@@ -30,12 +28,7 @@ import chat.simplex.app.views.helpers.badgeLayout
fun ChatPreviewView(chat: Chat) {
Row {
val cInfo = chat.chatInfo
Box(contentAlignment = Alignment.BottomStart) {
ChatInfoImage(cInfo, size = 72.dp)
if (cInfo is ChatInfo.Direct) {
ChatStatusImage(chat)
}
}
ChatInfoImage(cInfo, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
@@ -63,10 +56,10 @@ fun ChatPreviewView(chat: Chat) {
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
Box(
contentAlignment = Alignment.TopEnd
) {
Text(
ts,
@@ -76,17 +69,29 @@ fun ChatPreviewView(chat: Chat) {
)
val n = chat.chatStats.unreadCount
if (n > 0) {
Text(
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
color = MaterialTheme.colors.onPrimary,
fontSize = 14.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.align(Alignment.End)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
}
}
if (cInfo is ChatInfo.Direct) {
Box(
Modifier.padding(top = 52.dp),
contentAlignment = Alignment.Center
) {
ChatStatusImage(chat)
}
}
}
}
@@ -98,17 +103,19 @@ fun ChatStatusImage(chat: Chat) {
val descr = s.statusString
if (s is Chat.NetworkStatus.Error) {
Icon(
Icons.Filled.Circle,
Icons.Outlined.ErrorOutline,
contentDescription = descr,
tint = HighOrLowlight,
modifier = Modifier.padding(start = 6.dp).padding(bottom = 4.dp).size(6.dp)
modifier = Modifier
.size(19.dp)
)
} else if (!(s is Chat.NetworkStatus.Connected)) {
Icon(
Icons.Filled.MoreHoriz,
contentDescription = descr,
tint = HighOrLowlight,
modifier = Modifier.width(19.dp).padding(start = 6.dp)
} else if (s !is Chat.NetworkStatus.Connected) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = HighOrLowlight,
strokeWidth = 1.5.dp
)
}
}
@@ -0,0 +1,54 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.views.newchat.ActionButton
sealed class AttachmentOption {
object TakePhoto: AttachmentOption()
object PickImage: AttachmentOption()
object PickFile: AttachmentOption()
}
@Composable
fun ChooseAttachmentView(
attachmentOption: MutableState<AttachmentOption?>,
hide: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hide()
}
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
attachmentOption.value = AttachmentOption.TakePhoto
hide()
}
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
attachmentOption.value = AttachmentOption.PickImage
hide()
}
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
attachmentOption.value = AttachmentOption.PickFile
hide()
}
}
}
}
@@ -127,18 +127,15 @@ class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
}
}
}
//class GetGalleryContent: ActivityResultContracts.GetContent() {
// override fun createIntent(context: Context, input: String): Intent {
// super.createIntent(context, input)
// return Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
// }
//}
//@Composable
//fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
@Composable
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
@@ -155,8 +152,6 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
fun GetImageBottomSheet(
imageBitmap: MutableState<Bitmap?>,
onImageChange: (Bitmap) -> Unit,
fileUri: MutableState<Uri?>? = null,
onFileChange: ((Uri) -> Unit)? = null,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
@@ -182,20 +177,6 @@ fun GetImageBottomSheet(
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null && fileUri != null && onFileChange != null) {
val fileSize = getFileSize(context, uri)
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
fileUri.value = uri
onFileChange(uri)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.large_file),
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
)
}
}
}
Box(
modifier = Modifier
@@ -226,12 +207,6 @@ fun GetImageBottomSheet(
galleryLauncher.launch("image/*")
hideBottomSheet()
}
if (fileUri != null && onFileChange != null) {
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
filesLauncher.launch("*/*")
hideBottomSheet()
}
}
}
}
}
@@ -29,7 +29,7 @@ fun SMPServersView(chatModel: ChatModel) {
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
var userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
@@ -24,7 +24,6 @@ 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.call.VideoCallView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo
@@ -37,12 +36,16 @@ fun SettingsView(chatModel: ChatModel) {
runServiceInBackground = chatModel.runServiceInBackground,
setRunServiceInBackground = { on ->
chatModel.controller.setRunServiceInBackground(on)
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.setBackgroundServiceNoticeShown(false)
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
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) } },
showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
}
@@ -58,7 +61,7 @@ fun SettingsLayout(
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
showVideoChatPrototype: () -> Unit
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
@@ -242,7 +245,7 @@ fun PreviewSettingsLayout() {
showModal = {{}},
showCustomModal = {{}},
showTerminal = {},
showVideoChatPrototype = {}
// showVideoChatPrototype = {}
)
}
}
@@ -54,8 +54,11 @@
<!-- background service notice - SimpleXAPI.kt -->
<string name="private_instant_notifications">Приватные мгновенные уведомления!</string>
<string name="private_instant_notifications_disabled">Приватные уведомления выключены!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает <b>фоновый сервис <xliff:g id="appName">SimpleX</xliff:g></b>, который потребляет несколько процентов батареи в день.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Он может быть выключен через Настройки</b> – вы продолжите получать уведомления о сообщениях пока приложение запущено.</string>
<string name="turn_off_battery_optimization">Для использования приватных уведомлений, пожалуйста отключите оптимизацию батареи для <xliff:g id="appName">SimpleX</xliff:g> в слеующем диалоге. Иначе уведомления будут выключены.</string>
<string name="turning_off_background_service">Оптимизация батареи включена, поэтому приватные уведомления выключены. Вы можете снова включить их через Настройки.</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
@@ -165,6 +168,13 @@
<string name="accept_contact_button">Принять</string>
<string name="reject_contact_button">Отклонить</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
<string name="clear_chat_question">Очистить разговор?</string>
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
<string name="clear_verb">Очистить</string>
<string name="clear_chat_button">Очистить разговор</string>
<string name="mark_read">Прочитано</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
@@ -286,7 +296,7 @@
<string name="callstatus_accepted">принятый звонок</string>
<string name="callstatus_connecting">соединяется…</string>
<string name="callstatus_in_progress">активный звонок</string>
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s!</xliff:g></string>
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">ошибка соединения</string>
<!-- CallState -->
@@ -329,4 +339,40 @@
<string name="or">или</string>
<string name="connect_with_the_developers">Соединиться с разработчиками</string>
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">Чтобы задать вопросы и получать уведомления о <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<!-- Call -->
<string name="incoming_video_call">Входящий видеозвонок</string>
<string name="incoming_audio_call">Входящий аудиозвонок</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> хочет связаться с вами через </string>
<string name="video_call_no_encryption">видеозвонок (не e2e зашифрованный)</string>
<string name="encrypted_video_call">e2e зашифрованный видеозвонок</string>
<string name="audio_call_no_encryption">аудиозвонок (не e2e зашифрованный)</string>
<string name="encrypted_audio_call">e2e зашифрованный аудиозвонок</string>
<string name="answer">Принять</string>
<string name="call_already_ended">Звонок уже завершен!</string>
<string name="icon_descr_video_call">видеозвонок</string>
<string name="icon_descr_audio_call">аудиозвонок</string>
<!-- Call overlay -->
<string name="status_e2e_encrypted">e2e зашифровано</string>
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
<string name="status_contact_has_e2e_encryption">у контакта есть e2e шифрование</string>
<string name="status_contact_has_no_e2e_encryption">у контакта нет e2e шифрования</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="call_connection_via_relay">через relay сервер</string>
<string name="icon_descr_hang_up">Закончить звонок</string>
<string name="icon_descr_video_off">Выключить видео</string>
<string name="icon_descr_video_on">Включить видео</string>
<string name="icon_descr_audio_off">Выключить звук</string>
<string name="icon_descr_audio_on">Включить звук</string>
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
<!-- Call items -->
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
<string name="icon_descr_call_missed">Пропущенный звонок</string>
<string name="icon_descr_call_rejected">Отклоненный звонок</string>
<string name="icon_descr_call_connecting">Соединяющийся звонок</string>
<string name="icon_descr_call_progress">Текущий звонок</string>
<string name="icon_descr_call_ended">Звонок завершен</string>
<string name="answer_call">Принять звонок</string>
</resources>
@@ -54,8 +54,11 @@
<!-- background service notice - SimpleXAPI.kt -->
<string name="private_instant_notifications">Private instant notifications!</string>
<string name="private_instant_notifications_disabled">Private notifications disabled!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">To preserve your privacy, instead of push notifications the app has a <b><xliff:g id="appName">SimpleX</xliff:g> background service</b> it uses a few percent of the battery per day.</string>
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>It can be disabled via settings</b> notifications will still be shown while the app is running.</string>
<string name="turn_off_battery_optimization">In order to use it, please <b>disable battery optimization</b> for <xliff:g id="appName">SimpleX</xliff:g> in the next dialog. Otherwise, the notifications will be disabled.</string>
<string name="turning_off_background_service">Battery optimization is active, turning off Private notifications. You can re-enable them via settings.</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
@@ -166,6 +169,13 @@
<string name="accept_contact_button">Accept</string>
<string name="reject_contact_button">Reject</string>
<!-- Clear Chat - ChatListNavLinkView.kt -->
<string name="clear_chat_question">Clear conversation?</string>
<string name="clear_chat_warning">All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</string>
<string name="clear_verb">Clear</string>
<string name="clear_chat_button">Clear conversation</string>
<string name="mark_read">Mark read</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_your_contact">You invited your contact</string>
<string name="you_accepted_connection">You accepted connection</string>
@@ -287,7 +297,7 @@
<string name="callstatus_accepted">accepted</string>
<string name="callstatus_connecting">connecting…</string>
<string name="callstatus_in_progress">in progress</string>
<string name="callstatus_ended">ended <xliff:g id="duration" example="01:15">%1$s!</xliff:g></string>
<string name="callstatus_ended">ended <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
<string name="callstatus_error">error</string>
<!-- CallState -->
@@ -330,4 +340,40 @@
<string name="or">or</string>
<string name="connect_with_the_developers">Connect with the developers</string>
<string name="to_ask_any_questions_and_to_receive_simplex_chat_updates">To ask any questions and to receive <xliff:g id="appNameFull">SimpleX Chat</xliff:g> updates.</string>
<!-- Call -->
<string name="incoming_video_call">Incoming video call</string>
<string name="incoming_audio_call">Incoming audio call</string>
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> wants to connect with you via</string>
<string name="video_call_no_encryption">video call (not e2e encrypted)</string>
<string name="encrypted_video_call">e2e encrypted video call</string>
<string name="audio_call_no_encryption">audio call (not e2e encrypted)</string>
<string name="encrypted_audio_call">e2e encrypted audio call</string>
<string name="answer">Answer</string>
<string name="call_already_ended">Call already ended!</string>
<string name="icon_descr_video_call">video call</string>
<string name="icon_descr_audio_call">audio call</string>
<!-- Call overlay -->
<string name="status_e2e_encrypted">e2e encrypted</string>
<string name="status_no_e2e_encryption">no e2e encryption</string>
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="call_connection_via_relay">via relay</string>
<string name="icon_descr_hang_up">Hang up</string>
<string name="icon_descr_video_off">Video off</string>
<string name="icon_descr_video_on">Video on</string>
<string name="icon_descr_audio_off">Audio off</string>
<string name="icon_descr_audio_on">Audio on</string>
<string name="icon_descr_flip_camera">Flip camera</string>
<!-- Call items -->
<string name="icon_descr_call_pending_sent">Pending call</string>
<string name="icon_descr_call_missed">Missed call</string>
<string name="icon_descr_call_rejected">Rejected call</string>
<string name="icon_descr_call_connecting">Connecting call</string>
<string name="icon_descr_call_progress">Call in progress</string>
<string name="icon_descr_call_ended">Call ended</string>
<string name="answer_call">Answer call</string>
</resources>
+3 -3
View File
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.3'
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
@@ -16,8 +16,8 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.1.3' apply false
id 'com.android.library' version '7.1.3' apply false
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
}
+1 -1
View File
@@ -1,6 +1,6 @@
#Mon Feb 14 14:23:51 GMT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+15 -1
View File
@@ -27,10 +27,11 @@ final class ChatModel: ObservableObject {
@Published var deviceToken: String?
@Published var tokenStatus = NtfTknStatus.new
// current WebRTC call
@Published var callInvitations: Dictionary<String, CallInvitation> = [:]
@Published var callInvitations: Dictionary<ChatId, CallInvitation> = [:]
@Published var activeCallInvitation: ContactRef?
@Published var activeCall: Call?
@Published var callCommand: WCallCommand?
@Published var showCallView = false
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -185,6 +186,19 @@ final class ChatModel: ObservableObject {
}
}
func clearChat(_ cInfo: ChatInfo) {
// clear preview
if let chat = getChat(cInfo.id) {
chat.chatItems = []
chat.chatStats = ChatStats()
chat.chatInfo = cInfo
}
// clear current chat
if chatId == cInfo.id {
chatItems = []
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update preview
if let i = getChatIndex(cInfo.id) {
+3 -3
View File
@@ -38,10 +38,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
} else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall),
let chatId = content.userInfo["chatId"] as? String,
case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo,
let invitation = chatModel.callInvitations[chatId] {
let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
if action == ntfActionAcceptCall {
chatModel.activeCallInvitation = nil
chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia)
chatModel.chatId = nil
chatModel.showCallView = true
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
} else {
Task {
@@ -56,7 +57,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
}
}
chatModel.callInvitations.removeValue(forKey: chatId)
} else {
chatModel.chatId = content.targetContentIdentifier
}
@@ -30,6 +30,7 @@ enum ChatCommand {
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
case apiClearChat(type: ChatType, id: Int64)
case apiUpdateProfile(profile: Profile)
case apiParseMarkdown(text: String)
case createMyAddress
@@ -72,6 +73,7 @@ enum ChatCommand {
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
case let .apiParseMarkdown(text): return "/_parse \(text)"
case .createMyAddress: return "/address"
@@ -114,6 +116,7 @@ enum ChatCommand {
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
case .apiClearChat: return "apiClearChat"
case .apiUpdateProfile: return "apiUpdateProfile"
case .apiParseMarkdown: return "apiParseMarkdown"
case .createMyAddress: return "createMyAddress"
@@ -161,6 +164,7 @@ enum ChatResponse: Decodable, Error {
case sentInvitation
case contactAlreadyExists(contact: Contact)
case contactDeleted(contact: Contact)
case chatCleared(chatInfo: ChatInfo)
case userProfileNoChange
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case apiParsedMarkdown(formattedText: [FormattedText]?)
@@ -222,6 +226,7 @@ enum ChatResponse: Decodable, Error {
case .sentInvitation: return "sentInvitation"
case .contactAlreadyExists: return "contactAlreadyExists"
case .contactDeleted: return "contactDeleted"
case .chatCleared: return "chatCleared"
case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileUpdated"
case .apiParsedMarkdown: return "apiParsedMarkdown"
@@ -284,6 +289,7 @@ enum ChatResponse: Decodable, Error {
case .sentInvitation: return noDetails
case let .contactAlreadyExists(contact): return String(describing: contact)
case let .contactDeleted(contact): return String(describing: contact)
case let .chatCleared(chatInfo): return String(describing: chatInfo)
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .apiParsedMarkdown(formattedText): return String(describing: formattedText)
+26 -2
View File
@@ -7,6 +7,7 @@
//
import Foundation
import SwiftUI
struct WebRTCCallOffer: Encodable {
var callType: CallType
@@ -15,16 +16,33 @@ struct WebRTCCallOffer: Encodable {
struct WebRTCSession: Codable {
var rtcSession: String
var rtcIceCandidates: [String]
var rtcIceCandidates: String
}
struct WebRTCExtraInfo: Codable {
var rtcIceCandidates: [String]
var rtcIceCandidates: String
}
struct CallInvitation {
var peerMedia: CallMediaType
var sharedKey: String?
var callTypeText: LocalizedStringKey {
get {
switch peerMedia {
case .video: return sharedKey == nil ? "video call (not e2e encrypted)." : "**e2e encrypted** video call."
case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)." : "**e2e encrypted** audio call."
}
}
}
var callTitle: LocalizedStringKey {
get {
switch peerMedia {
case .video: return "Incoming video call"
case .audio: return "Incoming audio call"
}
}
}
var encryptionText: LocalizedStringKey { get { sharedKey == nil ? "no e2e encryption" : "with e2e encryption" } }
}
struct CallType: Codable {
@@ -37,12 +55,18 @@ enum CallMediaType: String, Codable, Equatable {
case audio = "audio"
}
enum VideoCamera: String, Codable, Equatable {
case user = "user"
case environment = "environment"
}
struct CallCapabilities: Codable, Equatable {
var encryption: Bool
}
enum WebRTCCallStatus: String, Encodable {
case connected = "connected"
case connecting = "connecting"
case disconnected = "disconnected"
case failed = "failed"
}
+20 -3
View File
@@ -168,6 +168,15 @@ enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
var updatedAt: Date {
switch self {
case let .direct(contact): return contact.updatedAt
case let .group(groupInfo): return groupInfo.updatedAt
case let .contactRequest(contactRequest): return contactRequest.updatedAt
case let .contactConnection(contactConnection): return contactConnection.updatedAt
}
}
struct SampleData {
var direct: ChatInfo
var group: ChatInfo
@@ -201,6 +210,7 @@ struct Contact: Identifiable, Decodable, NamedChat {
var activeConn: Connection
var viaGroup: Int64?
var createdAt: Date
var updatedAt: Date
var id: ChatId { get { "@\(contactId)" } }
var apiId: Int64 { get { contactId } }
@@ -214,7 +224,8 @@ struct Contact: Identifiable, Decodable, NamedChat {
localDisplayName: "alice",
profile: Profile.sampleData,
activeConn: Connection.sampleData,
createdAt: .now
createdAt: .now,
updatedAt: .now
)
}
@@ -350,6 +361,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
var localDisplayName: GroupName
var groupProfile: GroupProfile
var createdAt: Date
var updatedAt: Date
var id: ChatId { get { "#\(groupId)" } }
var apiId: Int64 { get { groupId } }
@@ -362,7 +374,8 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
groupId: 1,
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
createdAt: .now
createdAt: .now,
updatedAt: .now
)
}
@@ -871,8 +884,12 @@ enum CICallStatus: String, Decodable {
case .accepted: return NSLocalizedString("accepted", comment: "call status")
case .negotiated: return NSLocalizedString("connecting…", comment: "call status")
case .progress: return NSLocalizedString("in progress", comment: "call status")
case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %02d:%02d", comment: "call status"), sec / 60, sec % 60)
case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %@", comment: "call status"), CICallStatus.durationText(sec))
case .error: return NSLocalizedString("error", comment: "call status")
}
}
static func durationText(_ sec: Int) -> String {
String(format: "%02d:%02d", sec / 60, sec % 60)
}
}
@@ -8,6 +8,7 @@
import Foundation
import UserNotifications
import SwiftUI
let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
@@ -50,10 +51,13 @@ func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutable
}
func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent {
createNotification(
let text = invitation.peerMedia == .video
? NSLocalizedString("Incoming video call", comment: "notification")
: NSLocalizedString("Incoming audio call", comment: "notification")
return createNotification(
categoryIdentifier: ntfCategoryCallInvitation,
title: "\(contact.chatViewName):",
body: String.localizedStringWithFormat(NSLocalizedString("Incoming %@ call", comment: "notification body"), invitation.peerMedia.rawValue),
body: text,
targetContentIdentifier: nil,
userInfo: ["chatId": contact.id]
)
+20 -4
View File
@@ -286,6 +286,22 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws {
throw r
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(updatedChatInfo) = r { return updatedChatInfo }
throw r
}
func clearChat(_ chat: Chat) async {
do {
let cInfo = chat.chatInfo
let updatedChatInfo = try await apiClearChat(type: cInfo.chatType, id: cInfo.apiId)
DispatchQueue.main.async { ChatModel.shared.clearChat(updatedChatInfo) }
} catch {
logger.error("clearChat apiClearChat error: \(responseError(error))")
}
}
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
let r = await chatSendCmd(.apiUpdateProfile(profile: profile))
switch r {
@@ -382,18 +398,18 @@ func apiRejectCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiRejectCall(contact: contact))
}
func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String], media: CallMediaType, capabilities: CallCapabilities) async throws {
func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities) async throws {
let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession)
try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer))
}
func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String]) async throws {
func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws {
let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
}
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: [String]) async throws {
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
}
@@ -624,7 +640,7 @@ func processReceivedMsg(_ res: ChatResponse) {
// TODO check encryption is compatible
withCall(contact) { call in
m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey)
m.callCommand = .accept(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey)
m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true)
}
case let .callAnswer(contact, answer):
withCall(contact) { call in
+63 -31
View File
@@ -11,7 +11,6 @@ import SwiftUI
struct ActiveCallView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) private var dismiss
@Binding var showCallView: Bool
@State private var coordinator: WebRTCCoordinator? = nil
@State private var webViewReady: Bool = false
@State private var webViewMsg: WVAPIMessage? = nil
@@ -26,6 +25,7 @@ struct ActiveCallView: View {
.background(.black)
ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() })
}
.preferredColorScheme(.dark)
}
private func sendCommandToWebView() {
@@ -64,11 +64,13 @@ struct ActiveCallView: View {
m.activeCall = call.copy(callState: .connected)
}
try await apiCallStatus(call.contact, state.connectionState)
case let .connected(connectionInfo):
m.activeCall = call.copy(callState: .connected, connectionInfo: connectionInfo)
case .ended:
m.activeCall = nil
m.activeCallInvitation = nil
m.callCommand = nil
showCallView = false
m.showCallView = false
case .ok:
switch msg.command {
case let .media(media, enable):
@@ -76,11 +78,13 @@ struct ActiveCallView: View {
case .video: m.activeCall = call.copy(videoEnabled: enable)
case .audio: m.activeCall = call.copy(audioEnabled: enable)
}
case let .camera(camera):
m.activeCall = call.copy(localCamera: camera)
case .end:
m.activeCall = nil
m.activeCallInvitation = nil
m.callCommand = nil
showCallView = false
m.showCallView = false
default: ()
}
case let .error(message):
@@ -107,6 +111,31 @@ struct ActiveCallOverlay: View {
.foregroundColor(.white)
.opacity(0.8)
.padding()
Spacer()
HStack {
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled)
}
Spacer()
Color.clear.frame(width: 40, height: 40)
Spacer()
callButton("phone.down.fill", size: 60) { dismiss() }
.foregroundColor(.red)
Spacer()
controlButton(call, "arrow.triangle.2.circlepath") {
chatModel.callCommand = .camera(camera: call.localCamera == .user ? .environment : .user)
}
Spacer()
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .center)
case .audio:
VStack {
ProfileImage(imageStr: call.contact.profile.image)
@@ -118,30 +147,20 @@ struct ActiveCallOverlay: View {
.opacity(0.8)
.padding()
.frame(maxHeight: .infinity)
}
Spacer()
ZStack(alignment: .bottom) {
VStack(alignment: .leading) {
if call.localMedia == .video {
callButton(call.videoEnabled ? "video.fill" : "video.slash", size: 48) {
chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled)
}
.foregroundColor(.white)
.opacity(0.85)
}
callButton(call.audioEnabled ? "mic.fill" : "mic.slash", size: 48) {
Spacer()
ZStack(alignment: .bottom) {
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled)
}
.foregroundColor(.white)
.opacity(0.85)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top)
callButton("phone.down.fill", size: 60) { dismiss() }
.foregroundColor(.red)
}
callButton("phone.down.fill", size: 60) { dismiss() }
.foregroundColor(.red)
.padding(.bottom, 60)
.padding(.horizontal, 48)
}
.padding(.bottom, 60)
.padding(.horizontal, 48)
}
}
.frame(maxWidth: .infinity)
@@ -153,18 +172,31 @@ struct ActiveCallOverlay: View {
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: alignment)
let status = call.callState == .connected
? call.encrypted
? "end-to-end encrypted"
: "no end-to-end encryption"
: call.callState.text
Text(status)
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: alignment)
Group {
Text(call.callState.text)
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo?.text {
Text("(") + Text(connInfo) + Text(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: alignment)
}
}
private func callButton(_ imageName: String, size: CGFloat, perform: @escaping () -> Void) -> some View {
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
if call.hasMedia {
callButton(imageName, size: 40, perform)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 40, height: 40)
}
}
private func callButton(_ imageName: String, size: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button {
perform()
} label: {
+156 -69
View File
@@ -22,6 +22,8 @@ class Call: Equatable {
var sharedKey: String?
var audioEnabled: Bool
var videoEnabled: Bool
var localCamera: VideoCamera
var connectionInfo: ConnectionInfo?
init(
contact: Contact,
@@ -31,7 +33,9 @@ class Call: Equatable {
peerMedia: CallMediaType? = nil,
sharedKey: String? = nil,
audioEnabled: Bool? = nil,
videoEnabled: Bool? = nil
videoEnabled: Bool? = nil,
localCamera: VideoCamera = .user,
connectionInfo: ConnectionInfo? = nil
) {
self.contact = contact
self.callState = callState
@@ -41,6 +45,8 @@ class Call: Equatable {
self.sharedKey = sharedKey
self.audioEnabled = audioEnabled ?? true
self.videoEnabled = videoEnabled ?? (localMedia == .video)
self.localCamera = localCamera
self.connectionInfo = connectionInfo
}
func copy(
@@ -51,7 +57,9 @@ class Call: Equatable {
peerMedia: CallMediaType? = nil,
sharedKey: String? = nil,
audioEnabled: Bool? = nil,
videoEnabled: Bool? = nil
videoEnabled: Bool? = nil,
localCamera: VideoCamera? = nil,
connectionInfo: ConnectionInfo? = nil
) -> Call {
Call (
contact: contact ?? self.contact,
@@ -61,13 +69,25 @@ class Call: Equatable {
peerMedia: peerMedia ?? self.peerMedia,
sharedKey: sharedKey ?? self.sharedKey,
audioEnabled: audioEnabled ?? self.audioEnabled,
videoEnabled: videoEnabled ?? self.videoEnabled
videoEnabled: videoEnabled ?? self.videoEnabled,
localCamera: localCamera ?? self.localCamera,
connectionInfo: connectionInfo ?? self.connectionInfo
)
}
var encrypted: Bool {
(localCapabilities?.encryption ?? false) && sharedKey != nil
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var encryptionStatus: LocalizedStringKey {
get {
switch callState {
case .waitCapabilities: return ""
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
case .invitationReceived: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
}
}
}
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
}
enum CallState {
@@ -97,29 +117,34 @@ struct WVAPICall: Encodable {
var command: WCallCommand
}
struct WVAPIMessage: Equatable, Decodable {
struct WVAPIMessage: Equatable, Decodable, Encodable {
var corrId: Int?
var resp: WCallResponse
var command: WCallCommand?
}
enum WCallCommand: Equatable, Encodable, Decodable {
case capabilities
case start(media: CallMediaType, aesKey: String? = nil)
case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
case answer(answer: String, iceCandidates: [String])
case ice(iceCandidates: [String])
case capabilities(useWorker: Bool? = nil)
case start(media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case media(media: CallMediaType, enable: Bool)
case camera(camera: VideoCamera)
case end
enum CodingKeys: String, CodingKey {
case type
case media
case camera
case aesKey
case useWorker
case offer
case answer
case iceCandidates
case enable
case iceServers
case relay
}
var cmdType: String {
@@ -127,10 +152,11 @@ enum WCallCommand: Equatable, Encodable, Decodable {
switch self {
case .capabilities: return "capabilities"
case .start: return "start"
case .accept: return "accept"
case .offer: return "offer"
case .answer: return "answer"
case .ice: return "ice"
case .media: return "media"
case .camera: return "camera"
case .end: return "end"
}
}
@@ -139,18 +165,25 @@ enum WCallCommand: Equatable, Encodable, Decodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .capabilities:
case let .capabilities(useWorker):
try container.encode("capabilities", forKey: .type)
case let .start(media, aesKey):
try container.encode(useWorker, forKey: .useWorker)
case let .start(media, aesKey, useWorker, iceServers, relay):
try container.encode("start", forKey: .type)
try container.encode(media, forKey: .media)
try container.encode(aesKey, forKey: .aesKey)
case let .accept(offer, iceCandidates, media, aesKey):
try container.encode("accept", forKey: .type)
try container.encode(useWorker, forKey: .useWorker)
try container.encode(iceServers, forKey: .iceServers)
try container.encode(relay, forKey: .relay)
case let .offer(offer, iceCandidates, media, aesKey, useWorker, iceServers, relay):
try container.encode("offer", forKey: .type)
try container.encode(offer, forKey: .offer)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encode(media, forKey: .media)
try container.encode(aesKey, forKey: .aesKey)
try container.encode(useWorker, forKey: .useWorker)
try container.encode(iceServers, forKey: .iceServers)
try container.encode(relay, forKey: .relay)
case let .answer(answer, iceCandidates):
try container.encode("answer", forKey: .type)
try container.encode(answer, forKey: .answer)
@@ -162,6 +195,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
try container.encode("media", forKey: .type)
try container.encode(media, forKey: .media)
try container.encode(enable, forKey: .enable)
case let .camera(camera):
try container.encode("camera", forKey: .type)
try container.encode(camera, forKey: .camera)
case .end:
try container.encode("end", forKey: .type)
}
@@ -172,28 +208,38 @@ enum WCallCommand: Equatable, Encodable, Decodable {
let type = try container.decode(String.self, forKey: CodingKeys.type)
switch type {
case "capabilities":
self = .capabilities
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
self = .capabilities(useWorker: useWorker)
case "start":
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
self = .start(media: media, aesKey: aesKey)
case "accept":
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
let iceServers = try container.decode(([RTCIceServer]?).self, forKey: .iceServers)
let relay = try container.decode((Bool?).self, forKey: .relay)
self = .start(media: media, aesKey: aesKey, useWorker: useWorker, iceServers: iceServers, relay: relay)
case "offer":
let offer = try container.decode(String.self, forKey: CodingKeys.offer)
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey)
let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker)
let iceServers = try container.decode(([RTCIceServer]?).self, forKey: .iceServers)
let relay = try container.decode((Bool?).self, forKey: .relay)
self = .offer(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey, useWorker: useWorker, iceServers: iceServers, relay: relay)
case "answer":
let answer = try container.decode(String.self, forKey: CodingKeys.answer)
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .answer(answer: answer, iceCandidates: iceCandidates)
case "ice":
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
case "media":
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
self = .media(media: media, enable: enable)
case "camera":
let camera = try container.decode(VideoCamera.self, forKey: CodingKeys.camera)
self = .camera(camera: camera)
case "end":
self = .end
default:
@@ -205,12 +251,11 @@ enum WCallCommand: Equatable, Encodable, Decodable {
enum WCallResponse: Equatable, Decodable {
case capabilities(capabilities: CallCapabilities)
case offer(offer: String, iceCandidates: [String], capabilities: CallCapabilities)
// TODO remove accept, it is needed for debugging
// case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
case answer(answer: String, iceCandidates: [String])
case ice(iceCandidates: [String])
case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo)
case ended
case ok
case error(message: String)
@@ -223,10 +268,8 @@ enum WCallResponse: Equatable, Decodable {
case answer
case iceCandidates
case state
case connectionInfo
case message
// TODO remove media, aesKey
case media
case aesKey
}
var respType: String {
@@ -234,10 +277,10 @@ enum WCallResponse: Equatable, Decodable {
switch self {
case .capabilities: return("capabilities")
case .offer: return("offer")
// case .accept: return("accept")
case .answer: return("answer (TODO remove)")
case .answer: return("answer")
case .ice: return("ice")
case .connection: return("connection")
case .connected: return("connected")
case .ended: return("ended")
case .ok: return("ok")
case .error: return("error")
@@ -256,19 +299,22 @@ enum WCallResponse: Equatable, Decodable {
self = .capabilities(capabilities: capabilities)
case "offer":
let offer = try container.decode(String.self, forKey: CodingKeys.offer)
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities)
self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities)
case "answer":
let answer = try container.decode(String.self, forKey: CodingKeys.answer)
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .answer(answer: answer, iceCandidates: iceCandidates)
case "ice":
let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
case "connection":
let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
self = .connection(state: state)
case "connected":
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
self = .connected(connectionInfo: connectionInfo)
case "ended":
self = .ended
case "ok":
@@ -286,39 +332,42 @@ enum WCallResponse: Equatable, Decodable {
}
// This protocol is for debugging
//extension WCallResponse: Encodable {
// func encode(to encoder: Encoder) throws {
// var container = encoder.container(keyedBy: CodingKeys.self)
// switch self {
// case .capabilities:
// try container.encode("capabilities", forKey: .type)
// case let .offer(offer, iceCandidates, capabilities):
// try container.encode("offer", forKey: .type)
// try container.encode(offer, forKey: .offer)
// try container.encode(iceCandidates, forKey: .iceCandidates)
// try container.encode(capabilities, forKey: .capabilities)
// case let .answer(answer, iceCandidates):
// try container.encode("answer", forKey: .type)
// try container.encode(answer, forKey: .answer)
// try container.encode(iceCandidates, forKey: .iceCandidates)
// case let .ice(iceCandidates):
// try container.encode("ice", forKey: .type)
// try container.encode(iceCandidates, forKey: .iceCandidates)
// case let .connection(state):
// try container.encode("connection", forKey: .type)
// try container.encode(state, forKey: .state)
// case .ended:
// try container.encode("ended", forKey: .type)
// case .ok:
// try container.encode("ok", forKey: .type)
// case let .error(message):
// try container.encode("error", forKey: .type)
// try container.encode(message, forKey: .message)
// case let .invalid(type):
// try container.encode(type, forKey: .type)
// }
// }
//}
extension WCallResponse: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .capabilities:
try container.encode("capabilities", forKey: .type)
case let .offer(offer, iceCandidates, capabilities):
try container.encode("offer", forKey: .type)
try container.encode(offer, forKey: .offer)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encode(capabilities, forKey: .capabilities)
case let .answer(answer, iceCandidates):
try container.encode("answer", forKey: .type)
try container.encode(answer, forKey: .answer)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .ice(iceCandidates):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .connection(state):
try container.encode("connection", forKey: .type)
try container.encode(state, forKey: .state)
case let .connected(connectionInfo):
try container.encode("connected", forKey: .type)
try container.encode(connectionInfo, forKey: .connectionInfo)
case .ended:
try container.encode("ended", forKey: .type)
case .ok:
try container.encode("ok", forKey: .type)
case let .error(message):
try container.encode("error", forKey: .type)
try container.encode(message, forKey: .message)
case let .invalid(type):
try container.encode(type, forKey: .type)
}
}
}
struct ConnectionState: Codable, Equatable {
var connectionState: String
@@ -326,3 +375,41 @@ struct ConnectionState: Codable, Equatable {
var iceGatheringState: String
var signalingState: String
}
struct ConnectionInfo: Codable, Equatable {
var localCandidate: RTCIceCandidate?
var remoteCandidate: RTCIceCandidate?
var text: LocalizedStringKey {
get {
if localCandidate?.candidateType == .host && remoteCandidate?.candidateType == .host {
return "peer-to-peer"
} else if localCandidate?.candidateType == .relay && remoteCandidate?.candidateType == .relay {
return "via relay"
} else {
let unknown = NSLocalizedString("unknown", comment: "connection info")
return "\(localCandidate?.candidateType?.rawValue ?? unknown) / \(remoteCandidate?.candidateType?.rawValue ?? unknown)"
}
}
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
struct RTCIceCandidate: Codable, Equatable {
var candidateType: RTCIceCandidateType?
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate/type
enum RTCIceCandidateType: String, Codable {
case host = "host"
case serverReflexive = "srflx"
case peerReflexive = "prflx"
case relay = "relay"
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
struct RTCIceServer: Codable, Equatable {
var urls: [String]
var username: String? = nil
var credential: String? = nil
}
+74 -69
View File
@@ -34,8 +34,11 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
if let msgStr = message.body as? String,
let msg: WVAPIMessage = decodeJSON(msgStr) {
webViewMsg.wrappedValue = msg
if case .invalid = msg.resp {
logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
}
} else {
logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))")
}
}
@@ -90,74 +93,76 @@ struct WebRTCView: UIViewRepresentable {
}
}
//struct CallViewDebug: View {
// @State private var coordinator: WebRTCCoordinator? = nil
// @State private var commandStr = ""
// @State private var webViewReady: Bool = false
// @State private var webViewMsg: WVAPIMessage? = nil
// @FocusState private var keyboardVisible: Bool
//
// var body: some View {
// VStack(spacing: 30) {
// WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260)
// .onChange(of: webViewMsg) { _ in
// if let resp = webViewMsg {
// commandStr = encodeJSON(resp)
// }
// }
// TextEditor(text: $commandStr)
// .focused($keyboardVisible)
// .disableAutocorrection(true)
// .textInputAutocapitalization(.never)
// .padding(.horizontal, 5)
// .padding(.top, 2)
// .frame(height: 112)
// .overlay(
// RoundedRectangle(cornerRadius: 10)
// .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
// )
// HStack(spacing: 20) {
// Button("Copy") {
// UIPasteboard.general.string = commandStr
// }
// Button("Paste") {
// commandStr = UIPasteboard.general.string ?? ""
// }
// Button("Clear") {
// commandStr = ""
// }
// Button("Send") {
// if let c = coordinator,
// let command: WCallCommand = decodeJSON(commandStr) {
// c.sendCommand(command: command)
// }
// }
// }
// HStack(spacing: 20) {
// Button("Capabilities") {
//
// }
// Button("Start") {
// if let c = coordinator {
// c.sendCommand(command: .start(media: .video))
// }
// }
// Button("Accept") {
//
// }
// Button("Answer") {
//
// }
// Button("ICE") {
//
// }
// Button("End") {
//
// }
// }
// }
// }
//}
struct CallViewDebug: View {
@State private var coordinator: WebRTCCoordinator? = nil
@State private var commandStr = ""
@State private var webViewReady: Bool = false
@State private var webViewMsg: WVAPIMessage? = nil
@FocusState private var keyboardVisible: Bool
var body: some View {
VStack(spacing: 30) {
WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260)
.onChange(of: webViewMsg) { _ in
if let resp = webViewMsg {
commandStr = encodeJSON(resp)
}
}
TextEditor(text: $commandStr)
.focused($keyboardVisible)
.disableAutocorrection(true)
.textInputAutocapitalization(.never)
.padding(.horizontal, 5)
.padding(.top, 2)
.frame(height: 112)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
HStack(spacing: 20) {
Button("Copy") {
UIPasteboard.general.string = commandStr
}
Button("Paste") {
commandStr = UIPasteboard.general.string ?? ""
}
Button("Clear") {
commandStr = ""
}
Button("Send") {
if let c = coordinator,
let command: WCallCommand = decodeJSON(commandStr) {
c.sendCommand(command: command)
}
}
}
HStack(spacing: 20) {
Button("Capabilities") {
if let c = coordinator {
c.sendCommand(command: .capabilities(useWorker: true))
}
}
Button("Start") {
if let c = coordinator {
c.sendCommand(command: .start(media: .video))
}
}
Button("Accept") {
}
Button("Answer") {
}
Button("ICE") {
}
Button("End") {
}
}
}
}
}
//
//struct CallViewDebug_Previews: PreviewProvider {
// static var previews: some View {
+50 -6
View File
@@ -13,9 +13,16 @@ struct ChatInfoView: View {
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var chat: Chat
@Binding var showChatInfo: Bool
@State var showDeleteAlert = false
@State var alert: ChatInfoViewAlert? = nil
@State var deletingContact: Contact?
enum ChatInfoViewAlert: Identifiable {
case deleteContactAlert
case clearChatAlert
var id: ChatInfoViewAlert { get { self } }
}
var body: some View {
VStack{
ChatInfoImage(chat: chat)
@@ -27,8 +34,8 @@ struct ChatInfoView: View {
Text(chat.chatInfo.fullName).font(.title)
.padding(.bottom)
if case let .direct(contact) = chat.chatInfo {
VStack {
VStack {
if case let .direct(contact) = chat.chatInfo {
HStack {
serverImage()
Text(chat.serverInfo.networkStatus.statusString)
@@ -41,18 +48,39 @@ struct ChatInfoView: View {
.padding(.vertical, 8)
Spacer()
Button() {
alert = .clearChatAlert
} label: {
Label("Clear conversation", systemImage: "gobackward")
}
.tint(Color.orange)
Button(role: .destructive) {
deletingContact = contact
showDeleteAlert = true
alert = .deleteContactAlert
} label: {
Label("Delete contact", systemImage: "trash")
}
.padding()
}
else if case .group = chat.chatInfo {
Spacer()
Button() {
alert = .clearChatAlert
} label: {
Label("Clear conversation", systemImage: "gobackward")
}
.tint(Color.orange)
.padding()
}
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .deleteContactAlert: return deleteContactAlert(deletingContact!)
case .clearChatAlert: return clearChatAlert()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.alert(isPresented: $showDeleteAlert) { deleteContactAlert(deletingContact!) }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
func serverImage() -> some View {
@@ -81,6 +109,22 @@ struct ChatInfoView: View {
secondaryButton: .cancel()
)
}
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
primaryButton: .destructive(Text("Clear")) {
Task {
await clearChat(chat)
DispatchQueue.main.async {
showChatInfo = false
}
}
},
secondaryButton: .cancel()
)
}
}
struct ChatInfoView_Previews: PreviewProvider {
@@ -0,0 +1,90 @@
//
// CICallItemView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 20/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CICallItemView: View {
@EnvironmentObject var m: ChatModel
var chatInfo: ChatInfo
var chatItem: ChatItem
var status: CICallStatus
var duration: Int
var body: some View {
let sent = chatItem.chatDir.sent
VStack(spacing: 4) {
switch status {
case .pending:
if sent {
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
} else {
acceptCallButton()
}
case .missed: missedCallIcon(sent).foregroundColor(.red)
case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary)
case .accepted: connectingCallIcon()
case .negotiated: connectingCallIcon()
case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green)
case .ended: endedCallIcon(sent)
case .error: missedCallIcon(sent).foregroundColor(.orange)
}
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
}
private func missedCallIcon(_ sent: Bool) -> some View {
Image(systemName: sent ? "phone.arrow.up.right" : "phone.arrow.down.left")
}
private func connectingCallIcon() -> some View {
Image(systemName: "phone.connection").foregroundColor(.green)
}
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
}
}
@ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chatInfo {
Button {
if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
m.activeCallInvitation = nil
m.activeCall = Call(
contact: contact,
callState: .invitationReceived,
localMedia: invitation.peerMedia,
sharedKey: invitation.sharedKey
)
m.showCallView = true
m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
} else {
AlertManager.shared.showAlertMsg(title: "Call already ended!")
}
} label: {
Label("Answer call", systemImage: "phone.arrow.down.left")
}
} else {
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
}
}
}
//struct CICallItemView_Previews: PreviewProvider {
// static var previews: some View {
// CICallItemView()
// }
//}
+29 -15
View File
@@ -9,34 +9,48 @@
import SwiftUI
struct ChatItemView: View {
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity
var body: some View {
if chatItem.isMsgContent() {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth)
}
} else if chatItem.isDeletedContent() {
DeletedItemView(chatItem: chatItem, showMember: showMember)
} else if chatItem.isCall() {
switch chatItem.content {
case .sndMsgContent: contentItemView()
case .rcvMsgContent: contentItemView()
case .sndDeleted: deletedItemView()
case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration)
case let .rcvCall(status, duration): callItemView(status, duration)
}
}
@ViewBuilder private func contentItemView() -> some View {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth)
}
}
private func deletedItemView() -> some View {
DeletedItemView(chatItem: chatItem, showMember: showMember)
}
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration)
}
}
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
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())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 70))
}
+11 -10
View File
@@ -14,7 +14,6 @@ struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
@Binding var showCallView: Bool
@State private var composeState = ComposeState()
@State private var deletingItem: ChatItem? = nil
@FocusState private var keyboardVisible: Bool
@@ -125,8 +124,8 @@ struct ChatView: View {
callState: .waitCapabilities,
localMedia: media
)
showCallView = true
chatModel.callCommand = .capabilities
chatModel.showCallView = true
chatModel.callCommand = .capabilities(useWorker: true)
} label: {
Image(systemName: imageName)
}
@@ -134,7 +133,7 @@ struct ChatView: View {
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, maxWidth: maxWidth)
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
.contextMenu {
if ci.isMsgContent() {
Button {
@@ -178,9 +177,12 @@ struct ChatView: View {
Button(role: .destructive) {
showDeleteMessage = true
deletingItem = ci
} label: {
Label("Delete", systemImage: "trash")
}
} label: { Label("Delete", systemImage: "trash") }
} else if ci.isDeletedContent() {
Button(role: .destructive) {
showDeleteMessage = true
deletingItem = ci
} label: { Label("Delete", systemImage: "trash") }
}
}
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
@@ -227,7 +229,7 @@ struct ChatView: View {
}
func markAllRead() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
if chatModel.chatId == chat.id {
Task { await markChatRead(chat) }
}
@@ -260,7 +262,6 @@ struct ChatView: View {
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
@State var showCallView = false
let chatModel = ChatModel()
chatModel.chatId = "@1"
chatModel.chatItems = [
@@ -274,7 +275,7 @@ struct ChatView_Previews: PreviewProvider {
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: []), showCallView: $showCallView)
return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(chatModel)
}
}
@@ -124,9 +124,8 @@ struct ComposeView: View {
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@State private var showTakePhoto = false
@State var chosenImage: UIImage? = nil
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
@@ -171,27 +170,26 @@ struct ComposeView: View {
}
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
imageSource = .camera
showImagePicker = true
showTakePhoto = true
}
Button("Choose from library") {
imageSource = .imageLibrary
showImagePicker = true
}
Button("Choose file") {
showFileImporter = true
}
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
case .imageLibrary:
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
case .camera:
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
}
.onChange(of: chosenImage) { image in
if let image = image,
let imagePreview = resizeImageToStrSize(image, maxDataSize: 14000) {
@@ -11,7 +11,6 @@ import SwiftUI
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@State var chat: Chat
@Binding var showCallView: Bool
@State private var showContactRequestDialog = false
var body: some View {
@@ -28,7 +27,7 @@ struct ChatListNavLink: View {
}
private func chatView() -> some View {
ChatView(chat: chat, showCallView: $showCallView)
ChatView(chat: chat)
.onAppear {
do {
let cInfo = chat.chatInfo
@@ -54,6 +53,9 @@ struct ChatListNavLink: View {
markReadButton()
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
clearChatButton()
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
AlertManager.shared.showAlert(
@@ -90,6 +92,9 @@ struct ChatListNavLink: View {
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
clearChatButton()
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: {
@@ -108,6 +113,15 @@ struct ChatListNavLink: View {
.tint(Color.accentColor)
}
private func clearChatButton() -> some View {
Button {
AlertManager.shared.showAlert(clearChatAlert())
} label: {
Label("Clear", systemImage: "gobackward")
}
.tint(Color.orange)
}
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest, chat: chat)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
@@ -173,6 +187,17 @@ struct ChatListNavLink: View {
)
}
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."),
primaryButton: .destructive(Text("Clear")) {
Task { await clearChat(chat) }
},
secondaryButton: .cancel()
)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert(
title: Text("Delete group"),
@@ -253,20 +278,19 @@ struct ChatListNavLink: View {
struct ChatListNavLink_Previews: PreviewProvider {
static var previews: some View {
@State var chatId: String? = "@1"
@State var showCallView = false
return Group {
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
), showCallView: $showCallView)
))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
), showCallView: $showCallView)
))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
), showCallView: $showCallView)
))
}
.previewLayout(.fixed(width: 360, height: 80))
}
@@ -13,7 +13,6 @@ struct ChatListView: View {
// not really used in this view
@State private var showSettings = false
@State private var searchText = ""
@State private var showCallView = false
@AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true
var user: User
@@ -22,7 +21,7 @@ struct ChatListView: View {
let v = NavigationView {
List {
ForEach(filteredChats()) { chat in
ChatListNavLink(chat: chat, showCallView: $showCallView)
ChatListNavLink(chat: chat)
.padding(.trailing, -16)
}
}
@@ -50,11 +49,11 @@ struct ChatListView: View {
NewChatButton()
}
}
.fullScreenCover(isPresented: $showCallView) {
ActiveCallView(showCallView: $showCallView)
.fullScreenCover(isPresented: $chatModel.showCallView) {
ActiveCallView()
}
.onChange(of: showCallView) { _ in
if (showCallView) { return }
.onChange(of: chatModel.showCallView) { _ in
if (chatModel.showCallView) { return }
if let call = chatModel.activeCall {
Task {
do {
@@ -69,7 +68,7 @@ struct ChatListView: View {
.onChange(of: chatModel.activeCallInvitation) { _ in
if let contactRef = chatModel.activeCallInvitation,
case let .direct(contact) = chatModel.getChat(contactRef.id)?.chatInfo,
let invitation = chatModel.callInvitations.removeValue(forKey: contactRef.id) {
let invitation = chatModel.callInvitations[contactRef.id] {
answerCallAlert(contact, invitation)
}
}
@@ -98,22 +97,27 @@ struct ChatListView: View {
}
private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) {
AlertManager.shared.showAlert(Alert(
title: Text("Incoming call"),
return AlertManager.shared.showAlert(Alert(
title: Text(invitation.callTitle),
message: Text(contact.profile.displayName).bold() +
Text(" wants to connect with you via ") +
Text(invitation.callTypeText),
primaryButton: .default(Text("Answer")) {
if chatModel.activeCallInvitation == nil {
DispatchQueue.main.async {
AlertManager.shared.showAlertMsg(title: "Call already ended!")
}
} else {
if let activeCallInvitation = chatModel.activeCallInvitation {
chatModel.callInvitations.removeValue(forKey: activeCallInvitation.id)
chatModel.activeCallInvitation = nil
chatModel.activeCall = Call(
contact: contact,
callState: .invitationReceived,
localMedia: invitation.peerMedia
localMedia: invitation.peerMedia,
sharedKey: invitation.sharedKey
)
showCallView = true
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey)
chatModel.showCallView = true
chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
} else {
DispatchQueue.main.async {
AlertManager.shared.showAlertMsg(title: "Call already ended!")
}
}
},
secondaryButton: .cancel()
@@ -29,7 +29,7 @@ struct ChatPreviewView: View {
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.createdAt))
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
+10 -10
View File
@@ -148,16 +148,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group{
ChatItemView(chatItem: sentFile)
ChatItemView(chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation))
ChatItemView(chatItem: fileChatItemWtFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -9,11 +9,6 @@
import SwiftUI
import PhotosUI
enum ImageSource {
case imageLibrary
case camera
}
struct LibraryImagePicker: UIViewControllerRepresentable {
typealias UIViewControllerType = PHPickerViewController
@Binding var image: UIImage?
@@ -126,12 +126,12 @@ struct SettingsView: View {
// notificationsToggle(token)
// }
// }
// NavigationLink {
// CallViewDebug()
// .frame(maxHeight: .infinity, alignment: .top)
// } label: {
NavigationLink {
CallViewDebug()
.frame(maxHeight: .infinity, alignment: .top)
} label: {
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
// }
}
}
}
.navigationTitle("Your settings")
@@ -14,7 +14,7 @@ struct UserProfile: View {
@State private var editProfile = false
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil
var body: some View {
@@ -87,24 +87,23 @@ struct UserProfile: View {
.frame(maxHeight: .infinity, alignment: .top)
.confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
imageSource = .camera
showImagePicker = true
showTakePhoto = true
}
Button("Choose from library") {
imageSource = .imageLibrary
showImagePicker = true
}
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
case .imageLibrary:
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
case .camera:
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
}
.onChange(of: chosenImage) { image in
if let image = image {
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
@@ -20,6 +20,11 @@
<target> (can be copied)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" wants to connect with you via " xml:space="preserve">
<source> wants to connect with you via </source>
<target> wants to connect with you via </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="!1 colored!" xml:space="preserve">
<source>!1 colored!</source>
<target>!1 colored!</target>
@@ -30,6 +35,11 @@
<target>#secret#</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ / %@" xml:space="preserve">
<source>%@ / %@</source>
<target>%@ / %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ is connected!" xml:space="preserve">
<source>%@ is connected!</source>
<target>%@ is connected!</target>
@@ -50,6 +60,11 @@
<target>%lldk</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="(" xml:space="preserve">
<source>(</source>
<target>(</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=")" xml:space="preserve">
<source>)</source>
<target>)</target>
@@ -75,8 +90,18 @@
<target>**Scan QR code**: to connect to your contact in person or via video call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
<source>**e2e encrypted** audio call.</source>
<target>**e2e encrypted** audio call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
<source>**e2e encrypted** video call.</source>
<target>**e2e encrypted** video call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="*bold*" xml:space="preserve">
<source>*bold*</source>
<source>\*bold*</source>
<target>\*bold*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -95,11 +120,6 @@
<target>: </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=": %@" xml:space="preserve">
<source>: %@</source>
<target>: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="About SimpleX" xml:space="preserve">
<source>About SimpleX</source>
<target>About SimpleX</target>
@@ -130,6 +150,11 @@
<target>Add contact to start a new chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." xml:space="preserve">
<source>All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</source>
<target>All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected" xml:space="preserve">
<source>All your contacts will remain connected</source>
<target>All your contacts will remain connected</target>
@@ -145,6 +170,11 @@
<target>Answer</target>
<note>accept incoming call via notification</note>
</trans-unit>
<trans-unit id="Answer call" xml:space="preserve">
<source>Answer call</source>
<target>Answer call</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Attach" xml:space="preserve">
<source>Attach</source>
<target>Attach</target>
@@ -160,6 +190,11 @@
<target>Cancel</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Capabilities" xml:space="preserve">
<source>Capabilities</source>
<target>Capabilities</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chat console" xml:space="preserve">
<source>Chat console</source>
<target>Chat console</target>
@@ -200,6 +235,16 @@
<target>Clear</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Clear conversation" xml:space="preserve">
<source>Clear conversation</source>
<target>Clear conversation</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Clear conversation?" xml:space="preserve">
<source>Clear conversation?</source>
<target>Clear conversation?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Configure SMP servers" xml:space="preserve">
<source>Configure SMP servers</source>
<target>Configure SMP servers</target>
@@ -415,6 +460,11 @@
<target>Enable notifications? (BETA)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="End" xml:space="preserve">
<source>End</source>
<target>End</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enter one SMP server per line:" xml:space="preserve">
<source>Enter one SMP server per line:</source>
<target>Enter one SMP server per line:</target>
@@ -490,6 +540,11 @@
<target>How to use markdown</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="ICE" xml:space="preserve">
<source>ICE</source>
<target>ICE</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="If you can't meet in person, **show QR code in the video call**, or share the link." xml:space="preserve">
<source>If you can't meet in person, **show QR code in the video call**, or share the link.</source>
<target>If you can't meet in person, **show QR code in the video call**, or share the link.</target>
@@ -520,16 +575,21 @@
<target>In person or via a video call the most secure way to connect.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incoming %@ call" xml:space="preserve">
<source>Incoming %@ call</source>
<target>Incoming %@ call</target>
<note>notification body</note>
<trans-unit id="Incoming audio call" xml:space="preserve">
<source>Incoming audio call</source>
<target>Incoming audio call</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Incoming call" xml:space="preserve">
<source>Incoming call</source>
<target>Incoming call</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Incoming video call" xml:space="preserve">
<source>Incoming video call</source>
<target>Incoming video call</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
<source>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</source>
<target>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</target>
@@ -615,9 +675,9 @@
<target>Open Settings</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open-source protocol anybody can run the servers." xml:space="preserve">
<source>Open-source protocol anybody can run the servers.</source>
<target>Open-source protocol anybody can run the servers.</target>
<trans-unit id="Open-source protocol and code anybody can run the servers." xml:space="preserve">
<source>Open-source protocol and code anybody can run the servers.</source>
<target>Open-source protocol and code anybody can run the servers.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Or open the link in the browser and tap **Open in mobile**." xml:space="preserve">
@@ -735,6 +795,11 @@
<target>Scan contact's QR code</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send" xml:space="preserve">
<source>Send</source>
<target>Send</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Server connected" xml:space="preserve">
<source>Server connected</source>
<target>Server connected</target>
@@ -765,6 +830,11 @@
<target>Show pending connections</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Start" xml:space="preserve">
<source>Start</source>
<target>Start</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source>
<target>Take picture</target>
@@ -1029,12 +1099,12 @@ SimpleX servers cannot see your profile.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="_italic_" xml:space="preserve">
<source>_italic_</source>
<source>\_italic_</source>
<target>\_italic_</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="`a + b`" xml:space="preserve">
<source>`a + b`</source>
<source>\`a + b`</source>
<target>\`a + b`</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -1048,6 +1118,11 @@ SimpleX servers cannot see your profile.</target>
<target>accepted</target>
<note>call status</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
<source>audio call (not e2e encrypted).</source>
<target>audio call (not e2e encrypted).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
<target>bold</target>
@@ -1089,19 +1164,29 @@ SimpleX servers cannot see your profile.</target>
<target>connection:%@</target>
<note>connection information</note>
</trans-unit>
<trans-unit id="contact has e2e encryption" xml:space="preserve">
<source>contact has e2e encryption</source>
<target>contact has e2e encryption</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="contact has no e2e encryption" xml:space="preserve">
<source>contact has no e2e encryption</source>
<target>contact has no e2e encryption</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="deleted" xml:space="preserve">
<source>deleted</source>
<target>deleted</target>
<note>deleted chat item</note>
</trans-unit>
<trans-unit id="end-to-end encrypted" xml:space="preserve">
<source>end-to-end encrypted</source>
<target>end-to-end encrypted</target>
<trans-unit id="e2e encrypted" xml:space="preserve">
<source>e2e encrypted</source>
<target>e2e encrypted</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="ended %02d:%02d" xml:space="preserve">
<source>ended %1$d:%2$d</source>
<target>ended %1$d:%2$d</target>
<trans-unit id="ended %@" xml:space="preserve">
<source>ended %@</source>
<target>ended %@</target>
<note>call status</note>
</trans-unit>
<trans-unit id="error" xml:space="preserve">
@@ -1129,9 +1214,9 @@ SimpleX servers cannot see your profile.</target>
<target>missed</target>
<note>call status</note>
</trans-unit>
<trans-unit id="no end-to-end encryption" xml:space="preserve">
<source>no end-to-end encryption</source>
<target>no end-to-end encryption</target>
<trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source>
<target>no e2e encryption</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="or" xml:space="preserve">
@@ -1139,6 +1224,11 @@ SimpleX servers cannot see your profile.</target>
<target>or</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="peer-to-peer" xml:space="preserve">
<source>peer-to-peer</source>
<target>peer-to-peer</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="received answer…" xml:space="preserve">
<source>received answer…</source>
<target>received answer…</target>
@@ -1164,6 +1254,11 @@ SimpleX servers cannot see your profile.</target>
<target>strike</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="unknown" xml:space="preserve">
<source>unknown</source>
<target>unknown</target>
<note>connection info</note>
</trans-unit>
<trans-unit id="v%@ (%@)" xml:space="preserve">
<source>v%@ (%@)</source>
<target>v%@ (%@)</target>
@@ -1179,6 +1274,16 @@ SimpleX servers cannot see your profile.</target>
<target>via one-time link</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="via relay" xml:space="preserve">
<source>via relay</source>
<target>via relay</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
<source>video call (not e2e encrypted).</source>
<target>video call (not e2e encrypted).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="waiting for answer…" xml:space="preserve">
<source>waiting for answer…</source>
<target>waiting for answer…</target>
@@ -1194,13 +1299,18 @@ SimpleX servers cannot see your profile.</target>
<target>wants to connect to you!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="with e2e encryption" xml:space="preserve">
<source>with e2e encryption</source>
<target>with e2e encryption</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you shared one-time link" xml:space="preserve">
<source>you shared one-time link</source>
<target>you shared one-time link</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="~strike~" xml:space="preserve">
<source>~strike~</source>
<source>\~strike~</source>
<target>\~strike~</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -1270,15 +1380,30 @@ SimpleX servers cannot see your profile.</target>
<target>%@ wants to connect!</target>
<note>notification title</note>
</trans-unit>
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
<source>**e2e encrypted** audio call.</source>
<target>**e2e encrypted** audio call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
<source>**e2e encrypted** video call.</source>
<target>**e2e encrypted** video call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accept contact request from %@?" xml:space="preserve">
<source>Accept contact request from %@?</source>
<target>Accept contact request from %@?</target>
<note>notification body</note>
</trans-unit>
<trans-unit id="Incoming %@ call" xml:space="preserve">
<source>Incoming %@ call</source>
<target>Incoming %@ call</target>
<note>notification body</note>
<trans-unit id="Incoming audio call" xml:space="preserve">
<source>Incoming audio call</source>
<target>Incoming audio call</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Incoming video call" xml:space="preserve">
<source>Incoming video call</source>
<target>Incoming video call</target>
<note>notification</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
@@ -1290,6 +1415,11 @@ SimpleX servers cannot see your profile.</target>
<target>accepted</target>
<note>call status</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
<source>audio call (not e2e encrypted).</source>
<target>audio call (not e2e encrypted).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="calling…" xml:space="preserve">
<source>calling…</source>
<target>calling…</target>
@@ -1316,9 +1446,9 @@ SimpleX servers cannot see your profile.</target>
<target>deleted</target>
<note>deleted chat item</note>
</trans-unit>
<trans-unit id="ended %02d:%02d" xml:space="preserve">
<source>ended %1$d:%2$d</source>
<target>ended %1$d:%2$d</target>
<trans-unit id="ended %@" xml:space="preserve">
<source>ended %@</source>
<target>ended %@</target>
<note>call status</note>
</trans-unit>
<trans-unit id="error" xml:space="preserve">
@@ -1341,6 +1471,11 @@ SimpleX servers cannot see your profile.</target>
<target>missed</target>
<note>call status</note>
</trans-unit>
<trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source>
<target>no e2e encryption</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="rejected" xml:space="preserve">
<source>rejected</source>
<target>rejected</target>
@@ -1356,6 +1491,16 @@ SimpleX servers cannot see your profile.</target>
<target>via one-time link</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
<source>video call (not e2e encrypted).</source>
<target>video call (not e2e encrypted).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="with e2e encryption" xml:space="preserve">
<source>with e2e encryption</source>
<target>with e2e encryption</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you shared one-time link" xml:space="preserve">
<source>you shared one-time link</source>
<target>you shared one-time link</target>
@@ -9,3 +9,15 @@
/* No comment provided by engineer. */
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
/* No comment provided by engineer. */
"*bold*" = "\\*bold*";
/* No comment provided by engineer. */
"_italic_" = "\\_italic_";
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* No comment provided by engineer. */
"`a + b`" = "\\`a + b`";
@@ -20,6 +20,11 @@
<target> (можно скопировать)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" wants to connect with you via " xml:space="preserve">
<source> wants to connect with you via </source>
<target> хочет связаться с вами через </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="!1 colored!" xml:space="preserve">
<source>!1 colored!</source>
<target>!1 цвет!</target>
@@ -30,6 +35,11 @@
<target>#секрет#</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ / %@" xml:space="preserve">
<source>%@ / %@</source>
<target>%@ / %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ is connected!" xml:space="preserve">
<source>%@ is connected!</source>
<target>Установлено соединение с %@!</target>
@@ -50,6 +60,11 @@
<target>%lldk</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="(" xml:space="preserve">
<source>(</source>
<target>(</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=")" xml:space="preserve">
<source>)</source>
<target>)</target>
@@ -75,8 +90,18 @@
<target>**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
<source>**e2e encrypted** audio call.</source>
<target>**e2e зашифрованный** аудиозвонок.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
<source>**e2e encrypted** video call.</source>
<target>**e2e зашифрованный** видеозвонок.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="*bold*" xml:space="preserve">
<source>*bold*</source>
<source>\*bold*</source>
<target>\*жирный*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -95,11 +120,6 @@
<target>: </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=": %@" xml:space="preserve">
<source>: %@</source>
<target>: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="About SimpleX" xml:space="preserve">
<source>About SimpleX</source>
<target>О SimpleX</target>
@@ -130,6 +150,11 @@
<target>Добавьте контакт, чтобы начать разговор</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." xml:space="preserve">
<source>All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.</source>
<target>Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected" xml:space="preserve">
<source>All your contacts will remain connected</source>
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
@@ -145,6 +170,11 @@
<target>Ответить</target>
<note>accept incoming call via notification</note>
</trans-unit>
<trans-unit id="Answer call" xml:space="preserve">
<source>Answer call</source>
<target>Принять звонок</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Attach" xml:space="preserve">
<source>Attach</source>
<target>Прикрепить</target>
@@ -160,6 +190,11 @@
<target>Отменить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Capabilities" xml:space="preserve">
<source>Capabilities</source>
<target>Возможности</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chat console" xml:space="preserve">
<source>Chat console</source>
<target>Консоль</target>
@@ -200,6 +235,16 @@
<target>Очистить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Clear conversation" xml:space="preserve">
<source>Clear conversation</source>
<target>Очистить разговор</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Clear conversation?" xml:space="preserve">
<source>Clear conversation?</source>
<target>Очистить разговор?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Configure SMP servers" xml:space="preserve">
<source>Configure SMP servers</source>
<target>Настройка SMP серверов</target>
@@ -415,6 +460,11 @@
<target>Включить уведомления? (БЕТА)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="End" xml:space="preserve">
<source>End</source>
<target>Завершить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enter one SMP server per line:" xml:space="preserve">
<source>Enter one SMP server per line:</source>
<target>Введите SMP серверы, каждый на отдельной строке:</target>
@@ -490,6 +540,11 @@
<target>Как форматировать</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="ICE" xml:space="preserve">
<source>ICE</source>
<target>ICE</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="If you can't meet in person, **show QR code in the video call**, or share the link." xml:space="preserve">
<source>If you can't meet in person, **show QR code in the video call**, or share the link.</source>
<target>Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой.</target>
@@ -520,16 +575,21 @@
<target>При встрече или в видеозвонке – самый безопасный способ установить соединение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incoming %@ call" xml:space="preserve">
<source>Incoming %@ call</source>
<target>Входящий звонок</target>
<note>notification body</note>
<trans-unit id="Incoming audio call" xml:space="preserve">
<source>Incoming audio call</source>
<target>Входящий аудиозвонок</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Incoming call" xml:space="preserve">
<source>Incoming call</source>
<target>Входящий звонок</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Incoming video call" xml:space="preserve">
<source>Incoming video call</source>
<target>Входящий видеозвонок</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
<source>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</source>
<target>[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)</target>
@@ -735,6 +795,11 @@
<target>Сосканировать QR код контакта</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Send" xml:space="preserve">
<source>Send</source>
<target>Отправить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Server connected" xml:space="preserve">
<source>Server connected</source>
<target>Установлено соединение с сервером</target>
@@ -765,6 +830,11 @@
<target>Показать ожидаемые соединения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Start" xml:space="preserve">
<source>Start</source>
<target>Начать</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source>
<target>Сделать фото</target>
@@ -1029,12 +1099,12 @@ SimpleX серверы не могут получить доступ к ваше
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="_italic_" xml:space="preserve">
<source>_italic_</source>
<source>\_italic_</source>
<target>\_курсив_</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="`a + b`" xml:space="preserve">
<source>`a + b`</source>
<source>\`a + b`</source>
<target>\`a + b`</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -1048,6 +1118,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>принятый звонок</target>
<note>call status</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
<source>audio call (not e2e encrypted).</source>
<target>аудиозвонок (не e2e зашифрованный).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
<target>жирный</target>
@@ -1089,19 +1164,29 @@ SimpleX серверы не могут получить доступ к ваше
<target>connection:%@</target>
<note>connection information</note>
</trans-unit>
<trans-unit id="contact has e2e encryption" xml:space="preserve">
<source>contact has e2e encryption</source>
<target>у контакта есть e2e шифрование</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="contact has no e2e encryption" xml:space="preserve">
<source>contact has no e2e encryption</source>
<target>у контакта нет e2e шифрования</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="deleted" xml:space="preserve">
<source>deleted</source>
<target>удалено</target>
<note>deleted chat item</note>
</trans-unit>
<trans-unit id="end-to-end encrypted" xml:space="preserve">
<source>end-to-end encrypted</source>
<target>end-to-end зашифрованный звонок</target>
<trans-unit id="e2e encrypted" xml:space="preserve">
<source>e2e encrypted</source>
<target>e2e зашифровано</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="ended %02d:%02d" xml:space="preserve">
<source>ended %1$d:%2$d</source>
<target>звонок завершён %1$d:%2$d</target>
<trans-unit id="ended %@" xml:space="preserve">
<source>ended %@</source>
<target>завершен %@</target>
<note>call status</note>
</trans-unit>
<trans-unit id="error" xml:space="preserve">
@@ -1129,9 +1214,9 @@ SimpleX серверы не могут получить доступ к ваше
<target>пропущенный звонок</target>
<note>call status</note>
</trans-unit>
<trans-unit id="no end-to-end encryption" xml:space="preserve">
<source>no end-to-end encryption</source>
<target>end-to-end шифрование не поддерживается</target>
<trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source>
<target>нет e2e шифрования</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="or" xml:space="preserve">
@@ -1139,6 +1224,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>или</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="peer-to-peer" xml:space="preserve">
<source>peer-to-peer</source>
<target>peer-to-peer</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="received answer…" xml:space="preserve">
<source>received answer…</source>
<target>получен ответ…</target>
@@ -1164,6 +1254,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>зачеркнуть</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="unknown" xml:space="preserve">
<source>unknown</source>
<target>неизвестно</target>
<note>connection info</note>
</trans-unit>
<trans-unit id="v%@ (%@)" xml:space="preserve">
<source>v%@ (%@)</source>
<target>v%@ (%@)</target>
@@ -1179,6 +1274,16 @@ SimpleX серверы не могут получить доступ к ваше
<target>через одноразовую ссылку</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="via relay" xml:space="preserve">
<source>via relay</source>
<target>через relay сервер</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
<source>video call (not e2e encrypted).</source>
<target>видеозвонок (не e2e зашифрованный).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="waiting for answer…" xml:space="preserve">
<source>waiting for answer…</source>
<target>ожидается ответ…</target>
@@ -1194,13 +1299,18 @@ SimpleX серверы не могут получить доступ к ваше
<target>хочет соединиться с вами!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="with e2e encryption" xml:space="preserve">
<source>with e2e encryption</source>
<target>e2e зашифровано</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you shared one-time link" xml:space="preserve">
<source>you shared one-time link</source>
<target>вы создали ссылку</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="~strike~" xml:space="preserve">
<source>~strike~</source>
<source>\~strike~</source>
<target>\~зачеркнуть~</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -1270,15 +1380,30 @@ SimpleX серверы не могут получить доступ к ваше
<target>%@ хочет соединиться!</target>
<note>notification title</note>
</trans-unit>
<trans-unit id="**e2e encrypted** audio call." xml:space="preserve">
<source>**e2e encrypted** audio call.</source>
<target>**e2e зашифрованный** аудиозвонок.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** video call." xml:space="preserve">
<source>**e2e encrypted** video call.</source>
<target>**e2e зашифрованный** видеозвонок.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Accept contact request from %@?" xml:space="preserve">
<source>Accept contact request from %@?</source>
<target>Принять запрос на соединение от %@?</target>
<note>notification body</note>
</trans-unit>
<trans-unit id="Incoming %@ call" xml:space="preserve">
<source>Incoming %@ call</source>
<target>Входящий звонок</target>
<note>notification body</note>
<trans-unit id="Incoming audio call" xml:space="preserve">
<source>Incoming audio call</source>
<target>Входящий аудиозвонок</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Incoming video call" xml:space="preserve">
<source>Incoming video call</source>
<target>Входящий видеозвонок</target>
<note>notification</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
@@ -1290,6 +1415,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>принятый звонок</target>
<note>call status</note>
</trans-unit>
<trans-unit id="audio call (not e2e encrypted)." xml:space="preserve">
<source>audio call (not e2e encrypted).</source>
<target>аудиозвонок (не e2e зашифрованный).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="calling…" xml:space="preserve">
<source>calling…</source>
<target>входящий звонок…</target>
@@ -1316,9 +1446,9 @@ SimpleX серверы не могут получить доступ к ваше
<target>удалено</target>
<note>deleted chat item</note>
</trans-unit>
<trans-unit id="ended %02d:%02d" xml:space="preserve">
<source>ended %1$d:%2$d</source>
<target>звонок завершён %1$d:%2$d</target>
<trans-unit id="ended %@" xml:space="preserve">
<source>ended %@</source>
<target>завершен %@</target>
<note>call status</note>
</trans-unit>
<trans-unit id="error" xml:space="preserve">
@@ -1341,6 +1471,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>пропущенный звонок</target>
<note>call status</note>
</trans-unit>
<trans-unit id="no e2e encryption" xml:space="preserve">
<source>no e2e encryption</source>
<target>нет e2e шифрования</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="rejected" xml:space="preserve">
<source>rejected</source>
<target>отклоненный звонок</target>
@@ -1356,6 +1491,16 @@ SimpleX серверы не могут получить доступ к ваше
<target>через одноразовую ссылку</target>
<note>chat list item description</note>
</trans-unit>
<trans-unit id="video call (not e2e encrypted)." xml:space="preserve">
<source>video call (not e2e encrypted).</source>
<target>видеозвонок (не e2e зашифрованный).</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="with e2e encryption" xml:space="preserve">
<source>with e2e encryption</source>
<target>e2e зашифровано</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you shared one-time link" xml:space="preserve">
<source>you shared one-time link</source>
<target>вы создали одноразовую ссылку</target>
@@ -9,3 +9,15 @@
/* No comment provided by engineer. */
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
/* No comment provided by engineer. */
"*bold*" = "\\*bold*";
/* No comment provided by engineer. */
"_italic_" = "\\_italic_";
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* No comment provided by engineer. */
"`a + b`" = "\\`a + b`";
@@ -1,3 +1,9 @@
/* No comment provided by engineer. */
"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок.";
/* No comment provided by engineer. */
"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок.";
/* notification title */
"%@ is connected!" = "соединение с %@ установлено!";
@@ -10,6 +16,9 @@
/* call status */
"accepted" = "принятый звонок";
/* No comment provided by engineer. */
"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный).";
/* call status */
"calling…" = "входящий звонок…";
@@ -27,7 +36,7 @@
"deleted" = "удалено";
/* call status */
"ended %02d:%02d" = "звонок завершён %1$d:%2$d";
"ended %@" = "завершен %@";
/* call status */
"error" = "ошибка соединения";
@@ -35,8 +44,11 @@
/* call status */
"in progress" = "активный звонок";
/* notification body */
"Incoming %@ call" = "Входящий звонок";
/* notification */
"Incoming audio call" = "Входящий аудиозвонок";
/* notification */
"Incoming video call" = "Входящий видеозвонок";
/* chat list item title */
"invited to connect" = "приглашение соединиться";
@@ -44,6 +56,9 @@
/* call status */
"missed" = "пропущенный звонок";
/* No comment provided by engineer. */
"no e2e encryption" = "нет e2e шифрования";
/* call status */
"rejected" = "отклоненный звонок";
@@ -53,6 +68,12 @@
/* chat list item description */
"via one-time link" = "через одноразовую ссылку";
/* No comment provided by engineer. */
"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный).";
/* No comment provided by engineer. */
"with e2e encryption" = "e2e зашифровано";
/* notification body */
"You can now send messages to %@" = "Вы можете отправлять сообщения %@";
+42 -38
View File
@@ -12,6 +12,7 @@
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; };
@@ -90,16 +91,6 @@
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CFE0916282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0911282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a */; };
5CFE0917282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0911282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a */; };
5CFE0918282EE05E0002594B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0912282EE05E0002594B /* libffi.a */; };
5CFE0919282EE05E0002594B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0912282EE05E0002594B /* libffi.a */; };
5CFE091A282EE05E0002594B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0913282EE05E0002594B /* libgmpxx.a */; };
5CFE091B282EE05E0002594B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0913282EE05E0002594B /* libgmpxx.a */; };
5CFE091C282EE05E0002594B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0914282EE05E0002594B /* libgmp.a */; };
5CFE091D282EE05E0002594B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0914282EE05E0002594B /* libgmp.a */; };
5CFE091E282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0915282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a */; };
5CFE091F282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFE0915282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a */; };
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
@@ -107,6 +98,16 @@
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A6908128376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */; };
64A6908228376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */; };
64A6908328376BBA0076573F /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907D28376BB90076573F /* libffi.a */; };
64A6908428376BBA0076573F /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907D28376BB90076573F /* libffi.a */; };
64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907E28376BB90076573F /* libgmpxx.a */; };
64A6908628376BBA0076573F /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907E28376BB90076573F /* libgmpxx.a */; };
64A6908728376BBA0076573F /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907F28376BB90076573F /* libgmp.a */; };
64A6908828376BBA0076573F /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6907F28376BB90076573F /* libgmp.a */; };
64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */; };
64A6908A28376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64DAE1512809D9F5000DA960 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; };
@@ -128,6 +129,7 @@
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = "<group>"; };
@@ -206,11 +208,6 @@
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = "<group>"; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
5CFE0911282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a"; sourceTree = "<group>"; };
5CFE0912282EE05E0002594B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CFE0913282EE05E0002594B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CFE0914282EE05E0002594B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CFE0915282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a"; sourceTree = "<group>"; };
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
@@ -218,6 +215,11 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a"; sourceTree = "<group>"; };
64A6907D28376BB90076573F /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64A6907E28376BB90076573F /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64A6907F28376BB90076573F /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a"; 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>"; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
@@ -228,14 +230,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CFE091A282EE05E0002594B /* libgmpxx.a in Frameworks */,
5CFE091E282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a in Frameworks */,
5CFE091C282EE05E0002594B /* libgmp.a in Frameworks */,
5CFE0916282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a in Frameworks */,
64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */,
64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */,
64A6908728376BBA0076573F /* libgmp.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
5CFE0918282EE05E0002594B /* libffi.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
64A6908328376BBA0076573F /* libffi.a in Frameworks */,
64A6908128376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -250,13 +252,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CFE091F282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a in Frameworks */,
5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */,
5CFE091B282EE05E0002594B /* libgmpxx.a in Frameworks */,
64A6908628376BBA0076573F /* libgmpxx.a in Frameworks */,
64A6908A28376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */,
5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */,
5CFE0917282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a in Frameworks */,
5CFE091D282EE05E0002594B /* libgmp.a in Frameworks */,
5CFE0919282EE05E0002594B /* libffi.a in Frameworks */,
64A6908228376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a in Frameworks */,
64A6908828376BBA0076573F /* libgmp.a in Frameworks */,
64A6908428376BBA0076573F /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -305,11 +307,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5CFE0912282EE05E0002594B /* libffi.a */,
5CFE0914282EE05E0002594B /* libgmp.a */,
5CFE0913282EE05E0002594B /* libgmpxx.a */,
5CFE0915282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ-ghc8.10.7.a */,
5CFE0911282EE05E0002594B /* libHSsimplex-chat-2.0.1-4e10XUnKa54A7I6rlOZBZ.a */,
64A6907D28376BB90076573F /* libffi.a */,
64A6907F28376BB90076573F /* libgmp.a */,
64A6907E28376BB90076573F /* libgmpxx.a */,
64A6908028376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a */,
64A6907C28376BB90076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -496,6 +498,7 @@
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
5C029EA72837DBB3004A9677 /* CICallItemView.swift */,
);
path = ChatItem;
sourceTree = "<group>";
@@ -718,6 +721,7 @@
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */,
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
@@ -919,7 +923,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -941,7 +945,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -961,7 +965,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -983,7 +987,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1042,7 +1046,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1063,7 +1067,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1082,7 +1086,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1103,7 +1107,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 2.0.1;
MARKETING_VERSION = 2.1.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
+73 -7
View File
@@ -7,6 +7,9 @@
/* No comment provided by engineer. */
" (can be copied)" = " (можно скопировать)";
/* No comment provided by engineer. */
" wants to connect with you via " = " хочет связаться с вами через ";
/* No comment provided by engineer. */
"_italic_" = "\\_курсив_";
@@ -17,10 +20,10 @@
": " = ": ";
/* No comment provided by engineer. */
": %@" = ": %@";
"!1 colored!" = "!1 цвет!";
/* No comment provided by engineer. */
"!1 colored!" = "!1 цвет!";
"(" = "(";
/* No comment provided by engineer. */
")" = ")";
@@ -34,6 +37,12 @@
/* No comment provided by engineer. */
"**Create link / QR code** for your contact to use." = "**Создать ссылку / QR код** для вашего контакта.";
/* No comment provided by engineer. */
"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок.";
/* No comment provided by engineer. */
"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок.";
/* No comment provided by engineer. */
"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**.";
@@ -46,6 +55,9 @@
/* No comment provided by engineer. */
"#secret#" = "#секрет#";
/* No comment provided by engineer. */
"%@ / %@" = "%@ / %@";
/* notification title */
"%@ is connected!" = "Установлено соединение с %@!";
@@ -91,6 +103,9 @@
/* No comment provided by engineer. */
"Add contact to start a new chat" = "Добавьте контакт, чтобы начать разговор";
/* No comment provided by engineer. */
"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.";
/* No comment provided by engineer. */
"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся.";
@@ -100,9 +115,15 @@
/* accept incoming call via notification */
"Answer" = "Ответить";
/* No comment provided by engineer. */
"Answer call" = "Принять звонок";
/* No comment provided by engineer. */
"Attach" = "Прикрепить";
/* No comment provided by engineer. */
"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный).";
/* No comment provided by engineer. */
"bold" = "жирный";
@@ -115,6 +136,9 @@
/* No comment provided by engineer. */
"Cancel" = "Отменить";
/* No comment provided by engineer. */
"Capabilities" = "Возможности";
/* No comment provided by engineer. */
"Chat console" = "Консоль";
@@ -139,6 +163,12 @@
/* No comment provided by engineer. */
"Clear" = "Очистить";
/* No comment provided by engineer. */
"Clear conversation" = "Очистить разговор";
/* No comment provided by engineer. */
"Clear conversation?" = "Очистить разговор?";
/* No comment provided by engineer. */
"colored" = "цвет";
@@ -209,6 +239,12 @@
/* No comment provided by engineer. */
"Contact and all messages will be deleted - this cannot be undone!" = "Контакт и все сообщения будут удалены - это действие нельзя отменить!";
/* No comment provided by engineer. */
"contact has e2e encryption" = "у контакта есть e2e шифрование";
/* No comment provided by engineer. */
"contact has no e2e encryption" = "у контакта нет e2e шифрования";
/* notification */
"Contact is connected" = "Соединение с контактом установлено";
@@ -284,6 +320,9 @@
/* No comment provided by engineer. */
"Display name" = "Имя профиля";
/* No comment provided by engineer. */
"e2e encrypted" = "e2e зашифровано";
/* No comment provided by engineer. */
"Edit" = "Редактировать";
@@ -291,10 +330,10 @@
"Enable notifications? (BETA)" = "Включить уведомления? (БЕТА)";
/* No comment provided by engineer. */
"end-to-end encrypted" = "end-to-end зашифрованный звонок";
"End" = "Завершить";
/* call status */
"ended %02d:%02d" = "звонок завершён %1$d:%2$d";
"ended %@" = "завершен %@";
/* No comment provided by engineer. */
"Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:";
@@ -344,6 +383,9 @@
/* No comment provided by engineer. */
"How to use markdown" = "Как форматировать";
/* No comment provided by engineer. */
"ICE" = "ICE";
/* No comment provided by engineer. */
"If you can't meet in person, **show QR code in the video call**, or share the link." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка**, или поделиться ссылкой.";
@@ -365,12 +407,15 @@
/* call status */
"in progress" = "активный звонок";
/* notification body */
"Incoming %@ call" = "Входящий звонок";
/* notification */
"Incoming audio call" = "Входящий аудиозвонок";
/* notification */
"Incoming call" = "Входящий звонок";
/* notification */
"Incoming video call" = "Входящий видеозвонок";
/* No comment provided by engineer. */
"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)";
@@ -420,7 +465,7 @@
"New message" = "Новое сообщение";
/* No comment provided by engineer. */
"no end-to-end encryption" = "end-to-end шифрование не поддерживается";
"no e2e encryption" = "нет e2e шифрования";
/* No comment provided by engineer. */
"Notifications are disabled!" = "Уведомления выключены";
@@ -455,6 +500,9 @@
/* No comment provided by engineer. */
"Paste the link you received into the box below to connect with your contact." = "Чтобы соединиться, вставьте ссылку, полученную от вашего контакта.";
/* No comment provided by engineer. */
"peer-to-peer" = "peer-to-peer";
/* No comment provided by engineer. */
"People can connect to you only via the links you share." = "С вами можно соединиться только через созданные вами ссылки.";
@@ -515,6 +563,9 @@
/* No comment provided by engineer. */
"secret" = "секрет";
/* No comment provided by engineer. */
"Send" = "Отправить";
/* No comment provided by engineer. */
"Server connected" = "Установлено соединение с сервером";
@@ -536,6 +587,9 @@
/* No comment provided by engineer. */
"SMP servers" = "SMP серверы";
/* No comment provided by engineer. */
"Start" = "Начать";
/* No comment provided by engineer. */
"starting…" = "инициализация…";
@@ -599,6 +653,9 @@
/* No comment provided by engineer. */
"Unexpected error: %@" = "Неожиданная ошибка: %@";
/* connection info */
"unknown" = "неизвестно";
/* No comment provided by engineer. */
"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.";
@@ -617,6 +674,12 @@
/* chat list item description */
"via one-time link" = "через одноразовую ссылку";
/* No comment provided by engineer. */
"via relay" = "через relay сервер";
/* No comment provided by engineer. */
"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный).";
/* No comment provided by engineer. */
"waiting for answer…" = "ожидается ответ…";
@@ -635,6 +698,9 @@
/* No comment provided by engineer. */
"Welcome %@!" = "Здравствуйте %@!";
/* No comment provided by engineer. */
"with e2e encryption" = "e2e зашифровано";
/* No comment provided by engineer. */
"You" = "Вы";
+4 -4
View File
@@ -146,7 +146,7 @@ Once you have started the chat, you will be prompted to specify your "display na
The diagram below shows how to connect and message a contact:
<div align="center">
<img align="center" src="images/how-to-use-simplex.svg">
<img align="center" src="../images/how-to-use-simplex.svg">
</div>
Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel.
@@ -165,7 +165,7 @@ Use `/help` in chat to see the list of available commands.
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`. You can then send messages to the group by entering `#<group> <message>`. Use `/help groups` for other commands.
![simplex-chat](./images/groups.gif)
![simplex-chat](../images/groups.gif)
> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent.
@@ -173,7 +173,7 @@ To create a group use `/g <group>`, then add contacts to it with `/a <group> <na
You can send a file to your contact with `/f @<contact> <file_path>` - the recipient will have to accept it before it is sent. Use `/help files` for other commands.
![simplex-chat](./images/files.gif)
![simplex-chat](../images/files.gif)
You can send files to a group with `/f #<group> <file_path>`.
@@ -187,7 +187,7 @@ User address is "long-term" in a sense that it is a multiple-use connection link
Use `/help address` for other commands.
![simplex-chat](./images/user-addresses.gif)
![simplex-chat](../images/user-addresses.gif)
### Access chat history
+2 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 2.0.1
version: 2.1.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -26,6 +26,7 @@ dependencies:
- email-validate == 2.3.*
- exceptions == 0.10.*
- filepath == 1.4.*
- http-types == 0.12.*
- mtl == 2.2.*
- optparse-applicative >= 0.15 && < 0.17
- process == 1.6.*
+4
View File
@@ -3,8 +3,12 @@
# it can be tested in the browser from dist folder
cp ./src/call.html ./dist/call.html
cp ./src/style.css ./dist/style.css
cp ./node_modules/lz-string/libs/lz-string.min.js ./dist/lz-string.min.js
cp ./src/webcall.html ./dist/webcall.html
cp ./src/ui.js ./dist/ui.js
# copy to android app
cp ./src/call.html ../../apps/android/app/src/main/assets/www/call.html
cp ./src/style.css ../../apps/android/app/src/main/assets/www/style.css
cp ./dist/call.js ../../apps/android/app/src/main/assets/www/call.js
cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/android/app/src/main/assets/www/lz-string.min.js
+21 -4
View File
@@ -1,19 +1,33 @@
{
"name": "simplex-chat-webrtc",
"version": "0.0.1",
"description": "WebRTC call in browser and webview",
"name": "@simplex-chat/webrtc",
"version": "0.0.4",
"description": "WebRTC call in browser and webview for SimpleX Chat clients",
"main": "dist/call.js",
"types": "dist/call.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "prettier --write --ignore-unknown . && tsc && ./copy"
},
"repository": {
"type": "git",
"url": "git+https://github.com/simplex-chat/simplex-chat.git"
},
"keywords": [
"SimpleX",
"WebRTC"
],
"author": "",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-webrtc#readme",
"author": "SimpleX Chat",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/lz-string": "^1.3.34",
"husky": "^7.0.4",
"lint-staged": "^12.4.1",
"prettier": "^2.6.2",
@@ -21,5 +35,8 @@
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"lz-string": "^1.4.4"
}
}
+15 -2
View File
@@ -1,11 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link href="./style.css" rel="stylesheet" />
<script src="./lz-string.min.js"></script>
</head>
<body>
<video id="remote-video-stream" autoplay playsinline></video>
<video id="local-video-stream" muted autoplay playsinline></video>
<video
id="remote-video-stream"
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
<video
id="local-video-stream"
muted
autoplay
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
</body>
<footer>
<script src="./call.js"></script>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,6 +21,6 @@ body {
object-fit: cover;
margin: 16px;
border-radius: 16px;
bottom: 0;
top: 0;
right: 0;
}
+125
View File
@@ -0,0 +1,125 @@
;(async function run() {
// const START_E2EE_CALL_BTN = "start-e2ee-call"
// const START_CALL_BTN = "start-call"
const URL_FOR_PEER = "url-for-peer"
// const COPY_URL_FOR_PEER_BTN = "copy-url-for-peer"
const DATA_FOR_PEER = "data-for-peer"
// const COPY_DATA_FOR_PEER_BTN = "copy-data-for-peer"
const PASS_DATA_TO_PEER_TEXT = "pass-data-to-peer"
const SIMPLEX_CHAT_COMMAND = "simplex-chat-command"
const COPY_SIMPLEX_CHAT_COMMAND_BTN = "copy-simplex-chat-command"
const COMMAND_TO_PROCESS = "command-to-process"
const PROCESS_COMMAND_BTN = "process-command"
const urlForPeer = document.getElementById(URL_FOR_PEER)
const dataForPeer = document.getElementById(DATA_FOR_PEER)
const passDataToPeerText = document.getElementById(PASS_DATA_TO_PEER_TEXT)
const simplexChatCommand = document.getElementById(SIMPLEX_CHAT_COMMAND)
const commandToProcess = document.getElementById(COMMAND_TO_PROCESS)
const processCommandButton = document.getElementById(PROCESS_COMMAND_BTN)
// const startE2EECallButton = document.getElementById(START_E2EE_CALL_BTN)
// const {resp} = await processCommand({command: {type: "capabilities", useWorker: true}})
// if (resp?.capabilities?.encryption) {
// startE2EECallButton.onclick = startCall(true)
// } else {
// startE2EECallButton.style.display = "none"
// }
// const startCallButton = document.getElementById(START_CALL_BTN)
// startCallButton.onclick = startCall()
// const copyUrlButton = document.getElementById(COPY_URL_FOR_PEER_BTN)
// copyUrlButton.onclick = () => {
// navigator.clipboard.writeText(urlForPeer.innerText)
// commandToProcess.style.display = ""
// processCommandButton.style.display = ""
// }
// const copyDataButton = document.getElementById(COPY_DATA_FOR_PEER_BTN)
// copyDataButton.onclick = () => {
// navigator.clipboard.writeText(dataForPeer.innerText)
// commandToProcess.style.display = ""
// processCommandButton.style.display = ""
// }
const copySimplexChatCommandButton = document.getElementById(COPY_SIMPLEX_CHAT_COMMAND_BTN)
copySimplexChatCommandButton.onclick = () => {
navigator.clipboard.writeText(simplexChatCommand.innerText)
if (simplexChatCommand.innerText.startsWith("/_call offer")) {
commandToProcess.style.display = ""
processCommandButton.style.display = ""
}
}
processCommandButton.onclick = () => {
sendCommand(JSON.parse(commandToProcess.value))
commandToProcess.value = ""
}
const parsed = new URLSearchParams(document.location.hash.substring(1))
let commandStr = parsed.get("command")
if (commandStr) {
// startE2EECallButton.style.display = "none"
// startCallButton.style.display = "none"
await sendCommand(JSON.parse(decodeURIComponent(commandStr)))
}
// function startCall(encryption) {
// return async () => {
// let aesKey
// if (encryption) {
// const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"])
// const keyBytes = await crypto.subtle.exportKey("raw", key)
// aesKey = callCrypto.decodeAscii(callCrypto.encodeBase64url(new Uint8Array(keyBytes)))
// }
// startE2EECallButton.style.display = "none"
// startCallButton.style.display = "none"
// await sendCommand({type: "start", media: "video", aesKey, useWorker: true})
// }
// }
async function sendCommand(command) {
try {
console.log(command)
const {resp} = await processCommand({command})
console.log(resp)
switch (resp.type) {
case "offer": {
const {media} = command
const {offer, iceCandidates, capabilities} = resp
const aesKey = capabilities.encryption ? command.aesKey : undefined
const peerWCommand = {type: "offer", offer, iceCandidates, media, aesKey, useWorker: true}
const url = new URL(document.location)
parsed.set("command", encodeURIComponent(JSON.stringify(peerWCommand)))
url.hash = parsed.toString()
urlForPeer.innerText = url.toString()
dataForPeer.innerText = JSON.stringify(peerWCommand)
const webRTCCallOffer = {callType: {media, capabilities}, rtcSession: {rtcSession: offer, rtcIceCandidates: iceCandidates}}
const peerChatCommand = `/_call offer @${parsed.get("contact_id")} ${JSON.stringify(webRTCCallOffer)}`
simplexChatCommand.innerText = peerChatCommand
// copyUrlButton.style.display = ""
// copyDataButton.style.display = ""
copySimplexChatCommandButton.style.display = ""
return
}
case "answer": {
const {answer, iceCandidates} = resp
const peerWCommand = {type: "answer", answer, iceCandidates}
dataForPeer.innerText = JSON.stringify(peerWCommand)
const webRTCSession = {rtcSession: answer, rtcIceCandidates: iceCandidates}
const peerChatCommand = `/_call answer @${parsed.get("contact_id")} ${JSON.stringify(webRTCSession)}`
// copyUrlButton.style.display = "none"
// copyDataButton.style.display = ""
copySimplexChatCommandButton.style.display = ""
simplexChatCommand.innerText = peerChatCommand
return
}
case "ok":
if ((command.type = "answer")) {
console.log("connecting")
commandToProcess.style.display = "none"
processCommandButton.style.display = "none"
}
return
}
} catch (e) {
console.log("error: ", e)
}
}
})()
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<link href="./style.css" rel="stylesheet" />
<script src="./lz-string.min.js"></script>
<style>
#data-for-peer,
#url-for-peer,
#chat-command-for-peer,
#pass-data-to-peer {
position: absolute;
color: white;
}
#ui-overlay {
position: absolute;
}
</style>
</head>
<body>
<video id="remote-video-stream" autoplay playsinline></video>
<video id="local-video-stream" muted autoplay playsinline></video>
<div id="ui-overlay">
<div>
<!-- <button id="start-e2ee-call" type="submit">Start call with e2ee</button>
<button id="start-call" type="submit">Start call</button>
<button id="copy-url-for-peer" type="submit" style="display: none">Copy url for your contact</button>
<button id="copy-data-for-peer" type="submit" style="display: none">Copy data for your contact</button> -->
<button id="copy-simplex-chat-command" type="submit" style="display: none">Copy SimpleX Chat command</button>
<p id="pass-data-to-peer" style="display: none">Send copied data back to your contact</p>
</div>
<div>
<textarea
id="command-to-process"
cols="30"
rows="10"
style="display: none"
placeholder="Pass copied URL to your contact. Once they send you back the data, paste it here and click Connect"
></textarea>
</div>
<div>
<button id="process-command" type="submit" style="display: none">Connect</button>
</div>
<div id="url-for-peer" style="display: none"></div>
<div id="data-for-peer" style="display: none"></div>
<div id="simplex-chat-command" style="display: none"></div>
</div>
</body>
<footer>
<script src="./call.js"></script>
<script src="./ui.js"></script>
</footer>
</html>
+2 -1
View File
@@ -16,6 +16,7 @@
"sourceMap": true,
"strict": true,
"strictNullChecks": true,
"target": "ES2018"
"target": "ES2018",
"types": ["lz-string"]
}
}
+6 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 2.0.1
version: 2.1.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -70,6 +70,7 @@ library
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -106,6 +107,7 @@ executable simplex-bot
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -143,6 +145,7 @@ executable simplex-bot-advanced
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -181,6 +184,7 @@ executable simplex-chat
, email-validate ==2.3.*
, exceptions ==0.10.*
, filepath ==1.4.*
, http-types ==0.12.*
, mtl ==2.2.*
, network ==3.1.*
, optparse-applicative >=0.15 && <0.17
@@ -228,6 +232,7 @@ test-suite simplex-chat-test
, exceptions ==0.10.*
, filepath ==1.4.*
, hspec ==2.7.*
, http-types ==0.12.*
, mtl ==2.2.*
, network ==3.1.*
, optparse-applicative >=0.15 && <0.17
+141 -47
View File
@@ -30,7 +30,7 @@ import Data.Char (isSpace)
import Data.Fixed (div')
import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (find)
import Data.List (find, isSuffixOf)
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
@@ -105,6 +105,12 @@ _defaultSMPServers =
_defaultNtfServers :: [NtfServer]
_defaultNtfServers = ["smp://ZH1Dkt2_EQRbxUUyjLlcUjg1KAhBrqfvE0xfn7Ki0Zg=@ntf1.simplex.im"]
maxImageSize :: Integer
maxImageSize = 236700
fixedImagePreview :: ImageData
fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="
logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
@@ -334,15 +340,16 @@ processChatCommand = \case
case (mode, msgDir, itemSharedMsgId) of
(CIDMInternal, _, _) -> do
deleteCIFile user file
toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId
toCi <- withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMInternal
pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi
(CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do
SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId)
void $ sendDirectContactMessage ct (XMsgDel itemSharedMId)
deleteCIFile user file
toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId
toCi <- withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMBroadcast
setActive $ ActiveC c
pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi
(CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete
-- TODO for group integrity and pending messages, group items and messages are set to "deleted"; maybe a different workaround is needed
CTGroup -> do
Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
@@ -365,9 +372,9 @@ processChatCommand = \case
deleteCIFile :: MsgDirectionI d => User -> Maybe (CIFile d) -> m ()
deleteCIFile user file =
forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do
cancelFiles user [(fileId, AFS msgDirection fileStatus)]
withFilesFolder $ \filesFolder ->
deleteFiles filesFolder [filePath]
let fileInfo = CIFileInfo {fileId, fileStatus = AFS msgDirection fileStatus, filePath}
cancelFile user fileInfo
withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo
APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of
CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk
CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk
@@ -378,12 +385,12 @@ processChatCommand = \case
ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId
withStore (\st -> getContactGroupNames st userId ct) >>= \case
[] -> do
files <- withStore $ \st -> getContactFiles st userId ct
filesInfo <- withStore $ \st -> getContactFileInfo st userId ct
conns <- withStore $ \st -> getContactConnections st userId ct
withChatLock . procCmd $ do
cancelFiles user (map (\(fId, fStatus, _) -> (fId, fStatus)) files)
withFilesFolder $ \filesFolder ->
deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files)
forM_ filesInfo $ \fileInfo -> do
cancelFile user fileInfo
withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo
withAgent $ \a -> forM_ conns $ \conn ->
deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure ()
withStore $ \st -> deleteContact st userId ct
@@ -397,6 +404,40 @@ processChatCommand = \case
pure $ CRContactConnectionDeleted conn
CTGroup -> pure $ chatCmdError "not implemented"
CTContactRequest -> pure $ chatCmdError "not supported"
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
CTDirect -> do
ct <- withStore $ \st -> getContact st userId chatId
ciIdsAndFileInfo <- withStore $ \st -> getContactChatItemIdsAndFileInfo st user chatId
forM_ ciIdsAndFileInfo $ \(itemId, _, fileInfo_) -> do
forM_ fileInfo_ $ \fileInfo -> do
cancelFile user fileInfo
withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo
void $ withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMInternal
ct' <- case ciIdsAndFileInfo of
[] -> pure ct
_ -> do
let (_, lastItemTs, _) = last ciIdsAndFileInfo
withStore (\st -> updateContactTs st user ct lastItemTs)
pure (ct :: Contact) {updatedAt = lastItemTs}
pure $ CRChatCleared (AChatInfo SCTDirect (DirectChat ct'))
CTGroup -> do
gInfo <- withStore $ \st -> getGroupInfo st user chatId
ciIdsAndFileInfo <- withStore $ \st -> getGroupChatItemIdsAndFileInfo st user chatId
forM_ ciIdsAndFileInfo $ \(itemId, _, itemDeleted, fileInfo_) ->
unless itemDeleted $ do
forM_ fileInfo_ $ \fileInfo -> do
cancelFile user fileInfo
withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo
void $ withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId
gInfo' <- case ciIdsAndFileInfo of
[] -> pure gInfo
_ -> do
let (_, lastItemTs, _, _) = last ciIdsAndFileInfo
withStore (\st -> updateGroupTs st user gInfo lastItemTs)
pure (gInfo :: GroupInfo) {updatedAt = lastItemTs}
pure $ CRChatCleared (AChatInfo SCTGroup (GroupChat gInfo'))
CTContactConnection -> pure $ chatCmdError "not supported"
CTContactRequest -> pure $ chatCmdError "not supported"
APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do
cReq <- withStore $ \st -> getContactRequest st userId connReqId
procCmd $ CRAcceptingContactRequest <$> acceptContactRequest user cReq
@@ -407,22 +448,25 @@ processChatCommand = \case
`E.finally` deleteContactRequest st userId connReqId
withAgent $ \a -> rejectContact a connId invId
pure $ CRContactRequestRejected cReq
APISendCallInvitation contactId callType@CallType {capabilities = CallCapabilities {encryption}} -> withUser $ \user@User {userId} -> do
APISendCallInvitation contactId callType -> withUser $ \user@User {userId} -> do
-- party initiating call
ct <- withStore $ \st -> getContact st userId contactId
calls <- asks currentCalls
withChatLock $ do
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
msg@SndMessage {msgId} <- sendDirectContactMessage ct (XCallInv callId invitation)
msg <- sendDirectContactMessage ct (XCallInv callId invitation)
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing
let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState}
call_ <- atomically $ TM.lookupInsert contactId call' calls
forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected Nothing
toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
pure CRCmdOk
SendCallInvitation cName callType -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
processChatCommand $ APISendCallInvitation contactId callType
APIRejectCall contactId ->
-- party accepting call
withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of
@@ -434,8 +478,8 @@ processChatCommand = \case
-- party accepting call
withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of
CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do
-- TODO check that call type matches peerCallType
let offer = CallOffer {callType, rtcSession, callDhPubKey = localDhPubKey}
let callDhPubKey = if encryptedCall callType then localDhPubKey else Nothing
offer = CallOffer {callType, rtcSession, callDhPubKey}
callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey}
aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0
SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer)
@@ -507,6 +551,9 @@ processChatCommand = \case
DeleteContact cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId)
ClearContact cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
processChatCommand $ APIClearChat (ChatRef CTDirect contactId)
ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user)
CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do
(connId, cReq) <- withAgent (`createConnection` SCMContact)
@@ -629,6 +676,9 @@ processChatCommand = \case
mapM_ deleteMemberConnection members
withStore $ \st -> deleteGroup st user g
pure $ CRGroupDeletedUser gInfo
ClearGroup gName -> withUser $ \user -> do
groupId <- withStore $ \st -> getGroupIdByName st user gName
processChatCommand $ APIClearChat (ChatRef CTGroup groupId)
ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName))
ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user))
SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do
@@ -644,6 +694,15 @@ processChatCommand = \case
SendFile chatName f -> withUser $ \user -> do
chatRef <- getChatRef user chatName
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCFile "")
SendImage chatName f -> withUser $ \user -> do
chatRef <- getChatRef user chatName
filePath <- toFSFilePath f
unless (".jpg" `isSuffixOf` f || ".jpeg" `isSuffixOf` f) $ throwChatError CEFileImageType {filePath}
fileSize <- getFileSize filePath
unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath}
processChatCommand . APISendMessage chatRef $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview)
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
ReceiveFile fileId filePath_ -> withUser $ \user ->
withChatLock . procCmd $ do
ft <- withStore $ \st -> getRcvFileTransfer st user fileId
@@ -754,15 +813,14 @@ processChatCommand = \case
-- perform an action only if filesFolder is set (i.e. on mobile devices)
withFilesFolder :: (FilePath -> m ()) -> m ()
withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action
deleteFiles :: FilePath -> [Maybe FilePath] -> m ()
deleteFiles filesFolder filePaths =
forM_ filePaths $ \filePath_ ->
forM_ filePath_ $ \filePath -> do
let fsFilePath = filesFolder <> "/" <> filePath
removeFile fsFilePath `E.catch` \(_ :: E.SomeException) ->
removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure ()
cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m ()
cancelFiles user files = forM_ files $ \(fileId, AFS dir status) ->
deleteFile :: FilePath -> CIFileInfo -> m ()
deleteFile filesFolder CIFileInfo {filePath} =
forM_ filePath $ \fPath -> do
let fsFilePath = filesFolder <> "/" <> fPath
removeFile fsFilePath `E.catch` \(_ :: E.SomeException) ->
removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure ()
cancelFile :: User -> CIFileInfo -> m ()
cancelFile user CIFileInfo {fileId, fileStatus = (AFS dir status)} =
unless (ciFileEnded status) $
case dir of
SMDSnd -> do
@@ -786,6 +844,14 @@ processChatCommand = \case
_ -> TM.delete ctId calls
pure CRCmdOk
| otherwise -> throwChatError $ CECallContact contactId
forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse
forwardFile chatName fileId sendCommand = withUser $ \user -> do
withStore (\st -> getFileTransfer st user fileId) >>= \case
FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath
FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath
_ -> throwChatError CEFileNotReceived {fileId}
where
forward = processChatCommand . sendCommand chatName
updateCallItemStatus :: ChatMonad m => UserId -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m ()
updateCallItemStatus userId ct Call {chatItemId} receivedStatus msgId_ = do
@@ -811,6 +877,7 @@ callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do
(Just CISCallProgress, WCSConnected) -> Nothing -- if call in-progress received connected -> no change
(Just CISCallProgress, WCSDisconnected) -> Just (CISCallEnded, callDuration) -- calculate in-progress duration
(Just CISCallProgress, WCSFailed) -> Just (CISCallEnded, callDuration) -- whether call disconnected or failed
(Just CISCallPending, WCSDisconnected) -> Just (CISCallMissed, 0)
(Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change
(Just CISCallError, _) -> Nothing
(Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0
@@ -1416,23 +1483,41 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
pure $ Just ciFile
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m ()
messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do
messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = do
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
case msgDir of
SMDRcv -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId
SMDSnd -> messageError "x.msg.update: contact attempted invalid message update"
updateRcvChatItem `catchError` \e ->
case e of
(ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do
-- This patches initial sharedMsgId into chat item when locally deleted chat item
-- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete).
-- Chat item and update message which created it will have different sharedMsgId in this case...
ci@ChatItem {formattedText} <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta (CIRcvMsgContent mc) Nothing
toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
showMsgToast (c <> "> ") mc formattedText
setActive $ ActiveC c
_ -> throwError e
where
updateRcvChatItem = do
CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
case msgDir of
SMDRcv -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId
SMDSnd -> messageError "x.msg.update: contact attempted invalid message update"
messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m ()
messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
case msgDir of
SMDRcv -> do
-- TODO allow to locally delete items that were broadcast deleted by sender
toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId
toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
deleteRcvChatItem `catchError` \e ->
case e of
(ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound ct sMsgId
_ -> throwError e
where
deleteRcvChatItem = 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
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do
@@ -1579,10 +1664,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
-- to party accepting call
xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m ()
xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg@RcvMessage {msgId} msgMeta = do
xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg msgMeta = do
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
let CallType {capabilities = CallCapabilities {encryption}} = callType
dhKeyPair <- if encryption then Just <$> liftIO C.generateKeyPair' else pure Nothing
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
ci <- saveCallItem CISCallPending
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair))
callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey}
@@ -1592,7 +1676,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
-- (and replace it in ChatController)
-- practically, this should not happen
call_ <- atomically (TM.lookupInsert contactId call' calls)
forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected Nothing
toView $ CRCallInvitation ct callType sharedKey
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
where
@@ -1606,8 +1690,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
CallInvitationSent {localCallType, localDhPrivKey} -> do
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey)
callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey}
-- TODO decide if should askConfirmation
toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation = False}
askConfirmation = encryptedCall localCallType && not (encryptedCall callType)
toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation}
pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0)
_ -> do
msgCallStateError "x.call.offer" call
@@ -1989,9 +2073,12 @@ saveSndChatItem user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem =
liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) createdAt createdAt
saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv)
saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content ciFile = do
saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} = saveRcvChatItem' user cd msg sharedMsgId_
saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv)
saveRcvChatItem' user cd msg sharedMsgId_ MsgMeta {broker = (_, brokerTs)} content ciFile = do
createdAt <- liftIO getCurrentTime
(ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt
(ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg sharedMsgId_ content brokerTs createdAt
forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId
liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt
@@ -2124,9 +2211,11 @@ chatCommandP =
<|> "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode)
<|> "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))))
<|> "/_delete " *> (APIDeleteChat <$> chatRefP)
<|> "/_clear chat " *> (APIClearChat <$> chatRefP)
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
<|> "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP)
<|> ("/call @" <|> "/call ") *> (SendCallInvitation <$> displayName <*> pure defaultCallType)
<|> "/_call reject @" *> (APIRejectCall <$> A.decimal)
<|> "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP)
<|> "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP)
@@ -2153,6 +2242,9 @@ chatCommandP =
<|> ("/remove #" <|> "/remove " <|> "/rm #" <|> "/rm ") *> (RemoveMember <$> displayName <* A.space <*> displayName)
<|> ("/leave #" <|> "/leave " <|> "/l #" <|> "/l ") *> (LeaveGroup <$> displayName)
<|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName)
<|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName)
<|> "/clear #" *> (ClearGroup <$> displayName)
<|> ("/clear @" <|> "/clear ") *> (ClearContact <$> displayName)
<|> ("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName)
<|> ("/groups" <|> "/gs") $> ListGroups
<|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString)
@@ -2160,7 +2252,6 @@ chatCommandP =
<|> ("/contacts" <|> "/cs") $> ListContacts
<|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing))
<|> ("/connect" <|> "/c") $> AddContact
<|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName)
<|> (SendMessage <$> chatNameP <* A.space <*> A.takeByteString)
<|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv)
<|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd)
@@ -2169,6 +2260,9 @@ chatCommandP =
<|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString)
<|> ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP)
<|> ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath)
<|> ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath)
<|> ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal)
<|> ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal)
<|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath))
<|> ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal)
<|> ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal)
+10 -3
View File
@@ -3,6 +3,7 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Call where
@@ -96,6 +97,12 @@ data CallType = CallType
}
deriving (Eq, Show, Generic, FromJSON)
defaultCallType :: CallType
defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True}
encryptedCall :: CallType -> Bool
encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encryption
instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions
-- | * Types for chat protocol
@@ -177,8 +184,8 @@ instance ToJSON CallExtraInfo where
toEncoding = J.genericToEncoding J.defaultOptions
data WebRTCSession = WebRTCSession
{ rtcSession :: Text,
rtcIceCandidates :: [Text]
{ rtcSession :: Text, -- LZW compressed JSON encoding of offer or answer
rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates
}
deriving (Eq, Show, Generic, FromJSON)
@@ -187,7 +194,7 @@ instance ToJSON WebRTCSession where
toEncoding = J.genericToEncoding J.defaultOptions
data WebRTCExtraInfo = WebRTCExtraInfo
{ rtcIceCandidates :: [Text]
{ rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates
}
deriving (Eq, Show, Generic, FromJSON)
+12
View File
@@ -109,9 +109,11 @@ data ChatCommand
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
| APIDeleteChat ChatRef
| APIClearChat ChatRef
| APIAcceptContact Int64
| APIRejectContact Int64
| APISendCallInvitation ContactId CallType
| SendCallInvitation ContactName CallType
| APIRejectCall ContactId
| APISendCallOffer ContactId WebRTCCallOffer
| APISendCallAnswer ContactId WebRTCSession
@@ -132,6 +134,7 @@ data ChatCommand
| Connect (Maybe AConnectionRequestUri)
| ConnectSimplex
| DeleteContact ContactName
| ClearContact ContactName
| ListContacts
| CreateMyAddress
| DeleteMyAddress
@@ -151,11 +154,15 @@ data ChatCommand
| MemberRole GroupName ContactName GroupMemberRole
| LeaveGroup GroupName
| DeleteGroup GroupName
| ClearGroup GroupName
| ListMembers GroupName
| ListGroups
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
| LastMessages (Maybe ChatName) Int
| SendFile ChatName FilePath
| SendImage ChatName FilePath
| ForwardFile ChatName FileTransferId
| ForwardImage ChatName FileTransferId
| ReceiveFile FileTransferId (Maybe FilePath)
| CancelFile FileTransferId
| FileStatus FileTransferId
@@ -179,6 +186,7 @@ data ChatResponse
| CRChatItemStatusUpdated {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem}
| CRChatItemDeleted {deletedChatItem :: AChatItem, toChatItem :: AChatItem}
| CRChatItemDeletedNotFound {contact :: Contact, sharedMsgId :: SharedMsgId}
| CRBroadcastSent MsgContent Int ZonedTime
| CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile
| CRCmdAccepted {corr :: CorrId}
@@ -205,6 +213,7 @@ data ChatResponse
| CRContactUpdated {fromContact :: Contact, toContact :: Contact}
| CRContactsMerged {intoContact :: Contact, mergedContact :: Contact}
| CRContactDeleted {contact :: Contact}
| CRChatCleared {chatInfo :: AChatInfo}
| CRUserContactLinkCreated {connReqContact :: ConnReqContact}
| CRUserContactLinkDeleted
| CRReceivedContactRequest {contactRequest :: UserContactRequest}
@@ -343,6 +352,9 @@ data ChatErrorType
| CEFileSend {fileId :: FileTransferId, agentError :: AgentErrorType}
| CEFileRcvChunk {message :: String}
| CEFileInternal {message :: String}
| CEFileImageType {filePath :: FilePath}
| CEFileImageSize {filePath :: FilePath}
| CEFileNotReceived {fileId :: FileTransferId}
| CEInvalidQuote
| CEInvalidChatItemUpdate
| CEInvalidChatItemDelete
+10 -3
View File
@@ -83,7 +83,7 @@ chatHelpInfo =
green "Create your address: " <> highlight "/address",
"",
green "Other commands:",
indent <> highlight "/help <topic> " <> " - help on: messages, files, groups, address",
indent <> highlight "/help <topic> " <> " - help on: " <> listHighlight ["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",
@@ -102,11 +102,13 @@ filesHelpInfo =
[ green "File transfer commands:",
indent <> highlight "/file @<contact> <file_path> " <> " - send file to contact",
indent <> highlight "/file #<group> <file_path> " <> " - send file to group",
indent <> highlight "/image <name> [<file_path>] " <> " - send file as image to @contact or #group",
indent <> highlight "/freceive <file_id> [<file_path>]" <> " - accept to receive file",
indent <> highlight "/fforward <name> [<file_id>] " <> " - forward received file to @contact or #group",
indent <> highlight "/fcancel <file_id> " <> " - cancel sending / receiving file",
indent <> highlight "/fstatus <file_id> " <> " - show file transfer status",
"",
"The commands may be abbreviated: " <> listHighlight ["/f", "/fr", "/fc", "/fs"]
"The commands may be abbreviated: " <> listHighlight ["/f", "/img", "/fr", "/ff", "/fc", "/fs"]
]
groupsHelpInfo :: [StyledString]
@@ -147,7 +149,12 @@ messagesHelpInfo :: [StyledString]
messagesHelpInfo =
map
styleMarkdown
[ green "Sending replies to messages",
[ green "Show recent messages",
indent <> highlight "/tail @alice [N]" <> " - the last N messages with alice (10 by default)",
indent <> highlight "/tail #team [N] " <> " - the last N messages in the group team",
indent <> highlight "/tail [N] " <> " - the last N messages in all chats",
"",
green "Sending replies to messages",
"To quote a message that starts with \"hi\":",
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",
+22
View File
@@ -60,6 +60,13 @@ data ChatInfo (c :: ChatType) where
deriving instance Show (ChatInfo c)
chatInfoUpdatedAt :: ChatInfo c -> UTCTime
chatInfoUpdatedAt = \case
DirectChat Contact {updatedAt} -> updatedAt
GroupChat GroupInfo {updatedAt} -> updatedAt
ContactRequest UserContactRequest {updatedAt} -> updatedAt
ContactConnection PendingContactConnection {updatedAt} -> updatedAt
data JSONChatInfo
= JCInfoDirect {contact :: Contact}
| JCInfoGroup {groupInfo :: GroupInfo}
@@ -82,6 +89,14 @@ jsonChatInfo = \case
ContactRequest g -> JCInfoContactRequest g
ContactConnection c -> JCInfoContactConnection c
data AChatInfo = forall c. AChatInfo (SChatType c) (ChatInfo c)
deriving instance Show AChatInfo
instance ToJSON AChatInfo where
toJSON (AChatInfo _ c) = J.toJSON c
toEncoding (AChatInfo _ c) = J.toEncoding c
data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
{ chatDir :: CIDirection c d,
meta :: CIMeta d,
@@ -363,6 +378,13 @@ instance StrEncoding ACIFileStatus where
"rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled
_ -> fail "bad file status"
-- to conveniently read file data from db
data CIFileInfo = CIFileInfo
{ fileId :: Int64,
fileStatus :: ACIFileStatus,
filePath :: Maybe FilePath
}
data CIStatus (d :: MsgDirection) where
CISSndNew :: CIStatus 'MDSnd
CISSndSent :: CIStatus 'MDSnd
+154 -77
View File
@@ -116,7 +116,11 @@ module Simplex.Chat.Store
getFileTransfer,
getFileTransferProgress,
getSndFileTransfer,
getContactFiles,
getContactFileInfo,
getContactChatItemIdsAndFileInfo,
updateContactTs,
getGroupChatItemIdsAndFileInfo,
updateGroupTs,
createNewSndMessage,
createSndMsgDelivery,
createNewMessageAndRcvMsgDelivery,
@@ -143,9 +147,8 @@ module Simplex.Chat.Store
updateDirectChatItemStatus,
updateDirectCIFileStatus,
updateDirectChatItem,
deleteDirectChatItemInternal,
deleteDirectChatItemLocal,
deleteDirectChatItemRcvBroadcast,
deleteDirectChatItemSndBroadcast,
updateGroupChatItem,
deleteGroupChatItemInternal,
deleteGroupChatItemRcvBroadcast,
@@ -335,7 +338,7 @@ getConnReqContactXContactId st userId cReqHash = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -395,7 +398,7 @@ createDirectContact st userId activeConn@Connection {connId} profile =
liftIOEither . withTransaction st $ \db -> runExceptT $ do
createdAt <- liftIO getCurrentTime
(localDisplayName, contactId, _) <- ExceptT $ createContact_ db userId connId profile Nothing createdAt
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt}
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt, updatedAt = createdAt}
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64))
createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs =
@@ -535,20 +538,20 @@ updateContact_ db userId contactId displayName newName updatedAt = do
(newName, updatedAt, userId, contactId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, UTCTime)
type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, UTCTime, UTCTime)
toContact :: ContactRow :. ConnectionRow -> Contact
toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) =
toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt, updatedAt) :. connRow) =
let profile = Profile {displayName, fullName, image}
activeConn = toConnection connRow
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt}
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact
toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) =
toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt, updatedAt) :. connRow) =
let profile = Profile {displayName, fullName, image}
in case toMaybeConnection connRow of
Just activeConn ->
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt}
_ -> Left $ SEContactNotReady localDisplayName
-- TODO return the last connection that is ready, not any last connection
@@ -724,7 +727,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -853,7 +856,7 @@ createAcceptedContact st userId agentConnId localDisplayName profileId profile x
(userId, localDisplayName, profileId, currentTs, currentTs, xContactId)
contactId <- insertedRowId db
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 currentTs
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs}
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs, updatedAt = currentTs}
getLiveSndFileTransfers :: MonadUnliftIO m => SQLiteStore -> User -> m [SndFileTransfer]
getLiveSndFileTransfers st User {userId} =
@@ -1143,16 +1146,16 @@ getConnectionEntity st User {userId, userContactId} agentConnId =
<$> DB.query
db
[sql|
SELECT c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.created_at
SELECT c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.created_at, c.updated_at
FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
WHERE c.user_id = ? AND c.contact_id = ?
|]
(userId, contactId)
toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime)] -> Either StoreError Contact
toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt)] =
toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime, UTCTime)] -> Either StoreError Contact
toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt, updatedAt)] =
let profile = Profile {displayName, fullName, image}
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember_ db groupMemberId c = ExceptT $ do
@@ -1162,7 +1165,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
@@ -1253,7 +1256,7 @@ getGroupAndMember st User {userId, userContactId} groupMemberId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
@@ -1314,7 +1317,7 @@ createNewGroup st gVar user groupProfile =
groupId <- insertedRowId db
memberId <- encodedRandomBytes gVar 12
membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs
pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs}
pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs, updatedAt = currentTs}
-- | creates a new group record for the group the current user was invited to, or returns an existing one
createGroupInvitation ::
@@ -1347,7 +1350,7 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv
groupId <- insertedRowId db
_ <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown currentTs
membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) currentTs
pure $ GroupInfo {groupId, localDisplayName, groupProfile, membership, createdAt = currentTs}
pure $ GroupInfo {groupId, localDisplayName, groupProfile, membership, createdAt = currentTs, updatedAt = currentTs}
-- TODO return the last connection that is ready, not any last connection
-- requires updating connection status
@@ -1399,7 +1402,7 @@ getUserGroupDetails st User {userId, userContactId} =
<$> DB.query
db
[sql|
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at,
m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image
FROM groups g
@@ -1416,12 +1419,12 @@ getGroupInfoByName st user gName =
gId <- ExceptT $ getGroupIdByName_ db user gName
ExceptT $ getGroupInfo_ db user gId
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, UTCTime) :. GroupMemberRow
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, UTCTime, UTCTime) :. GroupMemberRow
toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt) :. userMemberRow) =
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt, updatedAt) :. userMemberRow) =
let membership = toGroupMember userContactId userMemberRow
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt}
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt, updatedAt}
getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember]
getGroupMembers st user gInfo = liftIO . withTransaction st $ \db -> getGroupMembers_ db user gInfo
@@ -1796,7 +1799,7 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
@@ -1838,7 +1841,7 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
db
[sql|
SELECT
ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at,
ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at, ct.updated_at,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM contacts ct
@@ -1854,11 +1857,11 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
|]
(userId, groupMemberId)
where
toContact' :: [(Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact
toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt) :. connRow] =
toContact' :: [(Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime, UTCTime) :. ConnectionRow] -> Maybe Contact
toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt, updatedAt) :. connRow] =
let profile = Profile {displayName, fullName, image}
activeConn = toConnection connRow
in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt}
toContact' _ = Nothing
createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m Int64
@@ -2280,18 +2283,80 @@ getFileTransferMeta_ db userId fileId =
fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) =
FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_}
getContactFiles :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [(Int64, ACIFileStatus, Maybe FilePath)]
getContactFiles st userId Contact {contactId} =
getContactFileInfo :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [CIFileInfo]
getContactFileInfo st userId Contact {contactId} =
liftIO . withTransaction st $ \db ->
DB.query
db
[sql|
map toFileInfo
<$> DB.query
db
[sql|
SELECT f.file_id, f.ci_file_status, f.file_path
FROM chat_items i
JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.contact_id = ?
|]
(userId, contactId)
(userId, contactId)
toFileInfo :: (Int64, ACIFileStatus, Maybe FilePath) -> CIFileInfo
toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath}
getContactChatItemIdsAndFileInfo :: MonadUnliftIO m => SQLiteStore -> User -> ContactId -> m [(ChatItemId, UTCTime, Maybe CIFileInfo)]
getContactChatItemIdsAndFileInfo st User {userId} contactId =
liftIO . withTransaction st $ \db ->
map toItemIdAndFileInfo
<$> DB.query
db
[sql|
SELECT i.chat_item_id, i.item_ts, f.file_id, f.ci_file_status, f.file_path
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.contact_id = ?
ORDER BY i.item_ts ASC
|]
(userId, contactId)
toItemIdAndFileInfo :: (ChatItemId, UTCTime, Maybe Int64, Maybe ACIFileStatus, Maybe FilePath) -> (ChatItemId, UTCTime, Maybe CIFileInfo)
toItemIdAndFileInfo (chatItemId, itemTs, fileId_, fileStatus_, filePath) =
case (fileId_, fileStatus_) of
(Just fileId, Just fileStatus) -> (chatItemId, itemTs, Just CIFileInfo {fileId, fileStatus, filePath})
_ -> (chatItemId, itemTs, Nothing)
updateContactTs :: MonadUnliftIO m => SQLiteStore -> User -> Contact -> UTCTime -> m ()
updateContactTs st User {userId} Contact {contactId} updatedAt =
liftIO . withTransaction st $ \db ->
DB.execute
db
"UPDATE contacts SET updated_at = ? WHERE user_id = ? AND contact_id = ?"
(updatedAt, userId, contactId)
getGroupChatItemIdsAndFileInfo :: MonadUnliftIO m => SQLiteStore -> User -> Int64 -> m [(ChatItemId, UTCTime, Bool, Maybe CIFileInfo)]
getGroupChatItemIdsAndFileInfo st User {userId} groupId =
liftIO . withTransaction st $ \db ->
map toItemIdDeletedAndFileInfo
<$> DB.query
db
[sql|
SELECT i.chat_item_id, i.item_ts, i.item_deleted, f.file_id, f.ci_file_status, f.file_path
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.group_id = ?
ORDER BY i.item_ts ASC
|]
(userId, groupId)
toItemIdDeletedAndFileInfo :: (ChatItemId, UTCTime, Bool, Maybe Int64, Maybe ACIFileStatus, Maybe FilePath) -> (ChatItemId, UTCTime, Bool, Maybe CIFileInfo)
toItemIdDeletedAndFileInfo (chatItemId, itemTs, itemDeleted, fileId_, fileStatus_, filePath) =
case (fileId_, fileStatus_) of
(Just fileId, Just fileStatus) -> (chatItemId, itemTs, itemDeleted, Just CIFileInfo {fileId, fileStatus, filePath})
_ -> (chatItemId, itemTs, itemDeleted, Nothing)
updateGroupTs :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> UTCTime -> m ()
updateGroupTs st User {userId} GroupInfo {groupId} updatedAt =
liftIO . withTransaction st $ \db ->
DB.execute
db
"UPDATE groups SET updated_at = ? WHERE user_id = ? AND group_id = ?"
(updatedAt, userId, groupId)
createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage
createNewSndMessage st gVar connOrGroupId mkMessage =
@@ -2449,8 +2514,8 @@ createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciCon
CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId)
CIQGroupRcv Nothing -> (Just False, Nothing)
createNewRcvChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDRcv -> RcvMessage -> CIContent 'MDRcv -> UTCTime -> UTCTime -> m (ChatItemId, Maybe (CIQuote c))
createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent, sharedMsgId_} ciContent itemTs createdAt =
createNewRcvChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> UTCTime -> UTCTime -> m (ChatItemId, Maybe (CIQuote c))
createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent itemTs createdAt =
liftIO . withTransaction st $ \db -> do
ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemTs createdAt
quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg
@@ -2572,12 +2637,8 @@ getChatPreviews st user withPCC =
pure $ sortOn (Down . ts) (directChats <> groupChats <> cReqChats <> connChats)
where
ts :: AChat -> UTCTime
ts (AChat _ Chat {chatItems = ci : _}) = chatItemTs ci
ts (AChat _ Chat {chatInfo}) = case chatInfo of
DirectChat Contact {createdAt} -> createdAt
GroupChat GroupInfo {createdAt} -> createdAt
ContactRequest UserContactRequest {updatedAt} -> updatedAt
ContactConnection PendingContactConnection {updatedAt} -> updatedAt
ts (AChat _ Chat {chatInfo, chatItems = ci : _}) = max (chatItemTs ci) (chatInfoUpdatedAt chatInfo)
ts (AChat _ Chat {chatInfo}) = chatInfoUpdatedAt chatInfo
chatItemTs :: CChatItem d -> UTCTime
chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs
@@ -2592,7 +2653,7 @@ getDirectChatPreviews_ db User {userId} = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at,
@@ -2619,7 +2680,7 @@ getDirectChatPreviews_ db User {userId} = do
LEFT JOIN (
SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread
FROM chat_items
WHERE item_status = ?
WHERE item_status = ? AND item_deleted != 1
GROUP BY contact_id
) ChatStats ON ChatStats.contact_id = ct.contact_id
LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id
@@ -2656,7 +2717,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
@@ -2693,7 +2754,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
LEFT JOIN (
SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread
FROM chat_items
WHERE item_status = ?
WHERE item_status = ? AND item_deleted != 1
GROUP BY group_id
) ChatStats ON ChatStats.group_id = g.group_id
LEFT JOIN group_members m ON m.group_member_id = i.group_member_id
@@ -2934,7 +2995,7 @@ getContact_ db userId contactId =
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -3124,7 +3185,7 @@ getGroupInfo_ db User {userId, userContactId} groupId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
@@ -3229,13 +3290,39 @@ updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExcep
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
deleteDirectChatItemInternal :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> m AChatItem
deleteDirectChatItemInternal st userId ct itemId =
deleteDirectChatItemLocal :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> m AChatItem
deleteDirectChatItemLocal st userId ct itemId mode =
liftIOEither . withTransaction st $ \db -> do
currentTs <- liftIO getCurrentTime
ci <- deleteDirectChatItem_ db userId ct itemId CIDMInternal True currentTs
setChatItemMessagesDeleted_ db itemId
pure ci
deleteChatItemMessages_ db itemId
deleteDirectChatItem_ db userId ct itemId mode
deleteDirectChatItem_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> IO (Either StoreError AChatItem)
deleteDirectChatItem_ db userId ct@Contact {contactId} itemId mode = runExceptT $ do
(CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId
let toContent = msgDirToDeletedContent_ msgDir mode
liftIO $ do
DB.execute
db
[sql|
DELETE FROM chat_items
WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
|]
(userId, contactId, itemId)
pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {content = toContent, meta = (meta ci) {itemText = ciDeleteModeToText mode, itemDeleted = True}, formattedText = Nothing})
deleteChatItemMessages_ :: DB.Connection -> ChatItemId -> IO ()
deleteChatItemMessages_ db itemId =
DB.execute
db
[sql|
DELETE FROM messages
WHERE message_id IN (
SELECT message_id
FROM chat_item_messages
WHERE chat_item_id = ?
)
|]
(Only itemId)
setChatItemMessagesDeleted_ :: DB.Connection -> ChatItemId -> IO ()
setChatItemMessagesDeleted_ db itemId =
@@ -3256,38 +3343,26 @@ setChatItemMessagesDeleted_ db itemId =
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
currentTs <- liftIO getCurrentTime
insertChatItemMessage_ db itemId msgId currentTs
updateDirectChatItemRcvDeleted_ db userId ct itemId currentTs
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
updateDirectChatItemRcvDeleted_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> UTCTime -> IO (Either StoreError AChatItem)
updateDirectChatItemRcvDeleted_ db userId ct@Contact {contactId} itemId currentTs = runExceptT $ do
(CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId
let toContent = msgDirToDeletedContent_ msgDir mode
let toContent = msgDirToDeletedContent_ msgDir CIDMBroadcast
toText = ciDeleteModeToText CIDMBroadcast
liftIO $ do
DB.execute
db
[sql|
UPDATE chat_items
SET item_content = ?, item_text = ?, item_deleted = ?, updated_at = ?
SET item_content = ?, item_text = ?, 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
(toContent, toText, currentTs, userId, contactId, itemId)
pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {content = toContent, meta = (meta ci) {itemText = toText}, formattedText = Nothing})
deleteQuote_ :: DB.Connection -> ChatItemId -> IO ()
deleteQuote_ db itemId =
@@ -3399,6 +3474,7 @@ deleteGroupChatItemInternal st user gInfo itemId =
currentTs <- liftIO getCurrentTime
ci <- deleteGroupChatItem_ db user gInfo itemId CIDMInternal True currentTs
setChatItemMessagesDeleted_ db itemId
DB.execute db "DELETE FROM files WHERE chat_item_id = ?" (Only itemId)
pure ci
deleteGroupChatItemRcvBroadcast :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> ChatItemId -> MessageId -> m AChatItem
@@ -3410,6 +3486,7 @@ deleteGroupChatItemSndBroadcast st user gInfo itemId msgId =
liftIOEither . withTransaction st $ \db -> do
ci <- deleteGroupChatItemBroadcast_ db user gInfo itemId True msgId
setChatItemMessagesDeleted_ db itemId
DB.execute db "DELETE FROM files WHERE chat_item_id = ?" (Only itemId)
pure ci
deleteGroupChatItemBroadcast_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Bool -> MessageId -> IO (Either StoreError AChatItem)
+4 -2
View File
@@ -72,7 +72,8 @@ data Contact = Contact
profile :: Profile,
activeConn :: Connection,
viaGroup :: Maybe Int64,
createdAt :: UTCTime
createdAt :: UTCTime,
updatedAt :: UTCTime
}
deriving (Eq, Show, Generic)
@@ -171,7 +172,8 @@ data GroupInfo = GroupInfo
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
membership :: GroupMember,
createdAt :: UTCTime
createdAt :: UTCTime,
updatedAt :: UTCTime
}
deriving (Eq, Show, Generic)
+90 -7
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
@@ -10,8 +11,10 @@
module Simplex.Chat.View where
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
@@ -21,7 +24,11 @@ import qualified Data.Text as T
import Data.Time.Clock (DiffTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (ZonedTime (..), localDay, localTimeOfDay, timeOfDayToTime, utcToZonedTime)
import GHC.Generics (Generic)
import qualified Network.HTTP.Types as Q
import Numeric (showFFloat)
import Simplex.Chat (maxImageSize)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Help
import Simplex.Chat.Markdown
@@ -31,8 +38,10 @@ import Simplex.Chat.Store (StoreError (..))
import Simplex.Chat.Styled
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Util (bshow)
import System.Console.ANSI.Types
@@ -54,6 +63,7 @@ responseToView testView = \case
CRChatItemStatusUpdated _ -> []
CRChatItemUpdated (AChatItem _ _ chat item) -> viewItemUpdate chat item
CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> viewItemDelete chat deletedItem toItem
CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"]
CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
CRCmdAccepted _ -> []
@@ -83,6 +93,7 @@ responseToView testView = \case
CRSentConfirmation -> ["confirmation sent!"]
CRSentInvitation -> ["connection request sent!"]
CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"]
CRChatCleared chatInfo -> viewChatCleared chatInfo
CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."]
CRContactAlreadyExists c -> [ttyFullContact c <> ": contact already exists"]
CRContactRequestAlreadyAccepted c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"]
@@ -139,9 +150,9 @@ responseToView testView = \case
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e ->
["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRCallInvitation {contact} -> ["call invitation from " <> ttyContact' contact]
CRCallOffer {contact} -> ["call offer from " <> ttyContact' contact]
CRCallAnswer {contact} -> ["call answer from " <> ttyContact' contact]
CRCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey
CRCallOffer {contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey
CRCallAnswer {contact, answer} -> viewCallAnswer contact answer
CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact]
CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"]
CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"]
@@ -254,14 +265,12 @@ viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem
(CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of
CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc meta
CIDMInternal -> ["message deleted"]
(CIDirectSnd, _, _) -> ["message deleted"]
_ -> []
_ -> ["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) [] mc meta
CIDMInternal -> ["message deleted"]
(CIGroupSnd, _, _) -> ["message deleted"]
_ -> []
_ -> ["message deleted"]
_ -> []
directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString]
@@ -315,6 +324,12 @@ viewConnReqInvitation cReq =
"and ask them to connect: " <> highlight' "/c <invitation_link_above>"
]
viewChatCleared :: AChatInfo -> [StyledString]
viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"]
GroupChat gi -> [ttyGroup' gi <> ": all messages are removed locally ONLY"]
_ -> []
viewContactsList :: [Contact] -> [StyledString]
viewContactsList =
let ldn = T.toLower . (localDisplayName :: Contact -> ContactName)
@@ -630,6 +645,71 @@ fileProgress :: [Integer] -> Integer -> Integer -> StyledString
fileProgress chunksNum chunkSize fileSize =
sShow (sum chunksNum * chunkSize * 100 `div` fileSize) <> "% of " <> humanReadableSize fileSize
viewCallInvitation :: Contact -> CallType -> Maybe C.Key -> [StyledString]
viewCallInvitation ct@Contact {contactId} callType@CallType {media} sharedKey =
[ ttyContact' ct <> " wants to connect with you via WebRTC " <> callMediaStr callType <> " call " <> encryptedCallText callType,
"To accept the call, please open the link below in your browser" <> supporedBrowsers callType,
"",
"https://simplex.chat/call#" <> plain queryString
]
where
aesKey = B.unpack . strEncode . C.unKey <$> sharedKey
queryString =
Q.renderSimpleQuery
False
[ ("command", LB.toStrict . J.encode $ WCCallStart {media, aesKey, useWorker = True}),
("contact_id", B.pack $ show contactId)
]
viewCallOffer :: Contact -> CallType -> WebRTCSession -> Maybe C.Key -> [StyledString]
viewCallOffer ct@Contact {contactId} callType@CallType {media} WebRTCSession {rtcSession = offer, rtcIceCandidates = iceCandidates} sharedKey =
[ ttyContact' ct <> " accepted your WebRTC " <> callMediaStr callType <> " call " <> encryptedCallText callType,
"To connect, please open the link below in your browser" <> supporedBrowsers callType,
"",
"https://simplex.chat/call#" <> plain queryString
]
where
aesKey = B.unpack . strEncode . C.unKey <$> sharedKey
queryString =
Q.renderSimpleQuery
False
[ ("command", LB.toStrict . J.encode $ WCCallOffer {offer, iceCandidates, media, aesKey, useWorker = True}),
("contact_id", B.pack $ show contactId)
]
viewCallAnswer :: Contact -> WebRTCSession -> [StyledString]
viewCallAnswer ct WebRTCSession {rtcSession = answer, rtcIceCandidates = iceCandidates} =
[ ttyContact' ct <> " continued the WebRTC call",
"To connect, please paste the data below in your browser window you opened earlier and click Connect button",
"",
plain . LB.toStrict . J.encode $ WCCallAnswer {answer, iceCandidates}
]
callMediaStr :: CallType -> StyledString
callMediaStr CallType {media} = case media of
CMVideo -> "video"
CMAudio -> "audio"
encryptedCallText :: CallType -> StyledString
encryptedCallText callType
| encryptedCall callType = "(e2e encrypted)"
| otherwise = "(not e2e encrypted)"
supporedBrowsers :: CallType -> StyledString
supporedBrowsers callType
| encryptedCall callType = " (only Chrome and Safari support e2e encryption for WebRTC, Safari may require enabling WebRTC insertable streams)"
| otherwise = ""
data WCallCommand
= WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool}
| WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool}
| WCCallAnswer {answer :: Text, iceCandidates :: Text}
deriving (Generic)
instance ToJSON WCallCommand where
toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "WCCall"
toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "WCCall"
viewChatError :: ChatError -> [StyledString]
viewChatError = \case
ChatError err -> case err of
@@ -660,6 +740,9 @@ viewChatError = \case
CEFileSend fileId e -> ["error sending file " <> sShow fileId <> ": " <> sShow e]
CEFileRcvChunk e -> ["error receiving file: " <> plain e]
CEFileInternal e -> ["file error: " <> plain e]
CEFileImageType _ -> ["max image size: " <> sShow maxImageSize <> " bytes, resize it or send as a file using " <> highlight' "/f"]
CEFileImageSize _ -> ["image type must be JPG, send as a file using " <> highlight' "/f"]
CEFileNotReceived fileId -> ["file " <> sShow fileId <> " not received"]
CEInvalidQuote -> ["cannot reply to this message"]
CEInvalidChatItemUpdate -> ["cannot update this item"]
CEInvalidChatItemDelete -> ["cannot delete this item"]
+59 -23
View File
@@ -9,6 +9,7 @@ import ChatClient
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM
import Control.Monad (forM_)
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
@@ -130,6 +131,11 @@ testAddContact =
alice <## "no contact bob_1"
alice @@@ [("@bob", "hi")]
bob @@@ [("@alice_1", "hi"), ("@alice", "hi")]
-- test clearing chat
alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY")
alice #$> ("/_get chat @2 count=100", chat, [])
bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY")
bob #$> ("/_get chat @2 count=100", chat, [])
where
chatsEmpty alice bob = do
alice @@@ [("@bob", "")]
@@ -241,49 +247,66 @@ testDirectMessageDelete =
\alice bob -> do
connectUsers alice bob
-- msg id 1
-- alice, bob: msg id 1
alice #> "@bob hello 🙂"
bob <# "alice> hello 🙂"
-- msg id 2
bob `send` "> @alice (hello) hey alic"
-- alice, bob: msg id 2
bob `send` "> @alice (hello 🙂) hey alic"
bob <# "@alice > hello 🙂"
bob <## " hey alic"
alice <# "bob> > hello 🙂"
alice <## " hey alic"
-- alice: deletes msg ids 1,2
alice #$> ("/_delete item @2 1 internal", id, "message deleted")
alice #$> ("/_delete item @2 2 internal", id, "message deleted")
alice @@@ [("@bob", "")]
alice #$> ("/_get chat @2 count=100", chat, [])
alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item")
alice #$> ("/_send @2 json {\"quotedItemId\": 1, \"msgContent\": {\"type\": \"text\", \"text\": \"quoting deleted message\"}}", id, "cannot reply to this message")
-- alice: msg id 1
bob #$> ("/_update item @2 2 text hey alice", id, "message updated")
alice <# "bob> [edited] hey alice"
alice @@@ [("@bob", "hey alice")]
alice #$> ("/_get chat @2 count=100", chat, [(0, "hey alice")])
-- msg id 3
-- bob: deletes msg id 2
bob #$> ("/_delete item @2 2 broadcast", id, "message deleted")
alice <# "bob> [deleted] hey alice"
alice @@@ [("@bob", "this item is deleted (broadcast)")]
alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")])
-- alice: deletes msg id 1 that was broadcast deleted by bob
alice #$> ("/_delete item @2 1 internal", id, "message deleted")
alice @@@ [("@bob", "")]
alice #$> ("/_get chat @2 count=100", chat, [])
-- alice: msg id 1, bob: msg id 2 (quoting message alice deleted locally)
bob `send` "> @alice (hello 🙂) do you receive my messages?"
bob <# "@alice > hello 🙂"
bob <## " do you receive my messages?"
alice <# "bob> > hello 🙂"
alice <## " do you receive my messages?"
alice @@@ [("@bob", "do you receive my messages?")]
alice #$> ("/_get chat @2 count=100", chat', [((0, "do you receive my messages?"), Just (1, "hello 🙂"))])
alice #$> ("/_delete item @2 1 broadcast", id, "cannot delete this item")
-- alice: msg id 2, bob: 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: deletes msg id 2
alice #$> ("/_delete item @2 2 internal", id, "message deleted")
alice @@@ [("@bob", "this item is deleted (broadcast)")]
alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")])
bob @@@ [("@alice", "hey alice")]
bob #$> ("/_get chat @2 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hey alice"), (Just (0, "hello 🙂")))])
-- bob: deletes msg id 3 (that alice deleted locally)
bob #$> ("/_delete item @2 3 broadcast", id, "message deleted")
alice <## "bob> [deleted - original message not found]"
alice @@@ [("@bob", "do you receive my messages?")]
alice #$> ("/_get chat @2 count=100", chat', [((0, "do you receive my messages?"), Just (1, "hello 🙂"))])
bob @@@ [("@alice", "do you receive my messages?")]
bob #$> ("/_get chat @2 count=100", chat', [((0, "hello 🙂"), Nothing), ((1, "do you receive my messages?"), Just (0, "hello 🙂"))])
testGroup :: IO ()
testGroup =
@@ -372,6 +395,13 @@ testGroup =
cath ##> "#team hello"
cath <## "you are no longer a member of the group"
bob <##> cath
-- test clearing chat
alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
alice #$> ("/_get chat #1 count=100", chat, [])
bob #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
bob #$> ("/_get chat #1 count=100", chat, [])
cath #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
cath #$> ("/_get chat #1 count=100", chat, [])
where
getReadChats alice bob cath = do
alice @@@ [("#team", "hey team"), ("@cath", ""), ("@bob", "")]
@@ -1870,7 +1900,7 @@ testWebRTCSession :: WebRTCSession
testWebRTCSession =
WebRTCSession
{ rtcSession = "{}",
rtcIceCandidates = [""]
rtcIceCandidates = "[]"
}
testWebRTCCallOffer :: WebRTCCallOffer
@@ -1883,6 +1913,9 @@ testWebRTCCallOffer =
serialize :: ToJSON a => a -> String
serialize = B.unpack . LB.toStrict . J.encode
repeatM_ :: Int -> IO a -> IO ()
repeatM_ n a = forM_ [1 .. n] $ const a
testNegotiateCall :: IO ()
testNegotiateCall =
testChat2 aliceProfile bobProfile $ \alice bob -> do
@@ -1891,13 +1924,15 @@ testNegotiateCall =
alice ##> ("/_call invite @2 " <> serialize testCallType)
alice <## "ok"
alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: calling...")])
bob <## "call invitation from alice"
bob <## "alice wants to connect with you via WebRTC video call (e2e encrypted)"
repeatM_ 3 $ getTermLine bob
bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: calling...")])
-- bob accepts call by sending WebRTC offer
bob ##> ("/_call offer @2 " <> serialize testWebRTCCallOffer)
bob <## "ok"
bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: accepted")])
alice <## "call offer from bob"
alice <## "bob accepted your WebRTC video call (e2e encrypted)"
repeatM_ 3 $ getTermLine alice
alice <## "message updated" -- call chat item updated
alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: accepted")])
-- alice confirms call by sending WebRTC answer
@@ -1907,7 +1942,8 @@ testNegotiateCall =
"message updated"
]
alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: connecting...")])
bob <## "call answer from alice"
bob <## "alice continued the WebRTC call"
repeatM_ 3 $ getTermLine bob
bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: connecting...")])
-- participants can update calls as connected
alice ##> "/_call status @2 connected"