Compare commits

...

58 Commits

Author SHA1 Message Date
JRoberts a20f0050b9 4.2.0 2022-10-26 11:36:06 +04:00
JRoberts e17e9c641d android: version 4.2-beta.0 (64) 2022-10-26 11:33:51 +04:00
JRoberts 2ac6866638 ios: version 4.2 (86) 2022-10-26 11:13:08 +04:00
JRoberts d7f319aa9e core: mark group contacts as used on send, receive, api (#1253) 2022-10-25 12:50:26 +04:00
Evgeny Poberezkin 1e10b0a49c website: test pages for hero animations (#1255) 2022-10-24 21:00:35 +01:00
JRoberts e9c321eaad android: mark untranslated texts 2022-10-24 20:48:23 +04:00
Stanislav Dmitrenko c58d069fbd android: Sender name in chatlist (#1251)
* android: Sender name in chatlist

* Sender is bold

* RTL for sender text
2022-10-24 16:47:03 +01:00
Stanislav Dmitrenko 15c8945fd3 android: Settings to auto-accept contact requests (#1250)
* android: Settings to auto-accept contact requests

* Link will not be recreated when not needed

* Layout

* Layout

* Button rename

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-24 16:45:12 +01:00
JRoberts 2109dca1c7 android: allow ContactConnected response to have omitted field (#1252) 2022-10-24 17:40:20 +04:00
JRoberts 7ef7d08c00 mobile: use viaGroupLink to avoid adding missing chats on subscriptions (#1249) 2022-10-24 16:22:00 +04:00
Evgeny Poberezkin e463e19114 rfc: chat settings (#1096)
* rfc: chat settings

* update doc

* update doc

* update doc

* comment in JSON
2022-10-24 12:45:33 +01:00
JRoberts deeb2e891a core: add viaGroupLink to Connection (#1248) 2022-10-24 14:28:58 +04:00
Evgeny Poberezkin 5265667c0c ios: settings to auto-accept contact requests (#1246)
* ios: settings to auto-accept contact requests

* use NavigationView

* fix share sheet, layout

* move buttons
2022-10-24 11:25:36 +01:00
JRoberts 15c1f9f9c8 core: group link contact connecting(ed) events to avoid adding previews in ui (#1242)
* core: group link contact connecting(ed) events to avoid adding ui previews

* fix test

* refactor

* ios types

* android types

* type in bot
2022-10-23 21:18:15 +01:00
Evgeny Poberezkin 341199d599 mobile: update type for UserContactLink changes, add addressAutoAccept API (#1245) 2022-10-23 11:16:56 +01:00
Evgeny Poberezkin dfb6dafd6f core: record constructors for UserContactLink (#1244) 2022-10-22 22:33:59 +01:00
Evgeny Poberezkin 7f544da6cf core: debug chat and agent locks, update simplexmq (#1243)
* core: debug chat and agent locks, update simplexmq

* add connId

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update lock strings

* fix encoding test

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-22 21:22:44 +01:00
JRoberts d0a0a0461f mobile: change role item texts, uncomment button (#1241)
* mobile: change role item texts, uncomment button

* Update apps/android/app/src/main/res/values-de/strings.xml

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-22 18:40:21 +04:00
JRoberts 1dca577ca8 mobile: invited via group link chat item (#1239) 2022-10-22 14:23:23 +04:00
JRoberts 26984b62fe core: delete broken chat item when removing invited member connected via group link; test removing invited member 2022-10-22 14:23:03 +04:00
Stanislav Dmitrenko 1470b8d128 core: auto accept via address and incognito mode specified (#1233)
* core: Auto accept via address and incognito mode specified

* Fix test

* Refactoring

* No forcing

* Apply suggestions from code review

* refactor

* refactor AutoAccept

* Test

* Test

* allow different test output order

* rename

* rename

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-21 17:14:12 +01:00
JRoberts 5bcb725ea5 core: exclude contacts accepted via group link from chat previews (#1234)
* RGEInvitedViaGroupLink

* CRSentGroupInvitationViaLink

* via_group_link filtering

* reset

* refactor

* remove brackets
2022-10-21 17:35:07 +04:00
Stanislav Dmitrenko 7f9c4ede02 android: Mark chat unread (#1235)
* android: Mark chat unread

* Fix

* Fix2

* Fix3

* Refactoring

* Icon

* update icon

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-21 13:29:08 +01:00
Evgeny Poberezkin 34a74da0b9 ios: mark chat unread (#1237)
* ios: mark unread (wip)

* mark unread works
2022-10-21 12:32:11 +01:00
JRoberts 98cb1c39f2 core: allow to delete contacts that are in groups; group contacts management rfc (#1229) 2022-10-20 19:27:00 +04:00
Evgeny Poberezkin c4fc8a97b1 core: option to receive file inline up to maximum "offered" size (#1232)
* core: option to receive file inline up to maximum "offered" size

* comment
2022-10-20 14:32:20 +01:00
Stanislav Dmitrenko 9edb54b45c android: Unencrypted all fix (#1231) 2022-10-20 10:16:15 +01:00
Stanislav Dmitrenko 213b586f8f core: Forcing chat unread (#1228)
* core: Forcing chat unread

* Implementation

* Renaming

* Removed unused code

* test

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-19 19:38:44 +01:00
Stanislav Dmitrenko e0582d656e android: Different width for image in a message based on orientation (#1227)
* android: Different width for image in a message based on orientation

* Remember

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-18 19:49:27 +01:00
JRoberts 65f8ad4423 Merge branch 'stable' 2022-10-18 18:18:36 +04:00
JRoberts b115bda8f5 4.1.1 2022-10-18 18:06:47 +04:00
JRoberts 85ab37ae5d android: version 4.1 (63) 2022-10-18 18:05:08 +04:00
JRoberts dc5f6673a2 ios: version 4.1 (85) 2022-10-18 17:54:37 +04:00
Evgeny Poberezkin ada4c1d2ec Merge branch 'stable' 2022-10-18 13:11:56 +01:00
Stanislav Dmitrenko 1f8bfbe3f5 android: Fixed conflics (#1225)
* ios: use transparent background for images without text and quote (#1224)

* android: Some small fixes (#1221)

* android: Some small fixes

* Alpha in preview image

* ImageView width limitation for portrait images

* Sharing files with a text

* Do not create new link on orientation change

* Skipping quoted item when applying transparent background

* Commented out sharing a text of image message

* Revert "ImageView width limitation for portrait images"

This reverts commit b1f20b51da.

* White color

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-18 13:01:37 +01:00
Stanislav Dmitrenko cf67c951fc android: Scrolling state will be preserved (#1222)
* android: Scrolling state will be preserved

* Auth changes in terminal view

* Changes
2022-10-18 11:05:31 +01:00
Stanislav Dmitrenko b78a5931e9 android: Some small fixes (#1221)
* android: Some small fixes

* Alpha in preview image

* ImageView width limitation for portrait images

* Sharing files with a text

* Do not create new link on orientation change

* Skipping quoted item when applying transparent background

* Commented out sharing a text of image message

* Revert "ImageView width limitation for portrait images"

This reverts commit b1f20b51da.

* White color
2022-10-18 10:45:49 +01:00
JRoberts e57b9f4cea core: don't calculate chat stats to speed up loading individual chats (#1218) 2022-10-18 10:16:28 +01:00
Evgeny Poberezkin 104e1040bf ios: use transparent background for images without text and quote (#1224) 2022-10-18 10:07:45 +01:00
JRoberts 2c347cb7b9 core: CRSubscriptionEnd (#1223)
* core: CRSubscriptionEnd

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-17 21:35:29 +01:00
Stanislav Dmitrenko fad60a1d24 android: EditText instead of BasicTextView with ability to get images… (#1213)
* android: EditText instead of BasicTextView with ability to get images from keyboard

* Padding

* Empty, not blank

* Keyboard handling

* Setting text of edited message and bigger padding
2022-10-17 21:25:42 +01:00
Stanislav Dmitrenko 61ed866c24 android: Better way of saving even big files (#1212) 2022-10-16 13:26:10 +01:00
Stanislav Dmitrenko 656c10976b android: Fix autoscroll to bottom when going from group to direct (#1209)
* android: Fix autoscroll to bottom when going from group to direct
- and makes scrolling smoother because of -1 number of recompose

* Rename
2022-10-16 12:41:01 +01:00
Evgeny Poberezkin 63f40f030a android: change indigo color to improve dark mode readability (#1219) 2022-10-15 21:55:37 +01:00
Evgeny Poberezkin dec8e136f9 android: increase paddings in link views (#1217)
* android: increase paddings in link views

* increase padding on scan screen

* Update apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* Update apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-15 18:49:10 +01:00
JRoberts a525b4e5db ios: group links (#1214)
* libraries

* api

* ui

* via nav link

* translations

* align android translations
2022-10-15 18:09:25 +04:00
JRoberts 2f8b4a3e93 Merge branch 'stable' 2022-10-15 16:15:07 +04:00
JRoberts 39818b6fbb ios: version 4.1 (84) 2022-10-15 16:14:32 +04:00
JRoberts 3523c7ebd7 ios: fix ContactConnectionInfo share sheet (#1215) 2022-10-15 15:47:04 +04:00
JRoberts 1b3984f52f core: create group invitation connection asynchronously on group link auto-accept (#1211) 2022-10-15 14:48:07 +04:00
Stanislav Dmitrenko 560807b4b7 android: Group links (#1210)
* android: Group links

* Alerts with network errors

* Alert for get link too

* Layout changes

* Divider

* Revert "Alert for get link too"

This reverts commit b0eaf50cdc.

* Update apps/android/app/src/main/res/values-de/strings.xml

* Update apps/android/app/src/main/res/values/strings.xml

* Update apps/android/app/src/main/res/values/strings.xml

* Update apps/android/app/src/main/res/values-ru/strings.xml

* Update apps/android/app/src/main/res/values-ru/strings.xml

* Update apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt

* apiDeleteGroupLink call

* names

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-14 19:30:09 +04:00
Evgeny Poberezkin fb03a119ea core: support inline file transfers (#1187)
* core: support inline file transfers

* parameterize ChatMessage

* send files inline when accepted

* accept inline file transfers (almost works)

* db error SERcvFileInvalid

* inline file transfer works (TODO fix test)

* inline file transfer tests, change encodings

* fixture

* combine messages into x.file.acpt.inv, refactor

* inline file mode

* decide whether to receive file inline on the recipient side, not only via file invitation

* test inline files "sent" mode

* check that file was offered inline

* update schema

* enable encryption tests

* test name

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* fix the list of rcv files to subscribe too

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-10-14 13:06:33 +01:00
JRoberts f7da034cf1 core: use acceptContactAsync on auto-accept, reuse incognito profile for contacts accepted via group link (#1208)
* update simplexmq (acceptContactAsync)

* acceptContactRequestAsync, single profile

* refactor

* refactor 2

* refactor

* Update src/Simplex/Chat/Store.hs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-10-14 14:57:01 +04:00
Stanislav Dmitrenko 4a259e44b1 android: Link preview with image URLs and more options for finding image on a site (#1207) 2022-10-13 19:11:59 +01:00
Stanislav Dmitrenko 799d9443bd android: Multiple images sharing (#1206) 2022-10-13 18:39:24 +01:00
JRoberts 3bf8361911 core: group links (#1194) 2022-10-13 17:12:22 +04:00
JRoberts 9670da4646 android: version 4.1 (62) 2022-10-13 14:14:01 +04:00
Stanislav Dmitrenko 1e22028189 android: Fix connecting via link from Firefox (#1205)
* android: Fix connecting via link from Firefox

* Replace slash
2022-10-13 10:49:57 +01:00
314 changed files with 5025 additions and 1705 deletions
+1
View File
@@ -10,6 +10,7 @@
/.idea/deploymentTargetDropDown.xml
/.idea/misc.xml
/.idea/uiDesigner.xml
/.idea/kotlinc.xml
.DS_Store
/build
/captures
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 61
versionName "4.1"
versionCode 64
versionName "4.2-beta.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -69,6 +69,11 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity-alias
@@ -98,7 +98,7 @@ class MainActivity: FragmentActivity() {
override fun onStart() {
super.onStart()
val enteredBackgroundVal = enteredBackground.value
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) {
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
runAuthenticate()
}
}
@@ -398,13 +398,24 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
chatModel.sharedContent.value = SharedContent.Text(it)
}
intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
chatModel.sharedContent.value = SharedContent.Image(it)
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it))
} // All other mime types
else -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
chatModel.sharedContent.value = SharedContent.File(it)
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
}
}
}
Intent.ACTION_SEND_MULTIPLE -> {
// Close active chat and show a list of chats
chatModel.chatId.value = null
chatModel.clearOverlays.value = true
when {
intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>)?.let {
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
} // All other mime types
else -> {}
}
}
}
}
@@ -38,7 +38,7 @@ class ChatModel(val controller: ChatController) {
val groupMembers = mutableStateListOf<GroupMember>()
val terminalItems = mutableStateListOf<TerminalItem>()
val userAddress = mutableStateOf<String?>(null)
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
val userSMPServers = mutableStateOf<(List<String>)?>(null)
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
@@ -90,7 +90,7 @@ class ChatModel(val controller: ChatController) {
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact)
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact && !contact.viaGroupLink)
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
@@ -387,7 +387,7 @@ data class Chat (
val id: String get() = chatInfo.id
@Serializable
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0)
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false)
@Serializable
data class ServerInfo(val networkStatus: NetworkStatus)
@@ -540,6 +540,9 @@ data class Contact(
val isIndirectContact: Boolean get() =
activeConn.connLevel > 0 || viaGroup != null
val viaGroupLink: Boolean get() =
activeConn.viaGroupLink
val contactConnIncognito =
activeConn.customUserProfileId != null
@@ -571,10 +574,10 @@ class ContactSubStatus(
)
@Serializable
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val customUserProfileId: Long? = null) {
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null) {
val id: ChatId get() = ":$connId"
companion object {
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, customUserProfileId = null)
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
}
}
@@ -1040,6 +1043,7 @@ data class ChatItem (
is RcvGroupEvent.MemberRole -> true
is RcvGroupEvent.UserRole -> false
is RcvGroupEvent.MemberDeleted -> false
is RcvGroupEvent.InvitedViaGroupLink -> false
}
is CIContent.SndGroupEventContent -> true
else -> false
@@ -1547,17 +1551,19 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): RcvGroupEvent()
@Serializable @SerialName("invitedViaGroupLink") class InvitedViaGroupLink(): RcvGroupEvent()
val text: String get() = when (this) {
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected)
is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left)
is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text)
is MemberRole -> String.format(generalGetString(R.string.rcv_group_event_changed_member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(R.string.rcv_group_event_changed_your_role), role.text)
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
is GroupUpdated -> generalGetString(R.string.rcv_group_event_updated_group_profile)
is InvitedViaGroupLink -> generalGetString(R.string.rcv_group_event_invited_via_your_group_link)
}
}
@@ -1570,8 +1576,8 @@ sealed class SndGroupEvent() {
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
val text: String get() = when (this) {
is MemberRole -> String.format(generalGetString(R.string.member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(R.string.your_member_role), role.text)
is MemberRole -> String.format(generalGetString(R.string.snd_group_event_changed_member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(R.string.snd_group_event_changed_role_for_yourself), role.text)
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
@@ -552,15 +552,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
r is CR.ContactDeleted && type == ChatType.Direct -> return true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true
r is CR.GroupDeletedUser && type == ChatType.Group -> return true
r is CR.ChatCmdError -> {
val e = r.chatError
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.cannot_delete_contact),
String.format(generalGetString(R.string.contact_cannot_be_deleted_as_they_are_in_groups), e.errorType.contact.displayName, e.errorType.groupNames.joinToString(", "))
)
}
}
else -> {
val titleId = when (type) {
ChatType.Direct -> R.string.error_deleting_contact
@@ -637,9 +628,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return false
}
private suspend fun apiGetUserAddress(): String? {
private suspend fun apiGetUserAddress(): UserContactLinkRec? {
val r = sendCmd(CC.ShowMyAddress())
if (r is CR.UserContactLink) return r.connReqContact
if (r is CR.UserContactLink) return r.contactLink
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
return null
@@ -648,6 +639,17 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? {
val r = sendCmd(CC.AddressAutoAccept(autoAccept))
if (r is CR.UserContactLinkUpdated) return r.contactLink
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
return null
}
Log.e(TAG, "userAddressAutoAccept bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
return when {
@@ -723,6 +725,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return false
}
suspend fun apiChatUnread(type: ChatType, id: Long, unreadChat: Boolean): Boolean {
val r = sendCmd(CC.ApiChatUnread(type, id, unreadChat))
if (r is CR.CmdOk) return true
Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiReceiveFile(fileId: Long): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId))
return when (r) {
@@ -839,6 +848,40 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun apiCreateGroupLink(groupId: Long): String? {
return when (val r = sendCmd(CC.APICreateGroupLink(groupId))) {
is CR.GroupLinkCreated -> r.connReqContact
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiCreateGroupLink", generalGetString(R.string.error_creating_link_for_group), r)
}
null
}
}
}
suspend fun apiDeleteGroupLink(groupId: Long): Boolean {
return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) {
is CR.GroupLinkDeleted -> true
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiDeleteGroupLink", generalGetString(R.string.error_deleting_link_for_group), r)
}
false
}
}
}
suspend fun apiGetGroupLink(groupId: Long): String? {
return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) {
is CR.GroupLink -> r.connReqContact
else -> {
Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}")
null
}
}
}
private fun networkErrorAlert(r: CR): Boolean {
return when {
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
@@ -880,16 +923,20 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChat(r.connection.id)
}
is CR.ContactConnected -> {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected())
ntfManager.notifyContactConnected(r.contact)
if (!r.contact.viaGroupLink) {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected())
ntfManager.notifyContactConnected(r.contact)
}
}
is CR.ContactConnecting -> {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
if (!r.contact.viaGroupLink) {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
}
}
is CR.ReceivedContactRequest -> {
val contactRequest = r.contactRequest
@@ -1366,6 +1413,9 @@ sealed class CC {
class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class APICreateGroupLink(val groupId: Long): CC()
class APIDeleteGroupLink(val groupId: Long): CC()
class APIGetGroupLink(val groupId: Long): CC()
class GetUserSMPServers: CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class APISetChatItemTTL(val seconds: Long?): CC()
@@ -1387,6 +1437,7 @@ sealed class CC {
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
class AddressAutoAccept(val autoAccept: AutoAccept?): CC()
class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
class ApiRejectCall(val contact: Contact): CC()
class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC()
@@ -1397,6 +1448,7 @@ sealed class CC {
class ApiAcceptContact(val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long): CC()
val cmdString: String get() = when (this) {
@@ -1424,6 +1476,9 @@ sealed class CC {
is ApiLeaveGroup -> "/_leave #$groupId"
is ApiListMembers -> "/_members #$groupId"
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
is APICreateGroupLink -> "/_create link #$groupId"
is APIDeleteGroupLink -> "/_delete link #$groupId"
is APIGetGroupLink -> "/_get link #$groupId"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}"
@@ -1445,6 +1500,7 @@ sealed class CC {
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
is AddressAutoAccept -> "/auto_accept ${AutoAccept.cmdString(autoAccept)}"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}"
@@ -1455,6 +1511,7 @@ sealed class CC {
is ApiEndCall -> "/_call end @${contact.apiId}"
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> "/freceive $fileId"
}
@@ -1483,6 +1540,9 @@ sealed class CC {
is ApiLeaveGroup -> "apiLeaveGroup"
is ApiListMembers -> "apiListMembers"
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
is APICreateGroupLink -> "apiCreateGroupLink"
is APIDeleteGroupLink -> "apiDeleteGroupLink"
is APIGetGroupLink -> "apiGetGroupLink"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is APISetChatItemTTL -> "apiSetChatItemTTL"
@@ -1504,6 +1564,7 @@ sealed class CC {
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
is AddressAutoAccept -> "addressAutoAccept"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
is ApiSendCallInvitation -> "apiSendCallInvitation"
@@ -1514,6 +1575,7 @@ sealed class CC {
is ApiEndCall -> "apiEndCall"
is ApiCallStatus -> "apiCallStatus"
is ApiChatRead -> "apiChatRead"
is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile"
}
@@ -1699,10 +1761,11 @@ sealed class CR {
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR()
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR()
@Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR()
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact, val userCustomProfile: Profile? = null): CR()
@Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR()
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR()
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR()
@@ -1744,6 +1807,9 @@ sealed class CR {
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val groupInfo: GroupInfo, val connReqContact: String): CR()
@Serializable @SerialName("groupLink") class GroupLink(val groupInfo: GroupInfo, val connReqContact: String): CR()
@Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val groupInfo: GroupInfo): CR()
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val rcvFileTransfer: RcvFileTransfer): CR()
@@ -1792,6 +1858,7 @@ sealed class CR {
is ConnectionAliasUpdated -> "connectionAliasUpdated"
is ParsedMarkdown -> "apiParsedMarkdown"
is UserContactLink -> "userContactLink"
is UserContactLinkUpdated -> "userContactLinkUpdated"
is UserContactLinkCreated -> "userContactLinkCreated"
is UserContactLinkDeleted -> "userContactLinkDeleted"
is ContactConnected -> "contactConnected"
@@ -1835,6 +1902,9 @@ sealed class CR {
is ConnectedToGroupMember -> "connectedToGroupMember"
is GroupRemoved -> "groupRemoved"
is GroupUpdated -> "groupUpdated"
is GroupLinkCreated -> "groupLinkCreated"
is GroupLink -> "groupLink"
is GroupLinkDeleted -> "groupLinkDeleted"
is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled"
is RcvFileAccepted -> "rcvFileAccepted"
is RcvFileStart -> "rcvFileStart"
@@ -1881,7 +1951,8 @@ sealed class CR {
is ContactAliasUpdated -> json.encodeToString(toContact)
is ConnectionAliasUpdated -> json.encodeToString(toConnection)
is ParsedMarkdown -> json.encodeToString(formattedText)
is UserContactLink -> connReqContact
is UserContactLink -> contactLink.responseDetails
is UserContactLinkUpdated -> contactLink.responseDetails
is UserContactLinkCreated -> connReqContact
is UserContactLinkDeleted -> noDetails()
is ContactConnected -> json.encodeToString(contact)
@@ -1925,6 +1996,9 @@ sealed class CR {
is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member"
is GroupRemoved -> json.encodeToString(groupInfo)
is GroupUpdated -> json.encodeToString(toGroup)
is GroupLinkCreated -> "groupInfo: $groupInfo\nconnReqContact: $connReqContact"
is GroupLink -> "groupInfo: $groupInfo\nconnReqContact: $connReqContact"
is GroupLinkDeleted -> json.encodeToString(groupInfo)
is RcvFileAcceptedSndCancelled -> noDetails()
is RcvFileAccepted -> json.encodeToString(chatItem)
is RcvFileStart -> json.encodeToString(chatItem)
@@ -1981,6 +2055,24 @@ abstract class TerminalItem {
@Serializable
class ConnectionStats(val rcvServers: List<String>?, val sndServers: List<String>?)
@Serializable
class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) {
val responseDetails: String get() = "connReqContact: ${connReqContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}"
}
@Serializable
class AutoAccept(val acceptIncognito: Boolean, val autoReply: MsgContent?) {
companion object {
fun cmdString(autoAccept: AutoAccept?): String {
if (autoAccept == null) return "off"
val s = "on" + if (autoAccept.acceptIncognito) " incognito=on" else ""
val msg = autoAccept.autoReply ?: return s
return s + " " + msg.cmdString
}
}
}
@Serializable
sealed class ChatError {
val string: String get() = when (this) {
@@ -2000,12 +2092,10 @@ sealed class ChatErrorType {
val string: String get() = when (this) {
is NoActiveUser -> "noActiveUser"
is InvalidConnReq -> "invalidConnReq"
is ContactGroups -> "groupNames $groupNames"
is СommandError -> "commandError $message"
}
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List<String>): ChatErrorType()
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
}
@@ -7,7 +7,7 @@ val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
val Indigo = Color(0xff330099)
val Indigo = Color(0xFF9966FF)
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
val SimplexGreen = Color(77, 218, 103, 255)
val SecretColor = Color(0x40808080)
@@ -2,6 +2,7 @@ package chat.simplex.app.views
import android.content.Context
import android.content.res.Configuration
import android.os.SystemClock
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -31,18 +32,28 @@ import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
private val lastSuccessfulAuth: MutableState<Long?> = mutableStateOf(null)
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
BackHandler(onBack = close)
val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) }
val lastSuccessfulAuth = remember { lastSuccessfulAuth }
BackHandler(onBack = {
lastSuccessfulAuth.value = null
close()
})
val authorized = remember { !chatModel.controller.appPrefs.performLA.get() }
val context = LocalContext.current
LaunchedEffect(authorized.value) {
if (!authorized.value) {
runAuth(authorized = authorized, context)
LaunchedEffect(lastSuccessfulAuth.value) {
if (!authorized && !authorizedPreviously(lastSuccessfulAuth)) {
runAuth(lastSuccessfulAuth, context)
}
}
if (authorized.value) {
if (authorized || authorizedPreviously(lastSuccessfulAuth)) {
LaunchedEffect(Unit) {
// Update auth each time user visits this screen in authenticated state just to prolong authorized time
lastSuccessfulAuth.value = SystemClock.elapsedRealtime()
}
TerminalLayout(
chatModel.terminalItems,
composeState,
@@ -61,7 +72,7 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
runAuth(authorized = authorized, context)
runAuth(lastSuccessfulAuth, context)
}
)
}
@@ -70,15 +81,18 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
}
}
private fun runAuth(authorized: MutableState<Boolean>, context: Context) {
private fun authorizedPreviously(lastSuccessfulAuth: State<Long?>): Boolean =
lastSuccessfulAuth.value?.let { SystemClock.elapsedRealtime() - it < 30_000 } ?: false
private fun runAuth(lastSuccessfulAuth: MutableState<Long?>, context: Context) {
authenticate(
generalGetString(R.string.auth_open_chat_console),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> authorized.value = true
is LAResult.Error, LAResult.Failed -> authorized.value = false
lastSuccessfulAuth.value = when (laResult) {
LAResult.Success, LAResult.Unavailable -> SystemClock.elapsedRealtime()
is LAResult.Error, LAResult.Failed -> null
}
}
)
@@ -139,9 +153,14 @@ fun TerminalLayout(
}
}
private var lazyListState = 0 to 0
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState()
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
@@ -93,7 +93,7 @@ sealed class WCallResponse {
@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 RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) {
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(callType.media) {
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
@@ -48,11 +48,10 @@ import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import java.io.File
import kotlin.math.sign
import kotlin.math.roundToInt
@Composable
fun ChatView(chatModel: ChatModel) {
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
@@ -69,20 +68,23 @@ fun ChatView(chatModel: ChatModel) {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
activeChat = if (chatModel.chatId.value == null) {
null
} else {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
chatModel.getChat(chatModel.chatId.value!!)
if (activeChat.value?.id != chatModel.chatId.value) {
activeChat.value = if (chatModel.chatId.value == null) {
null
} else {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
chatModel.getChat(chatModel.chatId.value!!)
}
}
markUnreadChatAsRead(activeChat, chatModel)
}
}
if (activeChat == null || user == null) {
if (activeChat.value == null || user == null) {
chatModel.chatId.value = null
} else {
val chat = activeChat!!
val chat = activeChat.value!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
@@ -120,7 +122,7 @@ fun ChatView(chatModel: ChatModel) {
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
ModalManager.shared.showModalCloseable(true) { close ->
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
activeChat = it
activeChat.value = it
}
}
} else if (cInfo is ChatInfo.Group) {
@@ -447,16 +449,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) {
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false
}
ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
@@ -548,7 +541,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
@@ -578,6 +571,21 @@ fun BoxWithConstraintsScope.ChatItemsList(
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
}
@Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true to chatId) }
LaunchedEffect(chatId, shouldAutoScroll) {
if ((shouldAutoScroll.first || shouldAutoScroll.second != chatId) && listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false to chatId
}
}
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
chatItems: List<ChatItem>,
@@ -784,6 +792,22 @@ private fun bottomEndFloatingButton(
}
}
private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: ChatModel) {
val chat = activeChat.value
if (chat?.chatStats?.unreadChat != true) return
withApi {
val success = chatModel.controller.apiChatUnread(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
false
)
if (success && chat.id == activeChat.value?.id) {
activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))
chatModel.replaceChat(chat.id, activeChat.value!!)
}
}
}
private fun providerForGallery(
listStateIndex: Int,
chatItems: List<ChatItem>,
@@ -10,7 +10,6 @@ import androidx.compose.material.icons.outlined.Close
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.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -194,7 +194,7 @@ fun ComposeView(
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val processPickedImage = { uris: List<Uri> ->
val processPickedImage = { uris: List<Uri>, text: String? ->
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
@@ -223,17 +223,17 @@ fun ComposeView(
if (imagesPreview.isNotEmpty()) {
chosenContent.value = content
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagesPreview))
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview))
}
}
val processPickedFile = { uri: Uri? ->
val processPickedFile = { uri: Uri?, text: String? ->
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))
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName))
}
} else {
AlertManager.shared.showAlertMsg(
@@ -243,9 +243,9 @@ fun ComposeView(
}
}
}
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage)
val galleryLauncherFallback = rememberGetMultipleContentsLauncher(processPickedImage)
val filesLauncher = rememberGetContentLauncher(processPickedFile)
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it, null) }
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
@@ -294,6 +294,9 @@ fun ComposeView(
if (lp != null && pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
pendingLinkUrl.value = null
} else if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
pendingLinkUrl.value = null
}
}
}
@@ -500,8 +503,8 @@ fun ComposeView(
LaunchedEffect(chatModel.sharedContent.value) {
when (val shared = chatModel.sharedContent.value) {
is SharedContent.Text -> onMessageChange(shared.text)
is SharedContent.Image -> processPickedImage(listOf(shared.uri))
is SharedContent.File -> processPickedFile(shared.uri)
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
null -> {}
}
chatModel.sharedContent.value = null
@@ -1,12 +1,15 @@
package chat.simplex.app.views.chat
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.text.InputType
import android.view.ViewGroup
import android.view.inputmethod.*
import android.widget.EditText
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
@@ -14,27 +17,25 @@ import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.text.BidiFormatter
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doOnTextChanged
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.SharedContent
import kotlinx.coroutines.delay
import kotlin.math.min
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
@@ -43,82 +44,114 @@ fun SendMsgView(
textStyle: MutableState<TextStyle>
) {
val cs = composeState.value
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
// In replying state
focusRequester.requestFocus()
delay(50)
keyboard?.show()
when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> {
delay(100)
showKeyboard = true
}
is ComposeContextItem.EditingItem -> {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
}
}
}
val isRtl = remember(cs.message) { BidiFormatter.getInstance().isRtl(cs.message.subSequence(0, min(50, cs.message.length))) }
BasicTextField(
value = cs.message,
onValueChange = onMessageChange,
textStyle = textStyle.value,
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Bottom
) {
CompositionLocalProvider(
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
Column(Modifier.padding(vertical = 8.dp)) {
Box {
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: android.view.OnReceiveContentListener?
) {
Box(
Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.padding(top = 5.dp)
.padding(bottom = 7.dp)
) {
innerTextField()
}
super.setOnReceiveContentListener(mimeTypes, listener)
}
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
) {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp),
color = HighOrLowlight,
strokeWidth = 3.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
return@OnCommitContentListener false
}
)
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
true
}
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
}
}
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
) {
CircularProgressIndicator(
Modifier
.size(36.dp)
.padding(4.dp),
color = HighOrLowlight,
strokeWidth = 3.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
}
)
}
}
}
)
}
}
@Preview(showBackground = true)
@@ -65,6 +65,12 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = {
withApi {
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
}
}
)
}
}
@@ -117,6 +123,7 @@ fun GroupChatInfoLayout(
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
) {
Column(
Modifier
@@ -134,6 +141,8 @@ fun GroupChatInfoLayout(
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView(manageGroupLink) { GroupLinkButton() }
SectionDivider()
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
SectionItemView(onAddMembersClick) {
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
@@ -150,7 +159,6 @@ fun GroupChatInfoLayout(
MembersList(members, showMemberInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
@@ -268,6 +276,23 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
}
}
@Composable
fun GroupLinkButton() {
Row(
Modifier
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.Link,
stringResource(R.string.group_link),
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
}
}
@Composable
fun EditGroupProfileButton() {
Row(
@@ -330,7 +355,7 @@ fun PreviewGroupChatInfoLayout() {
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
)
}
}
@@ -0,0 +1,111 @@
package chat.simplex.app.views.chat.group
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
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.platform.LocalContext
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.ChatModel
import chat.simplex.app.model.GroupInfo
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
var groupLink by remember { mutableStateOf(connReqContact) }
val cxt = LocalContext.current
GroupLinkLayout(
groupLink = groupLink,
createLink = {
withApi {
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
}
},
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
deleteLink = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_link_question),
text = generalGetString(R.string.all_group_members_will_remain_connected),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
withApi {
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
}
}
}
)
}
)
}
@Composable
fun GroupLinkLayout(
groupLink: String?,
createLink: () -> Unit,
share: () -> Unit,
deleteLink: () -> Unit
) {
Column(
Modifier.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
AppBarTitle(stringResource(R.string.group_link), false)
Text(
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
if (groupLink == null) {
Text(
stringResource(R.string.if_you_later_delete_link_you_wont_lose_members),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
} else {
Text(
stringResource(R.string.if_you_delete_group_link_you_wont_lose_members),
Modifier.padding(bottom = 12.dp),
lineHeight = 22.sp
)
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 10.dp)
) {
SimpleButton(
stringResource(R.string.share_link),
icon = Icons.Outlined.Share,
click = share
)
SimpleButton(
stringResource(R.string.delete_link),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteLink
)
}
}
}
}
}
@@ -136,14 +136,14 @@ fun GroupMemberInfoLayout(
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
SectionDivider()
/*val roles = remember { member.canChangeRoleTo(groupInfo) }
val roles = remember { member.canChangeRoleTo(groupInfo) }
if (roles != null) {
SectionItemView {
RoleSelectionRow(roles, newRole, onRoleSelected)
}
} else {*/
} else {
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
//}
}
val conn = member.activeConn
if (conn != null) {
SectionDivider()
@@ -1,3 +1,5 @@
package chat.simplex.app.views.chat.item
import android.graphics.Bitmap
import android.os.Build.VERSION.SDK_INT
import androidx.compose.foundation.Image
@@ -9,8 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -18,16 +19,16 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import chat.simplex.app.BuildConfig
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.CIFileStatus
import chat.simplex.app.views.chat.item.ImageFullScreenView
import chat.simplex.app.views.chat.item.ImageGalleryProvider
import chat.simplex.app.views.helpers.*
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
@@ -93,6 +94,12 @@ fun CIImageView(
}
}
@Composable
fun imageViewFullWidth(): Dp {
val approximatePadding = 100.dp
return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) }
}
@Composable
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
Image(
@@ -101,7 +108,7 @@ fun CIImageView(
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(1000.dp)
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
@@ -118,7 +125,7 @@ fun CIImageView(
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(1000.dp)
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
@@ -134,21 +141,20 @@ fun CIImageView(
return false
}
Box(contentAlignment = Alignment.TopEnd) {
fun imageAndFilePath(file: CIFile?): Pair<Bitmap?, String?> {
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
val filePath = getLoadedFilePath(SimplexApp.context, file)
return imageBitmap to filePath
}
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd
) {
val context = LocalContext.current
val imageBitmap: Bitmap? = getLoadedImage(context, file)
val filePath = getLoadedFilePath(context, file)
val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
if (imageBitmap != null && filePath != null) {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
@@ -190,3 +196,13 @@ fun CIImageView(
loadingIndicator()
}
}
private val imageLoader = ImageLoader.Builder(SimplexApp.context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
@@ -1,6 +1,7 @@
package chat.simplex.app.views.chat.item
import android.content.*
import android.net.Uri
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -18,6 +19,7 @@ import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -81,7 +83,11 @@ fun ChatItemView(
showMenu.value = false
})
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
shareText(cxt, cItem.content.text)
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(cxt, cItem.text, filePath)
else -> shareText(cxt, cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import CIImageView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -10,16 +9,17 @@ import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastMap
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -69,7 +69,10 @@ fun FramedItemView(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
.clickable { scrollToItem(qi.itemId?: return@clickable) }
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
)
) {
when (qi.content) {
is MsgContent.MCImage -> {
@@ -102,34 +105,37 @@ fun FramedItemView(
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
) {
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
.background(
when {
transparentBackground -> Color.Transparent
sent -> SentColorLight
else -> ReceivedColorLight
}
)) {
var metaColor = HighOrLowlight
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
val qi = ci.quotedItem
if (qi != null) {
ciQuoteView(qi)
}
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
Modifier
.padding(bottom = 2.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(ci.content.text)
Text("")
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
Column(
Modifier
.padding(bottom = 2.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(ci.content.text)
Text("")
}
}
}
} else {
Column(Modifier.fillMaxWidth()) {
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@Box, showMenu, receiveFile)
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "") {
metaColor = Color.White
} else {
@@ -174,6 +180,37 @@ fun CIMarkdownText(
}
}
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
@Composable
fun PriorityLayout(
modifier: Modifier = Modifier,
priorityLayoutId: String,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measureable, constraints ->
// Find important element which should tell what max width other elements can use
// Expecting only one such element. Can be less than one but not more
val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints)
val placeables: List<Placeable> = measureable.fastMap {
if (it.layoutId == priorityLayoutId)
imagePlaceable!!
else
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) }
// Limit width for every other element to width of important element and height for a sum of all elements
layout(imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width }, placeables.sumOf { it.height }) {
var y = 0
placeables.forEach {
it.place(0, y)
y += it.measuredHeight
}
}
}
}
class EditedProvider: PreviewParameterProvider<Boolean> {
override val values = listOf(false, true).asSequence()
}
@@ -60,8 +60,7 @@ fun MarkdownText (
else -> " "
}
CompositionLocalProvider(
// When we draw `sender` is has issues on LTR languages set globally with RTL text language
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current && sender == null)
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
else
LocalLayoutDirection.current
@@ -5,12 +5,13 @@ 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.filled.TheaterComedy
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.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -29,12 +30,13 @@ import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
val showMenu = remember { mutableStateOf(false) }
var showMarkRead by remember { mutableStateOf(false) }
val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
}
val stopped = chatModel.chatRunning.value == false
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
showMarkRead = chat.chatStats.unreadCount > 0
}
when (chat.chatInfo) {
is ChatInfo.Direct ->
@@ -123,6 +125,8 @@ suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
@@ -141,6 +145,8 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
else -> {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
ClearChatAction(chat, chatModel, showMenu)
@@ -167,6 +173,30 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
)
}
@Composable
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
DropdownMenuItem({
markChatUnread(chat, chatModel)
showMenu.value = false
}) {
Row {
Text(
stringResource(R.string.mark_unread),
modifier = Modifier
.fillMaxWidth()
.weight(1F)
.padding(end = 15.dp),
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.MarkChatUnread,
stringResource(R.string.mark_unread),
tint = MaterialTheme.colors.onBackground
)
}
}
}
@Composable
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -290,18 +320,45 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
)
}
fun markChatRead(chat: Chat, chatModel: ChatModel) {
// Just to be sure
if (chat.chatStats.unreadCount == 0) return
val minUnreadItemId = chat.chatStats.minUnreadItemId
chatModel.markChatItemsRead(chat.chatInfo)
fun markChatRead(c: Chat, chatModel: ChatModel) {
var chat = c
withApi {
chatModel.controller.apiChatRead(
if (chat.chatStats.unreadCount > 0) {
val minUnreadItemId = chat.chatStats.minUnreadItemId
chatModel.markChatItemsRead(chat.chatInfo)
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
)
chat = chatModel.getChat(chat.id) ?: return@withApi
}
if (chat.chatStats.unreadChat) {
val success = chatModel.controller.apiChatUnread(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
false
)
if (success) {
chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
}
}
}
}
fun markChatUnread(chat: Chat, chatModel: ChatModel) {
// Just to be sure
if (chat.chatStats.unreadChat) return
withApi {
val success = chatModel.controller.apiChatUnread(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
true
)
if (success) {
chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
}
}
}
@@ -3,8 +3,7 @@ package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -203,14 +202,21 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
Divider(Modifier.padding(top = AppBarHeight))
}
private var lazyListState = 0 to 0
@Composable
private fun ChatList(chatModel: ChatModel, search: String) {
val filter: (Chat) -> Boolean = { chat: Chat ->
chat.chatInfo.chatViewName.lowercase().contains(search.lowercase())
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } }
LazyColumn(
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
listState
) {
items(chats) { chat ->
ChatListNavLinkView(chat, chatModel)
@@ -84,7 +84,8 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
MarkdownText(
ci.text,
ci.formattedText,
sender = null,
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
senderBold = true,
metaText = null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -136,13 +137,13 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
)
val n = chat.chatStats.unreadCount
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
if (n > 0) {
if (n > 0 || chat.chatStats.unreadChat) {
Box(
Modifier.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
unreadCountStr(n),
if (n > 0) unreadCountStr(n) else "",
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
@@ -93,7 +93,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
Text(
when (chatModel.sharedContent.value) {
is SharedContent.Text -> stringResource(R.string.share_message)
is SharedContent.Image -> stringResource(R.string.share_image)
is SharedContent.Images -> stringResource(R.string.share_image)
is SharedContent.File -> stringResource(R.string.share_file)
else -> stringResource(R.string.share_message)
},
@@ -94,8 +94,7 @@ private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String):
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(chatArchivePath)
outputStream.write(file.readBytes())
File(chatArchivePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
@@ -58,13 +58,13 @@ fun DatabaseView(
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
importArchiveAlert(m, context, uri, progressIndicator)
importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator)
}
}
val chatDbDeleted = remember { m.chatDbDeleted }
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory(context))) }
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
@@ -489,8 +489,7 @@ private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableSt
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(filePath)
outputStream.write(file.readBytes())
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
@@ -507,16 +506,28 @@ private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableSt
}
)
private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
private fun importArchiveAlert(
m: ChatModel,
context: Context,
importedArchiveUri: Uri,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.import_database_question),
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(R.string.import_database_confirmation),
onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) }
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) }
)
}
private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
private fun importArchive(
m: ChatModel,
context: Context,
importedArchiveUri: Uri,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>
) {
progressIndicator.value = true
val archivePath = saveArchiveFromUri(context, importedArchiveUri)
if (archivePath != null) {
@@ -527,6 +538,7 @@ private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Ur
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiImportArchive(config)
DatabaseUtils.removeDatabaseKey()
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
@@ -7,8 +7,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
sealed class SharedContent {
data class Text(val text: String): SharedContent()
data class Image(val uri: Uri): SharedContent()
data class File(val uri: Uri): SharedContent()
data class Images(val text: String, val uris: List<Uri>): SharedContent()
data class File(val text: String, val uri: Uri): SharedContent()
}
enum class NewChatSheetState {
@@ -65,26 +65,28 @@ fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
}
private fun compressImageStr(bitmap: Bitmap): String {
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
val usePng = bitmap.hasAlpha()
val ext = if (usePng) "png" else "jpg"
return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP)
}
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
fun resizeImageToDataSize(image: Bitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream {
var img = image
var stream = compressImageData(img)
var stream = compressImageData(img, usePng)
while (stream.size() > maxDataSize) {
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
val clippedRatio = min(ratio, 2.0)
val width = (img.width.toDouble() / clippedRatio).toInt()
val height = img.height * width / img.width
img = Bitmap.createScaledBitmap(img, width, height, true)
stream = compressImageData(img)
stream = compressImageData(img, usePng)
}
return stream
}
private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
private fun compressImageData(bitmap: Bitmap, usePng: Boolean): ByteArrayOutputStream {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream)
bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream)
return stream
}
@@ -26,29 +26,44 @@ import chat.simplex.app.views.chat.item.SentColorLight
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.net.URL
private const val OG_SELECT_QUERY = "meta[property^=og:]"
private const val ICON_SELECT_QUERY = "link[rel^=icon],link[rel^=apple-touch-icon],link[rel^=shortcut icon]"
private val IMAGE_SUFFIXES = listOf(".jpg", ".png", ".ico", ".webp", ".gif")
suspend fun getLinkPreview(url: String): LinkPreview? {
return withContext(Dispatchers.IO) {
try {
val response = Jsoup.connect(url)
.ignoreContentType(true)
.timeout(10000)
.followRedirects(true)
.execute()
val doc = response.parse()
val ogTags = doc.select(OG_SELECT_QUERY)
val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
val title: String?
val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withContext null
var imageUri = when {
IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> {
title = u.path.substringAfterLast("/")
url
}
else -> {
val response = Jsoup.connect(url)
.ignoreContentType(true)
.timeout(10000)
.followRedirects(true)
.execute()
val doc = response.parse()
val ogTags = doc.select(OG_SELECT_QUERY)
title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title()
ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href")
}
}
if (imageUri != null) {
imageUri = normalizeImageUri(u, imageUri)
try {
val stream = java.net.URL(imageUri).openStream()
val stream = URL(imageUri).openStream()
val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000)
// TODO add once supported in iOS
// val description = ogTags.firstOrNull {
// it.attr("property") == "og:description"
// }?.attr("content") ?: ""
val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content")
if (title != null) {
return@withContext LinkPreview(url, title, description = "", image)
}
@@ -63,8 +78,6 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
}
}
@Composable
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
Row(
@@ -127,6 +140,37 @@ fun ChatItemLinkView(linkPreview: LinkPreview) {
}
}
private fun normalizeImageUri(u: URL, imageUri: String) = when {
!imageUri.lowercase().startsWith("http") -> {
"${u.protocol}://${u.host}" +
if (imageUri.startsWith("/"))
imageUri
else
// When an icon is used as an image with relative href path like: site=site.com, <link rel="icon" href="icon.png">
if (u.path.endsWith("/")) u.path + imageUri
else u.path.substringBeforeLast("/") + "/$imageUri"
}
else -> imageUri
}
/*fun normalizeImageUriTest() {
val expect = mapOf<Pair<URL, String>, String>(
URL("https://example.com") to "icon.png" to "https://example.com/icon.png",
URL("https://example.com/") to "icon.png" to "https://example.com/icon.png",
URL("https://example.com/") to "/icon.png" to "https://example.com/icon.png",
URL("https://example.com") to "assets/images/favicon.png" to "https://example.com/assets/images/favicon.png",
URL("https://example.com/") to "assets/images/favicon.png" to "https://example.com/assets/images/favicon.png",
URL("https://example.com/dir") to "/favicon.png" to "https://example.com/favicon.png",
URL("https://example.com/dir/") to "favicon.png" to "https://example.com/dir/favicon.png",
URL("https://example.com/dir/") to "/favicon.png" to "https://example.com/favicon.png",
URL("https://example.com/dir/page") to "favicon.png" to "https://example.com/dir/favicon.png",
URL("https://example.com/abcde.gif") to "https://example.com/abcde.gif" to "https://example.com/abcde.gif",
URL("https://example.com/abcde.gif?a=b") to "https://example.com/abcde.gif?a=b" to "https://example.com/abcde.gif?a=b",
)
expect.forEach {
Log.d(TAG, "Image URI ${normalizeImageUri(it.key.first, it.key.second)} == ${normalizeImageUri(it.key.first, it.key.second) == it.value}")
}
}*/
@Preview(showBackground = true)
@Preview(
@@ -60,6 +60,8 @@ class ModalManager {
modalCount.value = modalViews.size - toRemove.size
}
fun hasModalsOpen() = modalCount.value > 0
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
@@ -3,13 +3,15 @@ package chat.simplex.app.views.helpers
import android.content.*
import android.net.Uri
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
import chat.simplex.app.R
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
import java.io.BufferedOutputStream
import java.io.File
@@ -24,6 +26,22 @@ fun shareText(cxt: Context, text: String) {
cxt.startActivity(shareIntent)
}
fun shareFile(cxt: Context, text: String, filePath: String) {
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
val ext = filePath.substringAfterLast(".")
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
/*if (text.isNotEmpty()) {
putExtra(Intent.EXTRA_TEXT, text)
}*/
putExtra(Intent.EXTRA_STREAM, uri)
type = mimeType
}
val shareIntent = Intent.createChooser(sendIntent, null)
cxt.startActivity(shareIntent)
}
fun copyText(cxt: Context, text: String) {
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
@@ -40,8 +58,7 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(filePath)
outputStream.write(file.readBytes())
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
@@ -52,30 +69,32 @@ fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResu
}
)
fun imageMimeType(fileName: String): String {
val lowercaseName = fileName.lowercase()
return when {
lowercaseName.endsWith(".png") -> "image/png"
lowercaseName.endsWith(".gif") -> "image/gif"
lowercaseName.endsWith(".webp") -> "image/webp"
lowercaseName.endsWith(".avif") -> "image/avif"
lowercaseName.endsWith(".svg") -> "image/svg+xml"
else -> "image/jpeg"
}
}
fun saveImage(cxt: Context, ciFile: CIFile?) {
val filePath = getLoadedFilePath(cxt, ciFile)
val fileName = ciFile?.fileName
if (filePath != null && fileName != null) {
val values = ContentValues()
val lowercaseName = fileName.lowercase()
val mimeType = when {
lowercaseName.endsWith(".png") -> "image/png"
lowercaseName.endsWith(".gif") -> "image/gif"
lowercaseName.endsWith(".webp") -> "image/webp"
lowercaseName.endsWith(".avif") -> "image/avif"
lowercaseName.endsWith(".svg") -> "image/svg+xml"
else -> "image/jpeg"
}
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType)
values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName))
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.TITLE, fileName)
val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
uri?.let {
cxt.contentResolver.openOutputStream(uri)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(filePath)
outputStream.write(file.readBytes())
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.image_saved), Toast.LENGTH_SHORT).show()
}
@@ -28,6 +28,22 @@ fun SimpleButton(text: String, icon: ImageVector,
}
}
@Composable
fun SimpleButtonIconEnded(
text: String,
icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
click: () -> Unit
) {
SimpleButtonFrame(click) {
Text(text, style = MaterialTheme.typography.caption, color = color)
Icon(
icon, text, tint = color,
modifier = Modifier.padding(start = 8.dp)
)
}
}
@Composable
fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) {
Surface(shape = RoundedCornerShape(20.dp)) {
@@ -308,9 +308,10 @@ fun getFileSize(context: Context, uri: Uri): Long? {
fun saveImage(context: Context, image: Bitmap): String? {
return try {
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.jpg")
val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext")
val file = File(getAppFilePath(context, fileToSave))
val output = FileOutputStream(file)
dataResized.writeTo(output)
@@ -24,16 +24,8 @@ import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun AddContactView(chatModel: ChatModel, connReqInvitation: String, connIncognito: Boolean) {
fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
val cxt = LocalContext.current
LaunchedEffect(connReqInvitation) {
if (connReqInvitation.isNotEmpty()) {
chatModel.connReqInv.value = connReqInvitation
}
}
DisposableEffect(Unit) {
onDispose { chatModel.connReqInv.value = null }
}
AddContactLayout(
connReq = connReqInvitation,
connIncognito = connIncognito,
@@ -83,7 +75,7 @@ fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit)
annotatedStringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
lineHeight = 22.sp,
modifier = Modifier
.padding(bottom = if (screenHeight > 600.dp) 8.dp else 0.dp)
.padding(top = 16.dp, bottom = if (screenHeight > 600.dp) 16.dp else 0.dp)
)
Row(
Modifier.fillMaxWidth(),
@@ -30,16 +30,16 @@ fun ContactConnectionInfoView(
focusAlias: Boolean,
close: () -> Unit
) {
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. It will be managed by [AddContactView] itself
* Otherwise it will be called here AFTER [AddContactView] is launched and will clear the value too soon */
val allowDispose = remember { mutableStateOf(true) }
LaunchedEffect(connReqInvitation) {
allowDispose.value = true
chatModel.connReqInv.value = connReqInvitation
}
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv].
* Otherwise, it will be called here AFTER [AddContactView] is launched and will clear the value too soon.
* It will be dropped automatically when connection established or when user goes away from this screen.
**/
DisposableEffect(Unit) {
onDispose {
if (allowDispose.value) {
if (!ModalManager.shared.hasModalsOpen()) {
chatModel.connReqInv.value = null
}
}
@@ -54,7 +54,6 @@ fun ContactConnectionInfoView(
deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) },
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
showQr = {
allowDispose.value = false
ModalManager.shared.showModal {
Column(
Modifier
@@ -62,7 +61,7 @@ fun ContactConnectionInfoView(
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.SpaceBetween
) {
AddContactView(chatModel, connReqInvitation ?: return@showModal, contactConnection.incognito)
AddContactView(connReqInvitation ?: return@showModal, contactConnection.incognito)
}
}
}
@@ -5,6 +5,7 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
@@ -12,6 +13,7 @@ import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.usersettings.UserAddressView
@@ -22,16 +24,27 @@ enum class CreateLinkTab {
@Composable
fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
val selection = remember { mutableStateOf(initialSelection) }
val connReqInvitation = remember { mutableStateOf("") }
val creatingConnReq = remember { mutableStateOf(false) }
val connReqInvitation = rememberSaveable { m.connReqInv }
val creatingConnReq = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selection.value) {
if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() && !creatingConnReq.value) {
if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && !creatingConnReq.value) {
createInvitation(m, creatingConnReq, connReqInvitation)
}
}
/** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv].
* Otherwise, it will be called here AFTER [AddContactView] is launched and will clear the value too soon.
* It will be dropped automatically when connection established or when user goes away from this screen.
**/
DisposableEffect(Unit) {
onDispose {
if (!ModalManager.shared.hasModalsOpen()) {
m.connReqInv.value = null
}
}
}
val tabTitles = CreateLinkTab.values().map {
when {
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isEmpty() -> stringResource(R.string.create_one_time_link)
it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() -> stringResource(R.string.create_one_time_link)
it == CreateLinkTab.ONE_TIME -> stringResource(R.string.one_time_link)
it == CreateLinkTab.LONG_TERM -> stringResource(R.string.your_contact_address)
else -> ""
@@ -46,7 +59,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
Column(Modifier.weight(1f)) {
when (selection.value) {
CreateLinkTab.ONE_TIME -> {
AddContactView(m, connReqInvitation.value, m.incognito.value)
AddContactView(connReqInvitation.value ?: "", m.incognito.value)
}
CreateLinkTab.LONG_TERM -> {
UserAddressView(m)
@@ -79,7 +92,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
}
}
private fun createInvitation(m: ChatModel, creatingConnReq: MutableState<Boolean>, connReqInvitation: MutableState<String>) {
private fun createInvitation(m: ChatModel, creatingConnReq: MutableState<Boolean>, connReqInvitation: MutableState<String?>) {
creatingConnReq.value = true
withApi {
val connReq = m.controller.apiAddContact()
@@ -50,7 +50,7 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
}
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
val action = uri.path?.drop(1)
val action = uri.path?.drop(1)?.replace("/", "")
if (action == "contact" || action == "invitation") {
withApi { run(action) }
} else {
@@ -91,6 +91,7 @@ fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = 12.dp)
) { qrCodeScanner() }
Text(
annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link),
@@ -0,0 +1,167 @@
package chat.simplex.app.views.usersettings
import SectionCustomFooter
import SectionDivider
import SectionItemView
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.*
import chat.simplex.app.views.helpers.*
@Composable
fun AcceptRequestsView(m: ChatModel, contactLink: UserContactLinkRec) {
var contactLink by remember { mutableStateOf(contactLink) }
AcceptRequestsLayout(
contactLink,
saveState = { new: MutableState<AutoAcceptState>, old: MutableState<AutoAcceptState> ->
withApi {
val link = m.controller.userAddressAutoAccept(new.value.autoAccept)
if (link != null) {
contactLink = link
m.userAddress.value = link
old.value = new.value
}
}
}
)
}
@Composable
private fun AcceptRequestsLayout(
contactLink: UserContactLinkRec,
saveState: (new: MutableState<AutoAcceptState>, old: MutableState<AutoAcceptState>) -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(R.string.contact_requests))
val autoAcceptState = remember { mutableStateOf(AutoAcceptState(contactLink)) }
val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) }
SectionView(stringResource(R.string.accept_requests).uppercase()) {
SectionItemView {
PreferenceToggleWithIcon(stringResource(R.string.accept_automatically), Icons.Outlined.Check, checked = autoAcceptState.value.enable) {
autoAcceptState.value = if (!it)
AutoAcceptState()
else
AutoAcceptState(it, autoAcceptState.value.incognito, autoAcceptState.value.welcomeText)
}
}
if (autoAcceptState.value.enable) {
SectionDivider()
SectionItemView {
PreferenceToggleWithIcon(
stringResource(R.string.incognito),
if (autoAcceptState.value.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.TheaterComedy,
if (autoAcceptState.value.incognito) Indigo else HighOrLowlight,
autoAcceptState.value.incognito,
) {
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, it, autoAcceptState.value.welcomeText)
}
}
}
}
val welcomeText = remember { mutableStateOf(autoAcceptState.value.welcomeText) }
SectionCustomFooter(PaddingValues(horizontal = DEFAULT_PADDING)) {
ButtonsFooter(
cancel = {
autoAcceptState.value = autoAcceptStateSaved.value
welcomeText.value = autoAcceptStateSaved.value.welcomeText
},
save = { saveState(autoAcceptState, autoAcceptStateSaved) },
disabled = autoAcceptState.value == autoAcceptStateSaved.value
)
}
Spacer(Modifier.height(DEFAULT_PADDING))
if (autoAcceptState.value.enable) {
Text(
stringResource(R.string.section_title_welcome_message), color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
)
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
LaunchedEffect(welcomeText.value) {
if (welcomeText.value != autoAcceptState.value.welcomeText) {
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, autoAcceptState.value.incognito, welcomeText.value)
}
}
}
}
}
@Composable
private fun ButtonsFooter(cancel: () -> Unit, save: () -> Unit, disabled: Boolean) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
FooterButton(Icons.Outlined.Replay, stringResource(R.string.cancel_verb), cancel, disabled)
FooterButton(Icons.Outlined.Check, stringResource(R.string.save_verb), save, disabled)
}
}
private class AutoAcceptState {
var enable: Boolean = false
private set
var incognito: Boolean = false
private set
var welcomeText: String = ""
private set
constructor(enable: Boolean = false, incognito: Boolean = false, welcomeText: String = "") {
this.enable = enable
this.incognito = incognito
this.welcomeText = welcomeText
}
constructor(contactLink: UserContactLinkRec) {
contactLink.autoAccept?.let { aa ->
enable = true
incognito = aa.acceptIncognito
aa.autoReply?.let { msg ->
welcomeText = msg.text
} ?: run {
welcomeText = ""
}
}
}
val autoAccept: AutoAccept?
get() {
if (enable) {
var autoReply: MsgContent? = null
val s = welcomeText.trim()
if (s != "") {
autoReply = MsgContent.MCText(s)
}
return AutoAccept(incognito, autoReply)
}
return null
}
override fun equals(other: Any?): Boolean {
if (other !is AutoAcceptState) return false
return this.enable == other.enable && this.incognito == other.incognito && this.welcomeText == other.welcomeText
}
override fun hashCode(): Int {
var result = enable.hashCode()
result = 31 * result + incognito.hashCode()
result = 31 * result + welcomeText.hashCode()
return result
}
}
@@ -342,6 +342,36 @@ fun SettingsPreferenceItemWithInfo(
}
}
@Composable
fun PreferenceToggleWithIcon(
text: String,
icon: ImageVector,
iconColor: Color = HighOrLowlight,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
icon,
null,
tint = iconColor
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = checked,
onCheckedChange = {
onChange(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
)
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -2,10 +2,13 @@ package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -16,8 +19,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.model.UserContactLinkRec
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@@ -25,13 +28,21 @@ import chat.simplex.app.views.newchat.QRCode
fun UserAddressView(chatModel: ChatModel) {
val cxt = LocalContext.current
UserAddressLayout(
userAddress = chatModel.userAddress.value,
userAddress = remember { chatModel.userAddress }.value,
createAddress = {
withApi {
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
val connReqContact = chatModel.controller.apiCreateUserAddress()
if (connReqContact != null) {
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
}
}
},
share = { userAddress: String -> shareText(cxt, userAddress) },
acceptRequests = {
chatModel.userAddress.value?.let { address ->
ModalManager.shared.showModal(settings = true) { AcceptRequestsView(chatModel, address) }
}
},
deleteAddress = {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.delete_address__question),
@@ -50,12 +61,14 @@ fun UserAddressView(chatModel: ChatModel) {
@Composable
fun UserAddressLayout(
userAddress: String?,
userAddress: UserContactLinkRec?,
createAddress: () -> Unit,
share: (String) -> Unit,
acceptRequests: () -> Unit,
deleteAddress: () -> Unit
) {
Column(
Modifier.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
@@ -66,40 +79,45 @@ fun UserAddressLayout(
lineHeight = 22.sp
)
Column(
Modifier.fillMaxWidth(),
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
if (userAddress == null) {
Text(
stringResource(R.string.if_you_later_delete_address_you_wont_lose_contacts),
Modifier.padding(bottom = 12.dp),
Modifier.align(Alignment.Start).padding(bottom = 24.dp),
lineHeight = 22.sp
)
SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress)
} else {
Text(
stringResource(R.string.if_you_delete_address_you_wont_lose_contacts),
Modifier.padding(bottom = 12.dp),
Modifier.align(Alignment.Start).padding(bottom = 24.dp),
lineHeight = 22.sp
)
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
QRCode(userAddress.connReqContact, Modifier.aspectRatio(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 10.dp)
modifier = Modifier.padding(vertical = 16.dp)
) {
SimpleButton(
stringResource(R.string.share_link),
icon = Icons.Outlined.Share,
click = { share(userAddress) })
SimpleButton(
stringResource(R.string.delete_address),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteAddress
click = { share(userAddress.connReqContact) })
SimpleButtonIconEnded(
stringResource(R.string.contact_requests),
icon = Icons.Outlined.ChevronRight,
click = acceptRequests
)
}
SimpleButton(
stringResource(R.string.delete_address),
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteAddress
)
}
}
}
@@ -118,6 +136,7 @@ fun PreviewUserAddressLayoutNoAddress() {
userAddress = null,
createAddress = {},
share = { _ -> },
acceptRequests = {},
deleteAddress = {},
)
}
@@ -133,9 +152,10 @@ fun PreviewUserAddressLayoutNoAddress() {
fun PreviewUserAddressLayoutAddressCreated() {
SimpleXTheme {
UserAddressLayout(
userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
userAddress = UserContactLinkRec("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"),
createAddress = {},
share = { _ -> },
acceptRequests = {},
deleteAddress = {},
)
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<corners android:radius="18dp"/>
<solid android:color="@android:color/transparent"/>
<stroke
android:width="1dp"
android:color="#8b8786"/>
</shape>
</item>
</layer-list>
@@ -61,8 +61,6 @@
<string name="connection_error_auth_desc">Entweder hat Ihr Kontakt die Verbindung gelöscht, oder dieser Link wurde bereits verwendet, es könnte sich um einen Fehler handeln - Bitte melden Sie es uns.\nBitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben.</string>
<string name="error_accepting_contact_request">Fehler beim Akzeptieren der Kontaktanfrage</string>
<string name="sender_may_have_deleted_the_connection_request">Der Absender hat möglicherweise die Verbindungsanfrage gelöscht.</string>
<string name="cannot_delete_contact">Der Kontakt kann nicht gelöscht werden!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Der Kontakt mit <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> kann nicht gelöscht werden, da er Mitglied einer oder mehrerer dieser Gruppen ist <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="error_deleting_contact">Fehler beim Löschen des Kontakts</string>
<string name="error_deleting_group">Fehler beim Löschen der Gruppe</string>
<string name="error_deleting_contact_request">Fehler beim Löschen der Kontakt-Anfrage</string>
@@ -183,7 +181,7 @@
<string name="images_limit_title">Zu viele Bilder!</string>
<string name="images_limit_desc">Es können nur 10 Bilder auf einmal gesendet werden</string>
<!-- Images - CIImageView.kt -->
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Bild</string>
<string name="icon_descr_waiting_for_image">Warten auf ein Bild</string>
<string name="icon_descr_asked_to_receive">Um Empfang des Bildes gebeten</string>
@@ -273,6 +271,7 @@
<string name="delete_contact_menu_action">Kontakt löschen</string>
<string name="delete_group_menu_action">Gruppe löschen</string>
<string name="mark_read">Als gelesen markieren</string>
<string name="mark_unread">Mark unread</string>
<string name="set_contact_name">Kontaktname festlegen</string>
<!-- Actions - ChatListNavLinkView.kt -->
@@ -400,6 +399,12 @@
<string name="share_link">Link teilen</string>
<string name="delete_address">Adresse löschen</string>
<!-- AcceptRequestsView.kt -->
<string name="contact_requests">***Contact requests</string>
<string name="accept_requests">***Accept requests</string>
<string name="accept_automatically">***Automatically</string>
<string name="section_title_welcome_message">***WELCOME MESSAGE</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Angezeigter Name:</string>
<string name="full_name__field">"Vollständiger Name:</string>
@@ -707,16 +712,21 @@
<string name="group_invitation_expired">Die Gruppeneinladung ist abgelaufen</string>
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">Sie haben <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g> eingeladen.</string>
<string name="rcv_group_event_member_added">***sie haben <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g> eingeladen.</string>
<string name="rcv_group_event_member_connected">verbunden</string>
<string name="rcv_group_event_member_left">verlassen</string>
<string name="rcv_group_event_changed_member_role">***changed role of %s to %s</string>
<string name="rcv_group_event_changed_your_role">***changed your role to %s</string>
<string name="rcv_group_event_member_deleted">entfernte <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g>.</string>
<string name="rcv_group_event_user_deleted">hat Sie entfernt</string>
<string name="rcv_group_event_group_deleted">Gruppe gelöscht</string>
<string name="rcv_group_event_updated_group_profile">aktualisiertes Gruppenprofil</string>
<string name="snd_group_event_member_deleted">Sie haben <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g> entfernt.</string>
<string name="snd_group_event_user_left">Sie haben verlassen</string>
<string name="snd_group_event_group_profile_updated">Gruppenprofil aktualisiert</string>
<string name="rcv_group_event_group_deleted">gruppe gelöscht</string>
<string name="rcv_group_event_updated_group_profile">aktualisiertes gruppenprofil</string>
<string name="rcv_group_event_invited_via_your_group_link">***invited via your group link</string>
<string name="snd_group_event_changed_member_role">***you changed role of %s to %s</string>
<string name="snd_group_event_changed_role_for_yourself">***you changed role for yourself to %s</string>
<string name="snd_group_event_member_deleted">sie haben <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g> entfernt.</string>
<string name="snd_group_event_user_left">sie haben verlassen</string>
<string name="snd_group_event_group_profile_updated">gruppenprofil aktualisiert</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">Mitglied</string>
@@ -760,6 +770,16 @@
<string name="delete_group_for_self_cannot_undo_warning">Die Gruppe wird für Sie gelöscht - dies kann nicht rückgängig gemacht werden!</string>
<string name="button_leave_group">Gruppe verlassen</string>
<string name="button_edit_group_profile">Gruppenprofil bearbeiten</string>
<string name="group_link">***Group link</string>
<string name="button_create_group_link">***Create link</string>
<string name="delete_link_question">***Delete link?</string>
<string name="delete_link">***Delete link</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">***You can share a link or a QR code - anybody will be able to join the group.</string>
<string name="if_you_later_delete_link_you_wont_lose_members">***If you later delete it - you won\'t lose members of the group connected via the link.</string>
<string name="if_you_delete_group_link_you_wont_lose_members">***If you delete it - you won\'t lose members of the group connected via this link.</string>
<string name="all_group_members_will_remain_connected">***All group members will remain connected.</string>
<string name="error_creating_link_for_group">***Error creating group link</string>
<string name="error_deleting_link_for_group">***Error deleting group link</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FÜR KONSOLE</string>
@@ -780,8 +800,6 @@
<string name="member_role_will_be_changed_with_invitation">Die Mitgliederrolle wird auf \"%s\" geändert. Das Mitglied wird eine neue Einladung erhalten.</string>
<string name="error_removing_member">Fehler beim Entfernen des Mitglieds</string>
<string name="error_changing_role">Fehler beim Ändern der Rolle</string>
<string name="member_role">Mitglieder %s Rolle: %s</string>
<string name="your_member_role">Meine Rolle: %s</string>
<string name="info_row_group">Gruppe</string>
<string name="info_row_connection">Verbindung</string>
<string name="conn_level_desc_direct">direkt</string>
@@ -61,8 +61,6 @@
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
<string name="error_accepting_contact_request">Ошибка при принятии запроса на соединение</string>
<string name="sender_may_have_deleted_the_connection_request">Отправитель мог удалить запрос на соединение.</string>
<string name="cannot_delete_contact">Невозможно удалить контакт!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Контакт <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> не может быть удален, так как является членом групп(ы) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="error_deleting_contact">Ошибка удаления контакта</string>
<string name="error_deleting_group">Ошибка удаления группы</string>
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
@@ -183,7 +181,7 @@
<string name="images_limit_title">Слишком много изображений!</string>
<string name="images_limit_desc">Только 10 изображений могут быть отправлены одномоментно</string>
<!-- Images - CIImageView.kt -->
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Изображение</string>
<string name="icon_descr_waiting_for_image">Ожидается прием изображения</string>
<string name="icon_descr_asked_to_receive">Предложено получить изображение</string>
@@ -273,6 +271,7 @@
<string name="delete_contact_menu_action">Удалить</string>
<string name="delete_group_menu_action">Удалить</string>
<string name="mark_read">Прочитано</string>
<string name="mark_unread">Не прочитано</string>
<string name="set_contact_name">Имя контакта</string>
<!-- Actions - ChatListNavLinkView.kt -->
@@ -397,6 +396,12 @@
<string name="share_link">Поделиться\nссылкой</string>
<string name="delete_address">Удалить\nадрес</string>
<!-- AcceptRequestsView.kt -->
<string name="contact_requests">Запросы контактов</string>
<string name="accept_requests">Принимать запросы</string>
<string name="accept_automatically">Автоматически</string>
<string name="section_title_welcome_message">ПРИВЕТСТВЕННОЕ СООБЩЕНИЕ</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Имя профиля:</string>
<string name="full_name__field">"Полное имя:</string>
@@ -710,10 +715,15 @@
<string name="rcv_group_event_member_added">пригласил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">соединен(а)</string>
<string name="rcv_group_event_member_left">покинул(а) группу</string>
<string name="rcv_group_event_changed_member_role">поменял(а) роль члена %s на: %s</string>
<string name="rcv_group_event_changed_your_role">поменял(а) вашу роль на: %s</string>
<string name="rcv_group_event_member_deleted">удалил(а) <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">удалил(а) вас из группы</string>
<string name="rcv_group_event_group_deleted">удалил(а) группу</string>
<string name="rcv_group_event_updated_group_profile">обновил(а) профиль группы</string>
<string name="rcv_group_event_invited_via_your_group_link">приглашен(а) через вашу ссылку группы</string>
<string name="snd_group_event_changed_member_role">вы поменяли роль члена %s на: %s</string>
<string name="snd_group_event_changed_role_for_yourself">вы поменяли роль себе на: %s</string>
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
<string name="snd_group_event_group_profile_updated">профиль группы обновлен</string>
@@ -760,6 +770,16 @@
<string name="delete_group_for_self_cannot_undo_warning">Группа будет удалена для вас - это действие нельзя отменить!</string>
<string name="button_leave_group">Выйти из группы</string>
<string name="button_edit_group_profile">Редактировать профиль группы</string>
<string name="group_link">Ссылка группы</string>
<string name="button_create_group_link">Создать ссылку</string>
<string name="delete_link_question">Удалить ссылку?</string>
<string name="delete_link">Удалить ссылку</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе.</string>
<string name="if_you_later_delete_link_you_wont_lose_members">Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились.</string>
<string name="if_you_delete_group_link_you_wont_lose_members">Вы можете удалить ссылку, сохранив членов группы, которые через нее соединились.</string>
<string name="all_group_members_will_remain_connected">Все члены группы, которые соединились через эту ссылку, останутся в группе.</string>
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
@@ -780,8 +800,6 @@
<string name="member_role_will_be_changed_with_invitation">Роль будет изменена на \"%s\". Будет отправлено новое приглашение.</string>
<string name="error_removing_member">Ошибка при удалении члена группы</string>
<string name="error_changing_role">Ошибка при изменении роли</string>
<string name="member_role">роль %s: %s</string>
<string name="your_member_role">ваша роль: %s</string>
<string name="info_row_group">Группа</string>
<string name="info_row_connection">Соединение</string>
<string name="conn_level_desc_direct">прямое</string>
@@ -61,8 +61,6 @@
<string name="connection_error_auth_desc">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.</string>
<string name="error_accepting_contact_request">Error accepting contact request</string>
<string name="sender_may_have_deleted_the_connection_request">Sender may have deleted the connection request.</string>
<string name="cannot_delete_contact">Can\'t delete contact!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Contact <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> cannot be deleted, they are a member of the group(s) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="error_deleting_contact">Error deleting contact</string>
<string name="error_deleting_group">Error deleting group</string>
<string name="error_deleting_contact_request">Error deleting contact request</string>
@@ -183,7 +181,7 @@
<string name="images_limit_title">Too many images!</string>
<string name="images_limit_desc">Only 10 images can be sent at the same time</string>
<!-- Images - CIImageView.kt -->
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string>
<string name="icon_descr_waiting_for_image">Waiting for image</string>
<string name="icon_descr_asked_to_receive">Asked to receive the image</string>
@@ -273,6 +271,7 @@
<string name="delete_contact_menu_action">Delete</string>
<string name="delete_group_menu_action">Delete</string>
<string name="mark_read">Mark read</string>
<string name="mark_unread">Mark unread</string>
<string name="set_contact_name">Set contact name</string>
<!-- Actions - ChatListNavLinkView.kt -->
@@ -400,6 +399,12 @@
<string name="share_link">Share link</string>
<string name="delete_address">Delete address</string>
<!-- AcceptRequestsView.kt -->
<string name="contact_requests">Contact requests</string>
<string name="accept_requests">Accept requests</string>
<string name="accept_automatically">Automatically</string>
<string name="section_title_welcome_message">WELCOME MESSAGE</string>
<!-- User profile details - UserProfileView.kt -->
<string name="display_name__field">Display name:</string>
<string name="full_name__field">"Full name:</string>
@@ -710,10 +715,15 @@
<string name="rcv_group_event_member_added">invited <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_member_connected">connected</string>
<string name="rcv_group_event_member_left">left</string>
<string name="rcv_group_event_changed_member_role">changed role of %s to %s</string>
<string name="rcv_group_event_changed_your_role">changed your role to %s</string>
<string name="rcv_group_event_member_deleted">removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="rcv_group_event_user_deleted">removed you</string>
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="rcv_group_event_updated_group_profile">updated group profile</string>
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="snd_group_event_changed_member_role">you changed role of %s to %s</string>
<string name="snd_group_event_changed_role_for_yourself">you changed role for yourself to %s</string>
<string name="snd_group_event_member_deleted">you removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
@@ -760,6 +770,16 @@
<string name="delete_group_for_self_cannot_undo_warning">Group will be deleted for you - this cannot be undone!</string>
<string name="button_leave_group">Leave group</string>
<string name="button_edit_group_profile">Edit group profile</string>
<string name="group_link">Group link</string>
<string name="button_create_group_link">Create link</string>
<string name="delete_link_question">Delete link?</string>
<string name="delete_link">Delete link</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">You can share a link or a QR code - anybody will be able to join the group.</string>
<string name="if_you_later_delete_link_you_wont_lose_members">If you later delete it - you won\'t lose members of the group connected via the link.</string>
<string name="if_you_delete_group_link_you_wont_lose_members">If you delete it - you won\'t lose members of the group connected via this link.</string>
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
<string name="error_creating_link_for_group">Error creating group link</string>
<string name="error_deleting_link_for_group">Error deleting group link</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -780,8 +800,6 @@
<string name="member_role_will_be_changed_with_invitation">The role will be changed to \"%s\". The member will receive a new invitation.</string>
<string name="error_removing_member">Error removing member</string>
<string name="error_changing_role">Error changing role</string>
<string name="member_role">member %s role: %s</string>
<string name="your_member_role">your role: %s</string>
<string name="info_row_group">Group</string>
<string name="info_row_connection">Connection</string>
<string name="conn_level_desc_direct">direct</string>
+22 -5
View File
@@ -30,7 +30,7 @@ final class ChatModel: ObservableObject {
@Published var groupMembers: [GroupMember] = []
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: String?
@Published var userAddress: UserContactLink?
@Published var userSMPServers: [String]?
@Published var chatItemTTL: ChatItemTTL = .none
@Published var appOpenUrl: URL?
@@ -98,7 +98,7 @@ final class ChatModel: ObservableObject {
}
func updateContact(_ contact: Contact) {
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact)
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact && !contact.viaGroupLink)
}
func updateGroup(_ groupInfo: GroupInfo) {
@@ -113,6 +113,17 @@ final class ChatModel: ObservableObject {
}
}
private func _updateChat(_ id: ChatId, _ update: @escaping (Chat) -> Void) {
if let i = getChatIndex(id) {
// we need to separately update the chat object, as it is ObservedObject,
// and chat in the list so the list view is updated...
// simply updating chats[i] replaces the object without updating the current object in the list
let chat = chats[i]
update(chat)
chats[i] = chat
}
}
func updateNetworkStatus(_ id: ChatId, _ status: Chat.NetworkStatus) {
if let i = getChatIndex(id) {
chats[i].serverInfo.networkStatus = status
@@ -257,7 +268,7 @@ final class ChatModel: ObservableObject {
func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview
if let chat = getChat(cInfo.id) {
_updateChat(cInfo.id) { chat in
NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount)
chat.chatStats = ChatStats()
}
@@ -282,11 +293,11 @@ final class ChatModel: ObservableObject {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
markCurrentChatRead(fromIndex: i)
if let chat = getChat(cInfo.id) {
_updateChat(cInfo.id) { chat in
var unreadBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1
}
j -= 1
@@ -303,6 +314,12 @@ final class ChatModel: ObservableObject {
markChatItemsRead(cInfo)
}
}
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
_updateChat(cInfo.id) { chat in
chat.chatStats.unreadChat = unreadChat
}
}
func clearChat(_ cInfo: ChatInfo) {
// clear preview
+76 -31
View File
@@ -426,18 +426,10 @@ func deleteChat(_ chat: Chat) async {
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
switch error as? ChatResponse {
case let .chatCmdError(.error(.contactGroups(contact, groupNames))):
AlertManager.shared.showAlertMsg(
title: "Can't delete contact!",
message: "Contact \(contact.displayName) cannot be deleted, they are a member of the group(s) \(groupNames.joined(separator: ", "))."
)
default:
AlertManager.shared.showAlertMsg(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
AlertManager.shared.showAlertMsg(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
}
@@ -496,13 +488,20 @@ func apiDeleteUserAddress() async throws {
throw r
}
func apiGetUserAddress() throws -> String? {
func apiGetUserAddress() throws -> UserContactLink? {
let r = chatSendCmdSync(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)):
return nil
case let .userContactLink(contactLink): return contactLink
case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
default: throw r
}
}
func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? {
let r = await chatSendCmd(.addressAutoAccept(autoAccept: autoAccept))
switch r {
case let .userContactLinkUpdated(contactLink): return contactLink
case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil
default: throw r
}
}
@@ -537,6 +536,10 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
}
func receiveFile(fileId: Int64) async {
if let chatItem = await apiReceiveFile(fileId: fileId) {
DispatchQueue.main.async { chatItemSimpleUpdate(chatItem) }
@@ -642,16 +645,31 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
do {
let minItemId = chat.chatStats.minUnreadItemId
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
if chat.chatStats.unreadCount > 0 {
let minItemId = chat.chatStats.minUnreadItemId
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
await MainActor.run { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
}
if chat.chatStats.unreadChat {
await markChatUnread(chat, unreadChat: false)
}
} catch {
logger.error("markChatRead apiChatRead error: \(responseError(error))")
}
}
func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
do {
let cInfo = chat.chatInfo
try await apiChatUnread(type: cInfo.chatType, id: cInfo.apiId, unreadChat: unreadChat)
await MainActor.run { ChatModel.shared.markChatUnread(cInfo, unreadChat: unreadChat) }
} catch {
logger.error("markChatUnread apiChatUnread error: \(responseError(error))")
}
}
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
@@ -743,6 +761,29 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
throw r
}
func apiCreateGroupLink(_ groupId: Int64) async throws -> String {
let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId))
if case let .groupLinkCreated(_, connReq) = r { return connReq }
throw r
}
func apiDeleteGroupLink(_ groupId: Int64) async throws {
let r = await chatSendCmd(.apiDeleteGroupLink(groupId: groupId))
if case .groupLinkDeleted = r { return }
throw r
}
func apiGetGroupLink(_ groupId: Int64) throws -> String? {
let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId))
switch r {
case let .groupLink(_, connReq):
return connReq
case .chatCmdError(chatError: .errorStore(storeError: .groupLinkNotFound)):
return nil
default: throw r
}
}
func initializeChat(start: Bool, dbKey: String? = nil) throws {
logger.debug("initializeChat")
let m = ChatModel.shared
@@ -840,16 +881,20 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateContactConnection(connection)
case let .contactConnectionDeleted(connection):
m.removeChat(connection.id)
case let .contactConnected(contact):
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
m.updateNetworkStatus(contact.id, .connected)
NtfManager.shared.notifyContactConnected(contact)
case let .contactConnected(contact, _):
if !contact.viaGroupLink {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
m.updateNetworkStatus(contact.id, .connected)
NtfManager.shared.notifyContactConnected(contact)
}
case let .contactConnecting(contact):
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
if !contact.viaGroupLink {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
}
case let .receivedContactRequest(contactRequest):
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
if m.hasChat(contactRequest.id) {
@@ -56,14 +56,12 @@ struct ChatInfoView: View {
enum ChatInfoViewAlert: Identifiable {
case deleteContactAlert
case contactGroupsAlert(groupNames: [GroupName])
case clearChatAlert
case networkStatusAlert
var id: String {
switch self {
case .deleteContactAlert: return "deleteContactAlert"
case .contactGroupsAlert: return "contactGroupsAlert"
case .clearChatAlert: return "clearChatAlert"
case .networkStatusAlert: return "networkStatusAlert"
}
@@ -119,7 +117,6 @@ struct ChatInfoView: View {
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .deleteContactAlert: return deleteContactAlert()
case let .contactGroupsAlert(groupNames): return contactGroupsAlert(groupNames)
case .clearChatAlert: return clearChatAlert()
case .networkStatusAlert: return networkStatusAlert()
}
@@ -230,9 +227,6 @@ struct ChatInfoView: View {
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
if case let .chatCmdError(.error(.contactGroups(_, groupNames))) = error as? ChatResponse {
alert = .contactGroupsAlert(groupNames: groupNames)
}
}
}
},
@@ -240,13 +234,6 @@ struct ChatInfoView: View {
)
}
private func contactGroupsAlert(_ groupNames: [GroupName]) -> Alert {
Alert(
title: Text("Can't delete contact!"),
message: Text("Contact \(contact.displayName) cannot be deleted, they are a member of the group(s) \(groupNames.joined(separator: ", ")).")
)
}
private func clearChatAlert() -> Alert {
Alert(
title: Text("Clear conversation?"),
@@ -207,8 +207,17 @@ private struct MetaColorPreferenceKey: PreferenceKey {
}
}
func onlyImage(_ ci: ChatItem) -> Bool {
if case let .image(text, _) = ci.content.msgContent {
return ci.quotedItem == nil && text == ""
}
return false
}
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
ci.chatDir.sent
onlyImage(ci)
? Color.clear
: ci.chatDir.sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
}
@@ -62,6 +62,13 @@ struct ChatView: View {
.navigationTitle(cInfo.chatViewName)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.onAppear {
if chat.chatStats.unreadChat {
Task {
await markChatUnread(chat, unreadChat: false)
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
@@ -16,6 +16,7 @@ struct GroupChatInfoView: View {
var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
@State private var showGroupProfile: Bool = false
@@ -43,6 +44,7 @@ struct GroupChatInfoView: View {
Section("\(members.count + 1) members") {
if groupInfo.canAddMembers {
groupLinkButton()
if (chat.chatInfo.incognito) {
Label("Invite members", systemImage: "plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
@@ -105,7 +107,13 @@ struct GroupChatInfoView: View {
case .clearChatAlert: return clearChatAlert()
case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
}
}
.onAppear {
do {
groupLink = try apiGetGroupLink(groupInfo.groupId)
} catch let error {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
}
}
}
@@ -175,6 +183,16 @@ struct GroupChatInfoView: View {
}
}
private func groupLinkButton() -> some View {
NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
.navigationBarTitleDisplayMode(.inline)
} label: {
Label("Group link", systemImage: "link")
.foregroundColor(.accentColor)
}
}
func editGroupButton() -> some View {
Button {
showGroupProfile = true
@@ -0,0 +1,109 @@
//
// GroupLinkView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 15.10.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct GroupLinkView: View {
var groupId: Int64
@Binding var groupLink: String?
@State private var alert: GroupLinkAlert?
private enum GroupLinkAlert: Identifiable {
case deleteLink
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
case .deleteLink: return "deleteLink"
case let .error(title, _): return "error \(title)"
}
}
}
var body: some View {
ScrollView {
VStack (alignment: .leading) {
Text("Group link")
.font(.largeTitle)
.bold()
.padding(.bottom)
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
.padding(.bottom)
if let groupLink = groupLink {
QRCode(uri: groupLink)
HStack {
Button {
showShareSheet(items: [groupLink])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
.padding()
Button(role: .destructive) { alert = .deleteLink } label: {
Label("Delete link", systemImage: "trash")
}
.padding()
}
.frame(maxWidth: .infinity)
} else {
Button {
Task {
do {
groupLink = try await apiCreateGroupLink(groupId)
} catch let error {
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
let a = getErrorAlert(error, "Error creating group link")
alert = .error(title: a.title, error: "\(a.message)")
}
}
} label: { Label("Create link", systemImage: "link.badge.plus") }
.frame(maxWidth: .infinity)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alert in
switch alert {
case .deleteLink:
return Alert(
title: Text("Delete link?"),
message: Text("All group members will remain connected."),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
try await apiDeleteGroupLink(groupId)
await MainActor.run {
groupLink = nil
}
} catch let error {
logger.error("GroupLinkView apiDeleteGroupLink: \(responseError(error))")
}
}
}, secondaryButton: .cancel()
)
case let .error(title, error):
return Alert(title: Text(title), message: Text("\(error)"))
}
}
}
}
}
struct GroupLinkView_Previews: PreviewProvider {
static var previews: some View {
@State var groupLink: String? = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
@State var noGroupLink: String? = nil
return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink)
GroupLinkView(groupId: 1, groupLink: $noGroupLink)
}
}
}
@@ -48,27 +48,27 @@ struct GroupMemberInfoView: View {
Section("Member") {
infoRow("Group", groupInfo.displayName)
// HStack {
// if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
// Picker("Change role", selection: $newRole) {
// ForEach(roles) { role in
// Text(role.text)
// .foregroundStyle(.secondary)
// }
// }
// } else {
// Text("Role")
// Spacer()
// Text(member.memberRole.text)
// .foregroundStyle(.secondary)
// }
// }
// .onAppear { newRole = member.memberRole }
// .onChange(of: newRole) { _ in
// if newRole != member.memberRole {
// alert = .changeMemberRoleAlert(role: newRole)
// }
// }
HStack {
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
.foregroundStyle(.secondary)
}
}
} else {
Text("Role")
Spacer()
Text(member.memberRole.text)
.foregroundStyle(.secondary)
}
}
.onAppear { newRole = member.memberRole }
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(role: newRole)
}
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
@@ -53,9 +53,7 @@ struct ChatListNavLink: View {
disabled: !contact.ready
)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if chat.chatStats.unreadCount > 0 {
markReadButton()
}
markReadButton()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
@@ -116,9 +114,7 @@ struct ChatListNavLink: View {
)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if chat.chatStats.unreadCount > 0 {
markReadButton()
}
markReadButton()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
@@ -150,13 +146,23 @@ struct ChatListNavLink: View {
.tint(chat.chatInfo.incognito ? .indigo : .accentColor)
}
private func markReadButton() -> some View {
Button {
Task { await markChatRead(chat) }
} label: {
Label("Read", systemImage: "checkmark")
@ViewBuilder private func markReadButton() -> some View {
if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat {
Button {
Task { await markChatRead(chat) }
} label: {
Label("Read", systemImage: "checkmark")
}
.tint(Color.accentColor)
} else {
Button {
Task { await markChatUnread(chat) }
} label: {
Label("Unread", systemImage: "circlebadge.fill")
}
.tint(Color.accentColor)
}
.tint(Color.accentColor)
}
private func clearChatButton() -> some View {
@@ -17,7 +17,6 @@ struct ChatPreviewView: View {
var body: some View {
let cItem = chat.chatItems.last
let unread = chat.chatStats.unreadCount
return HStack(spacing: 8) {
ZStack(alignment: .bottomTrailing) {
ChatInfoImage(chat: chat)
@@ -41,7 +40,7 @@ struct ChatPreviewView: View {
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
chatPreviewText(cItem, unread)
chatPreviewText(cItem)
if case .direct = chat.chatInfo {
chatStatusImage()
.padding(.top, 24)
@@ -100,7 +99,7 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?, _ unread: Int) -> some View {
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
if let cItem = cItem {
ZStack(alignment: .topTrailing) {
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
@@ -109,8 +108,9 @@ struct ChatPreviewView: View {
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
if unread > 0 {
unreadCountText(unread)
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
@@ -185,7 +185,7 @@ struct ChatPreviewView: View {
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : "\(n)")
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
struct ChatPreviewView_Previews: PreviewProvider {
@@ -14,7 +14,6 @@ struct ContactConnectionInfo: View {
@Environment(\.dismiss) var dismiss: DismissAction
@State var contactConnection: PendingContactConnection
@State private var alert: CCInfoAlert?
@State private var showQRCodeView = false
@State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool
@@ -60,9 +59,12 @@ struct ContactConnectionInfo: View {
.onSubmit(setConnectionAlias)
}
if contactConnection.initiated && contactConnection.connReqInv != nil {
Button {
showQRCodeView = true
if contactConnection.initiated,
let connReqInv = contactConnection.connReqInv {
NavigationLink {
AddContactView(contactConnection: contactConnection, connReqInvitation: connReqInv)
.navigationTitle(CreateLinkTab.oneTime.title)
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Show QR code", systemImage: "qrcode")
.foregroundColor(contactConnection.incognito ? .indigo : .accentColor)
@@ -79,11 +81,6 @@ struct ContactConnectionInfo: View {
}
.listStyle(.insetGrouped)
}
.sheet(isPresented: $showQRCodeView) {
if let connReqInv = contactConnection.connReqInv {
AddContactView(contactConnection: contactConnection, connReqInvitation: connReqInv)
}
}
.alert(item: $alert) { _alert in
switch _alert {
case .deleteInvitationAlert:
@@ -14,15 +14,10 @@ struct AddContactView: View {
@EnvironmentObject private var chatModel: ChatModel
var contactConnection: PendingContactConnection? = nil
var connReqInvitation: String
var viaSettings = false
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("One-time invitation link")
.font(.largeTitle)
.bold()
.padding(viaSettings ? .bottom : .vertical)
Text("Your contact can scan it from the app.")
.padding(.bottom, 4)
if (contactConnection?.incognito ?? chatModel.incognito) {
@@ -11,6 +11,13 @@ import SwiftUI
enum CreateLinkTab {
case oneTime
case longTerm
var title: LocalizedStringKey {
switch self {
case .oneTime: return "One-time invitation link"
case .longTerm: return "Your contact address"
}
}
}
struct CreateLinkView: View {
@@ -18,11 +25,21 @@ struct CreateLinkView: View {
@State var selection: CreateLinkTab
@State var connReqInvitation: String = ""
@State private var creatingConnReq = false
var viaSettings = false
var viaNavLink = false
var body: some View {
if viaNavLink {
createLinkView()
} else {
NavigationView {
createLinkView()
}
}
}
private func createLinkView() -> some View {
TabView(selection: $selection) {
AddContactView(connReqInvitation: connReqInvitation, viaSettings: viaSettings)
AddContactView(connReqInvitation: connReqInvitation)
.tabItem {
Label(
connReqInvitation == ""
@@ -32,7 +49,7 @@ struct CreateLinkView: View {
)
}
.tag(CreateLinkTab.oneTime)
UserAddress(viaSettings: viaSettings)
UserAddress()
.tabItem {
Label("Your contact address", systemImage: "infinity.circle")
}
@@ -45,6 +62,8 @@ struct CreateLinkView: View {
}
.onAppear { m.connReqInv = connReqInvitation }
.onDisappear { m.connReqInv = nil }
.navigationTitle(selection.title)
.navigationBarTitleDisplayMode(.large)
}
private func createInvitation() {
@@ -0,0 +1,131 @@
//
// AcceptRequestsView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 23/10/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct AcceptRequestsView: View {
@EnvironmentObject private var m: ChatModel
@State var contactLink: UserContactLink
@State private var a = AutoAcceptState()
@State private var saved = AutoAcceptState()
@FocusState private var keyboardVisible: Bool
var body: some View {
List {
Section {
settingsRow("checkmark") {
Toggle("Automatically", isOn: $a.enable)
}
if a.enable {
settingsRow(
a.incognito ? "theatermasks.fill" : "theatermasks",
color: a.incognito ? .indigo : .secondary
) {
Toggle("Incognito", isOn: $a.incognito)
}
}
} header: {
Text("Accept requests")
} footer: {
saveButtons()
}
if a.enable {
Section {
TextEditor(text: $a.welcomeText)
.focused($keyboardVisible)
.padding(.horizontal, -5)
.padding(.top, -8)
.frame(height: 90, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
} header: {
Text("Welcome message")
}
}
}
.onAppear {
a = AutoAcceptState(contactLink: contactLink)
saved = a
}
.onChange(of: a.enable) { _ in
if !a.enable { a = AutoAcceptState() }
}
}
@ViewBuilder private func saveButtons() -> some View {
HStack {
Button {
a = saved
} label: {
Label("Cancel", systemImage: "arrow.counterclockwise")
}
Spacer()
Button {
Task {
do {
if let link = try await userAddressAutoAccept(a.autoAccept) {
contactLink = link
m.userAddress = link
saved = a
}
} catch let error {
logger.error("userAddressAutoAccept error: \(responseError(error))")
}
}
} label: {
Label("Save", systemImage: "checkmark")
}
}
.font(.body)
.disabled(a == saved)
}
private struct AutoAcceptState: Equatable {
var enable = false
var incognito = false
var welcomeText = ""
init(enable: Bool = false, incognito: Bool = false, welcomeText: String = "") {
self.enable = enable
self.incognito = incognito
self.welcomeText = welcomeText
}
init(contactLink: UserContactLink) {
if let aa = contactLink.autoAccept {
enable = true
incognito = aa.acceptIncognito
if let msg = aa.autoReply {
welcomeText = msg.text
} else {
welcomeText = ""
}
} else {
enable = false
incognito = false
welcomeText = ""
}
}
var autoAccept: AutoAccept? {
if enable {
var autoReply: MsgContent? = nil
let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
if s != "" { autoReply = .text(s) }
return AutoAccept(acceptIncognito: incognito, autoReply: autoReply)
}
return nil
}
}
}
struct AcceptRequestsView_Previews: PreviewProvider {
static var previews: some View {
AcceptRequestsView(contactLink: UserContactLink(connReqContact: ""))
}
}
@@ -93,7 +93,7 @@ struct SettingsView: View {
.disabled(chatModel.chatRunning != true)
NavigationLink {
CreateLinkView(selection: .longTerm, viaSettings: true)
CreateLinkView(selection: .longTerm, viaNavLink: true)
.navigationBarTitleDisplayMode(.inline)
} label: {
settingsRow("qrcode") { Text("Your SimpleX contact address") }
@@ -12,7 +12,7 @@ import SimpleXChat
struct UserAddress: View {
@EnvironmentObject private var chatModel: ChatModel
@State private var alert: UserAddressAlert?
var viaSettings = false
@State private var showAcceptRequests = false
private enum UserAddressAlert: Identifiable {
case deleteAddress
@@ -29,38 +29,49 @@ struct UserAddress: View {
var body: some View {
ScrollView {
VStack (alignment: .leading) {
Text("Your contact address")
.font(.largeTitle)
.bold()
.padding(viaSettings ? .bottom : .vertical)
Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.")
.padding(.bottom)
if let userAdress = chatModel.userAddress {
QRCode(uri: userAdress)
QRCode(uri: userAdress.connReqContact)
HStack {
Button {
showShareSheet(items: [userAdress])
showShareSheet(items: [userAdress.connReqContact])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
HStack {
Image(systemName: "square.and.arrow.up")
Text("Share link")
}
}
.padding()
Button(role: .destructive) { alert = .deleteAddress } label: {
Label("Delete address", systemImage: "trash")
NavigationLink {
if let contactLink = chatModel.userAddress {
AcceptRequestsView(contactLink: contactLink)
.navigationTitle("Contact requests")
.navigationBarTitleDisplayMode(.large)
}
} label: {
HStack {
Text("Contact requests")
Image(systemName: "chevron.right")
}
}
.padding()
}
.frame(maxWidth: .infinity)
Button(role: .destructive) { alert = .deleteAddress } label: {
Label("Delete address", systemImage: "trash")
}
.frame(maxWidth: .infinity)
} else {
Button {
Task {
do {
let userAddress = try await apiCreateUserAddress()
let connReqContact = try await apiCreateUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = userAddress
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
}
} catch let error {
logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
logger.error("UserAddress apiCreateUserAddress: \(responseError(error))")
let a = getErrorAlert(error, "Error creating address")
alert = .error(title: a.title, error: "\(a.message)")
}
@@ -71,6 +82,11 @@ struct UserAddress: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.sheet(isPresented: $showAcceptRequests) {
if let contactLink = chatModel.userAddress {
AcceptRequestsView(contactLink: contactLink)
}
}
.alert(item: $alert) { alert in
switch alert {
case .deleteAddress:
@@ -85,7 +101,7 @@ struct UserAddress: View {
chatModel.userAddress = nil
}
} catch let error {
logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
logger.error("UserAddress apiDeleteUserAddress: \(responseError(error))")
}
}
}, secondaryButton: .cancel()
@@ -101,7 +117,7 @@ struct UserAddress: View {
struct UserAddress_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
return Group {
UserAddress()
.environmentObject(chatModel)
@@ -258,6 +258,11 @@
<target>Erweiterte Netzwerkeinstellungen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All group members will remain connected." xml:space="preserve">
<source>All group members will remain connected.</source>
<target>***All group members will remain connected.</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>Alle Nachrichten werden gelöscht - dies kann nicht rückgängig gemacht werden! Die Nachrichten werden NUR bei Ihnen gelöscht.</target>
@@ -323,11 +328,6 @@
<target>Anrufe</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't delete contact!" xml:space="preserve">
<source>Can't delete contact!</source>
<target>Der Kontakt kann nicht gelöscht werden!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contact!" xml:space="preserve">
<source>Can't invite contact!</source>
<target>Kontakt kann nicht eingeladen werden!</target>
@@ -538,11 +538,6 @@
<target>Verbindungszeitüberschreitung</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact %@ cannot be deleted, they are a member of the group(s) %@." xml:space="preserve">
<source>Contact %@ cannot be deleted, they are a member of the group(s) %@.</source>
<target>Der Kontakt mit %@ kann nicht gelöscht werden, da er Mitglied einer oder mehrerer dieser Gruppen ist %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact already exists" xml:space="preserve">
<source>Contact already exists</source>
<target>Der Kontakt ist bereits vorhanden</target>
@@ -588,6 +583,11 @@
<target>Adresse erstellen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Create link" xml:space="preserve">
<source>Create link</source>
<target>***Create link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Create one-time invitation link" xml:space="preserve">
<source>Create one-time invitation link</source>
<target>Erstellen Sie einen einmaligen Einladungslink</target>
@@ -801,6 +801,16 @@
<target>Einladung löschen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete link" xml:space="preserve">
<source>Delete link</source>
<target>***Delete link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete link?" xml:space="preserve">
<source>Delete link?</source>
<target>***Delete link?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete message?" xml:space="preserve">
<source>Delete message?</source>
<target>Die Nachricht löschen?</target>
@@ -1016,6 +1026,11 @@
<target>Fehler beim Erzeugen der Gruppe</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating group link" xml:space="preserve">
<source>Error creating group link</source>
<target>***Error creating group link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error deleting chat database" xml:space="preserve">
<source>Error deleting chat database</source>
<target>Fehler beim Löschen der Chat-Datenbank</target>
@@ -1166,11 +1181,6 @@
<target>Datei: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Files" xml:space="preserve">
<source>Files</source>
<target>Dateien</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="For console" xml:space="preserve">
<source>For console</source>
<target>Für Konsole</target>
@@ -1221,6 +1231,11 @@
<target>Die Gruppeneinladung ist nicht mehr gültig, sie wurde vom Absender entfernt.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group link" xml:space="preserve">
<source>Group link</source>
<target>***Group link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group message:" xml:space="preserve">
<source>Group message:</source>
<target>Grppennachricht:</target>
@@ -2113,11 +2128,6 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Einmal-Einladungslink teilen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Shared one-time link" xml:space="preserve">
<source>Shared one-time link</source>
<target>Geteilter Einmal-Link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show QR code" xml:space="preserve">
<source>Show QR code</source>
<target>QR-Code anzeigen</target>
@@ -2437,6 +2447,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
<target>Stummschaltung aufheben</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unread" xml:space="preserve">
<source>Unread</source>
<target>***Unread</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update" xml:space="preserve">
<source>Update</source>
<target>Aktualisieren</target>
@@ -2577,6 +2592,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
<target>Über die Geräte-Einstellungen können Sie die Benachrichtigungsvorschau im Sperrbildschirm erlauben.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." xml:space="preserve">
<source>You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.</source>
<target>***You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." xml:space="preserve">
<source>You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.</source>
<target>Sie können Ihre Adresse als Link oder als QR-Code teilen Jeder kann sich darüber mit Ihnen verbinden. Sie werden Ihre mit dieser Adresse verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen.</target>
@@ -2846,6 +2866,16 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Anrufen…</target>
<note>call status</note>
</trans-unit>
<trans-unit id="changed role of %@ to %@" xml:space="preserve">
<source>changed role of %1$@ to %2$@</source>
<target>***changed role of %1$@ to %2$@</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="changed your role to %@" xml:space="preserve">
<source>changed your role to %@</source>
<target>***changed your role to %@</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="colored" xml:space="preserve">
<source>colored</source>
<target>farbig</target>
@@ -3021,6 +3051,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Für eine Verbindung eingeladen</target>
<note>chat list item title</note>
</trans-unit>
<trans-unit id="invited via your group link" xml:space="preserve">
<source>invited via your group link</source>
<target>***invited via your group link</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="italic" xml:space="preserve">
<source>italic</source>
<target>kursiv</target>
@@ -3041,12 +3076,6 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Mitglied</target>
<note>member role</note>
</trans-unit>
<trans-unit id="member %@ role: %@" xml:space="preserve">
<source>member %1$@ role: %2$@</source>
<target>Mitglieder %1$@ Rolle: %2$@</target>
<note>rcv group event chat item
snd group event chat item</note>
</trans-unit>
<trans-unit id="member connected" xml:space="preserve">
<source>connected</source>
<target>verbunden</target>
@@ -3202,6 +3231,16 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Sie sind zur Gruppe eingeladen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you changed role for yourself to %@" xml:space="preserve">
<source>you changed role for yourself to %@</source>
<target>***you changed role for yourself to %@</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="you changed role of %@ to %@" xml:space="preserve">
<source>you changed role of %1$@ to %2$@</source>
<target>***you changed role of %1$@ to %2$@</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="you left" xml:space="preserve">
<source>you left</source>
<target>Sie haben verlassen</target>
@@ -3227,12 +3266,6 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>Sie: </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="your role: %@" xml:space="preserve">
<source>your role: %@</source>
<target>Meine Rolle: %@</target>
<note>rcv group event chat item
snd group event chat item</note>
</trans-unit>
<trans-unit id="~strike~" xml:space="preserve">
<source>\~strike~</source>
<target>\~durchstreichen~</target>
@@ -258,6 +258,11 @@
<target>Advanced network settings</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All group members will remain connected." xml:space="preserve">
<source>All group members will remain connected.</source>
<target>All group members will remain connected.</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>
@@ -323,11 +328,6 @@
<target>Calls</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't delete contact!" xml:space="preserve">
<source>Can't delete contact!</source>
<target>Can't delete contact!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contact!" xml:space="preserve">
<source>Can't invite contact!</source>
<target>Can't invite contact!</target>
@@ -538,11 +538,6 @@
<target>Connection timeout</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact %@ cannot be deleted, they are a member of the group(s) %@." xml:space="preserve">
<source>Contact %@ cannot be deleted, they are a member of the group(s) %@.</source>
<target>Contact %@ cannot be deleted, they are a member of the group(s) %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact already exists" xml:space="preserve">
<source>Contact already exists</source>
<target>Contact already exists</target>
@@ -588,6 +583,11 @@
<target>Create address</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Create link" xml:space="preserve">
<source>Create link</source>
<target>Create link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Create one-time invitation link" xml:space="preserve">
<source>Create one-time invitation link</source>
<target>Create one-time invitation link</target>
@@ -801,6 +801,16 @@
<target>Delete invitation</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete link" xml:space="preserve">
<source>Delete link</source>
<target>Delete link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete link?" xml:space="preserve">
<source>Delete link?</source>
<target>Delete link?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete message?" xml:space="preserve">
<source>Delete message?</source>
<target>Delete message?</target>
@@ -1016,6 +1026,11 @@
<target>Error creating group</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating group link" xml:space="preserve">
<source>Error creating group link</source>
<target>Error creating group link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error deleting chat database" xml:space="preserve">
<source>Error deleting chat database</source>
<target>Error deleting chat database</target>
@@ -1166,11 +1181,6 @@
<target>File: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Files" xml:space="preserve">
<source>Files</source>
<target>Files</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="For console" xml:space="preserve">
<source>For console</source>
<target>For console</target>
@@ -1221,6 +1231,11 @@
<target>Group invitation is no longer valid, it was removed by sender.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group link" xml:space="preserve">
<source>Group link</source>
<target>Group link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group message:" xml:space="preserve">
<source>Group message:</source>
<target>Group message:</target>
@@ -2113,11 +2128,6 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Share one-time invitation link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Shared one-time link" xml:space="preserve">
<source>Shared one-time link</source>
<target>Shared one-time link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show QR code" xml:space="preserve">
<source>Show QR code</source>
<target>Show QR code</target>
@@ -2437,6 +2447,11 @@ To connect, please ask your contact to create another connection link and check
<target>Unmute</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unread" xml:space="preserve">
<source>Unread</source>
<target>Unread</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update" xml:space="preserve">
<source>Update</source>
<target>Update</target>
@@ -2577,6 +2592,11 @@ To connect, please ask your contact to create another connection link and check
<target>You can set lock screen notification preview via settings.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." xml:space="preserve">
<source>You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.</source>
<target>You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." xml:space="preserve">
<source>You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.</source>
<target>You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.</target>
@@ -2846,6 +2866,16 @@ SimpleX servers cannot see your profile.</target>
<target>calling…</target>
<note>call status</note>
</trans-unit>
<trans-unit id="changed role of %@ to %@" xml:space="preserve">
<source>changed role of %1$@ to %2$@</source>
<target>changed role of %1$@ to %2$@</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="changed your role to %@" xml:space="preserve">
<source>changed your role to %@</source>
<target>changed your role to %@</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="colored" xml:space="preserve">
<source>colored</source>
<target>colored</target>
@@ -3021,6 +3051,11 @@ SimpleX servers cannot see your profile.</target>
<target>invited to connect</target>
<note>chat list item title</note>
</trans-unit>
<trans-unit id="invited via your group link" xml:space="preserve">
<source>invited via your group link</source>
<target>invited via your group link</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="italic" xml:space="preserve">
<source>italic</source>
<target>italic</target>
@@ -3041,12 +3076,6 @@ SimpleX servers cannot see your profile.</target>
<target>member</target>
<note>member role</note>
</trans-unit>
<trans-unit id="member %@ role: %@" xml:space="preserve">
<source>member %1$@ role: %2$@</source>
<target>member %1$@ role: %2$@</target>
<note>rcv group event chat item
snd group event chat item</note>
</trans-unit>
<trans-unit id="member connected" xml:space="preserve">
<source>connected</source>
<target>connected</target>
@@ -3202,6 +3231,16 @@ SimpleX servers cannot see your profile.</target>
<target>you are invited to group</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you changed role for yourself to %@" xml:space="preserve">
<source>you changed role for yourself to %@</source>
<target>you changed role for yourself to %@</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="you changed role of %@ to %@" xml:space="preserve">
<source>you changed role of %1$@ to %2$@</source>
<target>you changed role of %1$@ to %2$@</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="you left" xml:space="preserve">
<source>you left</source>
<target>you left</target>
@@ -3227,12 +3266,6 @@ SimpleX servers cannot see your profile.</target>
<target>you: </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="your role: %@" xml:space="preserve">
<source>your role: %@</source>
<target>your role: %@</target>
<note>rcv group event chat item
snd group event chat item</note>
</trans-unit>
<trans-unit id="~strike~" xml:space="preserve">
<source>\~strike~</source>
<target>\~strike~</target>
@@ -258,6 +258,11 @@
<target>Настройки сети</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All group members will remain connected." xml:space="preserve">
<source>All group members will remain connected.</source>
<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>
@@ -323,11 +328,6 @@
<target>Звонки</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't delete contact!" xml:space="preserve">
<source>Can't delete contact!</source>
<target>Невозможно удалить контакт!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Can't invite contact!" xml:space="preserve">
<source>Can't invite contact!</source>
<target>Нельзя пригласить контакт!</target>
@@ -538,11 +538,6 @@
<target>Превышено время соединения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact %@ cannot be deleted, they are a member of the group(s) %@." xml:space="preserve">
<source>Contact %@ cannot be deleted, they are a member of the group(s) %@.</source>
<target>Контакт %@ не может быть удален, так как является членом групп(ы) %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Contact already exists" xml:space="preserve">
<source>Contact already exists</source>
<target>Существующий контакт</target>
@@ -588,6 +583,11 @@
<target>Создать адрес</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Create link" xml:space="preserve">
<source>Create link</source>
<target>Создать ссылку</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Create one-time invitation link" xml:space="preserve">
<source>Create one-time invitation link</source>
<target>Создать ссылку-приглашение</target>
@@ -801,6 +801,16 @@
<target>Удалить приглашение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete link" xml:space="preserve">
<source>Delete link</source>
<target>Удалить ссылку</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete link?" xml:space="preserve">
<source>Delete link?</source>
<target>Удалить ссылку?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete message?" xml:space="preserve">
<source>Delete message?</source>
<target>Удалить сообщение?</target>
@@ -1016,6 +1026,11 @@
<target>Ошибка при создании группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating group link" xml:space="preserve">
<source>Error creating group link</source>
<target>Ошибка при создании ссылки группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error deleting chat database" xml:space="preserve">
<source>Error deleting chat database</source>
<target>Ошибка при удалении данных чата</target>
@@ -1166,11 +1181,6 @@
<target>Файл: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Files" xml:space="preserve">
<source>Files</source>
<target>Файлы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="For console" xml:space="preserve">
<source>For console</source>
<target>Для консоли</target>
@@ -1221,6 +1231,11 @@
<target>Приглашение в группу больше не действительно, оно было удалено отправителем.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group link" xml:space="preserve">
<source>Group link</source>
<target>Ссылка группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group message:" xml:space="preserve">
<source>Group message:</source>
<target>Групповое сообщение:</target>
@@ -2113,11 +2128,6 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Поделиться ссылкой-приглашением</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Shared one-time link" xml:space="preserve">
<source>Shared one-time link</source>
<target>Одноразовая ссылка-приглашение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show QR code" xml:space="preserve">
<source>Show QR code</source>
<target>Показать QR код</target>
@@ -2437,6 +2447,11 @@ To connect, please ask your contact to create another connection link and check
<target>Уведомлять</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unread" xml:space="preserve">
<source>Unread</source>
<target>Не прочитано</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update" xml:space="preserve">
<source>Update</source>
<target>Обновить</target>
@@ -2577,6 +2592,11 @@ To connect, please ask your contact to create another connection link and check
<target>Вы можете установить просмотр уведомлений на экране блокировки в настройках.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." xml:space="preserve">
<source>You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.</source>
<target>Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе. Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." xml:space="preserve">
<source>You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.</source>
<target>Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</target>
@@ -2846,6 +2866,16 @@ SimpleX серверы не могут получить доступ к ваше
<target>входящий звонок…</target>
<note>call status</note>
</trans-unit>
<trans-unit id="changed role of %@ to %@" xml:space="preserve">
<source>changed role of %1$@ to %2$@</source>
<target>поменял(а) роль члена %1$@ на: %2$@</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="changed your role to %@" xml:space="preserve">
<source>changed your role to %@</source>
<target>поменял(а) вашу роль на: %@</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="colored" xml:space="preserve">
<source>colored</source>
<target>цвет</target>
@@ -3021,6 +3051,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>приглашение</target>
<note>chat list item title</note>
</trans-unit>
<trans-unit id="invited via your group link" xml:space="preserve">
<source>invited via your group link</source>
<target>приглашен(а) через вашу ссылку группы</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="italic" xml:space="preserve">
<source>italic</source>
<target>курсив</target>
@@ -3041,12 +3076,6 @@ SimpleX серверы не могут получить доступ к ваше
<target>член группы</target>
<note>member role</note>
</trans-unit>
<trans-unit id="member %@ role: %@" xml:space="preserve">
<source>member %1$@ role: %2$@</source>
<target>роль %1$@: %2$@</target>
<note>rcv group event chat item
snd group event chat item</note>
</trans-unit>
<trans-unit id="member connected" xml:space="preserve">
<source>connected</source>
<target>соединен(а)</target>
@@ -3202,6 +3231,16 @@ SimpleX серверы не могут получить доступ к ваше
<target>вы приглашены в группу</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="you changed role for yourself to %@" xml:space="preserve">
<source>you changed role for yourself to %@</source>
<target>вы поменяли роль себе на: %@</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="you changed role of %@ to %@" xml:space="preserve">
<source>you changed role of %1$@ to %2$@</source>
<target>вы поменяли роль члена %1$@ на: %2$@</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="you left" xml:space="preserve">
<source>you left</source>
<target>вы покинули группу</target>
@@ -3227,12 +3266,6 @@ SimpleX серверы не могут получить доступ к ваше
<target>вы: </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="your role: %@" xml:space="preserve">
<source>your role: %@</source>
<target>ваша роль: %@</target>
<note>rcv group event chat item
snd group event chat item</note>
</trans-unit>
<trans-unit id="~strike~" xml:space="preserve">
<source>\~strike~</source>
<target>\~зачеркнуть~</target>
@@ -207,7 +207,7 @@ func chatRecvMsg() async -> ChatResponse? {
func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? {
logger.debug("NotificationService processReceivedMsg: \(res.responseType)")
switch res {
case let .contactConnected(contact):
case let .contactConnected(contact, _):
return (contact.id, createContactConnectedNtf(contact))
// case let .contactConnecting(contact):
// TODO profile update
+36 -28
View File
@@ -89,6 +89,12 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; };
5CCA7DED2901E32900C8FEBA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCA7DE82901E32800C8FEBA /* libgmp.a */; };
5CCA7DEE2901E32900C8FEBA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCA7DE92901E32800C8FEBA /* libffi.a */; };
5CCA7DEF2901E32900C8FEBA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCA7DEA2901E32800C8FEBA /* libgmpxx.a */; };
5CCA7DF02901E32900C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCA7DEB2901E32800C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX-ghc8.10.7.a */; };
5CCA7DF12901E32900C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CCA7DEC2901E32800C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX.a */; };
5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */; };
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; };
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; };
@@ -123,11 +129,7 @@
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; };
6448BBA628EAF728000D2AB9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6448BBA128EAF728000D2AB9 /* libffi.a */; };
6448BBA728EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6448BBA228EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI-ghc8.10.7.a */; };
6448BBA828EAF728000D2AB9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6448BBA328EAF728000D2AB9 /* libgmpxx.a */; };
6448BBA928EAF728000D2AB9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6448BBA428EAF728000D2AB9 /* libgmp.a */; };
6448BBAA28EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6448BBA528EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI.a */; };
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
@@ -288,6 +290,12 @@
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5CCA7DE82901E32800C8FEBA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CCA7DE92901E32800C8FEBA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CCA7DEA2901E32800C8FEBA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CCA7DEB2901E32800C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX-ghc8.10.7.a"; sourceTree = "<group>"; };
5CCA7DEC2901E32800C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX.a"; sourceTree = "<group>"; };
5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptRequestsView.swift; sourceTree = "<group>"; };
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -320,11 +328,7 @@
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
6448BBA128EAF728000D2AB9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
6448BBA228EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI-ghc8.10.7.a"; sourceTree = "<group>"; };
6448BBA328EAF728000D2AB9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
6448BBA428EAF728000D2AB9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
6448BBA528EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI.a"; sourceTree = "<group>"; };
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
@@ -370,13 +374,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6448BBAA28EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
6448BBA828EAF728000D2AB9 /* libgmpxx.a in Frameworks */,
6448BBA728EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI-ghc8.10.7.a in Frameworks */,
6448BBA628EAF728000D2AB9 /* libffi.a in Frameworks */,
6448BBA928EAF728000D2AB9 /* libgmp.a in Frameworks */,
5CCA7DF02901E32900C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX-ghc8.10.7.a in Frameworks */,
5CCA7DEF2901E32900C8FEBA /* libgmpxx.a in Frameworks */,
5CCA7DED2901E32900C8FEBA /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5CCA7DF12901E32900C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX.a in Frameworks */,
5CCA7DEE2901E32900C8FEBA /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -431,11 +435,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
6448BBA128EAF728000D2AB9 /* libffi.a */,
6448BBA428EAF728000D2AB9 /* libgmp.a */,
6448BBA328EAF728000D2AB9 /* libgmpxx.a */,
6448BBA228EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI-ghc8.10.7.a */,
6448BBA528EAF728000D2AB9 /* libHSsimplex-chat-4.0.1-FvHSNBJjHeqKQoixUekUiI.a */,
5CCA7DE92901E32800C8FEBA /* libffi.a */,
5CCA7DE82901E32800C8FEBA /* libgmp.a */,
5CCA7DEA2901E32800C8FEBA /* libgmpxx.a */,
5CCA7DEB2901E32800C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX-ghc8.10.7.a */,
5CCA7DEC2901E32800C8FEBA /* libHSsimplex-chat-4.1.1-YMBxN0i6bGIdLfYB8ndX.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -571,6 +575,7 @@
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */,
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */,
@@ -679,6 +684,7 @@
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */,
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */,
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */,
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */,
);
path = Group;
sourceTree = "<group>";
@@ -889,6 +895,7 @@
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */,
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
@@ -943,6 +950,7 @@
5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */,
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */,
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */,
@@ -1203,7 +1211,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 83;
CURRENT_PROJECT_VERSION = 86;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1224,7 +1232,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 4.1;
MARKETING_VERSION = 4.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1245,7 +1253,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 83;
CURRENT_PROJECT_VERSION = 86;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1266,7 +1274,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 4.1;
MARKETING_VERSION = 4.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1324,7 +1332,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 83;
CURRENT_PROJECT_VERSION = 86;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1337,7 +1345,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 4.1;
MARKETING_VERSION = 4.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1354,7 +1362,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 83;
CURRENT_PROJECT_VERSION = 86;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1367,7 +1375,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 4.1;
MARKETING_VERSION = 4.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
+64 -5
View File
@@ -43,6 +43,9 @@ public enum ChatCommand {
case apiLeaveGroup(groupId: Int64)
case apiListMembers(groupId: Int64)
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
case apiCreateGroupLink(groupId: Int64)
case apiDeleteGroupLink(groupId: Int64)
case apiGetGroupLink(groupId: Int64)
case getUserSMPServers
case setUserSMPServers(smpServers: [String])
case apiSetChatItemTTL(seconds: Int64?)
@@ -63,6 +66,7 @@ public enum ChatCommand {
case createMyAddress
case deleteMyAddress
case showMyAddress
case addressAutoAccept(autoAccept: AutoAccept?)
case apiAcceptContact(contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
// WebRTC calls
@@ -75,6 +79,7 @@ public enum ChatCommand {
case apiGetCallInvitations
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64)
case string(String)
@@ -114,6 +119,9 @@ public enum ChatCommand {
case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
case let .apiListMembers(groupId): return "/_members #\(groupId)"
case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))"
case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)"
case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)"
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case .getUserSMPServers: return "/smp_servers"
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))"
@@ -134,6 +142,7 @@ public enum ChatCommand {
case .createMyAddress: return "/address"
case .deleteMyAddress: return "/delete_address"
case .showMyAddress: return "/show_address"
case let .addressAutoAccept(autoAccept): return "/auto_accept \(AutoAccept.cmdString(autoAccept))"
case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
@@ -145,6 +154,7 @@ public enum ChatCommand {
case .apiGetCallInvitations: return "/_call get"
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .receiveFile(fileId): return "/freceive \(fileId)"
case let .string(str): return str
}
@@ -184,6 +194,9 @@ public enum ChatCommand {
case .apiLeaveGroup: return "apiLeaveGroup"
case .apiListMembers: return "apiListMembers"
case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
case .apiCreateGroupLink: return "apiCreateGroupLink"
case .apiDeleteGroupLink: return "apiDeleteGroupLink"
case .apiGetGroupLink: return "apiGetGroupLink"
case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers"
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
@@ -204,6 +217,7 @@ public enum ChatCommand {
case .createMyAddress: return "createMyAddress"
case .deleteMyAddress: return "deleteMyAddress"
case .showMyAddress: return "showMyAddress"
case .addressAutoAccept: return "addressAutoAccept"
case .apiAcceptContact: return "apiAcceptContact"
case .apiRejectContact: return "apiRejectContact"
case .apiSendCallInvitation: return "apiSendCallInvitation"
@@ -215,6 +229,7 @@ public enum ChatCommand {
case .apiGetCallInvitations: return "apiGetCallInvitations"
case .apiCallStatus: return "apiCallStatus"
case .apiChatRead: return "apiChatRead"
case .apiChatUnread: return "apiChatUnread"
case .receiveFile: return "receiveFile"
case .string: return "console command"
}
@@ -282,10 +297,11 @@ public enum ChatResponse: Decodable, Error {
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case contactAliasUpdated(toContact: Contact)
case connectionAliasUpdated(toConnection: PendingContactConnection)
case userContactLink(connReqContact: String)
case userContactLink(contactLink: UserContactLink)
case userContactLinkUpdated(contactLink: UserContactLink)
case userContactLinkCreated(connReqContact: String)
case userContactLinkDeleted
case contactConnected(contact: Contact)
case contactConnected(contact: Contact, userCustomProfile: Profile?)
case contactConnecting(contact: Contact)
case receivedContactRequest(contactRequest: UserContactRequest)
case acceptingContactRequest(contact: Contact)
@@ -327,6 +343,9 @@ public enum ChatResponse: Decodable, Error {
case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember)
case groupRemoved(groupInfo: GroupInfo) // unused
case groupUpdated(toGroup: GroupInfo)
case groupLinkCreated(groupInfo: GroupInfo, connReqContact: String)
case groupLink(groupInfo: GroupInfo, connReqContact: String)
case groupLinkDeleted(groupInfo: GroupInfo)
// receiving file events
case rcvFileAccepted(chatItem: AChatItem)
case rcvFileAcceptedSndCancelled(rcvFileTransfer: RcvFileTransfer)
@@ -380,6 +399,7 @@ public enum ChatResponse: Decodable, Error {
case .contactAliasUpdated: return "contactAliasUpdated"
case .connectionAliasUpdated: return "connectionAliasUpdated"
case .userContactLink: return "userContactLink"
case .userContactLinkUpdated: return "userContactLinkUpdated"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
case .contactConnected: return "contactConnected"
@@ -423,6 +443,9 @@ public enum ChatResponse: Decodable, Error {
case .connectedToGroupMember: return "connectedToGroupMember"
case .groupRemoved: return "groupRemoved"
case .groupUpdated: return "groupUpdated"
case .groupLinkCreated: return "groupLinkCreated"
case .groupLink: return "groupLink"
case .groupLinkDeleted: return "groupLinkDeleted"
case .rcvFileAccepted: return "rcvFileAccepted"
case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled"
case .rcvFileStart: return "rcvFileStart"
@@ -476,10 +499,11 @@ public enum ChatResponse: Decodable, Error {
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .contactAliasUpdated(toContact): return String(describing: toContact)
case let .connectionAliasUpdated(toConnection): return String(describing: toConnection)
case let .userContactLink(connReq): return connReq
case let .userContactLink(contactLink): return contactLink.responseDetails
case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails
case let .userContactLinkCreated(connReq): return connReq
case .userContactLinkDeleted: return noDetails
case let .contactConnected(contact): return String(describing: contact)
case let .contactConnected(contact, _): return String(describing: contact)
case let .contactConnecting(contact): return String(describing: contact)
case let .receivedContactRequest(contactRequest): return String(describing: contactRequest)
case let .acceptingContactRequest(contact): return String(describing: contact)
@@ -520,6 +544,9 @@ public enum ChatResponse: Decodable, Error {
case let .connectedToGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)"
case let .groupRemoved(groupInfo): return String(describing: groupInfo)
case let .groupUpdated(toGroup): return String(describing: toGroup)
case let .groupLinkCreated(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)"
case let .groupLink(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)"
case let .groupLinkDeleted(groupInfo): return String(describing: groupInfo)
case let .rcvFileAccepted(chatItem): return String(describing: chatItem)
case .rcvFileAcceptedSndCancelled: return noDetails
case let .rcvFileStart(chatItem): return String(describing: chatItem)
@@ -681,6 +708,37 @@ public struct ConnectionStats: Codable {
public var sndServers: [String]?
}
public struct UserContactLink: Decodable {
public var connReqContact: String
public var autoAccept: AutoAccept?
public init(connReqContact: String, autoAccept: AutoAccept? = nil) {
self.connReqContact = connReqContact
self.autoAccept = autoAccept
}
var responseDetails: String {
"connReqContact: \(connReqContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))"
}
}
public struct AutoAccept: Codable {
public var acceptIncognito: Bool
public var autoReply: MsgContent?
public init(acceptIncognito: Bool, autoReply: MsgContent? = nil) {
self.acceptIncognito = acceptIncognito
self.autoReply = autoReply
}
static func cmdString(_ autoAccept: AutoAccept?) -> String {
guard let autoAccept = autoAccept else { return "off" }
let s = "on" + (autoAccept.acceptIncognito ? " incognito=on" : "")
guard let msg = autoAccept.autoReply else { return s }
return s + " " + msg.cmdString
}
}
public protocol SelectableItem: Hashable, Identifiable {
var label: LocalizedStringKey { get }
static var values: [Self] { get }
@@ -783,7 +841,6 @@ public enum ChatErrorType: Decodable {
case invalidConnReq
case invalidChatMessage(message: String)
case contactNotReady(contact: Contact)
case contactGroups(contact: Contact, groupNames: [GroupName])
case groupUserRole
case groupContactRole(contactName: ContactName)
case groupDuplicateMember(contactName: ContactName)
@@ -841,6 +898,8 @@ public enum StoreError: Decodable {
case quotedChatItemNotFound
case chatItemSharedMsgIdNotFound(sharedMsgId: String)
case chatItemNotFoundByFileId(fileId: Int64)
case duplicateGroupLink(groupInfo: GroupInfo)
case groupLinkNotFound(groupInfo: GroupInfo)
}
public enum DatabaseError: Decodable {
+18 -7
View File
@@ -303,13 +303,15 @@ public struct ChatData: Decodable, Identifiable {
}
public struct ChatStats: Decodable {
public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0) {
public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
self.unreadCount = unreadCount
self.minUnreadItemId = minUnreadItemId
self.unreadChat = unreadChat
}
public var unreadCount: Int = 0
public var minUnreadItemId: Int64 = 0
public var unreadChat: Bool = false
}
public struct Contact: Identifiable, Decodable, NamedChat {
@@ -335,6 +337,10 @@ public struct Contact: Identifiable, Decodable, NamedChat {
activeConn.connLevel > 0 || viaGroup != nil
}
public var viaGroupLink: Bool {
activeConn.viaGroupLink
}
public var contactConnIncognito: Bool {
activeConn.customUserProfileId != nil
}
@@ -366,6 +372,7 @@ public struct Connection: Decodable {
var connId: Int64
var connStatus: ConnStatus
public var connLevel: Int
public var viaGroupLink: Bool
public var customUserProfileId: Int64?
public var id: ChatId { get { ":\(connId)" } }
@@ -373,7 +380,8 @@ public struct Connection: Decodable {
static let sampleData = Connection(
connId: 1,
connStatus: .ready,
connLevel: 0
connLevel: 0,
viaGroupLink: false
)
}
@@ -913,6 +921,7 @@ public struct ChatItem: Identifiable, Decodable {
case .memberAdded: return false
case .memberLeft: return false
case .memberDeleted: return false
case .invitedViaGroupLink: return false
}
case .sndGroupEvent: return true
default: return false
@@ -1196,7 +1205,7 @@ public enum MsgContent {
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
var text: String {
public var text: String {
get {
switch self {
case let .text(text): return text
@@ -1446,6 +1455,7 @@ public enum RcvGroupEvent: Decodable {
case userDeleted
case groupDeleted
case groupUpdated(groupProfile: GroupProfile)
case invitedViaGroupLink
var text: String {
switch self {
@@ -1454,14 +1464,15 @@ public enum RcvGroupEvent: Decodable {
case .memberConnected: return NSLocalizedString("member connected", comment: "rcv group event chat item")
case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item")
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("member %@ role: %@", comment: "rcv group event chat item"), profile.profileViewName, role.text)
return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text)
case let .userRole(role):
return String.localizedStringWithFormat(NSLocalizedString("your role: %@", comment: "rcv group event chat item"), role.text)
return String.localizedStringWithFormat(NSLocalizedString("changed your role to %@", comment: "rcv group event chat item"), role.text)
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName)
case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item")
case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item")
}
}
}
@@ -1476,9 +1487,9 @@ public enum SndGroupEvent: Decodable {
var text: String {
switch self {
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("member %@ role: %@", comment: "snd group event chat item"), profile.profileViewName, role.text)
return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text)
case let .userRole(role):
return String.localizedStringWithFormat(NSLocalizedString("your role: %@", comment: "snd group event chat item"), role.text)
return String.localizedStringWithFormat(NSLocalizedString("you changed role for yourself to %@", comment: "snd group event chat item"), role.text)
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName)
case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item")
+45 -20
View File
@@ -170,6 +170,9 @@
/* No comment provided by engineer. */
"Advanced network settings" = "Erweiterte Netzwerkeinstellungen";
/* No comment provided by engineer. */
"All group members will remain connected." = "***All group members will remain connected.";
/* No comment provided by engineer. */
"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Alle Nachrichten werden gelöscht - dies kann nicht rückgängig gemacht werden! Die Nachrichten werden NUR bei Ihnen gelöscht.";
@@ -230,9 +233,6 @@
/* No comment provided by engineer. */
"Calls" = "Anrufe";
/* No comment provided by engineer. */
"Can't delete contact!" = "Der Kontakt kann nicht gelöscht werden!";
/* No comment provided by engineer. */
"Can't invite contact!" = "Kontakt kann nicht eingeladen werden!";
@@ -257,6 +257,15 @@
/* No comment provided by engineer. */
"Change member role?" = "Die Mitgliederrolle ändern?";
/* No comment provided by engineer. */
"Change role" = "Rolle ändern";
/* rcv group event chat item */
"changed role of %@ to %@" = "***changed role of %1$@ to %2$@";
/* rcv group event chat item */
"changed your role to %@" = "***changed your role to %@";
/* No comment provided by engineer. */
"Chat archive" = "Datenbank Archiv";
@@ -395,9 +404,6 @@
/* connection information */
"connection:%@" = "Verbindung:%@";
/* No comment provided by engineer. */
"Contact %@ cannot be deleted, they are a member of the group(s) %@." = "Der Kontakt mit %@ kann nicht gelöscht werden, da er Mitglied einer oder mehrerer dieser Gruppen ist %@.";
/* No comment provided by engineer. */
"Contact already exists" = "Der Kontakt ist bereits vorhanden";
@@ -431,6 +437,9 @@
/* No comment provided by engineer. */
"Create address" = "Adresse erstellen";
/* No comment provided by engineer. */
"Create link" = "***Create link";
/* No comment provided by engineer. */
"Create one-time invitation link" = "Erstellen Sie einen einmaligen Einladungslink";
@@ -557,6 +566,12 @@
/* No comment provided by engineer. */
"Delete invitation" = "Einladung löschen";
/* No comment provided by engineer. */
"Delete link" = "***Delete link";
/* No comment provided by engineer. */
"Delete link?" = "***Delete link?";
/* No comment provided by engineer. */
"Delete message?" = "Die Nachricht löschen?";
@@ -710,6 +725,9 @@
/* No comment provided by engineer. */
"Error creating group" = "Fehler beim Erzeugen der Gruppe";
/* No comment provided by engineer. */
"Error creating group link" = "***Error creating group link";
/* No comment provided by engineer. */
"Error deleting chat database" = "Fehler beim Löschen der Chat-Datenbank";
@@ -800,9 +818,6 @@
/* No comment provided by engineer. */
"File: %@" = "Datei: %@";
/* No comment provided by engineer. */
"Files" = "Dateien";
/* No comment provided by engineer. */
"For console" = "Für Konsole";
@@ -836,6 +851,9 @@
/* No comment provided by engineer. */
"Group invitation is no longer valid, it was removed by sender." = "Die Gruppeneinladung ist nicht mehr gültig, sie wurde vom Absender entfernt.";
/* No comment provided by engineer. */
"Group link" = "***Group link";
/* notification */
"Group message:" = "Grppennachricht:";
@@ -968,6 +986,9 @@
/* chat list item title */
"invited to connect" = "Für eine Verbindung eingeladen";
/* rcv group event chat item */
"invited via your group link" = "***invited via your group link";
/* No comment provided by engineer. */
"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Für die sichere Speicherung des Passworts wird der iOS Schlüsselbund verwendet - dies erlaubt den Empfang von Push-Benachrichtigungen.";
@@ -1052,10 +1073,6 @@
/* No comment provided by engineer. */
"Member" = "Mitglied";
/* rcv group event chat item
snd group event chat item */
"member %@ role: %@" = "Mitglieder %1$@ Rolle: %2$@";
/* rcv group event chat item */
"member connected" = "verbunden";
@@ -1362,6 +1379,9 @@
/* No comment provided by engineer. */
"Revert" = "Zurückkehren";
/* No comment provided by engineer. */
"Role" = "Rolle";
/* No comment provided by engineer. */
"Run chat" = "Chat starten";
@@ -1452,9 +1472,6 @@
/* No comment provided by engineer. */
"Share one-time invitation link" = "Einmal-Einladungslink teilen";
/* No comment provided by engineer. */
"Shared one-time link" = "Geteilter Einmal-Link";
/* No comment provided by engineer. */
"Show preview" = "Vorschau anzeigen";
@@ -1662,6 +1679,9 @@
/* No comment provided by engineer. */
"Unmute" = "Stummschaltung aufheben";
/* No comment provided by engineer. */
"Unread" = "***Unread";
/* No comment provided by engineer. */
"Update" = "Aktualisieren";
@@ -1776,6 +1796,9 @@
/* No comment provided by engineer. */
"You can set lock screen notification preview via settings." = "Über die Geräte-Einstellungen können Sie die Benachrichtigungsvorschau im Sperrbildschirm erlauben.";
/* No comment provided by engineer. */
"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "***You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.";
/* No comment provided by engineer. */
"You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." = "Sie können Ihre Adresse als Link oder als QR-Code teilen Jeder kann sich darüber mit Ihnen verbinden. Sie werden Ihre mit dieser Adresse verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen.";
@@ -1785,6 +1808,12 @@
/* No comment provided by engineer. */
"You can use markdown to format messages:" = "Um Nachrichteninhalte zu formatieren, können Sie Markdowns verwenden:";
/* snd group event chat item */
"you changed role for yourself to %@" = "***you changed role for yourself to %@";
/* snd group event chat item */
"you changed role of %@ to %@" = "***you changed role of %1$@ to %2$@";
/* No comment provided by engineer. */
"You control through which server(s) **to receive** the messages, your contacts the servers you use to message them." = "Sie legen fest, über welche Server Sie Ihre Nachrichten **empfangen** und an Ihre Kontakte **senden**.";
@@ -1902,10 +1931,6 @@
/* No comment provided by engineer. */
"Your random profile" = "Ihr Zufallsprofil";
/* rcv group event chat item
snd group event chat item */
"your role: %@" = "Meine Rolle: %@";
/* No comment provided by engineer. */
"Your settings" = "Meine Einstellungen";
+39 -20
View File
@@ -170,6 +170,9 @@
/* No comment provided by engineer. */
"Advanced network settings" = "Настройки сети";
/* No comment provided by engineer. */
"All group members will remain connected." = "Все члены группы, которые соединились через эту ссылку, останутся в группе.";
/* No comment provided by engineer. */
"All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.";
@@ -230,9 +233,6 @@
/* No comment provided by engineer. */
"Calls" = "Звонки";
/* No comment provided by engineer. */
"Can't delete contact!" = "Невозможно удалить контакт!";
/* No comment provided by engineer. */
"Can't invite contact!" = "Нельзя пригласить контакт!";
@@ -260,6 +260,12 @@
/* No comment provided by engineer. */
"Change role" = "Поменять роль";
/* rcv group event chat item */
"changed role of %@ to %@" = "поменял(а) роль члена %1$@ на: %2$@";
/* rcv group event chat item */
"changed your role to %@" = "поменял(а) вашу роль на: %@";
/* No comment provided by engineer. */
"Chat archive" = "Архив чата";
@@ -398,9 +404,6 @@
/* connection information */
"connection:%@" = "connection:%@";
/* No comment provided by engineer. */
"Contact %@ cannot be deleted, they are a member of the group(s) %@." = "Контакт %@ не может быть удален, так как является членом групп(ы) %@.";
/* No comment provided by engineer. */
"Contact already exists" = "Существующий контакт";
@@ -434,6 +437,9 @@
/* No comment provided by engineer. */
"Create address" = "Создать адрес";
/* No comment provided by engineer. */
"Create link" = "Создать ссылку";
/* No comment provided by engineer. */
"Create one-time invitation link" = "Создать ссылку-приглашение";
@@ -560,6 +566,12 @@
/* No comment provided by engineer. */
"Delete invitation" = "Удалить приглашение";
/* No comment provided by engineer. */
"Delete link" = "Удалить ссылку";
/* No comment provided by engineer. */
"Delete link?" = "Удалить ссылку?";
/* No comment provided by engineer. */
"Delete message?" = "Удалить сообщение?";
@@ -713,6 +725,9 @@
/* No comment provided by engineer. */
"Error creating group" = "Ошибка при создании группы";
/* No comment provided by engineer. */
"Error creating group link" = "Ошибка при создании ссылки группы";
/* No comment provided by engineer. */
"Error deleting chat database" = "Ошибка при удалении данных чата";
@@ -803,9 +818,6 @@
/* No comment provided by engineer. */
"File: %@" = "Файл: %@";
/* No comment provided by engineer. */
"Files" = "Файлы";
/* No comment provided by engineer. */
"For console" = "Для консоли";
@@ -839,6 +851,9 @@
/* No comment provided by engineer. */
"Group invitation is no longer valid, it was removed by sender." = "Приглашение в группу больше не действительно, оно было удалено отправителем.";
/* No comment provided by engineer. */
"Group link" = "Ссылка группы";
/* notification */
"Group message:" = "Групповое сообщение:";
@@ -971,6 +986,9 @@
/* chat list item title */
"invited to connect" = "приглашение";
/* rcv group event chat item */
"invited via your group link" = "приглашен(а) через вашу ссылку группы";
/* No comment provided by engineer. */
"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS Keychain используется для безопасного хранения пароля - это позволяет получать мгновенные уведомления.";
@@ -1055,10 +1073,6 @@
/* No comment provided by engineer. */
"Member" = "Член группы";
/* rcv group event chat item
snd group event chat item */
"member %@ role: %@" = "роль %1$@: %2$@";
/* rcv group event chat item */
"member connected" = "соединен(а)";
@@ -1458,9 +1472,6 @@
/* No comment provided by engineer. */
"Share one-time invitation link" = "Поделиться ссылкой-приглашением";
/* No comment provided by engineer. */
"Shared one-time link" = "Одноразовая ссылка-приглашение";
/* No comment provided by engineer. */
"Show preview" = "Показывать уведомления";
@@ -1668,6 +1679,9 @@
/* No comment provided by engineer. */
"Unmute" = "Уведомлять";
/* No comment provided by engineer. */
"Unread" = "Не прочитано";
/* No comment provided by engineer. */
"Update" = "Обновить";
@@ -1782,6 +1796,9 @@
/* No comment provided by engineer. */
"You can set lock screen notification preview via settings." = "Вы можете установить просмотр уведомлений на экране блокировки в настройках.";
/* No comment provided by engineer. */
"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе. Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились.";
/* No comment provided by engineer. */
"You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." = "Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.";
@@ -1791,6 +1808,12 @@
/* No comment provided by engineer. */
"You can use markdown to format messages:" = "Вы можете форматировать сообщения:";
/* snd group event chat item */
"you changed role for yourself to %@" = "вы поменяли роль себе на: %@";
/* snd group event chat item */
"you changed role of %@ to %@" = "вы поменяли роль члена %1$@ на: %2$@";
/* No comment provided by engineer. */
"You control through which server(s) **to receive** the messages, your contacts the servers you use to message them." = "Вы определяете через какие серверы вы **получаете сообщения**, ваши контакты - серверы, которые вы используете для отправки.";
@@ -1908,10 +1931,6 @@
/* No comment provided by engineer. */
"Your random profile" = "Ваш случайный профиль";
/* rcv group event chat item
snd group event chat item */
"your role: %@" = "ваша роль: %@";
/* No comment provided by engineer. */
"Your settings" = "Настройки";
+1 -1
View File
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 10e0e58ec3a7c97b5503a49440be08487111960d
tag: 19aef52135b288dc92c4a2ec6d0be4b8283649ab
source-repository-package
type: git
+120
View File
@@ -0,0 +1,120 @@
# Chat settings
Scope: this doc covers permissions and configuration that is specific for one contact or group, and should or should not be taken into account when sending messages.
## Problem
Certain chat features are not desirable for some contacts/groups, some other require contact specific configuration.
These settings can be symmetric or asymmetric. Symmetric settings require mutual agreement. Asymmetric can be set by one party independently of the other. The focus of this RFC is asymmetric settings, as they are simpler - they require one-way notification for their change.
These settings can be local or remote. Local settings only affect chat functionality for a given contact or group, but are not known to the remote parties. For each type of local setting user only needs one value per chat. Remote settings should be taken into account when sending the message, to avoid rejected/ignored messages. For each type of remote setting user needs two values per chat - the value received from the other party (it should be taken into account when sending the message) and the te value sent to other party (that should be taken into account when processing received messages).
Local settings can be implemented as an extension of global user settings, they are not the focus of this RFC.
This RFC focus is asymmetric remote settings that include:
- permission to send voice messages - some users prefer to not receive them.
- permission to delete sent messages and the maximum duration when it is allowed.
- permission to send images - e.g. some automatic clients/bots may not support images, and this would explicitly prohibit it.
- permission to edit sent messages and the maximum duration when it is allowed.
These permissions are not taken into account for group memberships, instead group permissions are that are set by group owners.
## Solution
### Protocol
Broadcast user settings and preferences in the same way as profile updates by adding `preferences` property alongside `profile` property - it will be sent as part of `x.info` message.
For groups these are also added to group profile and sent via `x.grp.info` message.
`preferences` property is a dictionary with boolean or number values - clients must ignore unknown values.
Current schema for `preferences` member:
```js
{
"definitions": {
"enabled": {
"properties": {
"enable": { "enum": ["on", "off"] }
},
"additionalProperties": true
}
},
"optionalProperties": {
"voice": { "ref": "enabled" }
// "image": { "ref": "enabled" },
// "file": { "ref": "enabled" },
// "delete": { "ref": "enabled" },
// "acceptDelete": { "ref": "enabled" },
// "edit": { "ref": "enabled" },
// "receipts": { "ref": "enabled" }
},
"additionalProperties": true
}
```
`enable` enum can be potentially extended to mutual
Every time user updates the settings and update profile should be sent to affected contacts.
### Database schema
```sql
ALTER TABLE users ADD COLUMN preferences TEXT;
ALTER TABLE group ADD COLUMN preferences TEXT;
ALTER TABLE contacts ADD COLUMN user_preferences TEXT;
ALTER TABLE contacts ADD COLUMN contact_preferences TEXT;
```
Preferences in the user record would have all fields that the user supports, sent preferences on the contact level (`user_preferences`) - only those that are different from global. `contact_preferences` in contact will have all supported fields.
Group preferences are set by owners and do not depend on user preferences.
### Types
```haskell
data ChatPreferences = ChatPreferences {
voice :: Maybe Preference -- only this field is needed right now
-- image :: Maybe Preference,
-- file :: Maybe Preference,
-- delete :: Maybe Preference,
-- acceptDelete :: Maybe Preference,
-- edit :: Maybe Preference,
-- receipts :: Maybe Preference
}
data Preference = Preference {enable :: PrefSwitch}
data PrefSwitch = PSOn | PSOff -- for example it can be extended to include PSMutual, that is only enabled if it's enabled by another party
```
### UI
UI in the contact will need to differentiate between on/off and unset values in which case the global setting will be used.
### API
```
/_set prefs <json>
/_set prefs @1 <json>
/_set prefs #1 <json>
```
Optionally, it may help to have this type to send to the UI merged preferences:
```haskell
data MergedChatPreferences = MergedChatPreferences {
voice :: MergedPreference
}
data MergedPreference = MergedPreference {
value :: Preference, global :: Bool
}
```
But it may be easier to merge in place in the UI from two ChatPreferences values.
+53
View File
@@ -0,0 +1,53 @@
# Group links
## Problem
Friction in group discovery and invitation/joining. Currently each group member has to be invited manually, and has first to be connected to an existing group member, even if group is considered as open/public by its owners.
## Solution
Allow to create group link(s) - a group link is a new type of contact address link, with enabled auto accept. After receiving and automatically accepting a group join request via such link, a contact is automatically invited to group. This allows to publish such link on a site or another platform and reduce friction required to join a "public" group.
## Design considerations
Add group link metadata to contact link?
- It can be used to display information about the group on the link web page, e.g. a group profile or its part, though it introduces a privacy concern.
- Can be used to display only the fact that it's a group invitation link.
- If we use a separate contact address for each group link, and we don't do auto-join on receiving group invitation corresponding to the group link, group link metadata in contact link is not necessary.
Use group link metadata when accepting / joining?
- When accepting we can use the fact that this contact request link is tied to a specific group to automatically invite to group, it's not necessary to parse out this part in agent.
- When joining it could be parsed out in agent and communicated to chat for chat to know it has to automatically accept the corresponding group invite that should follow. Alternatively we can ignore this metadata in agent/chat and let the user to accept incoming group invitation manually. The advantages of the second approach are that it's simpler and that the user can inspect received group profile before accepting. The disadvantage is more friction, though it's on joining side, not on the inviting side (join is performed only once).
Allow each group member who can invite to create their own link, or have such link be a part of group's profile, so it can be created by owners?
- The advantage of the second approach is that owners can set up an always online client, so the connectivity in group would be better compared to one that could be provided by regular clients. Another advantage is that link can be shared by any member role, even without permission to invite members.
- The disadvantage is that if group has no owners, the link can no longer be created. Also since all member would be using the same link for sharing, it would become a group identifier signalling they're members of the same group.
- There're probably other advantages/disadvantages to be considered. Other considerations:
- Group owners can overwrite each others links.
- When member leaves group or is removed from group, he should remove corresponding contact address to avoid accepting respective contact requests.
- When member role is changed, link/contact address should be conditionally removed if new role doesn't allow adding members.
Allow to create a link with incognito membership?
- Even though we currently don't allow to add members for incognito memberships to avoid incognito / non incognito profiles confusion, when accepting group join requests via group links, same incognito profile that is used for membership could be shared.
- Take into account the plan to allow configuring auto-accept on regular contact requests so that accepting is always incognito, regardless of incognito mode. For group links it has to be yet another specific mode of auto accept so that the same profile is re-used instead of usual per request incognito profile.
## Implementation
```
ALTER TABLE user_contact_links ADD COLUMN group_id INTEGER REFERENCES groups ON DELETE CASCADE;
```
API:
- APICreateGroupLink GroupId
- APIDeleteGroupLink GroupId
- APIShowGroupLink GroupId
- CreateGroupLink GroupName -- for terminal
- DeleteGroupLink GroupName -- for terminal
- ShowGroupLink GroupName -- for terminal
- CRGroupLinkCreated ConnReqContact -- response on create
- CRGroupLink ConnReqContact -- response on show
@@ -0,0 +1,83 @@
# Group contacts management
## Problem
Currently for each joining group member two connections are created - one for group communication, and one for member's direct contact. The member's contact connection is created to enable members to communicate with each other directly outside of group, as in "Send direct message" functionality we have in mobile applications, similarly to the same feature available in other messengers.
It works well for small groups where members trust each other, since it allows for immediate communication between members after connections are established on the group join - otherwise group members would have to establish direct connection separately, and often wait for several more asynchronous interactions before being able to send messages. It doesn't work as well in some other communication scenarios, and entails certain problems:
- Larger "public" groups and/or groups where members don't trust each other - such groups' members may want to communicate with other group members only inside the group, and not have a direct contact with each joining member.
- Groups where owner/host doesn't want group members to be able to communicate directly between each other, e.g. inter-business communication, or some other asymmetric scenario where sensitive messages should not be shared between members.
> It should be mentioned though that a group connection is no different from a direct connection on the protocol level, and it's entirely possible to use a group connection for direct communication with modified clients, so any change to the existing group protocol we make to SimpleX Chat clients does not fully solve this issue - group's owner/host can't know whether members' clients are unmodified. So probably we shouldn't be taking this scenario into consideration.
- Using a modified client, a host can carry out MITM attack(s) when introducing a new group member to existing members, so group members can't know whether their direct connection is secure.
> This problem can be partially softened by allowing members to validate some connection fingerprint out-of-band, or even to "straighten" their connection (replace it with one established by passing secret out-of-band). This can be a separate feature. It requires members to have access to some out-of-band channel and is also not possible entirely in the current group protocol, so anyway it's another scenario where automatically establishing a direct connection backfires.
> Another point to consider is that currently we do not expose the information about direct connection "indirectness" level enough for users to distinguish contacts created via groups (and so may have been compromised by a MITM attack) - we can mark such contacts in UI.
- Users can't delete group member contacts while they have groups that have these contacts as members. Users may want it either because of one of the reasons above, or purely out of aesthetic reasons, e.g. to avoid cluttering chat with unused contacts. It's not a protocol limitation, only a property of existing implementation, so we should be able to change it with some schema/code changes.
Another related problem is that for group members that join via a group link, a contact is created and not even hidden from chat list (unlike introduced members' contacts). This is true for both sides - the joining member, and the host who invites via a group link.
## Solution
Out of the listed problems in existing group protocol we could change the fact that direct connections and contacts are created unconditionally, allow to delete group member contacts without deleting groups and hide/disable/delete contacts created via group links.
### Establishing direct connections
- We can add a group wide configuration deciding whether to establish direct connections for this group or not, configurable by a group owner.
- Should it be a part of group profile? If so, profile update can be received by group members asynchronously and they should be able to process situations where one has this setting enabled and another not - probably if any one of them has it disabled, direct connection shouldn't be created. E.g., an introduced member can simply ignore `directConnReq` from `XGrpMemFwd`.
- Alternatively or additionally it can be a global user setting.
- If it's a user setting, it can be communicated when sending `XGrpAcpt` (additional field for host to consider in future introductions?), `XGrpMemIntro` (additional field in `MemberInfo`?), `XGrpMemInv`, `XGrpMemFwd` (make `directConnReq` Maybe in `IntroInvitation`?).
- How do we make it backwards compatible? Just send an empty string in `directConnReq` so the attempt to establish a direct connection by the introduced member is failed?
Should there still be an option to request direct connection with member, e.g. by sending a new type of message inside a group connection?
This is a rather complex change if it is to be properly communicated between group members and can be designed / implemented in a separate scope.
### Deleting group member contacts
Table `group_members` has `contact_id` foreign key with cascade deletion, options to allow contact deletion:
- When deleting contact, search group members with corresponding contact id and set it to null, also do not delete contact profile and local display name if it had associated members.
- Re-create table with constraint defined as `ON DELETE SET NULL` - the required migration is more complex than the first option and the resulting optimization is unnecessary.
Another option is to mark contact as deleted (could be a dedicated flag) and hide it, and delete the direct connection.
### Group link contacts
On the inviting side (the one that created link and auto-accepts group join requests):
- To hide joining contacts:
- We shouldn't create group invitation chat item in direct chat.
- Probably a new flag is required so that these contacts are filtered out together with introduced contacts (see `c.conn_level = 0 OR i.chat_item_id IS NOT NULL` filter in `getDirectChatPreviews_`).
- We should also filter out pending connections in `getContactConnectionChatPreviews_`, probably a separate flag is needed in the `connections` for that as well.
- We already filter out respective contact requests out in `getContactRequestChatPreviews_`, see `uc.group_id IS NULL`).
- User should still be made aware that his client auto-accepted and invited a joining member - it can be done via a new `RcvGroupEvent` chat item, e.g., "invited a new contact X via group link" (should it be created as unread?).
- Interaction between group links and "no direct connections for groups" feature:
- Delete created contact after member joins group?
- Communicate the setting to the joining member's client, if he still sends messages ignore them. Can be part of group link metadata.
On the joining side:
- Same or similar filtering logic for contact pending connections and contacts can be applied if we include metadata into group link - the fact that it's a group link should be enough.
- Interaction with "no direct connections for groups" feature - group link metadata can include flag that host's contact and direct connection are to be removed after joining. Joining client should respect this flag, otherwise his messages other than required for group join may be ignored by host's client - see above.
> The problem that a group link contact is not filtered out is less pressing on the joining side compared to the inviting side as the latter will/may have uncontrolled amount of contacts connected via a group link, when the joining side will only have one per link and it's created on user action, so we may ignore this for the joining side initially.
+2 -2
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 4.1.0
version: 4.2.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -34,7 +34,7 @@ dependencies:
- process == 1.6.*
- random >= 1.1 && < 1.3
- simple-logger == 0.1.*
- simplexmq >= 3.0
- simplexmq >= 3.3
- socks == 0.6.*
- sqlcipher-simple == 0.4.*
- stm == 2.5.*
@@ -388,6 +388,7 @@ export interface CRContactConnecting extends CR {
export interface CRContactConnected extends CR {
type: "contactConnected"
contact: Contact
userCustomProfile?: Profile
}
export interface CRContactAnotherClient extends CR {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."10e0e58ec3a7c97b5503a49440be08487111960d" = "1l31xagcazyp34pc0qqd53fjghkx5bkxmfsw45gvfxmj961xj32m";
"https://github.com/simplex-chat/simplexmq.git"."19aef52135b288dc92c4a2ec6d0be4b8283649ab" = "0xkhm9hplm55ms34ln7la8zm59ailsxziqbaj5y79z9lafysl15j";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
+6 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 4.1.0
version: 4.2.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -54,6 +54,11 @@ library
Simplex.Chat.Migrations.M20221001_shared_msg_id_indices
Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items
Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id
Simplex.Chat.Migrations.M20221011_user_contact_links_group_id
Simplex.Chat.Migrations.M20221012_inline_files
Simplex.Chat.Migrations.M20221019_unread_chat
Simplex.Chat.Migrations.M20221021_auto_accept__group_links
Simplex.Chat.Migrations.M20221024_contact_used
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.ProfileGenerator
+605 -343
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -38,7 +38,7 @@ chatBotRepl welcome answer _user cc = do
initializeBotAddress :: ChatController -> IO ()
initializeBotAddress cc = do
sendChatCmd cc "/show_address" >>= \case
CRUserContactLink uri _ _ -> showBotAddress uri
CRUserContactLink UserContactLink {connReqContact} -> showBotAddress connReqContact
CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do
putStrLn "No bot address, creating..."
sendChatCmd cc "/address" >>= \case
+50 -7
View File
@@ -36,10 +36,12 @@ import Simplex.Chat.Call
import Simplex.Chat.Markdown (MarkdownList)
import Simplex.Chat.Messages
import Simplex.Chat.Protocol
import Simplex.Chat.Store (StoreError)
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink)
import Simplex.Chat.Types
import Simplex.Messaging.Agent (AgentClient)
import Simplex.Messaging.Agent.Client (AgentLocks)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore)
import qualified Simplex.Messaging.Crypto as C
@@ -67,12 +69,29 @@ data ChatConfig = ChatConfig
defaultServers :: InitialAgentServers,
tbqSize :: Natural,
fileChunkSize :: Integer,
inlineFiles :: InlineFilesConfig,
subscriptionConcurrency :: Int,
subscriptionEvents :: Bool,
hostEvents :: Bool,
testView :: Bool
}
data InlineFilesConfig = InlineFilesConfig
{ offerChunks :: Integer,
sendChunks :: Integer,
totalSendChunks :: Integer,
receiveChunks :: Integer
}
defaultInlineFilesConfig :: InlineFilesConfig
defaultInlineFilesConfig =
InlineFilesConfig
{ offerChunks = 15, -- max when chunks are offered / received with the option - limited to 255 on the encoding level
sendChunks = 0, -- max per file when chunks will be sent inline without acceptance
totalSendChunks = 30, -- max per conversation when chunks will be sent inline without acceptance
receiveChunks = 5 -- max when chunks are accepted
}
data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName
deriving (Eq)
@@ -91,7 +110,7 @@ data ChatController = ChatController
outputQ :: TBQueue (Maybe CorrId, ChatResponse),
notifyQ :: TBQueue Notification,
sendNotification :: Notification -> IO (),
chatLock :: TMVar (),
chatLock :: Lock,
sndFiles :: TVar (Map Int64 Handle),
rcvFiles :: TVar (Map Int64 Handle),
currentCalls :: TMap ContactId Call,
@@ -132,6 +151,7 @@ data ChatCommand
| APIUpdateChatItem ChatRef ChatItemId MsgContent
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
| APIChatUnread ChatRef Bool
| APIDeleteChat ChatRef
| APIClearChat ChatRef
| APIAcceptContact Int64
@@ -161,6 +181,9 @@ data ChatCommand
| APILeaveGroup GroupId
| APIListMembers GroupId
| APIUpdateGroupProfile GroupId GroupProfile
| APICreateGroupLink GroupId
| APIDeleteGroupLink GroupId
| APIGetGroupLink GroupId
| GetUserSMPServers
| SetUserSMPServers [SMPServer]
| APISetChatItemTTL (Maybe Int64)
@@ -184,7 +207,7 @@ data ChatCommand
| CreateMyAddress
| DeleteMyAddress
| ShowMyAddress
| AddressAutoAccept Bool (Maybe MsgContent)
| AddressAutoAccept (Maybe AutoAccept)
| AcceptContact ContactName
| RejectContact ContactName
| SendMessage ChatName ByteString
@@ -203,13 +226,16 @@ data ChatCommand
| ListMembers GroupName
| ListGroups
| UpdateGroupProfile GroupName GroupProfile
| CreateGroupLink GroupName
| DeleteGroupLink GroupName
| ShowGroupLink GroupName
| 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)
| ReceiveFile {fileId :: FileTransferId, fileInline :: Maybe Bool, filePath :: Maybe FilePath}
| CancelFile FileTransferId
| FileStatus FileTransferId
| ShowProfile
@@ -217,6 +243,7 @@ data ChatCommand
| UpdateProfileImage (Maybe ImageData)
| QuitChat
| ShowVersion
| DebugLocks
deriving (Show)
data ChatResponse
@@ -248,8 +275,8 @@ data ChatResponse
| CRGroupCreated {groupInfo :: GroupInfo}
| CRGroupMembers {group :: Group}
| CRContactsList {contacts :: [Contact]}
| CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent}
| CRUserContactLinkUpdated {connReqContact :: ConnReqContact, autoAccept :: Bool, autoReply :: Maybe MsgContent}
| CRUserContactLink {contactLink :: UserContactLink}
| CRUserContactLinkUpdated {contactLink :: UserContactLink}
| CRContactRequestRejected {contactRequest :: UserContactRequest}
| CRUserAcceptedGroupSent {groupInfo :: GroupInfo}
| CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember}
@@ -291,10 +318,12 @@ data ChatResponse
| CRContactConnecting {contact :: Contact}
| CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile}
| CRContactAnotherClient {contact :: Contact}
| CRSubscriptionEnd {connectionEntity :: ConnectionEntity}
| CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]}
| CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]}
| CRContactSubError {contact :: Contact, chatError :: ChatError}
| CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]}
| CRUserContactSubSummary {userContactSubscriptions :: [UserContactSubStatus]}
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRGroupInvitation {groupInfo :: GroupInfo}
@@ -312,6 +341,10 @@ data ChatResponse
| CRGroupRemoved {groupInfo :: GroupInfo}
| CRGroupDeleted {groupInfo :: GroupInfo, member :: GroupMember}
| CRGroupUpdated {fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
| CRGroupLinkCreated {groupInfo :: GroupInfo, connReqContact :: ConnReqContact}
| CRGroupLink {groupInfo :: GroupInfo, connReqContact :: ConnReqContact}
| CRGroupLinkDeleted {groupInfo :: GroupInfo}
| CRAcceptingGroupJoinRequest {groupInfo :: GroupInfo, contact :: Contact}
| CRMemberSubError {groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError}
| CRMemberSubSummary {memberSubscriptions :: [MemberSubStatus]}
| CRGroupSubscribed {groupInfo :: GroupInfo}
@@ -332,6 +365,7 @@ data ChatResponse
| CRNewContactConnection {connection :: PendingContactConnection}
| CRContactConnectionDeleted {connection :: PendingContactConnection}
| CRSQLResult {rows :: [Text]}
| CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks}
| CRMessageError {severity :: Text, errorMessage :: Text}
| CRChatCmdError {chatError :: ChatError}
| CRChatError {chatError :: ChatError}
@@ -379,6 +413,16 @@ instance ToJSON MemberSubStatus where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data UserContactSubStatus = UserContactSubStatus
{ userContact :: UserContact,
userContactError :: Maybe ChatError
}
deriving (Show, Generic)
instance ToJSON UserContactSubStatus where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data PendingSubStatus = PendingSubStatus
{ connection :: PendingContactConnection,
connError :: Maybe ChatError
@@ -424,7 +468,6 @@ data ChatErrorType
| CEInvalidConnReq
| CEInvalidChatMessage {message :: String}
| CEContactNotReady {contact :: Contact}
| CEContactGroups {contact :: Contact, groupNames :: [GroupName]}
| CEGroupUserRole
| CEContactIncognitoCantInvite
| CEGroupIncognitoCantInvite
+17 -9
View File
@@ -213,7 +213,8 @@ instance ToJSON AChat where
data ChatStats = ChatStats
{ unreadCount :: Int,
minUnreadItemId :: ChatItemId
minUnreadItemId :: ChatItemId,
unreadChat :: Bool
}
deriving (Show, Generic)
@@ -338,6 +339,8 @@ data CIFileStatus (d :: MsgDirection) where
CIFSRcvComplete :: CIFileStatus 'MDRcv
CIFSRcvCancelled :: CIFileStatus 'MDRcv
deriving instance Eq (CIFileStatus d)
deriving instance Show (CIFileStatus d)
ciFileEnded :: CIFileStatus d -> Bool
@@ -505,17 +508,18 @@ rcvGroupEventToText = \case
RGEMemberAdded _ p -> "added " <> profileToText p
RGEMemberConnected -> "connected"
RGEMemberLeft -> "left"
RGEMemberRole _ p r -> "role of " <> profileToText p <> ": " <> safeDecodeUtf8 (strEncode r)
RGEUserRole r -> "your role: " <> safeDecodeUtf8 (strEncode r)
RGEMemberRole _ p r -> "changed role of " <> profileToText p <> " to " <> safeDecodeUtf8 (strEncode r)
RGEUserRole r -> "changed your role to " <> safeDecodeUtf8 (strEncode r)
RGEMemberDeleted _ p -> "removed " <> profileToText p
RGEUserDeleted -> "removed you"
RGEGroupDeleted -> "deleted group"
RGEGroupUpdated _ -> "group profile updated"
RGEInvitedViaGroupLink -> "invited via your group link"
sndGroupEventToText :: SndGroupEvent -> Text
sndGroupEventToText = \case
SGEMemberRole _ p r -> "role of " <> profileToText p <> ": " <> safeDecodeUtf8 (strEncode r)
SGEUserRole r -> "your role " <> safeDecodeUtf8 (strEncode r)
SGEMemberRole _ p r -> "changed role of " <> profileToText p <> " to " <> safeDecodeUtf8 (strEncode r)
SGEUserRole r -> "changed role for yourself to " <> safeDecodeUtf8 (strEncode r)
SGEMemberDeleted _ p -> "removed " <> profileToText p
SGEUserLeft -> "left"
SGEGroupUpdated _ -> "group profile updated"
@@ -554,6 +558,10 @@ data RcvGroupEvent
| RGEUserDeleted -- CRDeletedMemberUser
| RGEGroupDeleted -- CRGroupDeleted
| RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated
-- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations,
-- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message")
-- and be created as unread without adding / working around new status for sent items
| RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink
deriving (Show, Generic)
instance FromJSON RcvGroupEvent where
@@ -836,8 +844,8 @@ instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect
instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup
data NewMessage = NewMessage
{ chatMsgEvent :: ChatMsgEvent,
data NewMessage e = NewMessage
{ chatMsgEvent :: ChatMsgEvent e,
msgBody :: MsgBody
}
deriving (Show)
@@ -850,14 +858,14 @@ data SndMessage = SndMessage
data RcvMessage = RcvMessage
{ msgId :: MessageId,
chatMsgEvent :: ChatMsgEvent,
chatMsgEvent :: AChatMsgEvent,
sharedMsgId_ :: Maybe SharedMsgId,
msgBody :: MsgBody
}
data PendingGroupMessage = PendingGroupMessage
{ msgId :: MessageId,
cmEventTag :: CMEventTag,
cmEventTag :: ACMEventTag,
msgBody :: MsgBody,
introId_ :: Maybe Int64
}
@@ -0,0 +1,14 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221011_user_contact_links_group_id where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221011_user_contact_links_group_id :: Query
m20221011_user_contact_links_group_id =
[sql|
ALTER TABLE user_contact_links ADD COLUMN group_id INTEGER REFERENCES groups ON DELETE CASCADE;
CREATE UNIQUE INDEX idx_user_contact_links_group_id ON user_contact_links(group_id);
|]
@@ -0,0 +1,20 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221012_inline_files where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221012_inline_files :: Query
m20221012_inline_files =
[sql|
DROP INDEX idx_messages_direct_shared_msg_id;
ALTER TABLE files ADD COLUMN file_inline TEXT;
ALTER TABLE rcv_files ADD COLUMN rcv_file_inline TEXT;
ALTER TABLE rcv_files ADD COLUMN file_inline TEXT;
ALTER TABLE snd_files ADD COLUMN file_inline TEXT;
ALTER TABLE snd_files ADD COLUMN last_inline_msg_delivery_id INTEGER;
CREATE UNIQUE INDEX idx_snd_files_last_inline_msg_delivery_id ON snd_files(last_inline_msg_delivery_id);
|]
@@ -0,0 +1,20 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221019_unread_chat where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221019_unread_chat :: Query
m20221019_unread_chat =
[sql|
PRAGMA ignore_check_constraints=ON;
ALTER TABLE contacts ADD COLUMN unread_chat INTEGER DEFAULT 0 CHECK (unread_chat NOT NULL);
UPDATE contacts SET unread_chat = 0;
ALTER TABLE groups ADD COLUMN unread_chat INTEGER DEFAULT 0 CHECK (unread_chat NOT NULL);
UPDATE groups SET unread_chat = 0;
PRAGMA ignore_check_constraints=OFF;
|]
@@ -0,0 +1,20 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221021_auto_accept__group_links where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221021_auto_accept__group_links :: Query
m20221021_auto_accept__group_links =
[sql|
PRAGMA ignore_check_constraints=ON;
ALTER TABLE connections ADD COLUMN via_group_link INTEGER DEFAULT 0 CHECK (via_group_link NOT NULL); -- flag, 1 for connections via group link
UPDATE connections SET via_group_link = 0;
ALTER TABLE user_contact_links ADD column auto_accept_incognito INTEGER DEFAULT 0 CHECK (auto_accept_incognito NOT NULL);
UPDATE user_contact_links SET auto_accept_incognito = 0;
PRAGMA ignore_check_constraints=OFF;
|]
@@ -0,0 +1,22 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20221024_contact_used where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20221024_contact_used :: Query
m20221024_contact_used =
[sql|
PRAGMA ignore_check_constraints=ON;
ALTER TABLE contacts ADD COLUMN contact_used INTEGER DEFAULT 0 CHECK (contact_used NOT NULL);
UPDATE contacts SET contact_used = 0;
UPDATE contacts SET contact_used = 1 WHERE contact_id IN (
SELECT DISTINCT contact_id FROM chat_items WHERE contact_id IS NOT NULL
);
PRAGMA ignore_check_constraints=OFF;
|]
+20 -8
View File
@@ -56,6 +56,8 @@ is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user
updated_at TEXT CHECK(updated_at NOT NULL),
xcontact_id BLOB,
enable_ntfs INTEGER,
unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL),
contact_used INTEGER DEFAULT 0 CHECK(contact_used NOT NULL),
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
@@ -123,7 +125,8 @@ CREATE TABLE groups(
updated_at TEXT CHECK(updated_at NOT NULL),
chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL,
enable_ntfs INTEGER,
host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, -- received
host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL,
unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), -- received
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
@@ -182,7 +185,8 @@ CREATE TABLE files(
chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE,
updated_at TEXT CHECK(updated_at NOT NULL),
cancelled INTEGER,
ci_file_status TEXT
ci_file_status TEXT,
file_inline TEXT
);
CREATE TABLE snd_files(
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
@@ -191,6 +195,8 @@ CREATE TABLE snd_files(
group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL),
file_inline TEXT,
last_inline_msg_delivery_id INTEGER,
PRIMARY KEY(file_id, connection_id)
) WITHOUT ROWID;
CREATE TABLE rcv_files(
@@ -200,7 +206,9 @@ CREATE TABLE rcv_files(
file_queue_info BLOB
,
created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL)
updated_at TEXT CHECK(updated_at NOT NULL),
rcv_file_inline TEXT,
file_inline TEXT
);
CREATE TABLE snd_file_chunks(
file_id INTEGER NOT NULL,
@@ -245,6 +253,7 @@ CREATE TABLE connections(
custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL,
conn_req_inv BLOB,
local_alias DEFAULT '' CHECK(local_alias NOT NULL),
via_group_link INTEGER DEFAULT 0 CHECK(via_group_link NOT NULL),
FOREIGN KEY(snd_file_id, connection_id)
REFERENCES snd_files(file_id, connection_id)
ON DELETE CASCADE
@@ -259,6 +268,8 @@ CREATE TABLE user_contact_links(
updated_at TEXT CHECK(updated_at NOT NULL),
auto_accept INTEGER DEFAULT 0,
auto_reply_msg_content TEXT DEFAULT NULL,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
auto_accept_incognito INTEGER DEFAULT 0 CHECK(auto_accept_incognito NOT NULL),
UNIQUE(user_id, local_display_name)
);
CREATE TABLE contact_requests(
@@ -369,11 +380,6 @@ CREATE TABLE smp_servers(
UNIQUE(host, port)
);
CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id);
CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages(
connection_id,
shared_msg_id_user,
shared_msg_id
);
CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id);
CREATE TABLE calls(
-- stores call invitations state for communicating state between NSE and app when call notification comes
@@ -427,3 +433,9 @@ CREATE UNIQUE INDEX idx_chat_items_group_shared_msg_id ON chat_items(
shared_msg_id
);
CREATE INDEX idx_msg_deliveries_message_id ON msg_deliveries(message_id);
CREATE UNIQUE INDEX idx_user_contact_links_group_id ON user_contact_links(
group_id
);
CREATE UNIQUE INDEX idx_snd_files_last_inline_msg_delivery_id ON snd_files(
last_inline_msg_delivery_id
);
+277 -141
View File
@@ -9,7 +9,9 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.Chat.Protocol where
@@ -22,19 +24,25 @@ import qualified Data.Aeson.KeyMap as JM
import qualified Data.Aeson.Types as JT
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Internal (c2w, w2c)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime)
import Data.Type.Equality
import Data.Typeable (Typeable)
import Data.Word (Word32)
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics (Generic)
import Simplex.Chat.Call
import Simplex.Chat.Types
import Simplex.Chat.Util (safeDecodeUtf8)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (fromTextField_, fstToLower, sumTypeJSON)
import Simplex.Messaging.Parsers (fromTextField_, fstToLower, parseAll, sumTypeJSON)
import Simplex.Messaging.Util (eitherToMaybe, (<$?>))
data ConnectionEntity
@@ -59,18 +67,64 @@ updateEntityConnStatus connEntity connStatus = case connEntity of
where
st c = c {connStatus}
data MsgEncoding = Binary | Json
data SMsgEncoding (e :: MsgEncoding) where
SBinary :: SMsgEncoding 'Binary
SJson :: SMsgEncoding 'Json
deriving instance Show (SMsgEncoding e)
class MsgEncodingI (e :: MsgEncoding) where
encoding :: SMsgEncoding e
instance MsgEncodingI 'Binary where encoding = SBinary
instance MsgEncodingI 'Json where encoding = SJson
data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e)
instance TestEquality SMsgEncoding where
testEquality SBinary SBinary = Just Refl
testEquality SJson SJson = Just Refl
testEquality _ _ = Nothing
checkEncoding :: forall t e e'. (MsgEncodingI e, MsgEncodingI e') => t e' -> Either String (t e)
checkEncoding x = case testEquality (encoding @e) (encoding @e') of
Just Refl -> Right x
Nothing -> Left "bad encoding"
data AppMessage (e :: MsgEncoding) where
AMJson :: AppMessageJson -> AppMessage 'Json
AMBinary :: AppMessageBinary -> AppMessage 'Binary
-- chat message is sent as JSON with these properties
data AppMessage = AppMessage
data AppMessageJson = AppMessageJson
{ msgId :: Maybe SharedMsgId,
event :: Text,
params :: J.Object
}
deriving (Generic, FromJSON)
instance ToJSON AppMessage where
data AppMessageBinary = AppMessageBinary
{ msgId :: Maybe SharedMsgId,
tag :: Char,
body :: ByteString
}
instance ToJSON AppMessageJson where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
instance StrEncoding AppMessageBinary where
strEncode AppMessageBinary {tag, msgId, body} = smpEncode (tag, msgId', Tail body)
where
msgId' = maybe B.empty (\(SharedMsgId mId') -> mId') msgId
strP = do
(tag, msgId', Tail body) <- smpP
let msgId = if B.null msgId' then Nothing else Just (SharedMsgId msgId')
pure AppMessageBinary {tag, msgId, body}
newtype SharedMsgId = SharedMsgId ByteString
deriving (Eq, Show)
@@ -105,51 +159,99 @@ instance ToJSON MsgRef where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data ChatMessage = ChatMessage {msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent}
data ChatMessage e = ChatMessage {msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent e}
deriving (Eq, Show)
instance StrEncoding ChatMessage where
strEncode = LB.toStrict . J.encode . chatToAppMessage
strDecode = appToChatMessage <=< J.eitherDecodeStrict'
strP = strDecode <$?> A.takeByteString
data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e)
data ChatMsgEvent
= XMsgNew MsgContainer
| XMsgUpdate SharedMsgId MsgContent
| XMsgDel SharedMsgId
| XMsgDeleted
| XFile FileInvitation -- TODO discontinue
| XFileAcpt String -- direct file protocol
| XFileAcptInv SharedMsgId ConnReqInvitation String -- group file protocol
| XFileCancel SharedMsgId
| XInfo Profile
| XContact Profile (Maybe XContactId)
| XGrpInv GroupInvitation
| XGrpAcpt MemberId
| XGrpMemNew MemberInfo
| XGrpMemIntro MemberInfo
| XGrpMemInv MemberId IntroInvitation
| XGrpMemFwd MemberInfo IntroInvitation
| XGrpMemInfo MemberId Profile
| XGrpMemRole MemberId GroupMemberRole
| XGrpMemCon MemberId -- TODO not implemented
| XGrpMemConAll MemberId -- TODO not implemented
| XGrpMemDel MemberId
| XGrpLeave
| XGrpDel
| XGrpInfo GroupProfile
| XInfoProbe Probe
| XInfoProbeCheck ProbeHash
| XInfoProbeOk Probe
| XCallInv CallId CallInvitation
| XCallOffer CallId CallOffer
| XCallAnswer CallId CallAnswer
| XCallExtra CallId CallExtraInfo
| XCallEnd CallId
| XOk
| XUnknown {event :: Text, params :: J.Object}
instance MsgEncodingI e => StrEncoding (ChatMessage e) where
strEncode msg = case chatToAppMessage msg of
AMJson m -> LB.toStrict $ J.encode m
AMBinary m -> strEncode m
strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP
instance StrEncoding AChatMessage where
strEncode (ACMsg _ m) = strEncode m
strP =
A.peekChar' >>= \case
'{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString)
_ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP)
data ChatMsgEvent (e :: MsgEncoding) where
XMsgNew :: MsgContainer -> ChatMsgEvent 'Json
XMsgUpdate :: SharedMsgId -> MsgContent -> ChatMsgEvent 'Json
XMsgDel :: SharedMsgId -> ChatMsgEvent 'Json
XMsgDeleted :: ChatMsgEvent 'Json
XFile :: FileInvitation -> ChatMsgEvent 'Json -- TODO discontinue
XFileAcpt :: String -> ChatMsgEvent 'Json -- direct file protocol
XFileAcptInv :: SharedMsgId -> Maybe ConnReqInvitation -> String -> ChatMsgEvent 'Json
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
XInfo :: Profile -> ChatMsgEvent 'Json
XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
XGrpAcpt :: MemberId -> ChatMsgEvent 'Json
XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json
XGrpMemIntro :: MemberInfo -> ChatMsgEvent 'Json
XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json
XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json
XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json
XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json
XGrpMemCon :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented
XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented
XGrpMemDel :: MemberId -> ChatMsgEvent 'Json
XGrpLeave :: ChatMsgEvent 'Json
XGrpDel :: ChatMsgEvent 'Json
XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json
XInfoProbe :: Probe -> ChatMsgEvent 'Json
XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json
XInfoProbeOk :: Probe -> ChatMsgEvent 'Json
XCallInv :: CallId -> CallInvitation -> ChatMsgEvent 'Json
XCallOffer :: CallId -> CallOffer -> ChatMsgEvent 'Json
XCallAnswer :: CallId -> CallAnswer -> ChatMsgEvent 'Json
XCallExtra :: CallId -> CallExtraInfo -> ChatMsgEvent 'Json
XCallEnd :: CallId -> ChatMsgEvent 'Json
XOk :: ChatMsgEvent 'Json
XUnknown :: {event :: Text, params :: J.Object} -> ChatMsgEvent 'Json
BFileChunk :: SharedMsgId -> FileChunk -> ChatMsgEvent 'Binary
deriving instance Eq (ChatMsgEvent e)
deriving instance Show (ChatMsgEvent e)
data AChatMsgEvent = forall e. MsgEncodingI e => ACME (SMsgEncoding e) (ChatMsgEvent e)
deriving instance Show AChatMsgEvent
data FileChunk = FileChunk {chunkNo :: Integer, chunkBytes :: ByteString} | FileChunkCancel
deriving (Eq, Show)
instance Encoding FileChunk where
smpEncode = \case
FileChunk {chunkNo, chunkBytes} -> smpEncode ('F', fromIntegral chunkNo :: Word32, Tail chunkBytes)
FileChunkCancel -> smpEncode 'C'
smpP =
smpP >>= \case
'F' -> do
chunkNo <- fromIntegral <$> smpP @Word32
Tail chunkBytes <- smpP
pure FileChunk {chunkNo, chunkBytes}
'C' -> pure FileChunkCancel
_ -> fail "bad FileChunk"
newtype InlineFileChunk = IFC {unIFC :: FileChunk}
instance Encoding InlineFileChunk where
smpEncode (IFC chunk) = case chunk of
FileChunk {chunkNo, chunkBytes} -> smpEncode (w2c $ fromIntegral chunkNo, Tail chunkBytes)
FileChunkCancel -> smpEncode '\NUL'
smpP = do
c <- A.anyChar
IFC <$> case c of
'\NUL' -> pure FileChunkCancel
_ -> do
Tail chunkBytes <- smpP
pure FileChunk {chunkNo = fromIntegral $ c2w c, chunkBytes}
data QuotedMsg = QuotedMsg {msgRef :: MsgRef, content :: MsgContent}
deriving (Eq, Show, Generic, FromJSON)
@@ -157,9 +259,9 @@ instance ToJSON QuotedMsg where
toEncoding = J.genericToEncoding J.defaultOptions
toJSON = J.genericToJSON J.defaultOptions
cmToQuotedMsg :: ChatMsgEvent -> Maybe QuotedMsg
cmToQuotedMsg :: AChatMsgEvent -> Maybe QuotedMsg
cmToQuotedMsg = \case
XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg
ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg
_ -> Nothing
data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCFile_ | MCUnknown_ Text
@@ -273,7 +375,7 @@ msgContainerJSON = \case
where
withFile l = \case
Nothing -> l
Just f -> l <> ["file" .= fileInvitationJSON f]
Just f -> l <> ["file" .= f]
instance ToJSON MsgContent where
toJSON = \case
@@ -295,44 +397,48 @@ instance ToField MsgContent where
instance FromField MsgContent where
fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8
data CMEventTag
= XMsgNew_
| XMsgUpdate_
| XMsgDel_
| XMsgDeleted_
| XFile_
| XFileAcpt_
| XFileAcptInv_
| XFileCancel_
| XInfo_
| XContact_
| XGrpInv_
| XGrpAcpt_
| XGrpMemNew_
| XGrpMemIntro_
| XGrpMemInv_
| XGrpMemFwd_
| XGrpMemInfo_
| XGrpMemRole_
| XGrpMemCon_
| XGrpMemConAll_
| XGrpMemDel_
| XGrpLeave_
| XGrpDel_
| XGrpInfo_
| XInfoProbe_
| XInfoProbeCheck_
| XInfoProbeOk_
| XCallInv_
| XCallOffer_
| XCallAnswer_
| XCallExtra_
| XCallEnd_
| XOk_
| XUnknown_ Text
deriving (Eq, Show)
data CMEventTag (e :: MsgEncoding) where
XMsgNew_ :: CMEventTag 'Json
XMsgUpdate_ :: CMEventTag 'Json
XMsgDel_ :: CMEventTag 'Json
XMsgDeleted_ :: CMEventTag 'Json
XFile_ :: CMEventTag 'Json
XFileAcpt_ :: CMEventTag 'Json
XFileAcptInv_ :: CMEventTag 'Json
XFileCancel_ :: CMEventTag 'Json
XInfo_ :: CMEventTag 'Json
XContact_ :: CMEventTag 'Json
XGrpInv_ :: CMEventTag 'Json
XGrpAcpt_ :: CMEventTag 'Json
XGrpMemNew_ :: CMEventTag 'Json
XGrpMemIntro_ :: CMEventTag 'Json
XGrpMemInv_ :: CMEventTag 'Json
XGrpMemFwd_ :: CMEventTag 'Json
XGrpMemInfo_ :: CMEventTag 'Json
XGrpMemRole_ :: CMEventTag 'Json
XGrpMemCon_ :: CMEventTag 'Json
XGrpMemConAll_ :: CMEventTag 'Json
XGrpMemDel_ :: CMEventTag 'Json
XGrpLeave_ :: CMEventTag 'Json
XGrpDel_ :: CMEventTag 'Json
XGrpInfo_ :: CMEventTag 'Json
XInfoProbe_ :: CMEventTag 'Json
XInfoProbeCheck_ :: CMEventTag 'Json
XInfoProbeOk_ :: CMEventTag 'Json
XCallInv_ :: CMEventTag 'Json
XCallOffer_ :: CMEventTag 'Json
XCallAnswer_ :: CMEventTag 'Json
XCallExtra_ :: CMEventTag 'Json
XCallEnd_ :: CMEventTag 'Json
XOk_ :: CMEventTag 'Json
XUnknown_ :: Text -> CMEventTag 'Json
BFileChunk_ :: CMEventTag 'Binary
instance StrEncoding CMEventTag where
deriving instance Show (CMEventTag e)
deriving instance Eq (CMEventTag e)
instance MsgEncodingI e => StrEncoding (CMEventTag e) where
strEncode = \case
XMsgNew_ -> "x.msg.new"
XMsgUpdate_ -> "x.msg.update"
@@ -368,45 +474,54 @@ instance StrEncoding CMEventTag where
XCallEnd_ -> "x.call.end"
XOk_ -> "x.ok"
XUnknown_ t -> encodeUtf8 t
strDecode = \case
"x.msg.new" -> Right XMsgNew_
"x.msg.update" -> Right XMsgUpdate_
"x.msg.del" -> Right XMsgDel_
"x.msg.deleted" -> Right XMsgDeleted_
"x.file" -> Right XFile_
"x.file.acpt" -> Right XFileAcpt_
"x.file.acpt.inv" -> Right XFileAcptInv_
"x.file.cancel" -> Right XFileCancel_
"x.info" -> Right XInfo_
"x.contact" -> Right XContact_
"x.grp.inv" -> Right XGrpInv_
"x.grp.acpt" -> Right XGrpAcpt_
"x.grp.mem.new" -> Right XGrpMemNew_
"x.grp.mem.intro" -> Right XGrpMemIntro_
"x.grp.mem.inv" -> Right XGrpMemInv_
"x.grp.mem.fwd" -> Right XGrpMemFwd_
"x.grp.mem.info" -> Right XGrpMemInfo_
"x.grp.mem.role" -> Right XGrpMemRole_
"x.grp.mem.con" -> Right XGrpMemCon_
"x.grp.mem.con.all" -> Right XGrpMemConAll_
"x.grp.mem.del" -> Right XGrpMemDel_
"x.grp.leave" -> Right XGrpLeave_
"x.grp.del" -> Right XGrpDel_
"x.grp.info" -> Right XGrpInfo_
"x.info.probe" -> Right XInfoProbe_
"x.info.probe.check" -> Right XInfoProbeCheck_
"x.info.probe.ok" -> Right XInfoProbeOk_
"x.call.inv" -> Right XCallInv_
"x.call.offer" -> Right XCallOffer_
"x.call.answer" -> Right XCallAnswer_
"x.call.extra" -> Right XCallExtra_
"x.call.end" -> Right XCallEnd_
"x.ok" -> Right XOk_
t -> Right . XUnknown_ $ safeDecodeUtf8 t
BFileChunk_ -> "F"
strDecode = (\(ACMEventTag _ t) -> checkEncoding t) <=< strDecode
strP = strDecode <$?> A.takeTill (== ' ')
toCMEventTag :: ChatMsgEvent -> CMEventTag
toCMEventTag = \case
instance StrEncoding ACMEventTag where
strEncode (ACMEventTag _ t) = strEncode t
strP =
((,) <$> A.peekChar' <*> A.takeTill (== ' ')) >>= \case
('x', t) -> pure . ACMEventTag SJson $ case t of
"x.msg.new" -> XMsgNew_
"x.msg.update" -> XMsgUpdate_
"x.msg.del" -> XMsgDel_
"x.msg.deleted" -> XMsgDeleted_
"x.file" -> XFile_
"x.file.acpt" -> XFileAcpt_
"x.file.acpt.inv" -> XFileAcptInv_
"x.file.cancel" -> XFileCancel_
"x.info" -> XInfo_
"x.contact" -> XContact_
"x.grp.inv" -> XGrpInv_
"x.grp.acpt" -> XGrpAcpt_
"x.grp.mem.new" -> XGrpMemNew_
"x.grp.mem.intro" -> XGrpMemIntro_
"x.grp.mem.inv" -> XGrpMemInv_
"x.grp.mem.fwd" -> XGrpMemFwd_
"x.grp.mem.info" -> XGrpMemInfo_
"x.grp.mem.role" -> XGrpMemRole_
"x.grp.mem.con" -> XGrpMemCon_
"x.grp.mem.con.all" -> XGrpMemConAll_
"x.grp.mem.del" -> XGrpMemDel_
"x.grp.leave" -> XGrpLeave_
"x.grp.del" -> XGrpDel_
"x.grp.info" -> XGrpInfo_
"x.info.probe" -> XInfoProbe_
"x.info.probe.check" -> XInfoProbeCheck_
"x.info.probe.ok" -> XInfoProbeOk_
"x.call.inv" -> XCallInv_
"x.call.offer" -> XCallOffer_
"x.call.answer" -> XCallAnswer_
"x.call.extra" -> XCallExtra_
"x.call.end" -> XCallEnd_
"x.ok" -> XOk_
_ -> XUnknown_ $ safeDecodeUtf8 t
(_, "F") -> pure $ ACMEventTag SBinary BFileChunk_
_ -> fail "bad ACMEventTag"
toCMEventTag :: ChatMsgEvent e -> CMEventTag e
toCMEventTag msg = case msg of
XMsgNew _ -> XMsgNew_
XMsgUpdate _ _ -> XMsgUpdate_
XMsgDel _ -> XMsgDel_
@@ -441,18 +556,25 @@ toCMEventTag = \case
XCallEnd _ -> XCallEnd_
XOk -> XOk_
XUnknown t _ -> XUnknown_ t
BFileChunk _ _ -> BFileChunk_
cmEventTagT :: Text -> Maybe CMEventTag
cmEventTagT = eitherToMaybe . strDecode . encodeUtf8
instance MsgEncodingI e => TextEncoding (CMEventTag e) where
textEncode = decodeLatin1 . strEncode
textDecode = eitherToMaybe . strDecode . encodeUtf8
serializeCMEventTag :: CMEventTag -> Text
serializeCMEventTag = decodeLatin1 . strEncode
instance TextEncoding ACMEventTag where
textEncode (ACMEventTag _ t) = textEncode t
textDecode = eitherToMaybe . strDecode . encodeUtf8
instance FromField CMEventTag where fromField = fromTextField_ cmEventTagT
instance (MsgEncodingI e, Typeable e) => FromField (CMEventTag e) where fromField = fromTextField_ textDecode
instance ToField CMEventTag where toField = toField . serializeCMEventTag
instance MsgEncodingI e => ToField (CMEventTag e) where toField = toField . textEncode
hasNotification :: CMEventTag -> Bool
instance FromField ACMEventTag where fromField = fromTextField_ textDecode
instance ToField ACMEventTag where toField = toField . textEncode
hasNotification :: CMEventTag e -> Bool
hasNotification = \case
XMsgNew_ -> True
XFile_ -> True
@@ -463,8 +585,18 @@ hasNotification = \case
XCallInv_ -> True
_ -> False
appToChatMessage :: AppMessage -> Either String ChatMessage
appToChatMessage AppMessage {msgId, event, params} = do
appBinaryToCM :: AppMessageBinary -> Either String (ChatMessage 'Binary)
appBinaryToCM AppMessageBinary {msgId, tag, body} = do
eventTag <- strDecode $ B.singleton tag
chatMsgEvent <- parseAll (msg eventTag) body
pure ChatMessage {msgId, chatMsgEvent}
where
msg :: CMEventTag 'Binary -> A.Parser (ChatMsgEvent 'Binary)
msg = \case
BFileChunk_ -> BFileChunk <$> (SharedMsgId <$> smpP) <*> (unIFC <$> smpP)
appJsonToCM :: AppMessageJson -> Either String (ChatMessage 'Json)
appJsonToCM AppMessageJson {msgId, event, params} = do
eventTag <- strDecode $ encodeUtf8 event
chatMsgEvent <- msg eventTag
pure ChatMessage {msgId, chatMsgEvent}
@@ -473,6 +605,7 @@ appToChatMessage AppMessage {msgId, event, params} = do
p key = JT.parseEither (.: key) params
opt :: FromJSON a => J.Key -> Either String (Maybe a)
opt key = JT.parseEither (.:? key) params
msg :: CMEventTag 'Json -> Either String (ChatMsgEvent 'Json)
msg = \case
XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params
XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content"
@@ -480,7 +613,7 @@ appToChatMessage AppMessage {msgId, event, params} = do
XMsgDeleted_ -> pure XMsgDeleted
XFile_ -> XFile <$> p "file"
XFileAcpt_ -> XFileAcpt <$> p "fileName"
XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> p "fileConnReq" <*> p "fileName"
XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> opt "fileConnReq" <*> p "fileName"
XFileCancel_ -> XFileCancel <$> p "msgId"
XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
@@ -509,21 +642,29 @@ appToChatMessage AppMessage {msgId, event, params} = do
XOk_ -> pure XOk
XUnknown_ t -> pure $ XUnknown t params
chatToAppMessage :: ChatMessage -> AppMessage
chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, params}
chatToAppMessage :: forall e. MsgEncodingI e => ChatMessage e -> AppMessage e
chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of
SBinary ->
let (binaryMsgId, body) = toBody chatMsgEvent
in AMBinary AppMessageBinary {msgId = binaryMsgId, tag = B.head $ strEncode tag, body}
SJson -> AMJson AppMessageJson {msgId, event = textEncode tag, params = params chatMsgEvent}
where
event = serializeCMEventTag . toCMEventTag $ chatMsgEvent
tag = toCMEventTag chatMsgEvent
o :: [(J.Key, J.Value)] -> J.Object
o = JM.fromList
key .=? value = maybe id ((:) . (key .=)) value
params = case chatMsgEvent of
toBody :: ChatMsgEvent 'Binary -> (Maybe SharedMsgId, ByteString)
toBody = \case
BFileChunk (SharedMsgId msgId') chunk -> (Nothing, smpEncode (msgId', IFC chunk))
params :: ChatMsgEvent 'Json -> J.Object
params = \case
XMsgNew container -> msgContainerJSON container
XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content]
XMsgDel msgId' -> o ["msgId" .= msgId']
XMsgDeleted -> JM.empty
XFile fileInv -> o ["file" .= fileInvitationJSON fileInv]
XFile fileInv -> o ["file" .= fileInv]
XFileAcpt fileName -> o ["fileName" .= fileName]
XFileAcptInv sharedMsgId fileConnReq fileName -> o ["msgId" .= sharedMsgId, "fileConnReq" .= fileConnReq, "fileName" .= fileName]
XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName]
XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId]
XInfo profile -> o ["profile" .= profile]
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
@@ -551,8 +692,3 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
XCallEnd callId -> o ["callId" .= callId]
XOk -> JM.empty
XUnknown _ ps -> ps
fileInvitationJSON :: FileInvitation -> J.Object
fileInvitationJSON FileInvitation {fileName, fileSize, fileConnReq} = case fileConnReq of
Nothing -> JM.fromList ["fileName" .= fileName, "fileSize" .= fileSize]
Just fConnReq -> JM.fromList ["fileName" .= fileName, "fileSize" .= fileSize, "fileConnReq" .= fConnReq]
+519 -275
View File
File diff suppressed because it is too large Load Diff
+59 -9
View File
@@ -81,6 +81,7 @@ data Contact = Contact
profile :: LocalProfile,
activeConn :: Connection,
viaGroup :: Maybe Int64,
contactUsed :: Bool,
chatSettings :: ChatSettings,
createdAt :: UTCTime,
updatedAt :: UTCTime
@@ -110,10 +111,14 @@ instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptio
data UserContact = UserContact
{ userContactLinkId :: Int64,
connReqContact :: ConnReqContact
connReqContact :: ConnReqContact,
groupId :: Maybe GroupId
}
deriving (Eq, Show, Generic)
userContactGroupId :: UserContact -> Maybe GroupId
userContactGroupId UserContact {groupId} = groupId
instance ToJSON UserContact where
toJSON = J.genericToJSON J.defaultOptions
toEncoding = J.genericToEncoding J.defaultOptions
@@ -237,6 +242,8 @@ instance ToJSON Profile where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile
type LocalAlias = Text
data LocalProfile = LocalProfile
@@ -607,7 +614,8 @@ data SndFileTransfer = SndFileTransfer
recipientDisplayName :: ContactName,
connId :: Int64,
agentConnId :: AgentConnId,
fileStatus :: FileStatus
fileStatus :: FileStatus,
fileInline :: Maybe InlineFileMode
}
deriving (Eq, Show, Generic)
@@ -621,16 +629,48 @@ type FileTransferId = Int64
data FileInvitation = FileInvitation
{ fileName :: String,
fileSize :: Integer,
fileConnReq :: Maybe ConnReqInvitation
fileConnReq :: Maybe ConnReqInvitation,
fileInline :: Maybe InlineFileMode
}
deriving (Eq, Show, Generic, FromJSON)
deriving (Eq, Show, Generic)
instance ToJSON FileInvitation where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON FileInvitation where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
instance FromJSON FileInvitation where
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
data InlineFileMode
= IFMOffer -- file will be sent inline once accepted
| IFMSent -- file is sent inline without acceptance
deriving (Eq, Show, Generic)
instance TextEncoding InlineFileMode where
textEncode = \case
IFMOffer -> "offer"
IFMSent -> "sent"
textDecode = \case
"offer" -> Just IFMOffer
"sent" -> Just IFMSent
_ -> Nothing
instance FromField InlineFileMode where fromField = fromTextField_ textDecode
instance ToField InlineFileMode where toField = toField . textEncode
instance FromJSON InlineFileMode where
parseJSON = J.withText "InlineFileMode" $ maybe (fail "bad InlineFileMode") pure . textDecode
instance ToJSON InlineFileMode where
toJSON = J.String . textEncode
toEncoding = JE.text . textEncode
data RcvFileTransfer = RcvFileTransfer
{ fileId :: FileTransferId,
fileInvitation :: FileInvitation,
fileStatus :: RcvFileStatus,
rcvFileInline :: Maybe InlineFileMode,
senderDisplayName :: ContactName,
chunkSize :: Integer,
cancelled :: Bool,
@@ -718,6 +758,7 @@ data FileTransferMeta = FileTransferMeta
fileName :: String,
filePath :: String,
fileSize :: Integer,
fileInline :: Maybe InlineFileMode,
chunkSize :: Integer,
cancelled :: Bool
}
@@ -767,6 +808,7 @@ data Connection = Connection
connLevel :: Int,
viaContact :: Maybe Int64, -- group member contact ID, if not direct connection
viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address"
viaGroupLink :: Bool, -- whether contact connected via group link
customUserProfileId :: Maybe Int64,
connType :: ConnType,
connStatus :: ConnStatus,
@@ -959,9 +1001,11 @@ instance TextEncoding CommandStatus where
CSError -> "error"
data CommandFunction
= CFCreateConn
= CFCreateConnGrpMemInv
| CFCreateConnGrpInv
| CFJoinConn
| CFAllowConn
| CFAcceptContact
| CFAckMessage
| CFDeleteConn
deriving (Eq, Show, Generic)
@@ -972,24 +1016,30 @@ instance ToField CommandFunction where toField = toField . textEncode
instance TextEncoding CommandFunction where
textDecode = \case
"create_conn" -> Just CFCreateConn
"create_conn" -> Just CFCreateConnGrpMemInv
"create_conn_grp_inv" -> Just CFCreateConnGrpInv
"join_conn" -> Just CFJoinConn
"allow_conn" -> Just CFAllowConn
"accept_contact" -> Just CFAcceptContact
"ack_message" -> Just CFAckMessage
"delete_conn" -> Just CFDeleteConn
_ -> Nothing
textEncode = \case
CFCreateConn -> "create_conn"
CFCreateConnGrpMemInv -> "create_conn"
CFCreateConnGrpInv -> "create_conn_grp_inv"
CFJoinConn -> "join_conn"
CFAllowConn -> "allow_conn"
CFAcceptContact -> "accept_contact"
CFAckMessage -> "ack_message"
CFDeleteConn -> "delete_conn"
commandExpectedResponse :: CommandFunction -> ACommandTag 'Agent
commandExpectedResponse = \case
CFCreateConn -> INV_
CFCreateConnGrpMemInv -> INV_
CFCreateConnGrpInv -> INV_
CFJoinConn -> OK_
CFAllowConn -> OK_
CFAcceptContact -> OK_
CFAckMessage -> OK_
CFDeleteConn -> OK_
-3
View File
@@ -8,6 +8,3 @@ safeDecodeUtf8 :: ByteString -> Text
safeDecodeUtf8 = decodeUtf8With onError
where
onError _ _ = Just '?'
uncurry3 :: (a -> b -> c -> d) -> ((a, b, c) -> d)
uncurry3 f ~(a, b, c) = f a b c
+64 -38
View File
@@ -34,7 +34,7 @@ import Simplex.Chat.Help
import Simplex.Chat.Markdown
import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Protocol
import Simplex.Chat.Store (StoreError (..))
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
import Simplex.Chat.Styled
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
@@ -87,13 +87,16 @@ responseToView testView = \case
HSSettings -> settingsInfo
CRWelcome user -> chatWelcome user
CRContactsList cs -> viewContactsList cs
CRUserContactLink cReqUri autoAccept autoReply -> connReqContact_ "Your chat address:" cReqUri <> autoAcceptStatus_ autoAccept autoReply
CRUserContactLinkUpdated _ autoAccept autoReply -> autoAcceptStatus_ autoAccept autoReply
CRUserContactLink UserContactLink {connReqContact, autoAccept} -> connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept
CRUserContactLinkUpdated UserContactLink {autoAccept} -> autoAcceptStatus_ autoAccept
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"]
CRGroupCreated g -> viewGroupCreated g
CRGroupMembers g -> viewGroupMembers g
CRGroupsList gs -> viewGroupsList gs
CRSentGroupInvitation g c _ -> viewSentGroupInvitation g c
CRSentGroupInvitation g c _ ->
if viaGroupLink . contactConn $ c
then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"]
else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus
CRUserProfile p -> viewUserProfile p
CRUserProfileNoChange -> ["user profile did not change"]
@@ -134,6 +137,7 @@ responseToView testView = \case
CRContactConnecting _ -> []
CRContactConnected ct userCustomProfile -> viewContactConnected ct userCustomProfile testView
CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"]
CRSubscriptionEnd acEntity -> [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"]
CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e]
@@ -141,6 +145,13 @@ responseToView testView = \case
[sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors"
where
(errors, subscribed) = partition (isJust . contactError) summary
CRUserContactSubSummary summary ->
map addressSS addresses
<> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors")
where
(addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary
addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError
(groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks
CRGroupInvitation g -> [groupInvitation' g]
CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role
CRUserJoinedGroup g _ -> viewUserJoinedGroup g
@@ -158,6 +169,10 @@ responseToView testView = \case
CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"]
CRGroupDeleted g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"]
CRGroupUpdated g g' m -> viewGroupUpdated g g' m
CRGroupLinkCreated g cReq -> groupLink_ "Group link is created!" g cReq
CRGroupLink g cReq -> groupLink_ "Group link:" g cReq
CRGroupLinkDeleted g -> viewGroupLinkDeleted g
CRAcceptingGroupJoinRequest g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."]
CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e]
CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors"
CRGroupSubscribed g -> viewGroupSubscribed g
@@ -180,6 +195,10 @@ responseToView testView = \case
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
CRNtfMessages {} -> []
CRSQLResult rows -> map plain rows
CRDebugLocks {chatLockName, agentLocks} ->
[ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName,
plain $ "agent locks: " <> LB.unpack (J.encode agentLocks)
]
CRMessageError prefix err -> [plain prefix <> ": " <> plain err]
CRChatError e -> viewChatError e
where
@@ -230,7 +249,7 @@ showSMPServer = B.unpack . strEncode . host
viewHostEvent :: AProtocolType -> TransportHost -> String
viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h)
viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> [StyledString]
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> [StyledString]
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = case chat of
DirectChat c -> case chatDir of
CIDirectSnd -> case content of
@@ -379,10 +398,6 @@ viewConnReqInvitation cReq =
"and ask them to connect: " <> highlight' "/c <invitation_link_above>"
]
viewSentGroupInvitation :: GroupInfo -> Contact -> [StyledString]
viewSentGroupInvitation g c =
["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
viewChatCleared :: AChatInfo -> [StyledString]
viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"]
@@ -419,10 +434,29 @@ connReqContact_ intro cReq =
"to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)"
]
autoAcceptStatus_ :: Bool -> Maybe MsgContent -> [StyledString]
autoAcceptStatus_ autoAccept autoReply =
("auto_accept " <> if autoAccept then "on" else "off") :
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString]
autoAcceptStatus_ = \case
Just AutoAccept {acceptIncognito, autoReply} ->
("auto_accept on" <> if acceptIncognito then ", incognito" else "") :
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
_ -> ["auto_accept off"]
groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> [StyledString]
groupLink_ intro g cReq =
[ intro,
"",
(plain . strEncode) cReq,
"",
"Anybody can connect to you and join group with: " <> highlight' "/c <group_link_above>",
"to show it again: " <> highlight ("/show link #" <> groupName' g),
"to delete it: " <> highlight ("/delete link #" <> groupName' g) <> " (joined members will remain connected to you)"
]
viewGroupLinkDeleted :: GroupInfo -> [StyledString]
viewGroupLinkDeleted g =
[ "Group link is deleted - joined members will remain connected.",
"To create a new group link use " <> highlight ("/create link #" <> groupName' g)
]
viewSentInvitation :: Maybe Profile -> Bool -> [StyledString]
viewSentInvitation incognitoProfile testView =
@@ -720,9 +754,14 @@ viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString]
viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc)
viewSentFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString]
viewSentFileInvitation to CIFile {fileId, filePath} = case filePath of
Just fPath -> sentWithTime_ $ ttySentFile to fileId fPath
viewSentFileInvitation to CIFile {fileId, filePath, fileStatus} = case filePath of
Just fPath -> sentWithTime_ $ ttySentFile fPath
_ -> const []
where
ttySentFile fPath = ["/f " <> to <> ttyFilePath fPath] <> cancelSending
cancelSending = case fileStatus of
CIFSSndTransfer -> []
_ -> ["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString]
sentWithTime_ styledMsg CIMeta {localItemTs} =
@@ -734,9 +773,6 @@ ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M"
ttyMsgContent :: MsgContent -> [StyledString]
ttyMsgContent = msgPlain . msgContentText
ttySentFile :: StyledString -> FileTransferId -> FilePath -> [StyledString]
ttySentFile to fId fPath = ["/f " <> to <> ttyFilePath fPath, "use " <> highlight ("/fc " <> show fId) <> " to cancel sending"]
prependFirst :: StyledString -> [StyledString] -> [StyledString]
prependFirst s [] = [s]
prependFirst s (s' : ss) = (s <> s') : ss
@@ -765,21 +801,11 @@ viewReceivedFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledStr
viewReceivedFileInvitation from file meta = receivedWithTime_ from [] meta (receivedFileInvitation_ file)
receivedFileInvitation_ :: CIFile d -> [StyledString]
receivedFileInvitation_ CIFile {fileId, fileName, fileSize} =
[ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)",
-- below is printed for auto-accepted files as well; auto-accept is disabled in terminal though so in reality it never happens
"use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"
]
-- TODO remove
viewReceivedFileInvitation' :: StyledString -> RcvFileTransfer -> CIMeta d -> [StyledString]
viewReceivedFileInvitation' from RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} meta = receivedWithTime_ from [] meta (receivedFileInvitation_' fileId fileName fileSize)
receivedFileInvitation_' :: Int64 -> String -> Integer -> [StyledString]
receivedFileInvitation_' fileId fileName fileSize =
[ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)",
"use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"
]
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} =
["sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)"]
<> case fileStatus of
CIFSRcvAccepted -> []
_ -> ["use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"]
humanReadableSize :: Integer -> StyledString
humanReadableSize size
@@ -821,9 +847,8 @@ fileTransferStr fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath
viewFileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString]
viewFileTransferStatus (FTSnd FileTransferMeta {fileId, fileName, cancelled} [], _) =
[ "sending " <> fileTransferStr fileId fileName <> ": no file transfers"
<> if cancelled then ", file transfer cancelled" else ""
]
["sending " <> fileTransferStr fileId fileName <> ": no file transfers"]
<> ["file transfer cancelled" | cancelled]
viewFileTransferStatus (FTSnd FileTransferMeta {cancelled} fts@(ft : _), chunksNum) =
recipientStatuses <> ["file transfer cancelled" | cancelled]
where
@@ -935,7 +960,6 @@ viewChatError = \case
CEInvalidConnReq -> viewInvalidConnReq
CEInvalidChatMessage e -> ["chat message error: " <> sShow e]
CEContactNotReady c -> [ttyContact' c <> ": not ready"]
CEContactGroups c gNames -> [ttyContact' c <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames]
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
CEGroupUserRole -> ["you have insufficient permissions for this group command"]
@@ -950,7 +974,7 @@ viewChatError = \case
CEGroupCantResendInvitation g c -> viewCannotResendInvitation g c
CEGroupInternal s -> ["chat group bug: " <> plain s]
CEFileNotFound f -> ["file not found: " <> plain f]
CEFileAlreadyReceiving f -> ["file is already accepted: " <> plain f]
CEFileAlreadyReceiving f -> ["file is already being received: " <> plain f]
CEFileCancelled f -> ["file cancelled: " <> plain f]
CEFileAlreadyExists f -> ["file already exists: " <> plain f]
CEFileRead f e -> ["cannot read file " <> plain f, sShow e]
@@ -988,6 +1012,8 @@ viewChatError = \case
SEFileIdNotFoundBySharedMsgId _ -> [] -- recipient tried to accept cancelled file
SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity
SEQuotedChatItemNotFound -> ["message not found - reply is not sent"]
SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> groupName' g)]
SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> groupName' g)]
e -> ["chat db error: " <> sShow e]
ChatErrorDatabase err -> case err of
DBErrorEncrypted -> ["error: chat database is already encrypted"]
+1 -1
View File
@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 10e0e58ec3a7c97b5503a49440be08487111960d
commit: 19aef52135b288dc92c4a2ec6d0be4b8283649ab
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294
+6 -1
View File
@@ -13,6 +13,7 @@ import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception (bracket, bracket_)
import Control.Monad.Except
import Data.Functor (($>))
import Data.List (dropWhileEnd, find)
import Data.Maybe (fromJust, isNothing)
import qualified Data.Text as T
@@ -145,7 +146,11 @@ withNewTestChatOpts :: ChatOpts -> String -> Profile -> (TestCC -> IO a) -> IO a
withNewTestChatOpts = withNewTestChatCfgOpts testCfg
withNewTestChatCfgOpts :: ChatConfig -> ChatOpts -> String -> Profile -> (TestCC -> IO a) -> IO a
withNewTestChatCfgOpts cfg opts dbPrefix profile = bracket (createTestChat cfg opts dbPrefix profile) (\cc -> cc <// 100000 >> stopTestChat cc)
withNewTestChatCfgOpts cfg opts dbPrefix profile runTest =
bracket
(createTestChat cfg opts dbPrefix profile)
stopTestChat
(\cc -> runTest cc >>= ((cc <// 100000) $>))
withTestChatV1 :: String -> (TestCC -> IO a) -> IO a
withTestChatV1 = withTestChatCfg testCfgV1
+784 -196
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -57,6 +57,13 @@ memberSubSummary = "{\"resp\":{\"memberSubSummary\":{\"memberSubscriptions\":[]}
memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\",\"memberSubscriptions\":[]}}"
#endif
userContactSubSummary :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON)
userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{\"userContactSubscriptions\":[]}}}"
#else
userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\",\"userContactSubscriptions\":[]}}"
#endif
pendingSubSummary :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON)
pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{\"pendingSubscriptions\":[]}}}"
@@ -93,6 +100,7 @@ testChatApi = withTmpFiles $ do
chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` chatStarted
chatRecvMsg cc `shouldReturn` contactSubSummary
chatRecvMsg cc `shouldReturn` userContactSubSummary
chatRecvMsg cc `shouldReturn` memberSubSummary
chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary
chatRecvMsgWait cc 10000 `shouldReturn` ""
+22 -19
View File
@@ -47,33 +47,33 @@ testDhPubKey :: C.PublicKeyX448
testDhPubKey = "MEIwBQYDK2VvAzkAmKuSYeQ/m0SixPDS8Wq8VBaTS1cW+Lp0n0h4Diu+kUpR+qXx4SDJ32YGEFoGFGSbGPry5Ychr6U="
testE2ERatchetParams :: E2ERatchetParamsUri 'C.X448
testE2ERatchetParams = E2ERatchetParamsUri e2eEncryptVRange testDhPubKey testDhPubKey
testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKey testDhPubKey
testConnReq :: ConnectionRequestUri 'CMInvitation
testConnReq = CRInvitationUri connReqData testE2ERatchetParams
(==##) :: ByteString -> ChatMessage -> Expectation
(==##) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation
s ==## msg = do
strDecode s `shouldBe` Right msg
parseAll strP s `shouldBe` Right msg
(##==) :: ByteString -> ChatMessage -> Expectation
(##==) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation
s ##== msg =
J.eitherDecodeStrict' (strEncode msg)
`shouldBe` (J.eitherDecodeStrict' s :: Either String J.Value)
(##==##) :: ByteString -> ChatMessage -> Expectation
(##==##) :: MsgEncodingI e => ByteString -> ChatMessage e -> Expectation
s ##==## msg = do
s ##== msg
s ==## msg
(==#) :: ByteString -> ChatMsgEvent -> Expectation
(==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation
s ==# msg = s ==## ChatMessage Nothing msg
(#==) :: ByteString -> ChatMsgEvent -> Expectation
(#==) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation
s #== msg = s ##== ChatMessage Nothing msg
(#==#) :: ByteString -> ChatMsgEvent -> Expectation
(#==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation
s #==# msg = do
s #== msg
s ==# msg
@@ -122,10 +122,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing))
it "x.msg.new simple text with file" $
"{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing})))
#==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing, fileInline = Nothing})))
it "x.msg.new simple file with file" $
"{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}"
#==# XMsgNew (MCSimple (ExtMsgContent (MCFile "") (Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileConnReq = Nothing})))
#==# XMsgNew (MCSimple (ExtMsgContent (MCFile "") (Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileConnReq = Nothing, fileInline = Nothing})))
it "x.msg.new quote with file" $
"{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
##==## ChatMessage
@@ -138,13 +138,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
)
( ExtMsgContent
(MCText "hello to you too")
(Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing})
(Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing, fileInline = Nothing})
)
)
)
it "x.msg.new forward with file" $
"{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing})))
##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing, fileInline = Nothing})))
it "x.msg.update" $
"{\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
#==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello")
@@ -155,17 +155,20 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.msg.deleted\",\"params\":{}}"
#==# XMsgDeleted
it "x.file" $
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Just testConnReq}
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Just testConnReq, fileInline = Nothing}
it "x.file without file invitation" $
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing, fileInline = Nothing}
it "x.file.acpt" $
"{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
#==# XFileAcpt "photo.jpg"
it "x.file.acpt.inv" $
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg"
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}"
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg"
it "x.file.acpt.inv" $
"{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}"
#==# XFileAcptInv (SharedMsgId "\1\2\3\4") Nothing "photo.jpg"
it "x.file.cancel" $
"{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}"
#==# XFileCancel (SharedMsgId "\1\2\3\4")
@@ -188,7 +191,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}
it "x.grp.acpt without incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
@@ -200,10 +203,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
it "x.grp.mem.inv" $
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.fwd" $
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.info" $
"{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
BIN
View File
Binary file not shown.
+2
View File
@@ -12,6 +12,8 @@ module.exports = function (ty) {
ty.addPassthroughCopy("src/js")
ty.addPassthroughCopy("src/contact/*.js")
ty.addPassthroughCopy("src/call")
ty.addPassthroughCopy("src/hero-phone")
ty.addPassthroughCopy("src/hero-phone-dark")
ty.addPassthroughCopy("src/blog/images")
ty.addPassthroughCopy("src/images")
ty.addPassthroughCopy("src/CNAME")
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Some files were not shown because too many files have changed in this diff Show More