mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Merge pull request #685 from simplex-chat/master (version 2.1.0)
This commit is contained in:
Generated
-1
@@ -13,7 +13,6 @@
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+259
-105
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
// }
|
||||
//}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
Binary file not shown.
@@ -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>
|
||||
|
||||
BIN
Binary file not shown.
@@ -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 %@" = "Вы можете отправлять сообщения %@";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
> **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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
### Access chat history
|
||||
|
||||
|
||||
+2
-1
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -21,6 +21,6 @@ body {
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -16,6 +16,7 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "ES2018"
|
||||
"target": "ES2018",
|
||||
"types": ["lz-string"]
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user