mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
58 Commits
v4.1.0
...
v4.2.0-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a20f0050b9 | |||
| e17e9c641d | |||
| 2ac6866638 | |||
| d7f319aa9e | |||
| 1e10b0a49c | |||
| e9c321eaad | |||
| c58d069fbd | |||
| 15c8945fd3 | |||
| 2109dca1c7 | |||
| 7ef7d08c00 | |||
| e463e19114 | |||
| deeb2e891a | |||
| 5265667c0c | |||
| 15c1f9f9c8 | |||
| 341199d599 | |||
| dfb6dafd6f | |||
| 7f544da6cf | |||
| d0a0a0461f | |||
| 1dca577ca8 | |||
| 26984b62fe | |||
| 1470b8d128 | |||
| 5bcb725ea5 | |||
| 7f9c4ede02 | |||
| 34a74da0b9 | |||
| 98cb1c39f2 | |||
| c4fc8a97b1 | |||
| 9edb54b45c | |||
| 213b586f8f | |||
| e0582d656e | |||
| 65f8ad4423 | |||
| b115bda8f5 | |||
| 85ab37ae5d | |||
| dc5f6673a2 | |||
| ada4c1d2ec | |||
| 1f8bfbe3f5 | |||
| cf67c951fc | |||
| b78a5931e9 | |||
| e57b9f4cea | |||
| 104e1040bf | |||
| 2c347cb7b9 | |||
| fad60a1d24 | |||
| 61ed866c24 | |||
| 656c10976b | |||
| 63f40f030a | |||
| dec8e136f9 | |||
| a525b4e5db | |||
| 2f8b4a3e93 | |||
| 39818b6fbb | |||
| 3523c7ebd7 | |||
| 1b3984f52f | |||
| 560807b4b7 | |||
| fb03a119ea | |||
| f7da034cf1 | |||
| 4a259e44b1 | |||
| 799d9443bd | |||
| 3bf8361911 | |||
| 9670da4646 | |||
| 1e22028189 |
@@ -10,6 +10,7 @@
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/uiDesigner.xml
|
||||
/.idea/kotlinc.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+69
-12
@@ -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(),
|
||||
|
||||
+6
-7
@@ -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),
|
||||
|
||||
+167
@@ -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,
|
||||
|
||||
+37
-17
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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_
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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=\"}}}"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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
Reference in New Issue
Block a user