Compare commits
102 Commits
v4.3.2
...
v4.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec29d8ef4 | ||
|
|
6c4b92531f | ||
|
|
46d6159da5 | ||
|
|
aab6e1c52f | ||
|
|
c0a01318b5 | ||
|
|
13090ff6ed | ||
|
|
90a20cd52f | ||
|
|
74245d3f2b | ||
|
|
e48452ccff | ||
|
|
39370ba1ef | ||
|
|
a02cfb4f41 | ||
|
|
4370012b8a | ||
|
|
20c33aea72 | ||
|
|
c11a1aa0e6 | ||
|
|
166b789f3c | ||
|
|
bbc26e272c | ||
|
|
6c839f8075 | ||
|
|
be91f97c83 | ||
|
|
e085cb7350 | ||
|
|
12574bed96 | ||
|
|
2137893111 | ||
|
|
e552a28a4d | ||
|
|
b20031d875 | ||
|
|
558b3fa356 | ||
|
|
cb337cef10 | ||
|
|
cd63f81292 | ||
|
|
6205b03943 | ||
|
|
82924ce8c6 | ||
|
|
b1067c339c | ||
|
|
0d6e4b48f6 | ||
|
|
84d2c408ce | ||
|
|
de434b730e | ||
|
|
1251dbc4b0 | ||
|
|
d115ad228b | ||
|
|
28d6f62b74 | ||
|
|
2b9238144b | ||
|
|
a2e1b7ae0a | ||
|
|
a00bb6d5ef | ||
|
|
da12b651e4 | ||
|
|
a936c14cf2 | ||
|
|
e6aad24e5f | ||
|
|
8dac96f415 | ||
|
|
aae0802ec8 | ||
|
|
74a20ef70c | ||
|
|
a2a29628a7 | ||
|
|
0b046315ac | ||
|
|
372d7ffaa9 | ||
|
|
ece928d57e | ||
|
|
e1740a8be4 | ||
|
|
36eba01ef4 | ||
|
|
9e045a44db | ||
|
|
b7d42ef889 | ||
|
|
e55cd82ec3 | ||
|
|
34e08b2058 | ||
|
|
5e9b7366cc | ||
|
|
64fb1f0b85 | ||
|
|
84e43c57f6 | ||
|
|
ffa37b1684 | ||
|
|
86271fe109 | ||
|
|
5dab099b5c | ||
|
|
199e61e5c6 | ||
|
|
76b4fd34c1 | ||
|
|
b159496257 | ||
|
|
c0fb29d5f7 | ||
|
|
4ab7e5e1c8 | ||
|
|
9e847c2e1f | ||
|
|
d105e59655 | ||
|
|
f128ebac87 | ||
|
|
b4de9c266b | ||
|
|
e410fc7736 | ||
|
|
f5bd6eb4c3 | ||
|
|
cee403c1ed | ||
|
|
8786e2147a | ||
|
|
6b8705e9f4 | ||
|
|
acfb98bd81 | ||
|
|
c240456b80 | ||
|
|
9e1641a154 | ||
|
|
17cd3cdca4 | ||
|
|
aa264690ab | ||
|
|
0e837ae392 | ||
|
|
68525b4131 | ||
|
|
8775db7c97 | ||
|
|
f266debd56 | ||
|
|
044c7a8191 | ||
|
|
677c6aeb2e | ||
|
|
7b8f5be821 | ||
|
|
21765905a7 | ||
|
|
70a9c01477 | ||
|
|
678dbec3e2 | ||
|
|
bd4c7dffbf | ||
|
|
1eb4030080 | ||
|
|
bcc64442e9 | ||
|
|
1246b9e376 | ||
|
|
d6e9a87d58 | ||
|
|
cddd3cd673 | ||
|
|
e00ef7c7da | ||
|
|
1a201cfadf | ||
|
|
a4ecb41743 | ||
|
|
e347f5329c | ||
|
|
741b3e8848 | ||
|
|
7b4710d198 | ||
|
|
c77f6100c5 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 78
|
||||
versionName "4.3.2"
|
||||
versionCode 83
|
||||
versionName "4.4-beta.4"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
|
||||
@@ -42,7 +42,6 @@ import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
@@ -395,6 +394,7 @@ fun MainPage(
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
||||
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
@@ -478,7 +478,6 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
// TODO open from chat list view
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { linkType ->
|
||||
|
||||
@@ -115,8 +115,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
|
||||
* It can happen when app was started and a user enables battery optimization while app in background
|
||||
* */
|
||||
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||
if (chatModel.chatRunning.value != false &&
|
||||
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
|
||||
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
|
||||
) {
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
}
|
||||
else -> isAppOnForeground = false
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ package chat.simplex.app.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
@@ -21,6 +26,7 @@ import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import kotlin.time.*
|
||||
|
||||
/*
|
||||
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
|
||||
@@ -101,7 +107,7 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
|
||||
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directContact)
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
|
||||
|
||||
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
|
||||
|
||||
@@ -276,7 +282,13 @@ class ChatModel(val controller: ChatController) {
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
chatItems[i] = item.withStatus(CIStatus.RcvRead())
|
||||
val newItem = item.withStatus(CIStatus.RcvRead())
|
||||
chatItems[i] = newItem
|
||||
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
|
||||
chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
|
||||
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
|
||||
)
|
||||
}
|
||||
markedRead++
|
||||
}
|
||||
i += 1
|
||||
@@ -396,8 +408,8 @@ interface SomeChat {
|
||||
val sendMsgEnabled: Boolean
|
||||
val ntfsEnabled: Boolean
|
||||
val incognito: Boolean
|
||||
val voiceMessageAllowed: Boolean
|
||||
val fullDeletionAllowed: Boolean
|
||||
fun featureEnabled(feature: ChatFeature): Boolean
|
||||
val timedMessagesTTL: Int?
|
||||
val createdAt: Instant
|
||||
val updatedAt: Instant
|
||||
}
|
||||
@@ -459,8 +471,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val sendMsgEnabled get() = contact.sendMsgEnabled
|
||||
override val ntfsEnabled get() = contact.ntfsEnabled
|
||||
override val incognito get() = contact.incognito
|
||||
override val voiceMessageAllowed get() = contact.voiceMessageAllowed
|
||||
override val fullDeletionAllowed get() = contact.fullDeletionAllowed
|
||||
override fun featureEnabled(feature: ChatFeature) = contact.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL
|
||||
override val createdAt get() = contact.createdAt
|
||||
override val updatedAt get() = contact.updatedAt
|
||||
override val displayName get() = contact.displayName
|
||||
@@ -483,8 +495,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
|
||||
override val ntfsEnabled get() = groupInfo.ntfsEnabled
|
||||
override val incognito get() = groupInfo.incognito
|
||||
override val voiceMessageAllowed get() = groupInfo.voiceMessageAllowed
|
||||
override val fullDeletionAllowed get() = groupInfo.fullDeletionAllowed
|
||||
override fun featureEnabled(feature: ChatFeature) = groupInfo.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL
|
||||
override val createdAt get() = groupInfo.createdAt
|
||||
override val updatedAt get() = groupInfo.updatedAt
|
||||
override val displayName get() = groupInfo.displayName
|
||||
@@ -507,8 +519,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
|
||||
override val ntfsEnabled get() = contactRequest.ntfsEnabled
|
||||
override val incognito get() = contactRequest.incognito
|
||||
override val voiceMessageAllowed get() = contactRequest.voiceMessageAllowed
|
||||
override val fullDeletionAllowed get() = contactRequest.fullDeletionAllowed
|
||||
override fun featureEnabled(feature: ChatFeature) = contactRequest.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL
|
||||
override val createdAt get() = contactRequest.createdAt
|
||||
override val updatedAt get() = contactRequest.updatedAt
|
||||
override val displayName get() = contactRequest.displayName
|
||||
@@ -531,8 +543,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
||||
override val ntfsEnabled get() = contactConnection.incognito
|
||||
override val incognito get() = contactConnection.incognito
|
||||
override val voiceMessageAllowed get() = contactConnection.voiceMessageAllowed
|
||||
override val fullDeletionAllowed get() = contactConnection.fullDeletionAllowed
|
||||
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
|
||||
override val createdAt get() = contactConnection.createdAt
|
||||
override val updatedAt get() = contactConnection.updatedAt
|
||||
override val displayName get() = contactConnection.displayName
|
||||
@@ -568,19 +580,36 @@ data class Contact(
|
||||
override val sendMsgEnabled get() = true
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val incognito get() = contactConnIncognito
|
||||
override val voiceMessageAllowed get() = mergedPreferences.voice.enabled.forUser
|
||||
override val fullDeletionAllowed get() = mergedPreferences.fullDelete.enabled.forUser
|
||||
override fun featureEnabled(feature: ChatFeature) = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
|
||||
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
|
||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = profile.localAlias
|
||||
val verified get() = activeConn.connectionCode != null
|
||||
|
||||
val directContact: Boolean get() =
|
||||
val directOrUsed: Boolean get() =
|
||||
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
|
||||
|
||||
val contactConnIncognito =
|
||||
activeConn.customUserProfileId != null
|
||||
|
||||
fun allowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
|
||||
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
|
||||
}
|
||||
|
||||
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
|
||||
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = Contact(
|
||||
contactId = 1,
|
||||
@@ -612,13 +641,23 @@ class ContactSubStatus(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null) {
|
||||
data class Connection(
|
||||
val connId: Long,
|
||||
val connStatus: ConnStatus,
|
||||
val connLevel: Int,
|
||||
val viaGroupLink: Boolean,
|
||||
val customUserProfileId: Long? = null,
|
||||
val connectionCode: SecurityCode? = null
|
||||
) {
|
||||
val id: ChatId get() = ":$connId"
|
||||
companion object {
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SecurityCode(val securityCode: String, val verifiedAt: Instant)
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
override val displayName: String,
|
||||
@@ -691,8 +730,12 @@ data class GroupInfo (
|
||||
override val sendMsgEnabled get() = membership.memberActive
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val incognito get() = membership.memberIncognito
|
||||
override val voiceMessageAllowed get() = fullGroupPreferences.voice.on
|
||||
override val fullDeletionAllowed get() = fullGroupPreferences.fullDelete.on
|
||||
override fun featureEnabled(feature: ChatFeature) = when (feature) {
|
||||
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
|
||||
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
|
||||
ChatFeature.Voice -> fullGroupPreferences.voice.on
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
|
||||
override val displayName get() = groupProfile.displayName
|
||||
override val fullName get() = groupProfile.fullName
|
||||
override val image get() = groupProfile.image
|
||||
@@ -757,6 +800,7 @@ data class GroupMember (
|
||||
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
|
||||
val fullName: String get() = memberProfile.fullName
|
||||
val image: String? get() = memberProfile.image
|
||||
val verified get() = activeConn?.connectionCode != null
|
||||
|
||||
val chatViewName: String
|
||||
get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
|
||||
@@ -937,8 +981,8 @@ class UserContactRequest (
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = false
|
||||
override val voiceMessageAllowed get() = false
|
||||
override val fullDeletionAllowed get() = false
|
||||
override fun featureEnabled(feature: ChatFeature) = false
|
||||
override val timedMessagesTTL: Int? get() = null
|
||||
override val displayName get() = profile.displayName
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
@@ -975,8 +1019,8 @@ class PendingContactConnection(
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = customUserProfileId != null
|
||||
override val voiceMessageAllowed get() = false
|
||||
override val fullDeletionAllowed get() = false
|
||||
override fun featureEnabled(feature: ChatFeature) = false
|
||||
override val timedMessagesTTL: Int? get() = null
|
||||
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
|
||||
override val displayName: String get() {
|
||||
if (localAlias.isNotEmpty()) return localAlias
|
||||
@@ -1074,7 +1118,7 @@ data class ChatItem (
|
||||
}
|
||||
}
|
||||
|
||||
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
||||
val isRcvNew: Boolean get() = meta.isRcvNew
|
||||
|
||||
val memberDisplayName: String? get() =
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
|
||||
@@ -1117,6 +1161,8 @@ data class ChatItem (
|
||||
is CIContent.SndConnEventContent -> showNtfDir
|
||||
is CIContent.RcvChatFeature -> false
|
||||
is CIContent.SndChatFeature -> showNtfDir
|
||||
is CIContent.RcvChatPreference -> false
|
||||
is CIContent.SndChatPreference -> showNtfDir
|
||||
is CIContent.RcvGroupFeature -> false
|
||||
is CIContent.SndGroupFeature -> showNtfDir
|
||||
is CIContent.RcvChatFeatureRejected -> showNtfDir
|
||||
@@ -1136,11 +1182,12 @@ data class ChatItem (
|
||||
file: CIFile? = null,
|
||||
itemDeleted: Boolean = false,
|
||||
itemEdited: Boolean = false,
|
||||
itemTimed: CITimed? = null,
|
||||
editable: Boolean = true
|
||||
) =
|
||||
ChatItem(
|
||||
chatDir = dir,
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
|
||||
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, null, editable),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
|
||||
quotedItem = quotedItem,
|
||||
file = file
|
||||
@@ -1195,7 +1242,7 @@ data class ChatItem (
|
||||
)
|
||||
|
||||
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
|
||||
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
|
||||
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null)
|
||||
return ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
@@ -1216,8 +1263,11 @@ data class ChatItem (
|
||||
itemText = generalGetString(R.string.deleted_description),
|
||||
itemStatus = CIStatus.RcvRead(),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now(),
|
||||
itemDeleted = false,
|
||||
itemEdited = false,
|
||||
itemTimed = null,
|
||||
itemLive = false,
|
||||
editable = false
|
||||
),
|
||||
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||
@@ -1249,16 +1299,33 @@ data class CIMeta (
|
||||
val itemText: String,
|
||||
val itemStatus: CIStatus,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val itemDeleted: Boolean,
|
||||
val itemEdited: Boolean,
|
||||
val itemTimed: CITimed?,
|
||||
val itemLive: Boolean?,
|
||||
val editable: Boolean
|
||||
) {
|
||||
val timestampText: String get() = getTimestampText(itemTs)
|
||||
val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
|
||||
val isLive: Boolean get() = itemLive == true
|
||||
val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null
|
||||
|
||||
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
|
||||
|
||||
fun statusIcon(primaryColor: Color, metaColor: Color = HighOrLowlight): Pair<ImageVector, Color>? =
|
||||
when (itemStatus) {
|
||||
is CIStatus.SndSent -> Icons.Filled.Check to metaColor
|
||||
is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red
|
||||
is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow
|
||||
is CIStatus.RcvNew -> Icons.Filled.Circle to primaryColor
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(),
|
||||
itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true
|
||||
itemDeleted: Boolean = false, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true
|
||||
): CIMeta =
|
||||
CIMeta(
|
||||
itemId = id,
|
||||
@@ -1266,13 +1333,22 @@ data class CIMeta (
|
||||
itemText = text,
|
||||
itemStatus = status,
|
||||
createdAt = ts,
|
||||
updatedAt = ts,
|
||||
itemDeleted = itemDeleted,
|
||||
itemEdited = itemEdited,
|
||||
itemTimed = itemTimed,
|
||||
itemLive = itemLive,
|
||||
editable = editable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CITimed(
|
||||
val ttl: Int,
|
||||
val deleteAt: Instant?
|
||||
)
|
||||
|
||||
fun getTimestampText(t: Instant): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
|
||||
@@ -1320,10 +1396,12 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
|
||||
@@ -1341,13 +1419,33 @@ sealed class CIContent: ItemContent {
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
is RcvConnEventContent -> rcvConnEvent.text
|
||||
is SndConnEventContent -> sndConnEvent.text
|
||||
is RcvChatFeature -> "${feature.text}: ${enabled.text}"
|
||||
is SndChatFeature -> "${feature.text}: ${enabled.text}"
|
||||
is RcvGroupFeature -> "${groupFeature.text}: ${preference.enable.text}"
|
||||
is SndGroupFeature -> "${groupFeature.text}: ${preference.enable.text}"
|
||||
is RcvChatFeature -> featureText(feature, enabled.text, param)
|
||||
is SndChatFeature -> featureText(feature, enabled.text, param)
|
||||
is RcvChatPreference -> preferenceText(feature, allowed, param)
|
||||
is SndChatPreference -> preferenceText(feature, allowed, param)
|
||||
is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param)
|
||||
is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param)
|
||||
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun featureText(feature: Feature, enabled: String, param: Int?): String =
|
||||
if (feature.hasParam) {
|
||||
"${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
|
||||
} else {
|
||||
"${feature.text}: $enabled"
|
||||
}
|
||||
|
||||
fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when {
|
||||
allowed != FeatureAllowed.NO && feature.hasParam && param != null ->
|
||||
"offered ${feature.text}: ${TimedMessagesPreference.ttlText(param)}"
|
||||
allowed != FeatureAllowed.NO ->
|
||||
"offered ${feature.text}"
|
||||
else ->
|
||||
"cancelled ${feature.text}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1438,14 +1536,8 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is MCText -> "text $text"
|
||||
is MCLink -> "json ${json.encodeToString(this)}"
|
||||
is MCImage -> "json ${json.encodeToString(this)}"
|
||||
is MCVoice-> "json ${json.encodeToString(this)}"
|
||||
is MCFile -> "json ${json.encodeToString(this)}"
|
||||
is MCUnknown -> "json $json"
|
||||
}
|
||||
val cmdString: String get() =
|
||||
if (this is MCUnknown) "json $json" else "json ${json.encodeToString(this)}"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -121,6 +121,7 @@ class AppPreferences(val context: Context) {
|
||||
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
|
||||
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
|
||||
val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name)
|
||||
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
|
||||
|
||||
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
|
||||
val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
|
||||
@@ -131,6 +132,8 @@ class AppPreferences(val context: Context) {
|
||||
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
|
||||
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
|
||||
|
||||
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getInt(prefName, default),
|
||||
@@ -214,6 +217,7 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
|
||||
private const val SHARED_PREFS_INCOGNITO = "Incognito"
|
||||
private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab"
|
||||
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
|
||||
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
|
||||
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
|
||||
@@ -221,6 +225,7 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
|
||||
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
|
||||
private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
|
||||
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,10 +267,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
chatModel.appOpenUrl.value?.let {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(it, chatModel)
|
||||
}
|
||||
startReceiver()
|
||||
Log.d(TAG, "startChat: started")
|
||||
} else {
|
||||
@@ -404,19 +405,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
|
||||
suspend fun apiGetChats(): List<Chat> {
|
||||
val r = sendCmd(CC.ApiGetChats())
|
||||
if (r is CR.ApiChats ) return r.chats
|
||||
throw Exception("failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||
if (r is CR.ApiChats) return r.chats
|
||||
Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chats_title), generalGetString(R.string.contact_developers))
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
|
||||
val r = sendCmd(CC.ApiGetChat(type, id, pagination, search))
|
||||
if (r is CR.ApiChat ) return r.chat
|
||||
if (r is CR.ApiChat) return r.chat
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chat_title), generalGetString(R.string.contact_developers))
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
|
||||
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc)
|
||||
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? {
|
||||
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live)
|
||||
val r = sendCmd(cmd)
|
||||
return when (r) {
|
||||
is CR.NewChatItem -> r.chatItem
|
||||
@@ -429,8 +433,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? {
|
||||
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc))
|
||||
suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? {
|
||||
val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live))
|
||||
if (r is CR.ChatItemUpdated) return r.chatItem
|
||||
Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
@@ -553,6 +557,34 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiGetContactCode(contactId: Long): Pair<Contact, String> {
|
||||
val r = sendCmd(CC.APIGetContactCode(contactId))
|
||||
if (r is CR.ContactCode) return r.contact to r.connectionCode
|
||||
throw Exception("failed to get contact code: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair<GroupMember, String> {
|
||||
val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId))
|
||||
if (r is CR.GroupMemberCode) return r.member to r.connectionCode
|
||||
throw Exception("failed to get group member code: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair<Boolean, String>? {
|
||||
return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) {
|
||||
is CR.ConnectionVerified -> r.verified to r.expectedCode
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair<Boolean, String>? {
|
||||
return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) {
|
||||
is CR.ConnectionVerified -> r.verified to r.expectedCode
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
suspend fun apiAddContact(): String? {
|
||||
val r = sendCmd(CC.AddContact())
|
||||
return when (r) {
|
||||
@@ -952,6 +984,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature, param: Int? = null) {
|
||||
val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param)
|
||||
val toContact = apiSetContactPrefs(contact.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
chatModel.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
|
||||
private fun networkErrorAlert(r: CR): Boolean {
|
||||
return when {
|
||||
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
|
||||
@@ -993,7 +1033,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
chatModel.removeChat(r.connection.id)
|
||||
}
|
||||
is CR.ContactConnected -> {
|
||||
if (r.contact.directContact) {
|
||||
if (r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.dismissConnReqView(r.contact.activeConn.id)
|
||||
chatModel.removeChat(r.contact.activeConn.id)
|
||||
@@ -1002,7 +1042,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
}
|
||||
}
|
||||
is CR.ContactConnecting -> {
|
||||
if (r.contact.directContact) {
|
||||
if (r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
chatModel.dismissConnReqView(r.contact.activeConn.id)
|
||||
chatModel.removeChat(r.contact.activeConn.id)
|
||||
@@ -1249,6 +1289,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
fun showBackgroundServiceNoticeIfNeeded() {
|
||||
val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!)
|
||||
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||
// Nothing to do if mode is OFF. Can be selected on on-boarding stage
|
||||
if (mode == NotificationsMode.OFF) return
|
||||
|
||||
if (!appPrefs.backgroundServiceNoticeShown.get()) {
|
||||
// the branch for the new users who have never seen service notice
|
||||
if (!mode.requiresIgnoringBattery || isIgnoringBatteryOptimizations(appContext)) {
|
||||
@@ -1507,8 +1550,8 @@ sealed class CC {
|
||||
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
|
||||
class ApiGetChats: CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
|
||||
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC()
|
||||
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
class NewGroup(val groupProfile: GroupProfile): CC()
|
||||
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
|
||||
@@ -1533,6 +1576,10 @@ sealed class CC {
|
||||
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class APISwitchContact(val contactId: Long): CC()
|
||||
class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class APIGetContactCode(val contactId: Long): CC()
|
||||
class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC()
|
||||
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
|
||||
class AddContact: CC()
|
||||
class Connect(val connReq: String): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
|
||||
@@ -1574,8 +1621,8 @@ sealed class CC {
|
||||
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
|
||||
is ApiGetChats -> "/_get chats pcc=on"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is NewGroup -> "/_group ${json.encodeToString(groupProfile)}"
|
||||
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
|
||||
@@ -1600,6 +1647,10 @@ sealed class CC {
|
||||
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
|
||||
is APISwitchContact -> "/_switch @$contactId"
|
||||
is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId"
|
||||
is APIGetContactCode -> "/_get code @$contactId"
|
||||
is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId"
|
||||
is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else ""
|
||||
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
|
||||
is AddContact -> "/connect"
|
||||
is Connect -> "/connect $connReq"
|
||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
|
||||
@@ -1668,6 +1719,10 @@ sealed class CC {
|
||||
is APIGroupMemberInfo -> "apiGroupMemberInfo"
|
||||
is APISwitchContact -> "apiSwitchContact"
|
||||
is APISwitchGroupMember -> "apiSwitchGroupMember"
|
||||
is APIGetContactCode -> "apiGetContactCode"
|
||||
is APIGetGroupMemberCode -> "apiGetGroupMemberCode"
|
||||
is APIVerifyContact -> "apiVerifyContact"
|
||||
is APIVerifyGroupMember -> "apiVerifyGroupMember"
|
||||
is AddContact -> "addContact"
|
||||
is Connect -> "connect"
|
||||
is ApiDeleteChat -> "apiDeleteChat"
|
||||
@@ -1896,7 +1951,8 @@ data class NetCfg(
|
||||
val tcpConnectTimeout: Long, // microseconds
|
||||
val tcpTimeout: Long, // microseconds
|
||||
val tcpKeepAlive: KeepAliveOpts?,
|
||||
val smpPingInterval: Long // microseconds
|
||||
val smpPingInterval: Long, // microseconds
|
||||
val logTLSErrors: Boolean = false
|
||||
) {
|
||||
val useSocksProxy: Boolean get() = socksProxy != null
|
||||
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
|
||||
@@ -1968,62 +2024,150 @@ data class ChatSettings(
|
||||
|
||||
@Serializable
|
||||
data class FullChatPreferences(
|
||||
val fullDelete: ChatPreference,
|
||||
val voice: ChatPreference,
|
||||
val timedMessages: TimedMessagesPreference,
|
||||
val fullDelete: SimpleChatPreference,
|
||||
val voice: SimpleChatPreference,
|
||||
) {
|
||||
fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice)
|
||||
fun toPreferences(): ChatPreferences = ChatPreferences(timedMessages = timedMessages, fullDelete = fullDelete, voice = voice)
|
||||
|
||||
companion object {
|
||||
val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||
val sampleData = FullChatPreferences(
|
||||
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
|
||||
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
|
||||
voice = SimpleChatPreference(allow = FeatureAllowed.YES)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatPreferences(
|
||||
val fullDelete: ChatPreference? = null,
|
||||
val voice: ChatPreference? = null,
|
||||
val timedMessages: TimedMessagesPreference?,
|
||||
val fullDelete: SimpleChatPreference?,
|
||||
val voice: SimpleChatPreference?,
|
||||
) {
|
||||
fun setAllowed(feature: ChatFeature, allowed: FeatureAllowed = FeatureAllowed.YES, param: Int? = null): ChatPreferences =
|
||||
when (feature) {
|
||||
ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = param ?: this.timedMessages?.ttl))
|
||||
ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed))
|
||||
ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed))
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||
val sampleData = ChatPreferences(
|
||||
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
|
||||
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
|
||||
voice = SimpleChatPreference(allow = FeatureAllowed.YES)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatPreference {
|
||||
val allow: FeatureAllowed
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimpleChatPreference(
|
||||
override val allow: FeatureAllowed
|
||||
): ChatPreference
|
||||
|
||||
@Serializable
|
||||
data class TimedMessagesPreference(
|
||||
override val allow: FeatureAllowed,
|
||||
val ttl: Int? = null
|
||||
): ChatPreference {
|
||||
companion object {
|
||||
val ttlValues: List<Int?>
|
||||
get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400)
|
||||
|
||||
fun ttlText(ttl: Int?): String {
|
||||
ttl ?: return generalGetString(R.string.feature_off)
|
||||
if (ttl == 0) return String.format(generalGetString(R.string.ttl_sec), 0)
|
||||
val (m_, s) = divMod(ttl, 60)
|
||||
val (h_, m) = divMod(m_, 60)
|
||||
val (d_, h) = divMod(h_, 24)
|
||||
val (mm, d) = divMod(d_, 30)
|
||||
return maybe(mm, if (mm == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), mm)) +
|
||||
maybe(d, if (d == 1) String.format(generalGetString(R.string.ttl_day), 1) else if (d == 7) String.format(generalGetString(R.string.ttl_week), 1) else if (d == 14) String.format(generalGetString(R.string.ttl_weeks), 2) else String.format(generalGetString(R.string.ttl_days), d)) +
|
||||
maybe(h, if (h == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), h)) +
|
||||
maybe(m, String.format(generalGetString(R.string.ttl_min), m)) +
|
||||
maybe(s, String.format(generalGetString(R.string.ttl_sec), s))
|
||||
}
|
||||
|
||||
fun shortTtlText(ttl: Int?): String {
|
||||
ttl ?: return generalGetString(R.string.feature_off)
|
||||
val m = ttl / 60
|
||||
if (m == 0) {
|
||||
return String.format(generalGetString(R.string.ttl_s), ttl)
|
||||
}
|
||||
val h = m / 60
|
||||
if (h == 0) {
|
||||
return String.format(generalGetString(R.string.ttl_m), m)
|
||||
}
|
||||
val d = h / 24
|
||||
if (d == 0) {
|
||||
return String.format(generalGetString(R.string.ttl_h), h)
|
||||
}
|
||||
val mm = d / 30
|
||||
if (mm > 0) {
|
||||
return String.format(generalGetString(R.string.ttl_mth), mm)
|
||||
}
|
||||
val w = d / 7
|
||||
return if (w == 0 || d % 7 != 0) String.format(generalGetString(R.string.ttl_d), d) else String.format(generalGetString(R.string.ttl_w), w)
|
||||
}
|
||||
|
||||
fun divMod(n: Int, d: Int): Pair<Int, Int> =
|
||||
n / d to n % d
|
||||
|
||||
fun maybe(n: Int, s: String): String =
|
||||
if (n == 0) "" else s
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatPreference(
|
||||
val allow: FeatureAllowed
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContactUserPreferences(
|
||||
val timedMessages: ContactUserPreferenceTimed,
|
||||
val fullDelete: ContactUserPreference,
|
||||
val voice: ContactUserPreference,
|
||||
) {
|
||||
fun toPreferences(): ChatPreferences = ChatPreferences(
|
||||
timedMessages = timedMessages.userPreference.pref,
|
||||
fullDelete = fullDelete.userPreference.pref,
|
||||
voice = voice.userPreference.pref
|
||||
)
|
||||
|
||||
companion object {
|
||||
val sampleData = ContactUserPreferences(
|
||||
timedMessages = ContactUserPreferenceTimed(
|
||||
enabled = FeatureEnabled(forUser = false, forContact = false),
|
||||
userPreference = ContactUserPrefTimed.User(preference = TimedMessagesPreference(allow = FeatureAllowed.NO)),
|
||||
contactPreference = TimedMessagesPreference(allow = FeatureAllowed.NO)
|
||||
),
|
||||
fullDelete = ContactUserPreference(
|
||||
enabled = FeatureEnabled(forUser = false, forContact = false),
|
||||
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)),
|
||||
contactPreference = ChatPreference(allow = FeatureAllowed.NO)
|
||||
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)),
|
||||
contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO)
|
||||
),
|
||||
voice = ContactUserPreference(
|
||||
enabled = FeatureEnabled(forUser = true, forContact = true),
|
||||
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)),
|
||||
contactPreference = ChatPreference(allow = FeatureAllowed.YES)
|
||||
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
|
||||
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ContactUserPreference(
|
||||
data class ContactUserPreference (
|
||||
val enabled: FeatureEnabled,
|
||||
val userPreference: ContactUserPref,
|
||||
val contactPreference: ChatPreference,
|
||||
val contactPreference: SimpleChatPreference,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ContactUserPreferenceTimed (
|
||||
val enabled: FeatureEnabled,
|
||||
val userPreference: ContactUserPrefTimed,
|
||||
val contactPreference: TimedMessagesPreference,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -2043,10 +2187,10 @@ data class FeatureEnabled(
|
||||
get() = if (forUser) SimplexGreen else if (forContact) WarningYellow else HighOrLowlight
|
||||
|
||||
companion object {
|
||||
fun enabled(user: ChatPreference, contact: ChatPreference): FeatureEnabled =
|
||||
fun enabled(asymmetric: Boolean, user: ChatPreference, contact: ChatPreference): FeatureEnabled =
|
||||
when {
|
||||
user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = true)
|
||||
user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = true, forContact = false)
|
||||
user.allow == FeatureAllowed.ALWAYS && contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = asymmetric)
|
||||
user.allow == FeatureAllowed.NO && contact.allow == FeatureAllowed.ALWAYS -> FeatureEnabled(forUser = asymmetric, forContact = false)
|
||||
contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
|
||||
user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
|
||||
else -> FeatureEnabled(forUser = true, forContact = true)
|
||||
@@ -2056,49 +2200,95 @@ data class FeatureEnabled(
|
||||
|
||||
@Serializable
|
||||
sealed class ContactUserPref {
|
||||
abstract val pref: ChatPreference
|
||||
abstract val pref: SimpleChatPreference
|
||||
|
||||
// contact override is set
|
||||
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() {
|
||||
@Serializable @SerialName("contact") data class Contact(val preference: SimpleChatPreference): ContactUserPref() {
|
||||
override val pref get() = preference
|
||||
}
|
||||
// global user default is used
|
||||
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() {
|
||||
@Serializable @SerialName("user") data class User(val preference: SimpleChatPreference): ContactUserPref() {
|
||||
override val pref get() = preference
|
||||
}
|
||||
|
||||
val contactOverride: SimpleChatPreference?
|
||||
get() = when(this) {
|
||||
is Contact -> pref
|
||||
is User -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ContactUserPrefTimed {
|
||||
abstract val pref: TimedMessagesPreference
|
||||
|
||||
// contact override is set
|
||||
@Serializable @SerialName("contact") data class Contact(val preference: TimedMessagesPreference): ContactUserPrefTimed() {
|
||||
override val pref get() = preference
|
||||
}
|
||||
// global user default is used
|
||||
@Serializable @SerialName("user") data class User(val preference: TimedMessagesPreference): ContactUserPrefTimed() {
|
||||
override val pref get() = preference
|
||||
}
|
||||
|
||||
val contactOverride: TimedMessagesPreference?
|
||||
get() = when(this) {
|
||||
is Contact -> pref
|
||||
is User -> null
|
||||
}
|
||||
}
|
||||
|
||||
interface Feature {
|
||||
// val icon: ImageVector
|
||||
val text: String
|
||||
val iconFilled: ImageVector
|
||||
val hasParam: Boolean
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ChatFeature: Feature {
|
||||
@SerialName("timedMessages") TimedMessages,
|
||||
@SerialName("fullDelete") FullDelete,
|
||||
@SerialName("voice") Voice;
|
||||
|
||||
val asymmetric: Boolean get() = when (this) {
|
||||
TimedMessages -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
override val hasParam: Boolean get() = when(this) {
|
||||
TimedMessages -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
override val text: String
|
||||
get() = when(this) {
|
||||
TimedMessages -> generalGetString(R.string.timed_messages)
|
||||
FullDelete -> generalGetString(R.string.full_deletion)
|
||||
Voice -> generalGetString(R.string.voice_messages)
|
||||
}
|
||||
|
||||
val icon: ImageVector
|
||||
get() = when(this) {
|
||||
TimedMessages -> Icons.Outlined.Timer
|
||||
FullDelete -> Icons.Outlined.DeleteForever
|
||||
Voice -> Icons.Outlined.KeyboardVoice
|
||||
}
|
||||
|
||||
override val iconFilled: ImageVector
|
||||
get() = when(this) {
|
||||
TimedMessages -> Icons.Filled.Timer
|
||||
FullDelete -> Icons.Filled.DeleteForever
|
||||
Voice -> Icons.Filled.KeyboardVoice
|
||||
}
|
||||
|
||||
fun allowDescription(allowed: FeatureAllowed): String =
|
||||
when (this) {
|
||||
TimedMessages -> when (allowed) {
|
||||
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_disappearing_messages)
|
||||
FeatureAllowed.YES -> generalGetString(R.string.allow_disappearing_messages_only_if)
|
||||
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_disappearing_messages)
|
||||
}
|
||||
FullDelete -> when (allowed) {
|
||||
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete)
|
||||
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
|
||||
@@ -2113,6 +2303,12 @@ enum class ChatFeature: Feature {
|
||||
|
||||
fun enabledDescription(enabled: FeatureEnabled): String =
|
||||
when (this) {
|
||||
TimedMessages -> when {
|
||||
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_disappearing)
|
||||
enabled.forUser -> generalGetString(R.string.only_you_can_send_disappearing)
|
||||
enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_disappearing)
|
||||
else -> generalGetString(R.string.disappearing_prohibited_in_this_chat)
|
||||
}
|
||||
FullDelete -> when {
|
||||
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contacts_can_delete)
|
||||
enabled.forUser -> generalGetString(R.string.only_you_can_delete_messages)
|
||||
@@ -2130,12 +2326,19 @@ enum class ChatFeature: Feature {
|
||||
|
||||
@Serializable
|
||||
enum class GroupFeature: Feature {
|
||||
@SerialName("timedMessages") TimedMessages,
|
||||
@SerialName("directMessages") DirectMessages,
|
||||
@SerialName("fullDelete") FullDelete,
|
||||
@SerialName("voice") Voice;
|
||||
|
||||
override val hasParam: Boolean get() = when(this) {
|
||||
TimedMessages -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
override val text: String
|
||||
get() = when(this) {
|
||||
TimedMessages -> generalGetString(R.string.timed_messages)
|
||||
DirectMessages -> generalGetString(R.string.direct_messages)
|
||||
FullDelete -> generalGetString(R.string.full_deletion)
|
||||
Voice -> generalGetString(R.string.voice_messages)
|
||||
@@ -2143,6 +2346,7 @@ enum class GroupFeature: Feature {
|
||||
|
||||
val icon: ImageVector
|
||||
get() = when(this) {
|
||||
TimedMessages -> Icons.Outlined.Timer
|
||||
DirectMessages -> Icons.Outlined.SwapHorizontalCircle
|
||||
FullDelete -> Icons.Outlined.DeleteForever
|
||||
Voice -> Icons.Outlined.KeyboardVoice
|
||||
@@ -2150,6 +2354,7 @@ enum class GroupFeature: Feature {
|
||||
|
||||
override val iconFilled: ImageVector
|
||||
get() = when(this) {
|
||||
TimedMessages -> Icons.Filled.Timer
|
||||
DirectMessages -> Icons.Filled.SwapHorizontalCircle
|
||||
FullDelete -> Icons.Filled.DeleteForever
|
||||
Voice -> Icons.Filled.KeyboardVoice
|
||||
@@ -2158,6 +2363,10 @@ enum class GroupFeature: Feature {
|
||||
fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
|
||||
if (canEdit) {
|
||||
when(this) {
|
||||
TimedMessages -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_disappearing)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_disappearing)
|
||||
}
|
||||
DirectMessages -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_direct_messages)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_direct_messages)
|
||||
@@ -2173,6 +2382,10 @@ enum class GroupFeature: Feature {
|
||||
}
|
||||
} else {
|
||||
when(this) {
|
||||
TimedMessages -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_disappearing)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.disappearing_messages_are_prohibited)
|
||||
}
|
||||
DirectMessages -> when(enabled) {
|
||||
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_dms)
|
||||
GroupFeatureEnabled.OFF -> generalGetString(R.string.direct_messages_are_prohibited_in_chat)
|
||||
@@ -2218,22 +2431,31 @@ sealed class ContactFeatureAllowed {
|
||||
|
||||
@Serializable
|
||||
data class ContactFeaturesAllowed(
|
||||
val timedMessagesAllowed: Boolean,
|
||||
val timedMessagesTTL: Int?,
|
||||
val fullDelete: ContactFeatureAllowed,
|
||||
val voice: ContactFeatureAllowed
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = ContactFeaturesAllowed(
|
||||
timedMessagesAllowed = false,
|
||||
timedMessagesTTL = null,
|
||||
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
|
||||
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed =
|
||||
ContactFeaturesAllowed(
|
||||
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed {
|
||||
val pref = contactUserPreferences.timedMessages.userPreference
|
||||
val allow = pref.contactOverride?.allow
|
||||
return ContactFeaturesAllowed(
|
||||
timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS,
|
||||
timedMessagesTTL = pref.pref.ttl,
|
||||
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
|
||||
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
|
||||
)
|
||||
}
|
||||
|
||||
fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed =
|
||||
when (val pref = contactUserPreference.userPreference) {
|
||||
@@ -2247,16 +2469,17 @@ fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference
|
||||
|
||||
fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences =
|
||||
ChatPreferences(
|
||||
timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL),
|
||||
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
|
||||
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
|
||||
)
|
||||
|
||||
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? =
|
||||
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): SimpleChatPreference? =
|
||||
when(contactFeatureAllowed) {
|
||||
is ContactFeatureAllowed.UserDefault -> null
|
||||
is ContactFeatureAllowed.Always -> ChatPreference(allow = FeatureAllowed.ALWAYS)
|
||||
is ContactFeatureAllowed.Yes -> ChatPreference(allow = FeatureAllowed.YES)
|
||||
is ContactFeatureAllowed.No -> ChatPreference(allow = FeatureAllowed.NO)
|
||||
is ContactFeatureAllowed.Always -> SimpleChatPreference(allow = FeatureAllowed.ALWAYS)
|
||||
is ContactFeatureAllowed.Yes -> SimpleChatPreference(allow = FeatureAllowed.YES)
|
||||
is ContactFeatureAllowed.No -> SimpleChatPreference(allow = FeatureAllowed.NO)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -2275,26 +2498,38 @@ enum class FeatureAllowed {
|
||||
|
||||
@Serializable
|
||||
data class FullGroupPreferences(
|
||||
val timedMessages: TimedMessagesGroupPreference,
|
||||
val directMessages: GroupPreference,
|
||||
val fullDelete: GroupPreference,
|
||||
val voice: GroupPreference
|
||||
) {
|
||||
fun toGroupPreferences(): GroupPreferences =
|
||||
GroupPreferences(directMessages = directMessages, fullDelete = fullDelete, voice = voice)
|
||||
GroupPreferences(timedMessages = timedMessages, directMessages = directMessages, fullDelete = fullDelete, voice = voice)
|
||||
|
||||
companion object {
|
||||
val sampleData = FullGroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
|
||||
val sampleData = FullGroupPreferences(
|
||||
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
|
||||
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
|
||||
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
|
||||
voice = GroupPreference(GroupFeatureEnabled.ON)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GroupPreferences(
|
||||
val timedMessages: TimedMessagesGroupPreference?,
|
||||
val directMessages: GroupPreference?,
|
||||
val fullDelete: GroupPreference?,
|
||||
val voice: GroupPreference?
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = GroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
|
||||
val sampleData = GroupPreferences(
|
||||
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
|
||||
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
|
||||
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
|
||||
voice = GroupPreference(GroupFeatureEnabled.ON)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2305,6 +2540,14 @@ data class GroupPreference(
|
||||
val on: Boolean get() = enable == GroupFeatureEnabled.ON
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TimedMessagesGroupPreference(
|
||||
val enable: GroupFeatureEnabled,
|
||||
val ttl: Int? = null
|
||||
) {
|
||||
val on: Boolean get() = enable == GroupFeatureEnabled.ON
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class GroupFeatureEnabled {
|
||||
@SerialName("on") ON,
|
||||
@@ -2365,6 +2608,9 @@ sealed class CR {
|
||||
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
|
||||
@Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
|
||||
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
|
||||
@Serializable @SerialName("contactCode") class ContactCode(val contact: Contact, val connectionCode: String): CR()
|
||||
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
|
||||
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val verified: Boolean, val expectedCode: String): CR()
|
||||
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
|
||||
@@ -2463,6 +2709,9 @@ sealed class CR {
|
||||
is NetworkConfig -> "networkConfig"
|
||||
is ContactInfo -> "contactInfo"
|
||||
is GroupMemberInfo -> "groupMemberInfo"
|
||||
is ContactCode -> "contactCode"
|
||||
is GroupMemberCode -> "groupMemberCode"
|
||||
is ConnectionVerified -> "connectionVerified"
|
||||
is Invitation -> "invitation"
|
||||
is SentConfirmation -> "sentConfirmation"
|
||||
is SentInvitation -> "sentInvitation"
|
||||
@@ -2559,6 +2808,9 @@ sealed class CR {
|
||||
is NetworkConfig -> json.encodeToString(networkConfig)
|
||||
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
|
||||
is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}"
|
||||
is ContactCode -> "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode"
|
||||
is GroupMemberCode -> "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode"
|
||||
is ConnectionVerified -> "verified: $verified\nconnectionCode: $expectedCode"
|
||||
is Invitation -> connReqInvitation
|
||||
is SentConfirmation -> noDetails()
|
||||
is SentInvitation -> noDetails()
|
||||
|
||||
@@ -135,7 +135,21 @@ fun TerminalLayout(
|
||||
topBar = { CloseSheetBar(close) },
|
||||
bottomBar = {
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
|
||||
SendMsgView(
|
||||
composeState = composeState,
|
||||
showVoiceRecordIcon = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = false,
|
||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = sendCommand,
|
||||
sendLiveMessage = null,
|
||||
updateLiveMessage = null,
|
||||
::onMessageChange,
|
||||
textStyle
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
|
||||
@@ -111,9 +111,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
||||
Profile(displayName, fullName, null)
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
SimplexService.start(chatModel.controller.appContext)
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(
|
||||
@@ -46,6 +47,7 @@ fun ChatInfoView(
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
@@ -58,6 +60,7 @@ fun ChatInfoView(
|
||||
connStats,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
connectionCode,
|
||||
developerTools,
|
||||
onLocalAliasChanged = {
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||
@@ -74,6 +77,31 @@ fun ChatInfoView(
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
switchContactAddress = {
|
||||
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||
VerifyCodeView(
|
||||
ct.displayName,
|
||||
connectionCode,
|
||||
ct.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.updateContact(
|
||||
ct.copy(
|
||||
activeConn = ct.activeConn.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -123,12 +151,14 @@ fun ChatInfoLayout(
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
switchContactAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -154,6 +184,10 @@ fun ChatInfoLayout(
|
||||
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
SectionDivider()
|
||||
}
|
||||
ContactPreferencesButton(openPreferences)
|
||||
}
|
||||
|
||||
@@ -208,13 +242,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (contact.verified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
||||
}
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
@@ -276,7 +314,7 @@ fun LocalAliasEditor(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -308,7 +346,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
private fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
Box(Modifier.size(18.dp)) {
|
||||
when (networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
@@ -339,6 +377,16 @@ fun SwitchAddressButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
|
||||
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||
click = onClick,
|
||||
iconColor = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
@@ -360,7 +408,7 @@ fun ClearChatButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteContactButton(onClick: () -> Unit) {
|
||||
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_contact),
|
||||
@@ -403,6 +451,7 @@ fun PreviewChatInfoLayout() {
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
connectionCode = "123",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
onLocalAliasChanged = {},
|
||||
@@ -411,6 +460,7 @@ fun PreviewChatInfoLayout() {
|
||||
deleteContact = {},
|
||||
clearChat = {},
|
||||
switchContactAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
@@ -12,8 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
@@ -77,16 +77,23 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// .toList() is important for making observation working
|
||||
snapshotFlow { chatModel.chats.toList() }
|
||||
.distinctUntilChanged()
|
||||
.collect { chats ->
|
||||
chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }.let {
|
||||
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
||||
if (it?.chatInfo != activeChat.value?.chatInfo) {
|
||||
activeChat.value = it
|
||||
}}
|
||||
snapshotFlow {
|
||||
/**
|
||||
* It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
|
||||
* In this case only error log will be printed here (no crash).
|
||||
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
|
||||
* */
|
||||
try {
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
} catch (e: ConcurrentModificationException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
||||
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
|
||||
.collect { activeChat.value = it }
|
||||
}
|
||||
}
|
||||
val view = LocalView.current
|
||||
@@ -131,16 +138,17 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
withApi {
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
|
||||
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||
contact.value?.let { ct ->
|
||||
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
|
||||
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
|
||||
}
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupChatInfoView(chatModel, close)
|
||||
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,8 +157,21 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val (_, code) = if (member.memberActive) {
|
||||
try {
|
||||
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
member to null
|
||||
}
|
||||
} else {
|
||||
member to null
|
||||
}
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showModalCloseable(true) { close ->
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -205,6 +226,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
},
|
||||
acceptFeature = { contact, feature, param ->
|
||||
withApi {
|
||||
chatModel.controller.allowFeatureToContact(contact, feature, param)
|
||||
}
|
||||
},
|
||||
addMembers = { groupInfo ->
|
||||
hideKeyboard(view)
|
||||
withApi {
|
||||
@@ -261,6 +287,7 @@ fun ChatLayout(
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||
@@ -301,7 +328,7 @@ fun ChatLayout(
|
||||
ChatItemsList(
|
||||
chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
|
||||
receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -424,10 +451,15 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
|
||||
ContactVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
@@ -438,6 +470,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactVerifiedShield() {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||
|
||||
val CIListStateSaver = run {
|
||||
@@ -466,6 +503,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
onComposed: () -> Unit,
|
||||
@@ -571,11 +609,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -586,7 +624,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,17 +655,21 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
|
||||
// Don't autoscroll next time until it will be needed
|
||||
shouldAutoScroll = false to chatId
|
||||
}
|
||||
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
|
||||
/*
|
||||
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
|
||||
* When the first visible item (from bottom) is fully visible we can autoscroll to 0 item
|
||||
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
|
||||
* */
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatItems.lastOrNull()?.id }
|
||||
.distinctUntilChanged()
|
||||
.filter { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 }
|
||||
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||
.collect {
|
||||
listState.animateScrollToItem(0)
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
} else {
|
||||
listState.animateScrollBy(scrollDistance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -984,6 +1026,7 @@ fun PreviewChatLayout() {
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
@@ -1042,6 +1085,7 @@ fun PreviewGroupChatLayout() {
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
changeNtfsState = { _, _ -> },
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat
|
||||
import ComposeVoiceView
|
||||
import ComposeFileView
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
@@ -19,8 +20,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
@@ -41,6 +41,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -62,16 +63,25 @@ sealed class ComposeContextItem {
|
||||
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LiveMessage(
|
||||
val chatItem: ChatItem,
|
||||
val typedMsg: String,
|
||||
val sentMsg: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ComposeState(
|
||||
val message: String = "",
|
||||
val liveMessage: LiveMessage? = null,
|
||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||
val inProgress: Boolean = false,
|
||||
val useLinkPreviews: Boolean
|
||||
) {
|
||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
|
||||
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
|
||||
editingItem.content.text,
|
||||
liveMessage,
|
||||
chatItemPreview(editingItem),
|
||||
ComposeContextItem.EditingItem(editingItem),
|
||||
useLinkPreviews = useLinkPreviews
|
||||
@@ -89,7 +99,7 @@ data class ComposeState(
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
else -> message.isNotEmpty() || liveMessage != null
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
@@ -108,6 +118,16 @@ data class ComposeState(
|
||||
else -> null
|
||||
}
|
||||
|
||||
val attachmentDisabled: Boolean
|
||||
get() {
|
||||
if (editing || liveMessage != null) return true
|
||||
return when (preview) {
|
||||
ComposePreview.NoPreview -> false
|
||||
is ComposePreview.CLinkPreview -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
||||
save = { json.encodeToString(serializer(), it.value) },
|
||||
@@ -118,6 +138,15 @@ data class ComposeState(
|
||||
}
|
||||
}
|
||||
|
||||
sealed class RecordingState {
|
||||
object NotStarted: RecordingState()
|
||||
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
|
||||
class Finished(val filePath: String, val durationMs: Int): RecordingState()
|
||||
|
||||
val filePathNullable: String?
|
||||
get() = (this as? Started)?.filePath
|
||||
}
|
||||
|
||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
return when (val mc = chatItem.content.msgContent) {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
@@ -233,6 +262,7 @@ fun ComposeView(
|
||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
@@ -310,128 +340,157 @@ fun ComposeView(
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun checkLinkPreview(): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (val composePreview = cs.preview) {
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(cs.message)
|
||||
val lp = composePreview.linkPreview
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(cs.message, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(cs.message)
|
||||
}
|
||||
}
|
||||
else -> MsgContent.MCText(cs.message)
|
||||
fun clearState(live: Boolean = false) {
|
||||
if (live) {
|
||||
composeState.value = composeState.value.copy(inProgress = false)
|
||||
} else {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
resetLinkPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
val cs = composeState.value
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
recState.value = RecordingState.NotStarted
|
||||
textStyle.value = smallFont
|
||||
chosenContent.value = emptyList()
|
||||
chosenAudio.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = file,
|
||||
quotedItemId = quoted,
|
||||
mc = mc,
|
||||
live = live
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
return aChatItem?.chatItem
|
||||
}
|
||||
|
||||
|
||||
|
||||
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
var sent: ChatItem?
|
||||
val msgText = text ?: cs.message
|
||||
|
||||
fun sending() {
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
}
|
||||
|
||||
fun checkLinkPreview(): MsgContent {
|
||||
return when (val composePreview = cs.preview) {
|
||||
is ComposePreview.CLinkPreview -> {
|
||||
val url = parseMessage(msgText)
|
||||
val lp = composePreview.linkPreview
|
||||
if (lp != null && url == lp.uri) {
|
||||
MsgContent.MCLink(msgText, preview = lp)
|
||||
} else {
|
||||
MsgContent.MCText(msgText)
|
||||
}
|
||||
}
|
||||
else -> MsgContent.MCText(msgText)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
|
||||
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
|
||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
|
||||
val oldMsgContent = ei.content.msgContent
|
||||
if (oldMsgContent != null) {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = updateMsgContent(oldMsgContent),
|
||||
live = live
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
return updatedItem?.chatItem
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!live) {
|
||||
sending()
|
||||
}
|
||||
|
||||
if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||
val ei = cs.contextItem.chatItem
|
||||
sent = updateMessage(ei, cInfo, live)
|
||||
} else if (cs.liveMessage != null) {
|
||||
sent = updateMessage(cs.liveMessage.chatItem, cInfo, live)
|
||||
} else {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<String> = ArrayList()
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val chosenAudioVal = chosenAudio.value
|
||||
if (chosenAudioVal != null) {
|
||||
val file = chosenAudioVal.first.toFile().name
|
||||
files.add((file))
|
||||
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||
AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath)
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000))
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (cs.contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
sent = null
|
||||
msgs.forEachIndexed { index, content ->
|
||||
if (index > 0) delay(100)
|
||||
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
|
||||
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||
)
|
||||
}
|
||||
if (sent == null && chosenContent.value.isNotEmpty()) {
|
||||
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||
}
|
||||
}
|
||||
clearState(live)
|
||||
return sent
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
when (val contextItem = cs.contextItem) {
|
||||
is ComposeContextItem.EditingItem -> {
|
||||
val ei = contextItem.chatItem
|
||||
val oldMsgContent = ei.content.msgContent
|
||||
if (oldMsgContent != null) {
|
||||
withApi {
|
||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
itemId = ei.meta.itemId,
|
||||
mc = updateMsgContent(oldMsgContent)
|
||||
)
|
||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<String> = ArrayList()
|
||||
when (val preview = cs.preview) {
|
||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
|
||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||
is ComposePreview.ImagePreview -> {
|
||||
chosenContent.value.forEachIndexed { index, it ->
|
||||
val file = when (it) {
|
||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
files.add(file)
|
||||
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.VoicePreview -> {
|
||||
val chosenAudioVal = chosenAudio.value
|
||||
if (chosenAudioVal != null) {
|
||||
val file = chosenAudioVal.first.toFile().name
|
||||
files.add((file))
|
||||
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
val file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
files.add((file))
|
||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||
else -> null
|
||||
}
|
||||
if (msgs.isNotEmpty()) {
|
||||
withApi {
|
||||
msgs.forEachIndexed { index, content ->
|
||||
if (index > 0) delay(100)
|
||||
val aChatItem = chatModel.controller.apiSendMessage(
|
||||
type = cInfo.chatType,
|
||||
id = cInfo.apiId,
|
||||
file = files.getOrNull(index),
|
||||
quotedItemId = if (index == 0) quotedItemId else null,
|
||||
mc = content
|
||||
)
|
||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||
}
|
||||
clearState()
|
||||
}
|
||||
} else {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
withBGApi {
|
||||
sendMessageAsync(null, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,27 +516,11 @@ fun ComposeView(
|
||||
|
||||
fun allowVoiceToContact() {
|
||||
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
||||
val prefs = contact.mergedPreferences.toPreferences().copy(voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||
withApi {
|
||||
val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs)
|
||||
if (toContact != null) {
|
||||
chatModel.updateContact(toContact)
|
||||
}
|
||||
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
|
||||
}
|
||||
}
|
||||
|
||||
fun showDisabledVoiceAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.voice_messages_prohibited),
|
||||
text = generalGetString(
|
||||
if (chat.chatInfo is ChatInfo.Direct)
|
||||
R.string.ask_your_contact_to_enable_voice
|
||||
else
|
||||
R.string.only_group_owners_can_enable_voice
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelLinkPreview() {
|
||||
val uri = composeState.value.linkPreview?.uri
|
||||
if (uri != null) {
|
||||
@@ -493,7 +536,14 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
fun cancelVoice() {
|
||||
val filePath = recState.value.filePathNullable
|
||||
recState.value = RecordingState.NotStarted
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
withBGApi {
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
AudioPlayer.stop(filePath)
|
||||
filePath?.let { File(it).delete() }
|
||||
}
|
||||
chosenAudio.value = null
|
||||
}
|
||||
|
||||
@@ -502,6 +552,52 @@ fun ComposeView(
|
||||
chosenFile.value = null
|
||||
}
|
||||
|
||||
fun truncateToWords(s: String): String {
|
||||
var acc = ""
|
||||
val word = StringBuilder()
|
||||
for (c in s) {
|
||||
if (c.isLetter() || c.isDigit()) {
|
||||
word.append(c)
|
||||
} else {
|
||||
acc = acc + word.toString() + c
|
||||
word.clear()
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
suspend fun sendLiveMessage() {
|
||||
val typedMsg = composeState.value.message
|
||||
val sentMsg = truncateToWords(typedMsg)
|
||||
if (composeState.value.liveMessage == null) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun liveMessageToSend(lm: LiveMessage, t: String): String? {
|
||||
val s = if (t != lm.typedMsg) truncateToWords(t) else t
|
||||
return if (s != lm.sentMsg) s else null
|
||||
}
|
||||
|
||||
suspend fun updateLiveMessage() {
|
||||
val typedMsg = composeState.value.message
|
||||
val liveMessage = composeState.value.liveMessage
|
||||
if (liveMessage != null) {
|
||||
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
|
||||
if (sentMsg != null) {
|
||||
val ci = sendMessageAsync(sentMsg, live = true)
|
||||
if (ci != null) {
|
||||
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg))
|
||||
}
|
||||
} else if (liveMessage.typedMsg != typedMsg) {
|
||||
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun previewView() {
|
||||
when (val preview = composeState.value.preview) {
|
||||
@@ -564,47 +660,69 @@ fun ComposeView(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
|
||||
Icon(
|
||||
Icons.Filled.AttachFile,
|
||||
contentDescription = stringResource(R.string.attach),
|
||||
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.voiceMessageAllowed }
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||
contactPreference.allow == FeatureAllowed.YES
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
|
||||
LaunchedEffect(allowedVoiceByPrefs) {
|
||||
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
|
||||
// Voice was disabled right when this user records it, just cancel it
|
||||
cancelVoice()
|
||||
}
|
||||
}
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||
contactPreference.allow == FeatureAllowed.YES
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { recState.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
when(it) {
|
||||
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
||||
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
|
||||
is RecordingState.NotStarted -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activity = LocalContext.current as Activity
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation && composeState.value.liveMessage != null) {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
allowedVoiceByPrefs = allowedVoiceByPrefs,
|
||||
needToAllowVoiceToContact = needToAllowVoiceToContact,
|
||||
recState,
|
||||
chat.chatInfo is ChatInfo.Direct,
|
||||
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
sendMessage = {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
::onMessageChange,
|
||||
::onAudioAdded,
|
||||
::allowVoiceToContact,
|
||||
::showDisabledVoiceAlert,
|
||||
textStyle
|
||||
sendLiveMessage = ::sendLiveMessage,
|
||||
updateLiveMessage = ::updateLiveMessage,
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ fun ComposeVoiceView(
|
||||
val endTime = when {
|
||||
finishedRecording -> duration.value
|
||||
audioPlaying.value -> recordedDurationMs
|
||||
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
|
||||
else -> MAX_VOICE_MILLIS_FOR_SENDING
|
||||
}
|
||||
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||
|
||||
@@ -20,6 +20,7 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.PreferenceToggle
|
||||
|
||||
@Composable
|
||||
fun ContactPreferencesView(
|
||||
@@ -85,6 +86,14 @@ private fun ContactPreferencesLayout(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
|
||||
}
|
||||
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
|
||||
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
||||
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||
@@ -112,7 +121,8 @@ private fun FeatureSection(
|
||||
onSelected: (ContactFeatureAllowed) -> Unit
|
||||
) {
|
||||
val enabled = FeatureEnabled.enabled(
|
||||
user = ChatPreference(allow = allowFeature.value.allowed),
|
||||
feature.asymmetric,
|
||||
user = SimpleChatPreference(allow = allowFeature.value.allowed),
|
||||
contact = pref.contactPreference
|
||||
)
|
||||
|
||||
@@ -140,6 +150,50 @@ private fun FeatureSection(
|
||||
SectionTextFooter(feature.enabledDescription(enabled))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimedMessagesFeatureSection(
|
||||
featuresAllowed: ContactFeaturesAllowed,
|
||||
pref: ContactUserPreferenceTimed,
|
||||
allowFeature: State<Boolean>,
|
||||
onTTLUpdated: (Int?) -> Unit,
|
||||
onSelected: (Boolean, Int?) -> Unit
|
||||
) {
|
||||
val enabled = FeatureEnabled.enabled(
|
||||
ChatFeature.TimedMessages.asymmetric,
|
||||
user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO),
|
||||
contact = pref.contactPreference
|
||||
)
|
||||
|
||||
SectionView(
|
||||
ChatFeature.TimedMessages.text.uppercase(),
|
||||
icon = ChatFeature.TimedMessages.iconFilled,
|
||||
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||
leadingIcon = true,
|
||||
) {
|
||||
SectionItemView {
|
||||
PreferenceToggle(
|
||||
generalGetString(R.string.chat_preferences_you_allow),
|
||||
checked = allowFeature.value,
|
||||
) { allow ->
|
||||
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
InfoRow(
|
||||
generalGetString(R.string.chat_preferences_contact_allows),
|
||||
pref.contactPreference.allow.text
|
||||
)
|
||||
SectionDivider()
|
||||
if (featuresAllowed.timedMessagesAllowed) {
|
||||
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
|
||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||
}
|
||||
}
|
||||
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
@@ -153,6 +207,20 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
||||
val ttlValues = TimedMessagesPreference.ttlValues
|
||||
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.delete_after),
|
||||
values.map { it to TimedMessagesPreference.ttlText(it) },
|
||||
selection,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_preferences_question),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCodeScanner
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanCodeLayout(verifyCode, close)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.scan_code), false)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(ratio = 1F)
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
) {
|
||||
QRCodeScanner { text ->
|
||||
verifyCode(text) {
|
||||
if (it) {
|
||||
close()
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.incorrect_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scan_code_from_contacts_app))
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,30 @@ import android.text.InputType
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
@@ -35,184 +41,107 @@ import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.widget.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
showVoiceRecordIcon: Boolean,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
recState: MutableState<RecordingState>,
|
||||
isDirectChat: Boolean,
|
||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||
needToAllowVoiceToContact: Boolean,
|
||||
sendMessage: () -> Unit,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||
allowedVoiceByPrefs: Boolean,
|
||||
allowVoiceToContact: () -> Unit,
|
||||
showDisabledVoiceAlert: () -> Unit,
|
||||
sendMessage: () -> Unit,
|
||||
sendLiveMessage: ( suspend () -> Unit)? = null,
|
||||
updateLiveMessage: (suspend () -> Unit)? = null,
|
||||
onMessageChange: (String) -> Unit,
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 8.dp)) {
|
||||
Box {
|
||||
val cs = composeState.value
|
||||
val attachEnabled = !composeState.value.editing
|
||||
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
|
||||
Box(if (recordingTimeRange.first == 0L)
|
||||
Modifier
|
||||
else
|
||||
Modifier.clickable(false, onClick = {})
|
||||
) {
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
Box(Modifier.padding(vertical = 8.dp)) {
|
||||
val cs = composeState.value
|
||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||
// Disable clicks on text field
|
||||
if (cs.preview is ComposePreview.VoicePreview) {
|
||||
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||
val sendButtonSize = remember { Animatable(36f) }
|
||||
val sendButtonAlpha = remember { Animatable(1f) }
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// Making LiveMessage alive when screen orientation was changed
|
||||
if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
}
|
||||
}
|
||||
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.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
|
||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||
} else if (!showVoiceButton) {
|
||||
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
)
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
|
||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (isActive) {
|
||||
now = System.currentTimeMillis()
|
||||
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
rec.stop()
|
||||
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||
}
|
||||
val startStopRecording: () -> Unit = {
|
||||
when {
|
||||
showProgress -> ProgressIndicator()
|
||||
showVoiceButton -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
when {
|
||||
needToAllowVoiceToContact -> {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.allow_voice_messages_question),
|
||||
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||
confirmText = generalGetString(R.string.allow_verb),
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
onConfirm = allowVoiceToContact,
|
||||
)
|
||||
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
|
||||
DisallowedVoiceButton {
|
||||
if (needToAllowVoiceToContact) {
|
||||
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
||||
} else {
|
||||
showDisabledVoiceAlert(isDirectChat)
|
||||
}
|
||||
}
|
||||
}
|
||||
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
|
||||
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
|
||||
recordingInProgress.value -> stopRecordingAndAddAudio()
|
||||
filePath.value == null -> {
|
||||
recordingTimeRange = System.currentTimeMillis()..0L
|
||||
filePath.value = rec.start(stopRecordingAndAddAudio)
|
||||
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||
!permissionsState.allPermissionsGranted ->
|
||||
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
|
||||
else ->
|
||||
RecordVoiceView(recState, stopRecOnNextClick)
|
||||
}
|
||||
if (sendLiveMessage != null && updateLiveMessage != null && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StartLiveMessageButton {
|
||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var stopRecOnNextClick by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(stopRecOnNextClick) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
if (stopRecOnNextClick) {
|
||||
// Lock orientation to current orientation because screen rotation will break the recording
|
||||
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
else
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
val cleanUp = { remove: Boolean ->
|
||||
rec.stop()
|
||||
AudioPlayer.stop(filePath.value)
|
||||
if (remove) filePath.value?.let { File(it).delete() }
|
||||
filePath.value = null
|
||||
stopRecOnNextClick = false
|
||||
recordingTimeRange = 0L..0L
|
||||
}
|
||||
LaunchedEffect(cs.preview) {
|
||||
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
|
||||
// Pressed on X icon in preview
|
||||
cleanUp(true)
|
||||
}
|
||||
}
|
||||
val interactionSource = interactionSourceWithTapDetection(
|
||||
// It's just a key for triggering dropping a state in the compose function. Without it
|
||||
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
|
||||
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
|
||||
onPress = {
|
||||
if (filePath.value == null) startStopRecording()
|
||||
},
|
||||
onClick = {
|
||||
// Voice not allowed or not granted voice record permission for the app
|
||||
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
|
||||
if (!recordingInProgress.value && filePath.value != null) {
|
||||
sendMessage()
|
||||
cleanUp(false)
|
||||
} else if (stopRecOnNextClick) {
|
||||
stopRecordingAndAddAudio()
|
||||
stopRecOnNextClick = false
|
||||
} else {
|
||||
// tapped and didn't hold a finger
|
||||
stopRecOnNextClick = true
|
||||
}
|
||||
},
|
||||
onCancel = startStopRecording,
|
||||
onRelease = startStopRecording
|
||||
)
|
||||
val sendButtonModifier = if (recordingTimeRange.last != 0L)
|
||||
Modifier.clip(CircleShape).background(color)
|
||||
else
|
||||
Modifier
|
||||
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
|
||||
Icon(
|
||||
when {
|
||||
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
|
||||
stopRecOnNextClick -> Icons.Filled.Stop
|
||||
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
|
||||
else -> Icons.Outlined.KeyboardVoice
|
||||
},
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = when {
|
||||
recordingTimeRange.last != 0L -> Color.White
|
||||
stopRecOnNextClick -> MaterialTheme.colors.primary
|
||||
allowedVoiceByPrefs -> MaterialTheme.colors.primary
|
||||
else -> HighOrLowlight
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.then(sendButtonModifier)
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
rec.stop()
|
||||
}
|
||||
else -> {
|
||||
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (composeState.value.liveMessage == null &&
|
||||
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
|
||||
sendLiveMessage != null && updateLiveMessage != null
|
||||
) {
|
||||
var showDropdown by rememberSaveable { mutableStateOf(false) }
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage) { showDropdown = true }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = { showDropdown = false },
|
||||
Modifier.width(220.dp),
|
||||
) {
|
||||
ItemAction(
|
||||
generalGetString(R.string.send_live_message),
|
||||
Icons.Filled.Bolt,
|
||||
onClick = {
|
||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||
showDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SendTextButton(icon, color, sendButtonSize, sendButtonAlpha, cs.sendEnabled(), sendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,6 +241,269 @@ private fun NativeKeyboard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
|
||||
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||
DisposableEffect(Unit) { onDispose { rec.stop() } }
|
||||
val stopRecordingAndAddAudio: () -> Unit = {
|
||||
recState.value.filePathNullable?.let {
|
||||
recState.value = RecordingState.Finished(it, rec.stop())
|
||||
}
|
||||
}
|
||||
if (stopRecOnNextClick.value) {
|
||||
LaunchedEffect(recState.value) {
|
||||
if (recState.value is RecordingState.NotStarted) {
|
||||
stopRecOnNextClick.value = false
|
||||
}
|
||||
}
|
||||
// Lock orientation to current orientation because screen rotation will break the recording
|
||||
LockToCurrentOrientationUntilDispose()
|
||||
StopRecordButton(stopRecordingAndAddAudio)
|
||||
} else {
|
||||
val startRecording: () -> Unit = {
|
||||
recState.value = RecordingState.Started(
|
||||
filePath = rec.start { progress: Int?, finished: Boolean ->
|
||||
val state = recState.value
|
||||
if (state is RecordingState.Started && progress != null) {
|
||||
recState.value = if (!finished)
|
||||
RecordingState.Started(state.filePath, progress)
|
||||
else
|
||||
RecordingState.Finished(state.filePath, progress)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val interactionSource = interactionSourceWithTapDetection(
|
||||
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
|
||||
onClick = {
|
||||
if (stopRecOnNextClick.value) {
|
||||
stopRecordingAndAddAudio()
|
||||
} else {
|
||||
// tapped and didn't hold a finger
|
||||
stopRecOnNextClick.value = true
|
||||
}
|
||||
},
|
||||
onCancel = stopRecordingAndAddAudio,
|
||||
onRelease = stopRecordingAndAddAudio
|
||||
)
|
||||
RecordVoiceButton(interactionSource)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisallowedVoiceButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Outlined.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockToCurrentOrientationUntilDispose() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as Activity
|
||||
activity.requestedOrientation = when (activity.display?.rotation) {
|
||||
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun StopRecordButton(onClick: () -> Unit) {
|
||||
IconButton(onClick, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Stop,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
||||
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
stringResource(R.string.icon_descr_record_voice_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator() {
|
||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendTextButton(
|
||||
icon: ImageVector,
|
||||
backgroundColor: Color,
|
||||
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||
alpha: Animatable<Float, AnimationVector1D>,
|
||||
enabled: Boolean,
|
||||
sendMessage: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.combinedClickable(
|
||||
onClick = sendMessage,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(sizeDp.value.dp)
|
||||
.padding(4.dp)
|
||||
.alpha(alpha.value)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartLiveMessageButton(onClick: () -> Unit) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = Modifier.requiredSize(36.dp)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
enabled = true,
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Bolt,
|
||||
stringResource(R.string.icon_descr_send_message),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLiveMessage(
|
||||
scope: CoroutineScope,
|
||||
send: suspend () -> Unit,
|
||||
update: suspend () -> Unit,
|
||||
sendButtonSize: Animatable<Float, AnimationVector1D>,
|
||||
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
liveMessageAlertShown: SharedPreference<Boolean>
|
||||
) {
|
||||
fun run() {
|
||||
scope.launch {
|
||||
while (composeState.value.liveMessage != null) {
|
||||
sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
|
||||
}
|
||||
sendButtonSize.snapTo(36f)
|
||||
}
|
||||
scope.launch {
|
||||
while (composeState.value.liveMessage != null) {
|
||||
sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
|
||||
}
|
||||
sendButtonAlpha.snapTo(1f)
|
||||
}
|
||||
scope.launch {
|
||||
while (composeState.value.liveMessage != null) {
|
||||
delay(3000)
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() = withBGApi {
|
||||
if (composeState.value.liveMessage == null) {
|
||||
send()
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
if (liveMessageAlertShown.state.value) {
|
||||
start()
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.live_message),
|
||||
text = generalGetString(R.string.send_live_message_desc),
|
||||
confirmText = generalGetString(R.string.send_verb),
|
||||
onConfirm = {
|
||||
liveMessageAlertShown.set(true)
|
||||
start()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.allow_voice_messages_question),
|
||||
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||
confirmText = generalGetString(R.string.allow_verb),
|
||||
dismissText = generalGetString(R.string.cancel_verb),
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.voice_messages_prohibited),
|
||||
text = generalGetString(
|
||||
if (isDirectChat)
|
||||
R.string.ask_your_contact_to_enable_voice
|
||||
else
|
||||
R.string.only_group_owners_can_enable_voice
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -326,13 +518,14 @@ fun PreviewSendMsgView() {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -353,13 +546,14 @@ fun PreviewSendMsgViewEditing() {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -380,13 +574,14 @@ fun PreviewSendMsgViewInProgress() {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
showVoiceRecordIcon = false,
|
||||
allowedVoiceByPrefs = false,
|
||||
recState = mutableStateOf(RecordingState.NotStarted),
|
||||
isDirectChat = true,
|
||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||
needToAllowVoiceToContact = false,
|
||||
allowedVoiceByPrefs = true,
|
||||
allowVoiceToContact = {},
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
onAudioAdded = { _, _, _ -> },
|
||||
allowVoiceToContact = {},
|
||||
showDisabledVoiceAlert = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeView(
|
||||
displayName: String,
|
||||
connectionCode: String?,
|
||||
connectionVerified: Boolean,
|
||||
verify: suspend (String?) -> Pair<Boolean, String>?,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeLayout(
|
||||
displayName,
|
||||
connectionCode,
|
||||
connectionVerified,
|
||||
verifyCode = { newCode, cb ->
|
||||
withBGApi {
|
||||
val res = verify(newCode)
|
||||
if (res != null) {
|
||||
val (verified) = res
|
||||
cb(verified)
|
||||
if (verified) close()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerifyCodeLayout(
|
||||
displayName: String,
|
||||
connectionCode: String,
|
||||
connectionVerified: Boolean,
|
||||
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.security_code), false)
|
||||
val splitCode = splitToParts(connectionCode, 24)
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||
if (connectionVerified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
|
||||
Text(String.format(stringResource(R.string.is_verified), displayName))
|
||||
} else {
|
||||
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
||||
}
|
||||
}
|
||||
|
||||
SectionView {
|
||||
QRCode(connectionCode, Modifier.aspectRatio(1f))
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.weight(2f))
|
||||
SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) {
|
||||
Text(
|
||||
splitCode,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 20
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
Box(Modifier.weight(1f)) {
|
||||
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||
Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Text(
|
||||
generalGetString(R.string.to_verify_compare),
|
||||
Modifier.padding(bottom = DEFAULT_PADDING)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (connectionVerified) {
|
||||
SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
|
||||
verifyCode(null) {}
|
||||
}
|
||||
} else {
|
||||
SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
|
||||
ModalManager.shared.showModal {
|
||||
ScanCodeView(verifyCode) { }
|
||||
}
|
||||
}
|
||||
SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
|
||||
verifyCode(connectionCode) { verified ->
|
||||
if (!verified) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.incorrect_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitToParts(s: String, length: Int): String {
|
||||
if (length >= s.length) return s
|
||||
return (0..(s.length - 1) / length)
|
||||
.map { s.drop(it * length).take(length) }
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
@@ -32,7 +34,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
@@ -45,6 +47,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedBy { it.displayName.lowercase() },
|
||||
developerTools,
|
||||
groupLink,
|
||||
addMembers = {
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
@@ -56,8 +59,23 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
showMemberInfo = { member ->
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
val (_, code) = if (member.memberActive) {
|
||||
try {
|
||||
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
member to null
|
||||
}
|
||||
} else {
|
||||
member to null
|
||||
}
|
||||
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
|
||||
closeCurrent()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -78,8 +96,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
manageGroupLink = {
|
||||
withApi {
|
||||
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
|
||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -128,6 +145,7 @@ fun GroupChatInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
groupLink: String?,
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
@@ -163,7 +181,13 @@ fun GroupChatInfoLayout(
|
||||
|
||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
SectionItemView(manageGroupLink) { GroupLinkButton() }
|
||||
SectionItemView(manageGroupLink) {
|
||||
if (groupLink == null) {
|
||||
CreateGroupLinkButton()
|
||||
} else {
|
||||
GroupLinkButton()
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||
SectionItemView(onAddMembersClick) {
|
||||
@@ -206,7 +230,7 @@ fun GroupChatInfoLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -239,7 +263,7 @@ private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -255,7 +279,7 @@ fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
Column {
|
||||
members.forEachIndexed { index, member ->
|
||||
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
|
||||
@@ -269,7 +293,7 @@ fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -281,10 +305,15 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
) {
|
||||
ProfileImage(size = 46.dp, member.image)
|
||||
Column {
|
||||
Text(
|
||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.verified) {
|
||||
MemberVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
}
|
||||
val s = member.memberStatus.shortText
|
||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||
Text(
|
||||
@@ -304,7 +333,12 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupLinkButton() {
|
||||
private fun MemberVerifiedShield() {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupLinkButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
@@ -320,6 +354,23 @@ fun GroupLinkButton() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateGroupLinkButton() {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.AddLink,
|
||||
stringResource(R.string.create_group_link),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.create_group_link))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditGroupProfileButton() {
|
||||
Row(
|
||||
@@ -338,7 +389,7 @@ fun EditGroupProfileButton() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveGroupButton() {
|
||||
private fun LeaveGroupButton() {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -354,7 +405,7 @@ fun LeaveGroupButton() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteGroupButton() {
|
||||
private fun DeleteGroupButton() {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -382,6 +433,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
groupLink = null,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -15,22 +17,32 @@ 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.ui.theme.*
|
||||
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) }
|
||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) {
|
||||
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||
val cxt = LocalContext.current
|
||||
fun createLink() {
|
||||
creatingLink = true
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
onGroupLinkUpdated(groupLink)
|
||||
creatingLink = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (groupLink == null && !creatingLink) {
|
||||
createLink()
|
||||
}
|
||||
}
|
||||
GroupLinkLayout(
|
||||
groupLink = groupLink,
|
||||
createLink = {
|
||||
withApi {
|
||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||
}
|
||||
},
|
||||
creatingLink,
|
||||
createLink = ::createLink,
|
||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||
deleteLink = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -42,17 +54,22 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
||||
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||
if (r) {
|
||||
groupLink = null
|
||||
onGroupLinkUpdated(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
if (creatingLink) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupLinkLayout(
|
||||
groupLink: String?,
|
||||
creatingLink: Boolean,
|
||||
createLink: () -> Unit,
|
||||
share: () -> Unit,
|
||||
deleteLink: () -> Unit
|
||||
@@ -74,7 +91,7 @@ fun GroupLinkLayout(
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (groupLink == null) {
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
|
||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
|
||||
} else {
|
||||
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
||||
Row(
|
||||
@@ -99,3 +116,18 @@ fun GroupLinkLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressIndicator() {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -12,6 +13,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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -21,19 +23,20 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.SimplexServers
|
||||
import chat.simplex.app.views.chat.SwitchAddressButton
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoView(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
connectionCode: String?,
|
||||
chatModel: ChatModel,
|
||||
close: () -> Unit,
|
||||
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||
@@ -49,6 +52,7 @@ fun GroupMemberInfoView(
|
||||
connStats,
|
||||
newRole,
|
||||
developerTools,
|
||||
connectionCode,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
knownDirectChat = {
|
||||
withApi {
|
||||
@@ -91,6 +95,32 @@ fun GroupMemberInfoView(
|
||||
},
|
||||
switchMemberAddress = {
|
||||
switchMemberAddress(chatModel, groupInfo, member)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
VerifyCodeView(
|
||||
mem.displayName,
|
||||
connectionCode,
|
||||
mem.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.upsertGroupMember(
|
||||
groupInfo,
|
||||
mem.copy(
|
||||
activeConn = mem.activeConn?.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -120,12 +150,14 @@ fun GroupMemberInfoLayout(
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
connectionCode: String?,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
knownDirectChat: (Chat) -> Unit,
|
||||
newDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -141,17 +173,25 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
val chat = getContactChat(contactId)
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directContact) {
|
||||
if (member.memberActive) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
SectionView {
|
||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||
}
|
||||
SectionSpacer()
|
||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
SectionView {
|
||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||
val chat = getContactChat(contactId)
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
|
||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(member.verified, verifyClicked)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
@@ -178,10 +218,10 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
if (connStats != null) {
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
val rcvServers = connStats.rcvServers
|
||||
val sndServers = connStats.sndServers
|
||||
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||
@@ -196,8 +236,8 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
SectionView {
|
||||
@@ -224,12 +264,17 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.verified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
||||
}
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||
Text(
|
||||
member.fullName, style = MaterialTheme.typography.h2,
|
||||
@@ -319,12 +364,14 @@ fun PreviewGroupMemberInfoLayout() {
|
||||
connStats = null,
|
||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||
developerTools = false,
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
knownDirectChat = {},
|
||||
newDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
|
||||
|
||||
@Composable
|
||||
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||
@@ -74,18 +76,30 @@ private fun GroupPreferencesLayout(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.group_preferences))
|
||||
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
||||
val onTTLUpdated = { ttl: Int? ->
|
||||
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
|
||||
}
|
||||
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
|
||||
if (enable == GroupFeatureEnabled.ON) {
|
||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
|
||||
} else {
|
||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
||||
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo) {
|
||||
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
|
||||
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo) {
|
||||
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
|
||||
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) {
|
||||
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
||||
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||
}
|
||||
if (groupInfo.canEdit) {
|
||||
@@ -100,21 +114,34 @@ private fun GroupPreferencesLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
|
||||
private fun FeatureSection(
|
||||
feature: GroupFeature,
|
||||
enableFeature: State<GroupFeatureEnabled>,
|
||||
groupInfo: GroupInfo,
|
||||
preferences: FullGroupPreferences,
|
||||
onTTLUpdated: (Int?) -> Unit,
|
||||
onSelected: (GroupFeatureEnabled) -> Unit
|
||||
) {
|
||||
SectionView {
|
||||
val on = enableFeature.value == GroupFeatureEnabled.ON
|
||||
val icon = if (on) feature.iconFilled else feature.icon
|
||||
val iconTint = if (on) SimplexGreen else HighOrLowlight
|
||||
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView {
|
||||
ExposedDropDownSettingRow(
|
||||
PreferenceToggleWithIcon(
|
||||
feature.text,
|
||||
GroupFeatureEnabled.values().map { it to it.text },
|
||||
enableFeature,
|
||||
icon = icon,
|
||||
iconTint = iconTint,
|
||||
onSelected = onSelected
|
||||
)
|
||||
icon,
|
||||
iconTint,
|
||||
enableFeature.value == GroupFeatureEnabled.ON,
|
||||
) { checked ->
|
||||
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
|
||||
}
|
||||
}
|
||||
if (timedOn) {
|
||||
SectionDivider()
|
||||
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||
}
|
||||
} else {
|
||||
InfoRow(
|
||||
@@ -123,6 +150,10 @@ private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeat
|
||||
icon = icon,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
if (timedOn) {
|
||||
SectionDivider()
|
||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.*
|
||||
@@ -14,14 +15,15 @@ import chat.simplex.app.model.*
|
||||
fun CIChatFeatureView(
|
||||
chatItem: ChatItem,
|
||||
feature: Feature,
|
||||
iconColor: Color
|
||||
iconColor: Color,
|
||||
icon: ImageVector? = null
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(feature.iconFilled, feature.text, Modifier.size(15.dp), tint = iconColor)
|
||||
Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.dp), tint = iconColor)
|
||||
Text(
|
||||
chatEventText(chatItem),
|
||||
Modifier,
|
||||
|
||||
@@ -32,7 +32,7 @@ fun CIEventView(ci: ChatItem) {
|
||||
if (memberDisplayName != null) {
|
||||
chatEventTextView(
|
||||
buildAnnotatedString {
|
||||
withChatEventStyle(this, memberDisplayName)
|
||||
withStyle(chatEventStyle) { append(memberDisplayName) }
|
||||
append(" ")
|
||||
}.plus(chatEventText(ci))
|
||||
)
|
||||
@@ -43,15 +43,11 @@ fun CIEventView(ci: ChatItem) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) {
|
||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
||||
}
|
||||
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
|
||||
|
||||
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
withChatEventStyle(this, ci.content.text)
|
||||
append(" ")
|
||||
withChatEventStyle(this, ci.timestampText)
|
||||
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun CIFeaturePreferenceView(
|
||||
chatItem: ChatItem,
|
||||
contact: Contact?,
|
||||
feature: ChatFeature,
|
||||
allowed: FeatureAllowed,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
|
||||
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
||||
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
||||
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
||||
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
|
||||
val param = if (setParam) 86400 else null
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
|
||||
withAnnotation(tag = "Accept", annotation = "Accept") {
|
||||
withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") }
|
||||
}
|
||||
withStyle(chatEventStyle) { append(chatItem.timestampText) }
|
||||
}
|
||||
fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty()
|
||||
ClickableText(
|
||||
annotatedText,
|
||||
onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } },
|
||||
shouldConsumeEvent = ::accept
|
||||
)
|
||||
} else {
|
||||
Text(chatItem.content.text + " " + chatItem.timestampText,
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,62 +3,85 @@ package chat.simplex.app.views.chat.item
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.WarningYellow
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!chatItem.isDeletedContent) {
|
||||
if (chatItem.meta.itemEdited) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
modifier = Modifier.height(12.dp).padding(end = 1.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_edited),
|
||||
tint = metaColor,
|
||||
)
|
||||
}
|
||||
CIStatusView(chatItem.meta.itemStatus, metaColor)
|
||||
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
|
||||
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chatItem.isDeletedContent) {
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = metaColor,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
} else {
|
||||
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
|
||||
}
|
||||
Text(
|
||||
chatItem.timestampText,
|
||||
color = metaColor,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
// changing this function requires updating reserveSpaceForMeta
|
||||
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||
if (meta.itemEdited) {
|
||||
StatusIconText(Icons.Outlined.Edit, color)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
}
|
||||
if (meta.disappearing) {
|
||||
StatusIconText(Icons.Outlined.Timer, color)
|
||||
val ttl = meta.itemTimed?.ttl
|
||||
if (ttl != chatTTL) {
|
||||
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
||||
if (statusIcon != null) {
|
||||
val (icon, statusColor) = statusIcon
|
||||
StatusIconText(icon, statusColor)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
} else if (!meta.disappearing) {
|
||||
StatusIconText(Icons.Filled.Circle, Color.Transparent)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(meta.timestampText, color = color, fontSize = 13.sp)
|
||||
}
|
||||
|
||||
// the conditions in this function should match CIMetaText
|
||||
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
|
||||
val iconSpace = " "
|
||||
var res = ""
|
||||
if (meta.itemEdited) res += iconSpace
|
||||
if (meta.itemTimed != null) {
|
||||
res += iconSpace
|
||||
val ttl = meta.itemTimed?.ttl
|
||||
if (ttl != chatTTL) {
|
||||
res += TimedMessagesPreference.shortTtlText(ttl)
|
||||
}
|
||||
}
|
||||
if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
|
||||
res += iconSpace
|
||||
}
|
||||
return res + meta.timestampText
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
when (status) {
|
||||
is CIStatus.SndSent -> {
|
||||
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
|
||||
}
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
private fun StatusIconText(icon: ImageVector, color: Color) {
|
||||
Icon(icon, null, Modifier.height(12.dp), tint = color)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@@ -67,7 +90,8 @@ fun PreviewCIMetaView() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +102,8 @@ fun PreviewCIMetaViewUnread() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.RcvNew()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +114,8 @@ fun PreviewCIMetaViewSendFailed() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
status = CIStatus.SndError("CMD SYNTAX")
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +125,8 @@ fun PreviewCIMetaViewSendNoAuth() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,7 +136,8 @@ fun PreviewCIMetaViewSendSent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,7 +148,8 @@ fun PreviewCIMetaViewEdited() {
|
||||
chatItem = ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,7 +161,8 @@ fun PreviewCIMetaViewEditedUnread() {
|
||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.RcvNew()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,7 +174,8 @@ fun PreviewCIMetaViewEditedSent() {
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||
itemEdited = true,
|
||||
status=CIStatus.SndSent()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,6 +183,7 @@ fun PreviewCIMetaViewEditedSent() {
|
||||
@Composable
|
||||
fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData()
|
||||
chatItem = ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ fun CIVoiceView(
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
ci: ChatItem,
|
||||
timedMessagesTTL: Int?,
|
||||
longClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -63,7 +64,7 @@ fun CIVoiceView(
|
||||
durationText(time / 1000)
|
||||
}
|
||||
}
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, play, pause, longClick)
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
|
||||
} else {
|
||||
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||
val metaReserve = if (edited)
|
||||
@@ -86,6 +87,7 @@ private fun VoiceLayout(
|
||||
brokenAudio: Boolean,
|
||||
sent: Boolean,
|
||||
hasText: Boolean,
|
||||
timedMessagesTTL: Int?,
|
||||
play: () -> Unit,
|
||||
pause: () -> Unit,
|
||||
longClick: () -> Unit
|
||||
@@ -105,7 +107,7 @@ private fun VoiceLayout(
|
||||
Column {
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +117,7 @@ private fun VoiceLayout(
|
||||
Column {
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
@@ -42,6 +43,7 @@ fun ChatItemView(
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -49,7 +51,7 @@ fun ChatItemView(
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.fullDeletionAllowed }
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
|
||||
@@ -174,14 +176,14 @@ fun ChatItemView(
|
||||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted && !revealed.value) {
|
||||
MarkedDeletedItemView(cItem, showMember = showMember)
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) {
|
||||
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) {
|
||||
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
EmojiItemView(cItem, cInfo.timedMessagesTTL)
|
||||
MsgContentItemDropdownMenu()
|
||||
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, longClick = { onLinkLongClick("") })
|
||||
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
|
||||
MsgContentItemDropdownMenu()
|
||||
} else {
|
||||
framedItemView()
|
||||
@@ -194,7 +196,7 @@ fun ChatItemView(
|
||||
}
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, showMember = showMember)
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
@@ -215,7 +217,7 @@ fun ChatItemView(
|
||||
is CIContent.RcvDeleted -> DeletedItem()
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||
@@ -224,6 +226,11 @@ fun ChatItemView(
|
||||
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.RcvChatPreference -> {
|
||||
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
||||
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
||||
}
|
||||
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
|
||||
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||
@@ -319,6 +326,7 @@ fun PreviewChatItemView() {
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -338,6 +346,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = ReceivedColorLight,
|
||||
@@ -35,7 +35,7 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,8 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun PreviewDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem) {
|
||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
CIMetaView(chatItem)
|
||||
CIMetaView(chatItem, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.*
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -47,6 +48,7 @@ fun FramedItemView(
|
||||
scrollToItem: (Long) -> Unit = {},
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
val chatTTL = chatInfo.timedMessagesTTL
|
||||
|
||||
fun membership(): GroupMember? {
|
||||
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
||||
@@ -67,7 +69,7 @@ fun FramedItemView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ciDeletedView() {
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
|
||||
Row(
|
||||
Modifier
|
||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||
@@ -78,15 +80,19 @@ fun FramedItemView(
|
||||
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.marked_deleted_description),
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
caption,
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
|
||||
append(caption)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
)
|
||||
@@ -138,12 +144,12 @@ fun FramedItemView(
|
||||
@Composable
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||
if (text != "") {
|
||||
CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler)
|
||||
if (text != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
@@ -158,9 +164,13 @@ fun FramedItemView(
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted) { ciDeletedView() }
|
||||
if (ci.meta.itemDeleted) {
|
||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(R.string.live), false)
|
||||
}
|
||||
ci.quotedItem?.let { ciQuoteView(it) }
|
||||
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
||||
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -176,36 +186,36 @@ fun FramedItemView(
|
||||
when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||
if (mc.text == "") {
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVoice -> {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, longClick = { onLinkLongClick("") })
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||
if (mc.text != "") {
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||
is MsgContent.MCUnknown ->
|
||||
if (ci.file == null) {
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
} else {
|
||||
ciFileView(ci, mc.text)
|
||||
}
|
||||
is MsgContent.MCLink -> {
|
||||
ChatItemLinkView(mc.preview)
|
||||
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, metaColor)
|
||||
CIMetaView(ci, chatTTL, metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,15 +224,17 @@ fun FramedItemView(
|
||||
@Composable
|
||||
fun CIMarkdownText(
|
||||
ci: ChatItem,
|
||||
chatTTL: Int?,
|
||||
showMember: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
uriHandler: UriHandler?,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
MarkdownText(
|
||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
Surface(
|
||||
Modifier.clickable(onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -45,7 +45,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
|
||||
@@ -37,7 +37,7 @@ fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci)
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
fun PreviewMarkedDeletedItemView() {
|
||||
SimpleXTheme {
|
||||
DeletedItemView(
|
||||
ChatItem.getSampleData(itemDeleted = true)
|
||||
ChatItem.getSampleData(itemDeleted = true),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.util.Log
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -18,7 +22,9 @@ import androidx.compose.ui.unit.*
|
||||
import androidx.core.text.BidiFormatter
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.views.helpers.detectGesture
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
@@ -40,13 +46,31 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
|
||||
}
|
||||
}
|
||||
|
||||
private val noTyping: AnnotatedString = AnnotatedString(" ")
|
||||
|
||||
private val typingIndicators: List<AnnotatedString> = listOf(
|
||||
typing(FontWeight.Black) + typing() + typing(),
|
||||
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
|
||||
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
|
||||
typing() + typing() + typing(FontWeight.Bold)
|
||||
)
|
||||
|
||||
|
||||
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||
pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||
}
|
||||
|
||||
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||
|
||||
@Composable
|
||||
fun MarkdownText (
|
||||
text: String,
|
||||
formattedText: List<FormattedText>? = null,
|
||||
sender: String? = null,
|
||||
metaText: String? = null,
|
||||
edited: Boolean = false,
|
||||
meta: CIMeta? = null,
|
||||
chatTTL: Int? = null,
|
||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
@@ -59,22 +83,60 @@ fun MarkdownText (
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
}
|
||||
val reserve = when {
|
||||
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
|
||||
edited -> " "
|
||||
else -> " "
|
||||
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||
"\n"
|
||||
} else if (meta != null) {
|
||||
reserveSpaceForMeta(meta, chatTTL)
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
else
|
||||
LocalLayoutDirection.current
|
||||
) {
|
||||
var timer: Job? by remember { mutableStateOf(null) }
|
||||
var typingIdx by rememberSaveable { mutableStateOf(0) }
|
||||
fun stopTyping() {
|
||||
timer?.cancel()
|
||||
timer = null
|
||||
}
|
||||
fun switchTyping() {
|
||||
if (meta != null && meta.isLive && meta.recent) {
|
||||
timer = timer ?: scope.launch {
|
||||
while (isActive) {
|
||||
typingIdx = (typingIdx + 1) % typingIndicators.size
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
val activity = LocalContext.current as Activity
|
||||
LaunchedEffect(meta.recent, meta.isLive) {
|
||||
switchTyping()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formattedText == null) {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendSender(this, sender, senderBold)
|
||||
append(text)
|
||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
} else {
|
||||
@@ -100,10 +162,13 @@ fun MarkdownText (
|
||||
}
|
||||
}
|
||||
}
|
||||
if (meta?.isLive == true) {
|
||||
append(typingIndicator(meta.recent, typingIdx))
|
||||
}
|
||||
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
|
||||
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
|
||||
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
||||
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
||||
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
|
||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||
@@ -76,6 +77,15 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
||||
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
||||
MarkdownHelpView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,16 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.connectIfOpenedViaUri
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -41,9 +45,22 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
|
||||
else newChatSheetState.value = NewChatSheetState.GONE
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (shouldShowWhatsNew(chatModel)) {
|
||||
delay(1000L)
|
||||
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
|
||||
}
|
||||
LaunchedEffect(chatModel.appOpenUrl.value) {
|
||||
val url = chatModel.appOpenUrl.value
|
||||
if (url != null) {
|
||||
chatModel.appOpenUrl.value = null
|
||||
connectIfOpenedViaUri(url, chatModel)
|
||||
}
|
||||
}
|
||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
Scaffold(
|
||||
|
||||
@@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -63,11 +62,21 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifiedIcon() {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitle() {
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (cInfo.contact.verified) {
|
||||
VerifiedIcon()
|
||||
}
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||
@@ -88,7 +97,6 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
||||
linkMode = linkMode,
|
||||
senderBold = true,
|
||||
metaText = null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
|
||||
@@ -30,7 +30,7 @@ fun CloseSheetBar(close: () -> Unit) {
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||
val padding = if (withPadding)
|
||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
|
||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
|
||||
else
|
||||
PaddingValues(bottom = DEFAULT_PADDING)
|
||||
Text(
|
||||
|
||||
@@ -214,8 +214,8 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun interactionSourceWithTapDetection(key: Any, onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember(key) { MutableInteractionSource() }
|
||||
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
LaunchedEffect(interactionSource) {
|
||||
var firstTapTime = 0L
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
||||
import android.content.Context
|
||||
import android.media.*
|
||||
import android.media.AudioManager.AudioPlaybackCallback
|
||||
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
|
||||
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -10,16 +11,15 @@ import androidx.compose.runtime.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.views.helpers.AudioPlayer.duration
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
interface Recorder {
|
||||
val recordingInProgress: MutableState<Boolean>
|
||||
fun start(onStop: () -> Unit): String
|
||||
fun stop()
|
||||
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
|
||||
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
|
||||
fun stop(): Int
|
||||
}
|
||||
|
||||
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
@@ -27,8 +27,10 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
// Allows to stop the recorder from outside without having the recorder in a variable
|
||||
var stopRecording: (() -> Unit)? = null
|
||||
}
|
||||
override val recordingInProgress = mutableStateOf(false)
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var progressJob: Job? = null
|
||||
private var filePath: String? = null
|
||||
private var recStartedAt: Long? = null
|
||||
private fun initRecorder() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(SimplexApp.context)
|
||||
@@ -36,9 +38,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
override fun start(onStop: () -> Unit): String {
|
||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||
AudioPlayer.stop()
|
||||
recordingInProgress.value = true
|
||||
val rec: MediaRecorder
|
||||
recorder = initRecorder().also { rec = it }
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
@@ -47,28 +48,37 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
rec.setAudioChannels(1)
|
||||
rec.setAudioSamplingRate(16000)
|
||||
rec.setAudioEncodingBitRate(16000)
|
||||
rec.setMaxDuration(-1) // TODO set limit
|
||||
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
|
||||
rec.setMaxFileSize(recordedBytesLimit)
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||
rec.setOutputFile(filePath)
|
||||
val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||
filePath = path
|
||||
rec.setOutputFile(path)
|
||||
rec.prepare()
|
||||
rec.start()
|
||||
rec.setOnInfoListener { mr, what, extra ->
|
||||
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
|
||||
stop()
|
||||
onStop()
|
||||
recStartedAt = System.currentTimeMillis()
|
||||
progressJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
while(isActive) {
|
||||
onProgressUpdate(progress(), false)
|
||||
delay(50)
|
||||
}
|
||||
}.apply {
|
||||
invokeOnCompletion {
|
||||
onProgressUpdate(realDuration(path), true)
|
||||
}
|
||||
}
|
||||
stopRecording = { stop(); onStop() }
|
||||
return filePath
|
||||
rec.setOnInfoListener { _, what, _ ->
|
||||
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED || what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
stopRecording = { stop() }
|
||||
return path
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (!recordingInProgress.value) return
|
||||
override fun stop(): Int {
|
||||
val path = filePath ?: return 0
|
||||
stopRecording = null
|
||||
recordingInProgress.value = false
|
||||
recorder?.metrics?.
|
||||
runCatching {
|
||||
recorder?.stop()
|
||||
}
|
||||
@@ -76,16 +86,25 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||
recorder?.reset()
|
||||
}
|
||||
runCatching {
|
||||
// release all resources
|
||||
recorder?.release()
|
||||
}
|
||||
// Await coroutine finishes in order to send real duration to it's listener
|
||||
runBlocking {
|
||||
progressJob?.cancelAndJoin()
|
||||
}
|
||||
progressJob = null
|
||||
filePath = null
|
||||
recorder = null
|
||||
return (realDuration(path) ?: 0).also { recStartedAt = null }
|
||||
}
|
||||
|
||||
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
|
||||
stop()
|
||||
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
|
||||
}
|
||||
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
|
||||
|
||||
/**
|
||||
* Return real duration from [AudioPlayer] if it's possible (should always be possible).
|
||||
* As a fallback, return internally counted duration
|
||||
* */
|
||||
private fun realDuration(path: String): Int? = duration(path) ?: progress()
|
||||
}
|
||||
|
||||
object AudioPlayer {
|
||||
@@ -251,8 +270,8 @@ object AudioPlayer {
|
||||
audioPlaying.value = false
|
||||
}
|
||||
|
||||
fun duration(filePath: String): Int {
|
||||
var res = 0
|
||||
fun duration(filePath: String): Int? {
|
||||
var res: Int? = null
|
||||
kotlin.runCatching {
|
||||
helperPlayer.setDataSource(filePath)
|
||||
helperPlayer.prepare()
|
||||
|
||||
@@ -28,6 +28,22 @@ fun SimpleButton(text: String, icon: ImageVector,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButton(
|
||||
text: String, icon: ImageVector,
|
||||
color: Color = MaterialTheme.colors.primary,
|
||||
disabled: Boolean,
|
||||
click: () -> Unit
|
||||
) {
|
||||
SimpleButtonFrame(click, disabled = disabled) {
|
||||
Icon(
|
||||
icon, text, tint = if (disabled) HighOrLowlight else color,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) HighOrLowlight else color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButtonIconEnded(
|
||||
text: String,
|
||||
|
||||
@@ -228,7 +228,7 @@ const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
||||
|
||||
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
|
||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
|
||||
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
|
||||
enum class OnboardingStage {
|
||||
Step1_SimpleXInfo,
|
||||
Step2_CreateProfile,
|
||||
Step3_SetNotificationsMode,
|
||||
OnboardingComplete
|
||||
}
|
||||
|
||||
@@ -34,6 +35,9 @@ fun CreateProfile(chatModel: ChatModel) {
|
||||
.padding(20.dp)
|
||||
) {
|
||||
CreateProfilePanel(chatModel)
|
||||
LaunchedEffect(Unit) {
|
||||
setLastVersionDefault(chatModel)
|
||||
}
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import chat.simplex.app.views.usersettings.changeNotificationsMode
|
||||
|
||||
@Composable
|
||||
fun SetNotificationsMode(m: ChatModel) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.onboarding_notifications_mode_title), false)
|
||||
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
|
||||
Text(stringResource(R.string.onboarding_notifications_mode_subtitle))
|
||||
Spacer(Modifier.padding(DEFAULT_PADDING_HALF))
|
||||
NotificationButton(currentMode, NotificationsMode.OFF, R.string.onboarding_notifications_mode_off, R.string.onboarding_notifications_mode_off_desc)
|
||||
NotificationButton(currentMode, NotificationsMode.PERIODIC, R.string.onboarding_notifications_mode_periodic, R.string.onboarding_notifications_mode_periodic_desc)
|
||||
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage) {
|
||||
changeNotificationsMode(currentMode.value, m)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mode: NotificationsMode, @StringRes title: Int, @StringRes description: Int) {
|
||||
TextButton(
|
||||
onClick = { currentMode.value = mode },
|
||||
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight.copy(alpha = 0.5f)),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
|
||||
Text(
|
||||
stringResource(title),
|
||||
style = MaterialTheme.typography.h2,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.User
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ModalManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
|
||||
@@ -105,14 +104,14 @@ private fun InfoRow(icon: Painter, @StringRes titleId: Int, @StringRes textId: I
|
||||
@Composable
|
||||
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
|
||||
if (user == null) {
|
||||
ActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
|
||||
OnboardingActionButton(R.string.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, onclick)
|
||||
} else {
|
||||
ActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
|
||||
OnboardingActionButton(R.string.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, onclick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
fun OnboardingActionButton(
|
||||
@StringRes labelId: Int,
|
||||
onboarding: OnboardingStage?,
|
||||
onboardingStage: MutableState<OnboardingStage?>,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
|
||||
|
||||
@Composable
|
||||
fun featureDescription(icon: ImageVector, titleId: Int, descrId: Int, link: String?) {
|
||||
@Composable
|
||||
fun linkButton(link: String) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable { uriHandler.openUri(link) }
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
|
||||
Text(
|
||||
generalGetString(titleId),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (link != null) {
|
||||
linkButton(link)
|
||||
}
|
||||
}
|
||||
Text(generalGetString(descrId))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun pagination() {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
if (currentVersion.value > 0) {
|
||||
val prev = currentVersion.value - 1
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clickable { currentVersion.value = prev }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Outlined.ArrowBackIosNew, "previous", tint = MaterialTheme.colors.primary)
|
||||
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
if (currentVersion.value < versionDescriptions.lastIndex) {
|
||||
val next = currentVersion.value + 1
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clickable { currentVersion.value = next }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, "next", tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val v = versionDescriptions[currentVersion.value]
|
||||
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
String.format(generalGetString(R.string.new_in_version), v.version),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(DEFAULT_PADDING),
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
|
||||
v.features.forEach { feature ->
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link)
|
||||
}
|
||||
|
||||
if (!viaSettings) {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(
|
||||
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.ok),
|
||||
modifier = Modifier.clickable(onClick = close),
|
||||
style = MaterialTheme.typography.h3,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
pagination()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FeatureDescription(
|
||||
val icon: ImageVector,
|
||||
val titleId: Int,
|
||||
val descrId: Int,
|
||||
val link: String? = null
|
||||
)
|
||||
|
||||
private data class VersionDescription(
|
||||
val version: String,
|
||||
val features: List<FeatureDescription>
|
||||
)
|
||||
|
||||
private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
VersionDescription(
|
||||
version = "v4.2",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_2_security_assessment,
|
||||
descrId = R.string.v4_2_security_assessment_desc,
|
||||
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Group,
|
||||
titleId = R.string.v4_2_group_links,
|
||||
descrId = R.string.v4_2_group_links_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Check,
|
||||
titleId = R.string.v4_2_auto_accept_contact_requests,
|
||||
descrId = R.string.v4_2_auto_accept_contact_requests_desc
|
||||
),
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.3",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Mic,
|
||||
titleId = R.string.v4_3_voice_messages,
|
||||
descrId = R.string.v4_3_voice_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.DeleteForever,
|
||||
titleId = R.string.v4_3_irreversible_message_deletion,
|
||||
descrId = R.string.v4_3_irreversible_message_deletion_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.WifiTethering,
|
||||
titleId = R.string.v4_3_improved_server_configuration,
|
||||
descrId = R.string.v4_3_improved_server_configuration_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VisibilityOff,
|
||||
titleId = R.string.v4_3_improved_privacy_and_security,
|
||||
descrId = R.string.v4_3_improved_privacy_and_security_desc
|
||||
),
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.4",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Timer,
|
||||
titleId = R.string.v4_4_disappearing_messages,
|
||||
descrId = R.string.v4_4_disappearing_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Pending,
|
||||
titleId = R.string.v4_4_live_messages,
|
||||
descrId = R.string.v4_4_live_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_4_verify_connection_security,
|
||||
descrId = R.string.v4_4_verify_connection_security_desc
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
private val lastVersion = versionDescriptions.last().version
|
||||
|
||||
fun setLastVersionDefault(m: ChatModel) {
|
||||
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
|
||||
}
|
||||
|
||||
fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
val v = m.controller.appPrefs.whatsNewVersion.get()
|
||||
setLastVersionDefault(m)
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewWhatsNewView() {
|
||||
SimpleXTheme {
|
||||
WhatsNewView(
|
||||
viaSettings = true,
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,13 @@ import chat.simplex.app.model.Format
|
||||
import chat.simplex.app.model.FormatColor
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun MarkdownHelpView() {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = DEFAULT_PADDING)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.how_to_use_markdown), false)
|
||||
Text(stringResource(R.string.you_can_use_markdown_to_format_messages__prompt))
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
val bold = stringResource(R.string.bold)
|
||||
|
||||
@@ -46,25 +46,6 @@ enum class NotificationPreviewMode {
|
||||
fun NotificationsSettingsView(
|
||||
chatModel: ChatModel,
|
||||
) {
|
||||
val onNotificationsModeSelected = { mode: NotificationsMode ->
|
||||
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
|
||||
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.notificationsMode.value = mode
|
||||
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start(SimplexApp.context)
|
||||
else
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode ->
|
||||
chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name)
|
||||
chatModel.notificationPreviewMode.value = mode
|
||||
@@ -76,7 +57,7 @@ fun NotificationsSettingsView(
|
||||
showPage = { page ->
|
||||
ModalManager.shared.showModalCloseable(true) {
|
||||
when (page) {
|
||||
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected)
|
||||
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode) { changeNotificationsMode(it, chatModel) }
|
||||
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)
|
||||
}
|
||||
}
|
||||
@@ -159,7 +140,7 @@ fun NotificationPreviewView(
|
||||
}
|
||||
|
||||
// mode, name, description
|
||||
fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
|
||||
private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
|
||||
val res = ArrayList<ValueTitleDesc<NotificationsMode>>()
|
||||
res.add(
|
||||
ValueTitleDesc(
|
||||
@@ -211,3 +192,23 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
|
||||
)
|
||||
return res
|
||||
}
|
||||
|
||||
fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) {
|
||||
chatModel.controller.appPrefs.notificationsMode.set(mode.name)
|
||||
if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
chatModel.notificationsMode.value = mode
|
||||
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start(SimplexApp.context)
|
||||
else
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
|
||||
@@ -69,14 +69,19 @@ private fun PreferencesLayout(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_preferences))
|
||||
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) }
|
||||
TimedMessagesFeatureSection(timedMessages) {
|
||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesPreference(allow = if (it) FeatureAllowed.YES else FeatureAllowed.NO)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
|
||||
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
|
||||
applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
|
||||
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
|
||||
FeatureSection(ChatFeature.Voice, allowVoice) {
|
||||
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
|
||||
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
|
||||
}
|
||||
SectionSpacer()
|
||||
ResetSaveButtons(
|
||||
@@ -103,6 +108,22 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllo
|
||||
SectionTextFooter(feature.allowDescription(allowFeature.value))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimedMessagesFeatureSection(allowFeature: State<FeatureAllowed>, onSelected: (Boolean) -> Unit) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
PreferenceToggleWithIcon(
|
||||
ChatFeature.TimedMessages.text,
|
||||
ChatFeature.TimedMessages.icon,
|
||||
HighOrLowlight,
|
||||
allowFeature.value == FeatureAllowed.ALWAYS || allowFeature.value == FeatureAllowed.YES,
|
||||
onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(ChatFeature.TimedMessages.allowDescription(allowFeature.value))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
|
||||
@@ -34,6 +34,7 @@ import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.CreateLinkTab
|
||||
import chat.simplex.app.views.newchat.CreateLinkView
|
||||
import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
@@ -138,9 +139,9 @@ fun SettingsLayout(
|
||||
SectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
SettingsActionItem(Icons.Outlined.Add, stringResource(R.string.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
SectionDivider()
|
||||
@@ -488,7 +489,7 @@ fun PreviewSettingsLayout() {
|
||||
setPerformLA = {},
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {}},
|
||||
showCustomModal = { {} },
|
||||
showTerminal = {},
|
||||
// showVideoChatPrototype = {}
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
895
apps/android/app/src/main/res/values-fr/strings.xml
Normal file
895
apps/android/app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,895 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="thousand_abbreviation">k</string>
|
||||
<string name="connect_via_contact_link">Se connecter via le lien du contact \?</string>
|
||||
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Votre profil va être envoyé au contact qui vous a envoyé ce lien.</string>
|
||||
<string name="you_will_join_group">Vous allez rejoindre le groupe correspondant à ce lien et être mis en relation avec les autres membres du groupe.</string>
|
||||
<string name="connect_via_link_verb">Se connecter</string>
|
||||
<string name="connect_via_group_link">Se connecter via le lien du groupe \?</string>
|
||||
<string name="connect_via_invitation_link">Se connecter via un lien d\'invitation \?</string>
|
||||
<string name="server_error">erreur</string>
|
||||
<string name="server_connecting">connexion</string>
|
||||
<string name="server_connected">connecté</string>
|
||||
<string name="display_name_connection_established">connexion établie</string>
|
||||
<string name="display_name_invited_to_connect">invité à se connecter</string>
|
||||
<string name="simplex_link_invitation">Invitation unique SimpleX</string>
|
||||
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||
<string name="simplex_link_mode">Liens SimpleX</string>
|
||||
<string name="simplex_link_mode_description">Description</string>
|
||||
<string name="error_deleting_contact">Erreur lors de la suppression du contact</string>
|
||||
<string name="error_joining_group">Erreur lors de la liaison avec le groupe</string>
|
||||
<string name="sender_cancelled_file_transfer">L\'expéditeur a annulé le transfert de fichiers.</string>
|
||||
<string name="deleted_description">supprimé</string>
|
||||
<string name="marked_deleted_description">marquer comme supprimé</string>
|
||||
<string name="unknown_message_format">format de message inconnu</string>
|
||||
<string name="display_name_connecting">connexion…</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">vous avez partagé un lien unique en incognito</string>
|
||||
<string name="description_via_group_link">via le lien de groupe</string>
|
||||
<string name="description_via_contact_address_link">via le lien d\'adresse du contact</string>
|
||||
<string name="description_via_contact_address_link_incognito">mode incognito via le lien d\'adresse du contact</string>
|
||||
<string name="simplex_link_group">Lien de groupe SimpleX</string>
|
||||
<string name="simplex_link_mode_browser">Via navigateur</string>
|
||||
<string name="simplex_link_mode_browser_warning">Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge.</string>
|
||||
<string name="network_error_desc">Vérifiez votre connexion réseau avec <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> et réessayez.</string>
|
||||
<string name="error_receiving_file">Erreur lors de la réception du fichier</string>
|
||||
<string name="sender_may_have_deleted_the_connection_request">L\'expéditeur a peut-être supprimé la demande de connexion.</string>
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact.</string>
|
||||
<string name="sending_files_not_yet_supported">l\'envoi de fichiers n\'est pas encore supporté</string>
|
||||
<string name="sender_you_pronoun">vous</string>
|
||||
<string name="description_via_group_link_incognito">mode incognito via le lien de groupe</string>
|
||||
<string name="simplex_link_contact">Adresse de contact SimpleX</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact.</string>
|
||||
<string name="receiving_files_not_yet_supported">la réception de fichiers n\'est pas encore supportée</string>
|
||||
<string name="connection_local_display_name">connexion <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="description_you_shared_one_time_link">vous avez partagé un lien unique</string>
|
||||
<string name="description_via_one_time_link">via un lien unique</string>
|
||||
<string name="description_via_one_time_link_incognito">mode incognito via un lien unique</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs SMP sont au bon format, séparées par des lignes et ne sont pas dupliquées.</string>
|
||||
<string name="error_setting_network_config">Erreur lors de la mise à jour de la configuration réseau</string>
|
||||
<string name="error_creating_address">Erreur lors de la création de l\'adresse</string>
|
||||
<string name="contact_already_exists">Contact déjà existant</string>
|
||||
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre.</string>
|
||||
<string name="connection_error">Erreur de connexion</string>
|
||||
<string name="error_adding_members">Erreur lors de l\'ajout de membre·s</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
|
||||
<string name="invalid_message_format">format de message invalide</string>
|
||||
<string name="simplex_link_mode_full">Lien entier</string>
|
||||
<string name="error_saving_smp_servers">Erreur lors de la sauvegarde des serveurs SMP</string>
|
||||
<string name="cannot_receive_file">Impossible de recevoir le fichier</string>
|
||||
<string name="invalid_connection_link">Lien de connection invalide</string>
|
||||
<string name="connection_timeout">Délai de connexion</string>
|
||||
<string name="error_sending_message">Erreur lors de l\'envoi du message</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">Vous êtes déjà connecté à <xliff:g id="contactName" example="Alice">%1$s!</xliff:g>.</string>
|
||||
<string name="connection_error_auth">Erreur de connexion (AUTH)</string>
|
||||
<string name="connection_error_auth_desc">A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s\'agir d\'un bug - veuillez le signaler.
|
||||
\nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d\'une connexion réseau stable.</string>
|
||||
<string name="error_accepting_contact_request">Erreur de validation de la demande de contact</string>
|
||||
<string name="error_deleting_group">Erreur lors de la suppression du groupe</string>
|
||||
<string name="error_deleting_contact_request">Erreur lors de la suppression du contact</string>
|
||||
<string name="error_deleting_pending_contact_connection">Erreur lors de la suppression de la connexion en attente</string>
|
||||
<string name="error_changing_address">Erreur de changement d\'adresse</string>
|
||||
<string name="error_smp_test_failed_at_step">Échec du test à l\'étape %s.</string>
|
||||
<string name="error_smp_test_certificate">Il est possible que l\'empreinte du certificat dans l\'adresse du serveur soit incorrecte</string>
|
||||
<string name="smp_server_test_connect">Se connecter</string>
|
||||
<string name="smp_server_test_create_queue">Créer une file d\'attente</string>
|
||||
<string name="smp_server_test_secure_queue">File d\'attente sécurisée</string>
|
||||
<string name="smp_server_test_delete_queue">Supprimer la file d\'attente</string>
|
||||
<string name="smp_server_test_disconnect">Se déconnecter</string>
|
||||
<string name="icon_descr_instant_notifications">Notifications instantanées</string>
|
||||
<string name="service_notifications">Notifications instantanées !</string>
|
||||
<string name="service_notifications_disabled">Les notifications instantanées sont désactivées !</string>
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Il peut être désactivé via les paramètres</b> - les notifications seront toujours affichées lorsque l\'application est en cours d\'exécution.</string>
|
||||
<string name="turning_off_service_and_periodic">L\'optimisation de la batterie est active et désactive le service de fond et les demandes périodiques de nouveaux messages. Vous pouvez les réactiver via les paramètres.</string>
|
||||
<string name="periodic_notifications">Notifications périodiques</string>
|
||||
<string name="periodic_notifications_disabled">Les notifications périodiques sont désactivées !</string>
|
||||
<string name="enter_passphrase_notification_title">Une phrase secrète est nécessaire</string>
|
||||
<string name="turn_off_battery_optimization">Pour l\'utiliser, veuillez <b>désactiver l\'optimisation de la batterie</b> pour <xliff:g id="appName">SimpleX</xliff:g> dans la prochaine fenêtre de dialogue. Sinon, les notifications seront désactivées.</string>
|
||||
<string name="error_smp_test_server_auth">Le serveur requiert une autorisation pour créer des files d\'attente, vérifiez le mot de passe</string>
|
||||
<string name="periodic_notifications_desc">L\'application récupère périodiquement les nouveaux messages - elle utilise un peu votre batterie chaque jour. L\'application n\'utilise pas les notifications push - les données de votre appareil ne sont pas envoyées aux serveurs.</string>
|
||||
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Pour protéger votre vie privée, au lieu des notifications push, l\'application possède un <b><xliff:g id="appName">SimpleX</xliff:g> service de fond</b> - il utilise quelques pour cent de la batterie par jour.</string>
|
||||
<string name="hide_notification">Cacher</string>
|
||||
<string name="settings_notification_preview_mode_title">Montrer l\'aperçu</string>
|
||||
<string name="notification_preview_mode_contact">Nom du contact</string>
|
||||
<string name="notification_preview_somebody">Contact masqué :</string>
|
||||
<string name="notification_preview_new_message">nouveau message</string>
|
||||
<string name="notification_new_contact_request">Nouvelle demande de contact</string>
|
||||
<string name="notification_contact_connected">Connecté</string>
|
||||
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
|
||||
<string name="la_notice_turn_on">Activer</string>
|
||||
<string name="auth_simplex_lock_turned_on">SimpleX Lock activé</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Il vous sera demandé de vous authentifier lorsque vous démarrez ou reprenez l\'application après 30 secondes en arrière-plan.</string>
|
||||
<string name="auth_unlock">Déverrouiller</string>
|
||||
<string name="auth_enable_simplex_lock">Activer SimpleX Lock</string>
|
||||
<string name="auth_disable_simplex_lock">Désactiver SimpleX Lock</string>
|
||||
<string name="auth_unavailable">Authentification indisponible</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock.</string>
|
||||
<string name="auth_open_chat_console">Ouvrir la console du chat</string>
|
||||
<string name="message_delivery_error_title">Erreur de distribution du message</string>
|
||||
<string name="message_delivery_error_desc">Il est fort probable que ce contact ait supprimé la connexion avec vous.</string>
|
||||
<string name="reply_verb">Répondre</string>
|
||||
<string name="share_verb">Partager</string>
|
||||
<string name="copy_verb">Copier</string>
|
||||
<string name="delete_verb">Supprimer</string>
|
||||
<string name="save_verb">Sauvegarder</string>
|
||||
<string name="edit_verb">Modifier</string>
|
||||
<string name="reveal_verb">Révéler</string>
|
||||
<string name="hide_verb">Cacher</string>
|
||||
<string name="allow_verb">Autoriser</string>
|
||||
<string name="delete_message__question">Supprimer le message \?</string>
|
||||
<string name="for_me_only">Supprimer pour moi</string>
|
||||
<string name="your_chats">Vos chats</string>
|
||||
<string name="notification_preview_mode_message">Texte du message</string>
|
||||
<string name="notification_preview_mode_hidden">Caché</string>
|
||||
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Pour protéger vos informations, activez la fonction SimpleX Lock.
|
||||
\nVous serez invité à confirmer l\'authentification avant que cette fonction ne soit activée.</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">L\'authentification de l\'appareil n\'est pas activée. Vous pouvez activer SimpleX Lock via Paramètres, une fois que vous avez activé l\'authentification de l\'appareil.</string>
|
||||
<string name="database_initialization_error_desc">La base de données ne fonctionne pas correctement. Appuyez ici pour en savoir plus.</string>
|
||||
<string name="ntf_channel_calls">Appels SimpleX Chat</string>
|
||||
<string name="ntf_channel_messages">Messages SimpleX Chat</string>
|
||||
<string name="settings_notifications_mode_title">Service de notification</string>
|
||||
<string name="notifications_mode_periodic">Lancer périodiquement</string>
|
||||
<string name="notifications_mode_off">Exécuter lorsque l’app est ouverte</string>
|
||||
<string name="notifications_mode_service">Toujours activé</string>
|
||||
<string name="failed_to_parse_chat_title">Échec du chargement du chat</string>
|
||||
<string name="failed_to_parse_chats_title">Échec du chargement des chats</string>
|
||||
<string name="contact_developers">Veuillez mettre à jour l’app et contacter les développeurs.</string>
|
||||
<string name="simplex_service_notification_text">Récupération des messages…</string>
|
||||
<string name="settings_notification_preview_title">Aperçu de notification</string>
|
||||
<string name="database_initialization_error_title">Échec d’initialisation de la base de données</string>
|
||||
<string name="enter_passphrase_notification_desc">Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données</string>
|
||||
<string name="simplex_service_notification_title">service <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="this_text_is_available_in_settings">Ce texte est disponible dans les paramètres</string>
|
||||
<string name="group_preview_join_as">rejoindre en tant que %s</string>
|
||||
<string name="group_preview_you_are_invited">vous êtes invité·e au groupe</string>
|
||||
<string name="chat_with_developers">Discuter avec les développeurs</string>
|
||||
<string name="tap_to_start_new_chat">Appuyez pour commencer un nouveau chat</string>
|
||||
<string name="you_have_no_chats">Vous n\'avez aucune discussion</string>
|
||||
<string name="images_limit_title">Trop d’images !</string>
|
||||
<string name="share_file">Partager le fichier…</string>
|
||||
<string name="attach">Attacher</string>
|
||||
<string name="icon_descr_cancel_image_preview">Annuler l’aperçu d’image</string>
|
||||
<string name="icon_descr_cancel_file_preview">Annuler l’aperçu du fichier</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">échec d’envoi</string>
|
||||
<string name="icon_descr_received_msg_status_unread">non lu</string>
|
||||
<string name="welcome">Bienvenue !</string>
|
||||
<string name="contact_connection_pending">connexion…</string>
|
||||
<string name="group_connection_pending">connexion…</string>
|
||||
<string name="share_message">Partager le message…</string>
|
||||
<string name="share_image">Partager l’image…</string>
|
||||
<string name="images_limit_desc">Envoi de 10 images en même temps maximum</string>
|
||||
<string name="personal_welcome">Bienvenue <xliff:g>%1$s</xliff:g> !</string>
|
||||
<string name="notifications_mode_periodic_desc">Vérifie les nouveaux messages toutes les 10 minutes pendant 1 minute au maximum.</string>
|
||||
<string name="notification_preview_mode_contact_desc">Afficher uniquement le contact</string>
|
||||
<string name="for_everybody">Pour tous</string>
|
||||
<string name="icon_descr_sent_msg_status_sent">envoyé</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">envoi non autorisé</string>
|
||||
<string name="icon_descr_context">Icône contextuelle</string>
|
||||
<string name="image_descr">Image</string>
|
||||
<string name="image_decoding_exception_desc">L\'image ne peut pas être décodée. Veuillez essayer une autre image ou contacter les développeurs.</string>
|
||||
<string name="icon_descr_waiting_for_image">En attente de l\'image</string>
|
||||
<string name="icon_descr_asked_to_receive">Demandé à recevoir l\'image</string>
|
||||
<string name="icon_descr_image_snd_complete">Image envoyée</string>
|
||||
<string name="waiting_for_image">En attente de l\'image</string>
|
||||
<string name="image_saved">Image enregistrée dans la phototèque</string>
|
||||
<string name="icon_descr_file">Fichier</string>
|
||||
<string name="large_file">Fichier trop lourd !</string>
|
||||
<string name="file_saved">Fichier sauvegardé</string>
|
||||
<string name="file_not_found">Fichier introuvable</string>
|
||||
<string name="error_saving_file">Erreur lors de la sauvegarde du fichier</string>
|
||||
<string name="delete_contact_question">Supprimer le contact \?</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Le contact et tous les messages seront supprimés - impossible de revenir en arrière !</string>
|
||||
<string name="button_delete_contact">Supprimer le contact</string>
|
||||
<string name="icon_descr_server_status_connected">Connecté</string>
|
||||
<string name="icon_descr_send_message">Envoyer un message</string>
|
||||
<string name="switch_receiving_address_question">Changement d\'adresse de réception \?</string>
|
||||
<string name="icon_descr_record_voice_message">Enregistrer un message vocal</string>
|
||||
<string name="allow_voice_messages_question">Autoriser les messages vocaux \?</string>
|
||||
<string name="you_need_to_allow_to_send_voice">Vous devez autoriser votre contact à envoyer des messages vocaux pour pouvoir en envoyer.</string>
|
||||
<string name="voice_messages_prohibited">Messages vocaux interdits !</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Veuillez demander à votre contact de permettre l\'envoi de messages vocaux.</string>
|
||||
<string name="cancel_verb">Annuler</string>
|
||||
<string name="only_group_owners_can_enable_voice">Seuls les propriétaires de groupes peuvent activer les messages vocaux.</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="no_details">aucun détail</string>
|
||||
<string name="add_contact">Lien d\'invitation unique</string>
|
||||
<string name="copied">Copié dans le presse-papiers</string>
|
||||
<string name="share_one_time_link">Créer un lien d\'invitation unique</string>
|
||||
<string name="add_contact_or_create_group">Commencer une nouvelle discussion</string>
|
||||
<string name="connect_via_link_or_qr">Se connecter via un lien / code QR</string>
|
||||
<string name="scan_QR_code">Scanner un code QR</string>
|
||||
<string name="to_share_with_your_contact">(à partager avec votre contact)</string>
|
||||
<string name="create_group">Créer un groupe secret</string>
|
||||
<string name="from_gallery_button">Depuis la Phototèque</string>
|
||||
<string name="choose_file">Choisir le fichier</string>
|
||||
<string name="to_start_a_new_chat_help_header">Pour démarrer une nouvelle discussion</string>
|
||||
<string name="chat_help_tap_button">Appuyez sur le bouton</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Scanner un code QR</b> : pour vous connecter à votre contact qui vous montre un code QR.</string>
|
||||
<string name="to_connect_via_link_title">Pour se connecter via un lien</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Si vous avez reçu un lien d\'invitation <xliff:g id="appName">SimpleX Chat</xliff:g>, vous pouvez l\'ouvrir dans votre navigateur :</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 bureau : scanner le code QR affiché depuis l\'app, via <b>Scanner le code QR</b>.</string>
|
||||
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 mobile : appuyez sur <b>Ouvrir dans l\'application</b>, puis appuyez sur <b>se connecter</b> dans l\'app.</string>
|
||||
<string name="accept_contact_incognito_button">Accepter en incognito</string>
|
||||
<string name="reject_contact_button">Rejeter</string>
|
||||
<string name="clear_chat_question">Effacer la conversation \?</string>
|
||||
<string name="clear_chat_warning">Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous.</string>
|
||||
<string name="clear_chat_menu_action">Effacer</string>
|
||||
<string name="delete_contact_menu_action">Supprimer</string>
|
||||
<string name="delete_group_menu_action">Supprimer</string>
|
||||
<string name="mark_read">Marquer comme lu</string>
|
||||
<string name="mark_unread">Marquer non lu</string>
|
||||
<string name="set_contact_name">Définir le nom du contact</string>
|
||||
<string name="you_invited_your_contact">Vous avez invité votre contact</string>
|
||||
<string name="you_accepted_connection">Vous avez accepté la connexion</string>
|
||||
<string name="delete_pending_connection__question">Supprimer la connexion en attente \?</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">La connexion que vous avez acceptée sera annulée !</string>
|
||||
<string name="alert_title_contact_connection_pending">Le contact n\'est pas encore connecté !</string>
|
||||
<string name="icon_descr_close_button">Bouton fermer</string>
|
||||
<string name="image_descr_profile_image">image de profil</string>
|
||||
<string name="image_descr_link_preview">image d\'aperçu du lien</string>
|
||||
<string name="icon_descr_cancel_link_preview">annuler l\'aperçu du lien</string>
|
||||
<string name="icon_descr_settings">Paramètres</string>
|
||||
<string name="icon_descr_address">Adresse <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_help">aide</string>
|
||||
<string name="icon_descr_simplex_team">Équipe <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_more_button">Plus</string>
|
||||
<string name="show_QR_code">Afficher le code QR</string>
|
||||
<string name="invalid_QR_code">Code QR invalide</string>
|
||||
<string name="this_QR_code_is_not_a_link">Ce code QR n\'est pas un lien !</string>
|
||||
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard !</string>
|
||||
<string name="you_will_be_connected_when_your_contacts_device_is_online">Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard !</string>
|
||||
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Votre contact peut scanner le code QR depuis l\'app.</string>
|
||||
<string name="your_chat_profile_will_be_sent_to_your_contact">Votre profil de chat sera envoyé
|
||||
\nà votre contact</string>
|
||||
<string name="share_invitation_link">Partager le lien d\'invitation</string>
|
||||
<string name="your_profile_will_be_sent">Votre profil de chat sera envoyé à votre contact</string>
|
||||
<string name="paste_button">Coller</string>
|
||||
<string name="this_string_is_not_a_connection_link">Cette chaîne n\'est pas un lien de connexion !</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">Vous pouvez aussi vous connecter en cliquant sur le lien. Si il s\'ouvre dans le navigateur, cliquez sur <b>Ouvrir dans l\'app mobile</b>.</string>
|
||||
<string name="create_one_time_link">Créer un lien d\'invitation unique</string>
|
||||
<string name="text_field_set_contact_placeholder">Définir le nom du contact…</string>
|
||||
<string name="icon_descr_server_status_disconnected">Déconnecté</string>
|
||||
<string name="icon_descr_server_status_error">Erreur</string>
|
||||
<string name="icon_descr_server_status_pending">En attente</string>
|
||||
<string name="accept_connection_request__question">Accepter la demande de connexion \?</string>
|
||||
<string name="clear_verb">Effacer</string>
|
||||
<string name="clear_chat_button">Effacer la conversation</string>
|
||||
<string name="paste_connection_link_below_to_connect">Collez le lien que vous avez reçu dans le cadre ci-dessous pour vous connecter avec votre contact.</string>
|
||||
<string name="connect_via_link">Se connecter via un lien</string>
|
||||
<string name="clear_verification">Retirer la vérification</string>
|
||||
<string name="one_time_link">Lien d\'invitation unique</string>
|
||||
<string name="your_contact_address">Votre adresse de contact</string>
|
||||
<string name="scan_code">Scanner le code</string>
|
||||
<string name="incorrect_code">Code de sécurité incorrect !</string>
|
||||
<string name="security_code">Code de sécurité</string>
|
||||
<string name="mark_code_verified">Marquer comme vérifié</string>
|
||||
<string name="view_security_code">Afficher le code de sécurité</string>
|
||||
<string name="verify_security_code">Vérifier le code de sécurité</string>
|
||||
<string name="confirm_verb">Confirmer</string>
|
||||
<string name="reset_verb">Réinitialisation</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(scanner ou coller depuis le presse-papiers)</string>
|
||||
<string name="only_stored_on_members_devices">(uniquement stocké par les membres du groupe)</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Votre contact a besoin d\'être en ligne pour completer la connexion.
|
||||
\nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien).</string>
|
||||
<string name="contact_wants_to_connect_with_you">veut établir une connexion !</string>
|
||||
<string name="icon_descr_profile_image_placeholder">image de profil (placeholder)</string>
|
||||
<string name="image_descr_qr_code">Code QR</string>
|
||||
<string name="image_descr_simplex_logo">Logo <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="icon_descr_email">E-mail</string>
|
||||
<string name="connect_button">Se connecter</string>
|
||||
<string name="notifications_mode_off_desc">L\'application peut recevoir des notifications uniquement lorsqu\'elle est en cours d\'exécution, aucun service d\'arrière-plan ne sera lancé.</string>
|
||||
<string name="notifications_mode_service_desc">Le service d\'arrière-plan fonctionne en permanence. Les notifications s\'affichent dès que les messages sont disponibles.</string>
|
||||
<string name="notification_preview_mode_message_desc">Afficher le contact et le message</string>
|
||||
<string name="notification_display_mode_hidden_desc">Masquer le contact et le message</string>
|
||||
<string name="auth_log_in_using_credential">Connectez-vous en utilisant votre identifiant</string>
|
||||
<string name="auth_confirm_credential">Confirmez vos identifiants</string>
|
||||
<string name="auth_stop_chat">Arrêter le chat</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">Le message sera supprimé - impossible de revenir en arrière !</string>
|
||||
<string name="delete_message_mark_deleted_warning">Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message.</string>
|
||||
<string name="icon_descr_edited">modifié</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">L\'image sera reçue quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard !</string>
|
||||
<string name="image_decoding_exception_title">Erreur de décodage</string>
|
||||
<string name="contact_sent_large_file">Votre contact a envoyé un fichier dont la taille est supérieure à la taille maximale actuellement prise en charge (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
|
||||
<string name="waiting_for_file">En attente du fichier</string>
|
||||
<string name="voice_message">Message vocal</string>
|
||||
<string name="toast_permission_denied">Autorisation refusée !</string>
|
||||
<string name="use_camera_button">Utiliser l\'Appareil photo</string>
|
||||
<string name="thank_you_for_installing_simplex">Merci d\'avoir installé <xliff:g id="appNameFull">SimpleX Chat</xliff:g> !</string>
|
||||
<string name="you_can_connect_to_simplex_chat_founder">Vous pouvez <font color="#0088ff">vous connecter aux développeurs de <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour leur poser toutes vos questions et pour recevoir des informations sur les mises à jour</font>.</string>
|
||||
<string name="above_then_preposition_continuation">ci-dessus, puis :</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>Ajouter un nouveau contact</b> : afin de créer un code QR à usage unique pour votre contact.</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e.</string>
|
||||
<string name="accept_contact_button">Accepter</string>
|
||||
<string name="mute_chat">Muet</string>
|
||||
<string name="unmute_chat">Démute</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter !</string>
|
||||
<string name="invalid_contact_link">Lien invalide !</string>
|
||||
<string name="this_link_is_not_a_valid_connection_link">Ce lien n\'est pas un lien de connexion valide !</string>
|
||||
<string name="connection_request_sent">Demande de connexion envoyée !</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">Le fichier sera reçu quand votre contact sera en ligne, merci d\'attendre ou de revenir plus tard !</string>
|
||||
<string name="voice_message_send_text">Message vocal…</string>
|
||||
<string name="maximum_supported_file_size">La taille maximale supportés des fichiers actuellement est de <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="voice_message_with_duration">Message vocal (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||
<string name="notifications">Notifications</string>
|
||||
<string name="switch_receiving_address_desc">Cette fonctionnalité est expérimentale ! Elle ne fonctionnera que si l\'autre client a la version 4.2 installée. Vous devriez voir le message dans la conversation une fois le changement d\'adresse effectué. Vérifiez que vous pouvez toujours recevoir des messages de ce contact (ou membre du groupe).</string>
|
||||
<string name="you_will_be_connected_when_group_host_device_is_online">Vous serez connecté·e au groupe lorsque l\'appareil de l\'hôte sera en ligne, veuillez attendre ou vérifier plus tard !</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Si vous ne pouvez pas vous rencontrer en personne, <b>montrez le code QR lors d\'un appel vidéo</b>, ou partagez le lien.</string>
|
||||
<string name="scan_code_from_contacts_app">Scannez le code de sécurité depuis l\'application de votre contact.</string>
|
||||
<string name="to_verify_compare">Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils.</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Si vous ne pouvez pas vous rencontrer en personne, vous pouvez <b>scanner un code QR lors d\'un appel vidéo</b>, ou votre contact peut partager un lien d\'invitation.</string>
|
||||
<string name="smp_servers_add">Ajouter un serveur…</string>
|
||||
<string name="markdown_in_messages">Markdown dans les messages</string>
|
||||
<string name="smp_servers_preset_add">Ajouter des serveurs prédéfinis</string>
|
||||
<string name="use_simplex_chat_servers__question">Utiliser les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g> \?</string>
|
||||
<string name="smp_servers_delete_server">Supprimer le serveur</string>
|
||||
<string name="ensure_ICE_server_address_are_correct_format_and_unique">Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne.</string>
|
||||
<string name="network_enable_socks_info">Accéder aux serveurs via un proxy SOCKS sur le port 9050 \? Le proxy doit être démarré avant d\'activer cette option.</string>
|
||||
<string name="network_use_onion_hosts">Utiliser les hôtes .onions</string>
|
||||
<string name="network_use_onion_hosts_prefer_desc_in_alert">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
|
||||
<string name="network_use_onion_hosts_required_desc_in_alert">Les hôtes .onion seront nécessaires pour la connexion.</string>
|
||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Vous contrôlez par quel·s serveur·s vous pouvez <b>transmettre</b> ainsi que par quel·s serveur·s vous pouvez <b>recevoir</b> des messages de vos contacts.</string>
|
||||
<string name="your_settings">Vos paramètres</string>
|
||||
<string name="chat_lock">SimpleX Lock</string>
|
||||
<string name="chat_console">Console du chat</string>
|
||||
<string name="smp_servers">Serveurs SMP</string>
|
||||
<string name="smp_servers_test_servers">Tester les serveurs</string>
|
||||
<string name="smp_servers_save">Sauvegarder les serveurs</string>
|
||||
<string name="smp_servers_scan_qr">Scanner le code QR du serveur</string>
|
||||
<string name="smp_servers_use_server">Utiliser ce serveur</string>
|
||||
<string name="smp_servers_use_server_for_new_conn">Utiliser pour les nouvelles connexions</string>
|
||||
<string name="smp_servers_add_to_another_device">Ajouter à un autre appareil</string>
|
||||
<string name="install_simplex_chat_for_terminal">Installer <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour terminal</string>
|
||||
<string name="star_on_github">Star sur GitHub</string>
|
||||
<string name="contribute">Contribuer</string>
|
||||
<string name="rate_the_app">Évaluer l\'app</string>
|
||||
<string name="your_SMP_servers">Vos serveurs SMP</string>
|
||||
<string name="how_to_use_your_servers">Comment utiliser vos serveurs</string>
|
||||
<string name="saved_ICE_servers_will_be_removed">Les serveurs WebRTC ICE sauvegardés seront supprimés.</string>
|
||||
<string name="your_ICE_servers">Vos serveurs ICE</string>
|
||||
<string name="configure_ICE_servers">Configurer les serveurs ICE</string>
|
||||
<string name="network_settings">Paramètres réseau avancés</string>
|
||||
<string name="network_settings_title">Paramètres réseau</string>
|
||||
<string name="network_socks_toggle">Utiliser un proxy SOCKS (port 9050)</string>
|
||||
<string name="network_enable_socks">Utiliser un proxy SOCKS \?</string>
|
||||
<string name="network_disable_socks">Utiliser une connexion Internet directe \?</string>
|
||||
<string name="network_disable_socks_info">Si vous confirmez, les serveurs de messagerie seront en mesure de voir votre adresse IP, votre fournisseur ainsi que les serveurs auxquels vous vous connectez.</string>
|
||||
<string name="network_use_onion_hosts_no">Non</string>
|
||||
<string name="network_use_onion_hosts_required">Requis</string>
|
||||
<string name="network_use_onion_hosts_prefer_desc">Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles.</string>
|
||||
<string name="appearance_settings">Apparence</string>
|
||||
<string name="create_address">Créer une adresse</string>
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Vous pouvez partager votre adresse sous forme de lien ou de code QR - n\'importe qui pourra se connecter à vous. Vous ne perdrez pas vos contacts si vous les supprimez par la suite.</string>
|
||||
<string name="your_chat_profile">Votre profil de chat</string>
|
||||
<string name="edit_image">Modifier l\'image</string>
|
||||
<string name="save_and_notify_contacts">Sauvegarder et notifier les contacts</string>
|
||||
<string name="save_and_notify_group_members">Sauvegarder et en informer les membres du groupe</string>
|
||||
<string name="your_profile_is_stored_on_your_device">Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil.</string>
|
||||
<string name="profile_is_only_shared_with_your_contacts">Le profil n\'est partagé qu\'avec vos contacts.</string>
|
||||
<string name="display_name_cannot_contain_whitespace">Le nom d\'affichage ne peut pas contenir d\'espace.</string>
|
||||
<string name="full_name_optional__prompt">Nom complet (optionnel)</string>
|
||||
<string name="create_profile_button">Créer</string>
|
||||
<string name="about_simplex">À propos de SimpleX</string>
|
||||
<string name="you_can_use_markdown_to_format_messages__prompt">Vous pouvez utiliser le format markdown pour mettre en forme les messages :</string>
|
||||
<string name="bold">gras</string>
|
||||
<string name="italic">italique</string>
|
||||
<string name="strikethrough">barré</string>
|
||||
<string name="callstatus_accepted">appel accepté</string>
|
||||
<string name="callstatus_connecting">connexion à l\'appel…</string>
|
||||
<string name="callstatus_error">erreur d\'appel</string>
|
||||
<string name="callstate_received_answer">réponse reçu…</string>
|
||||
<string name="callstate_received_confirmation">confimation reçu…</string>
|
||||
<string name="callstate_connecting">connexion…</string>
|
||||
<string name="opensource_protocol_and_code_anybody_can_run_servers">Protocole et code open-source – tout le monde peut faire fonctionner les serveurs.</string>
|
||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Pour protéger la vie privée, au lieu d\'ID d\'utilisateur utilisés par toutes les autres plateformes, <xliff:g id="appName">SimpleX</xliff:g> possède des identifiants pour les files d\'attente de messages, distincts pour chacun de vos contacts.</string>
|
||||
<string name="read_more_in_github">Plus d\'informations sur notre GitHub.</string>
|
||||
<string name="paste_the_link_you_received">Coller le lien reçu</string>
|
||||
<string name="use_chat">Utiliser le chat</string>
|
||||
<string name="onboarding_notifications_mode_title">Notifications privées</string>
|
||||
<string name="onboarding_notifications_mode_subtitle">Peut être modifié ultérieurement via les paramètres.</string>
|
||||
<string name="onboarding_notifications_mode_off">Quand l\'application fonctionne</string>
|
||||
<string name="onboarding_notifications_mode_periodic">Périodique</string>
|
||||
<string name="onboarding_notifications_mode_service">Instantanée</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Économie de batterie</b>. Vous recevrez des notifications uniquement lorsque l\'application est en cours d\'exécution, le service de fond ne sera PAS utilisé.</string>
|
||||
<string name="about_simplex_chat">À propos de <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="how_to_use_simplex_chat">Comment l\'utiliser</string>
|
||||
<string name="markdown_help">Aide Markdown</string>
|
||||
<string name="save_servers_button">Sauvegarder</string>
|
||||
<string name="network_and_servers">Réseau et serveurs</string>
|
||||
<string name="save_and_notify_contact">Sauvegarder et en informer les contacts</string>
|
||||
<string name="exit_without_saving">Quitter sans sauvegarder</string>
|
||||
<string name="callstatus_rejected">appel rejeté</string>
|
||||
<string name="callstatus_in_progress">appel en cours</string>
|
||||
<string name="callstatus_ended">appel terminé <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstate_starting">lancement…</string>
|
||||
<string name="is_verified">%s est vérifié·e</string>
|
||||
<string name="is_not_verified">%s n\'est pas vérifié·e</string>
|
||||
<string name="your_simplex_contact_address">Votre adresse de contact <xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="database_passphrase_and_export">Phrase secrète et exportation de la base de données</string>
|
||||
<string name="chat_with_the_founder">Envoyez vos questions et idées</string>
|
||||
<string name="send_us_an_email">Envoyez nous un e-mail</string>
|
||||
<string name="smp_servers_preset_address">Adresse du serveur prédéfinie</string>
|
||||
<string name="smp_servers_test_server">Tester le serveur</string>
|
||||
<string name="smp_servers_test_failed">Échec du test du serveur !</string>
|
||||
<string name="smp_servers_test_some_failed">Certains serveurs n\'ont pas réussi le test :</string>
|
||||
<string name="smp_servers_enter_manually">Entrer un serveur manuellement</string>
|
||||
<string name="smp_servers_preset_server">Serveur prédéfini</string>
|
||||
<string name="smp_servers_your_server">Votre serveur</string>
|
||||
<string name="smp_servers_your_server_address">Votre adresse de serveur</string>
|
||||
<string name="smp_servers_invalid_address">Adresse de serveur invalide !</string>
|
||||
<string name="smp_servers_check_address">Vérifiez l\'adresse du serveur et réessayez.</string>
|
||||
<string name="using_simplex_chat_servers">Utilise les serveurs <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
||||
<string name="how_to">Comment faire</string>
|
||||
<string name="enter_one_ICE_server_per_line">Serveurs ICE (un par ligne)</string>
|
||||
<string name="error_saving_ICE_servers">Erreur lors de la sauvegarde des serveurs ICE</string>
|
||||
<string name="update_onion_hosts_settings_question">Mettre à jour le paramètre des hôtes .onion \?</string>
|
||||
<string name="network_use_onion_hosts_prefer">Quand disponible</string>
|
||||
<string name="network_use_onion_hosts_no_desc">Les hôtes .onion ne seront pas utilisés.</string>
|
||||
<string name="network_use_onion_hosts_required_desc">Les hôtes .onion seront nécessaires pour la connexion.</string>
|
||||
<string name="network_use_onion_hosts_no_desc_in_alert">Les hôtes .onion ne seront pas utilisés.</string>
|
||||
<string name="delete_address__question">Supprimer l\'adresse \?</string>
|
||||
<string name="all_your_contacts_will_remain_connected">Tous vos contacts resteront connectés.</string>
|
||||
<string name="share_link">Partager le lien</string>
|
||||
<string name="delete_address">Supprimer l\'adresse</string>
|
||||
<string name="contact_requests">Demandes de contact</string>
|
||||
<string name="accept_requests">Accepter les demandes</string>
|
||||
<string name="accept_automatically">Automatiquement</string>
|
||||
<string name="section_title_welcome_message">MESSAGE DE BIENVENUE</string>
|
||||
<string name="display_name__field">Nom affiché :</string>
|
||||
<string name="full_name__field">Nom complet :</string>
|
||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts.
|
||||
\n
|
||||
\nLes serveurs <xliff:g id="appName">SimpleX</xliff:g> ne peuvent pas voir votre profil.</string>
|
||||
<string name="delete_image">Supprimer l\'image</string>
|
||||
<string name="save_preferences_question">Sauvegarder les préférences \?</string>
|
||||
<string name="you_control_your_chat">Vous maîtrisez vos discussions !</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité.</string>
|
||||
<string name="we_do_not_store_contacts_or_messages_on_servers">Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs.</string>
|
||||
<string name="create_profile">Créer le profil</string>
|
||||
<string name="display_name">Nom affiché</string>
|
||||
<string name="how_to_use_markdown">Comment utiliser markdown</string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="colored">coloré</string>
|
||||
<string name="secret">secret</string>
|
||||
<string name="callstatus_calling">appel…</string>
|
||||
<string name="callstatus_missed">appel manqué</string>
|
||||
<string name="callstate_waiting_for_answer">en attente de réponse…</string>
|
||||
<string name="callstate_waiting_for_confirmation">en attente de confirmation…</string>
|
||||
<string name="callstate_connected">connecté</string>
|
||||
<string name="callstate_ended">terminé</string>
|
||||
<string name="next_generation_of_private_messaging">La nouvelle génération de messagerie privée</string>
|
||||
<string name="privacy_redefined">La vie privée redéfinie</string>
|
||||
<string name="first_platform_without_user_ids">La 1ère plateforme sans aucun identifiant d\'utilisateur – privée par design.</string>
|
||||
<string name="immune_to_spam_and_abuse">Protégé du spam et des abus</string>
|
||||
<string name="people_can_connect_only_via_links_you_share">Les gens peuvent se connecter à vous uniquement via les liens que vous partagez.</string>
|
||||
<string name="decentralized">Décentralisé</string>
|
||||
<string name="create_your_profile">Créez votre profil</string>
|
||||
<string name="make_private_connection">Établir une connexion privée</string>
|
||||
<string name="how_it_works">Comment ça fonctionne</string>
|
||||
<string name="how_simplex_works">Comment <xliff:g id="appName">SimpleX</xliff:g> fonctionne</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Beaucoup se demande : <i>si <xliff:g id="appName">SimpleX</xliff:g> n\'a pas d\'identifiant d\'utilisateur, comment peut-il transmettre des messages \?</i></string>
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Seuls les appareils clients stockent les profils des utilisateurs, les contacts, les groupes et les messages envoyés avec un <b>chiffrement de bout en bout à deux couches</b>.</string>
|
||||
<string name="read_more_in_github_with_link">Pour en savoir plus, consultez notre <font color="#0088ff">GitHub repository</font>.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Batterie peu utilisée</b>. Le service de fond vérifie les nouveaux messages toutes les 10 minutes. Vous risquez de manquer des appels et des messages urgents.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Batterie plus utilisée </b> ! Le service de fond est toujours en cours d\'exécution - les notifications s\'afficheront dès que les messages seront disponibles.</string>
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> message⸱s manqué⸱s</string>
|
||||
<string name="integrity_msg_bad_id">ID de message incorrecte</string>
|
||||
<string name="settings_section_title_settings">PARAMÈTRES</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">C\'est possible quand :
|
||||
\n1. Les messages expirent du serveur (après 30 jours si ils ne sont pas reçu).
|
||||
\n2. Le serveur que vous utilisez pour recevoir les messages de ce contact a été mise à jour ou redémarré.
|
||||
\n3. La connection est compromise.
|
||||
\nVeuillez vous connecter aux développeurs via les Paramètres pour recevoir les mises à jour concernant les serveurs.
|
||||
\nNous allons ajouter une redondance des serveurs pour éviter la perte de messages.</string>
|
||||
<string name="icon_descr_call_rejected">Appel rejeté</string>
|
||||
<string name="rcv_group_event_member_deleted">a retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="snd_group_event_member_deleted">vous avez retiré <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_invited_via_your_group_link">invité par votre lien de groupe</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">vous avez changé d\'adresse</string>
|
||||
<string name="stop_chat_to_export_import_or_delete_chat_database">Arrêtez le chat pour exporter, importer ou supprimer la base de données du chat. Vous ne pourrez pas recevoir et envoyer de messages pendant que le chat est arrêté.</string>
|
||||
<string name="enable_automatic_deletion_message">Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes.</string>
|
||||
<string name="encrypted_with_random_passphrase">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier.</string>
|
||||
<string name="restore_database">Restaurer la sauvegarde de la base de données</string>
|
||||
<string name="restore_passphrase_not_found_desc">La phrase secrète n\'a pas été trouvée dans le Keystore, veuillez la saisir manuellement. Cela a pu se produire si vous avez restauré les données de l\'app à l\'aide d\'un outil de sauvegarde. Si ce n\'est pas le cas, veuillez contacter les développeurs.</string>
|
||||
<string name="restore_database_alert_desc">Veuillez entrer le mot de passe précédent après avoir restauré la sauvegarde de la base de données. Cette action ne peut pas être annulée.</string>
|
||||
<string name="database_restore_error">Erreur de restauration de la base de données</string>
|
||||
<string name="archive_created_on_ts">Créé le <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||
<string name="encrypted_video_call">appel vidéo (chiffrement de bout en bout)</string>
|
||||
<string name="audio_call_no_encryption">appel audio (sans chiffrement)</string>
|
||||
<string name="encrypted_audio_call">appel audio (chiffrement de bout en bout)</string>
|
||||
<string name="accept">Accepter</string>
|
||||
<string name="reject">Rejeter</string>
|
||||
<string name="icon_descr_video_call">appel vidéo</string>
|
||||
<string name="icon_descr_audio_call">appel audio</string>
|
||||
<string name="accept_call_on_lock_screen">Accepter</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Activer les appels depuis l\'écran verrouillé via les Paramètres.</string>
|
||||
<string name="open_verb">Ouvrir</string>
|
||||
<string name="call_connection_via_relay">via relais</string>
|
||||
<string name="icon_descr_hang_up">Raccrocher</string>
|
||||
<string name="icon_descr_video_on">Vidéo ON</string>
|
||||
<string name="icon_descr_video_off">Vidéo OFF</string>
|
||||
<string name="icon_descr_call_progress">Appel en cours</string>
|
||||
<string name="icon_descr_call_ended">Appel terminé</string>
|
||||
<string name="your_privacy">Votre vie privée</string>
|
||||
<string name="settings_section_title_device">APPAREIL</string>
|
||||
<string name="settings_section_title_chats">CHATS</string>
|
||||
<string name="settings_developer_tools">Outils du développeur</string>
|
||||
<string name="settings_section_title_icon">ICONE DE L\'APP</string>
|
||||
<string name="your_chat_database">Votre base de données de chat</string>
|
||||
<string name="run_chat_section">LANCER LE CHAT</string>
|
||||
<string name="stop_chat_question">Arrêter le chat \?</string>
|
||||
<string name="restart_the_app_to_use_imported_chat_database">Redémarrez l\'application pour utiliser la base de données de chat importée.</string>
|
||||
<string name="data_section">DONNÉES</string>
|
||||
<string name="chat_item_ttl_day">1 jour</string>
|
||||
<string name="delete_messages">Supprimer les messages</string>
|
||||
<string name="save_passphrase_in_keychain">Sauvegarder la phrase secrète dans le keystore</string>
|
||||
<string name="database_encrypted">Base de données chiffrée !</string>
|
||||
<string name="error_encrypting_database">Erreur lors du chiffrement de la base de données</string>
|
||||
<string name="update_database">Mise à jour</string>
|
||||
<string name="encrypt_database">Chiffrer</string>
|
||||
<string name="enter_correct_current_passphrase">Veuillez entrer la phrase secrète actuelle correcte.</string>
|
||||
<string name="database_is_not_encrypted">Votre base de données de chat n\'est pas chiffrée - définissez une phrase secrète pour la protéger.</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Veuillez noter</b> : vous NE pourrez PAS récupérer ou modifier la phrase secrète si vous la perdez.</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">Le Keystore d\'Android sera utilisé pour stocker en toute sécurité la phrase secrète après sa modification ou redémarrage de l\'app - cela permettra de recevoir les notifications.</string>
|
||||
<string name="store_passphrase_securely_without_recover">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez.</string>
|
||||
<string name="passphrase_is_different">La phrase secrète de la base de données est différente de celle enregistrée dans le Keystore.</string>
|
||||
<string name="unknown_error">Erreur inconnue</string>
|
||||
<string name="enter_correct_passphrase">Entrez la phrase secrète correcte.</string>
|
||||
<string name="alert_message_no_group">Ce groupe n\'existe plus.</string>
|
||||
<string name="you_joined_this_group">Vous avez rejoint ce groupe</string>
|
||||
<string name="you_rejected_group_invitation">Vous avez rejeté l\'invitation du groupe</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed_for_member">vous avez changé d\'adresse pour %s</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
|
||||
<string name="incoming_video_call">Appel vidéo entrant</string>
|
||||
<string name="video_call_no_encryption">appel vidéo (sans chiffrement)</string>
|
||||
<string name="ignore">Ignorer</string>
|
||||
<string name="call_already_ended">Appel déjà terminé !</string>
|
||||
<string name="settings_audio_video_calls">Appels audio et vidéo</string>
|
||||
<string name="status_e2e_encrypted">chiffré de bout en bout</string>
|
||||
<string name="settings_section_title_develop">DEVELOPPER</string>
|
||||
<string name="settings_experimental_features">Fonctionnalités expérimentales</string>
|
||||
<string name="settings_section_title_socks">SOCKS PROXY</string>
|
||||
<string name="settings_section_title_themes">THEMES</string>
|
||||
<string name="settings_section_title_messages">MESSAGES</string>
|
||||
<string name="settings_section_title_calls">APPELS</string>
|
||||
<string name="import_database">Importer la base de données</string>
|
||||
<string name="new_database_archive">Nouvelle archive de base de données</string>
|
||||
<string name="old_database_archive">Archives de l\'ancienne base de données</string>
|
||||
<string name="delete_database">Supprimer la base de données</string>
|
||||
<string name="error_starting_chat">Erreur lors du démarrage du chat</string>
|
||||
<string name="import_database_confirmation">Importer</string>
|
||||
<string name="delete_chat_profile_action_cannot_be_undone_warning">Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus.</string>
|
||||
<string name="chat_database_deleted">Base de données du chat supprimée</string>
|
||||
<string name="restart_the_app_to_create_a_new_chat_profile">Redémarrez l\'application pour créer un nouveau profil de chat.</string>
|
||||
<string name="you_must_use_the_most_recent_version_of_database">Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts.</string>
|
||||
<string name="total_files_count_and_size">%d fichier·s avec une taille totale de %s</string>
|
||||
<string name="chat_item_ttl_none">jamais</string>
|
||||
<string name="chat_item_ttl_week">1 semaine</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">La base de données sera chiffrée et la phrase secrète sera stockée dans le Keystore.</string>
|
||||
<string name="database_encryption_will_be_updated">La phrase secrète de la base de données sera mise à jour et stockée dans le Keystore.</string>
|
||||
<string name="database_passphrase_will_be_updated">La phrase secrète de la base de données sera mise à jour.</string>
|
||||
<string name="store_passphrase_securely">Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez.</string>
|
||||
<string name="wrong_passphrase">Mauvaise phrase secrète pour la base de données</string>
|
||||
<string name="encrypted_database">Base de données chiffrée</string>
|
||||
<string name="database_error">Erreur de base de données</string>
|
||||
<string name="error_with_info">Erreur : %s</string>
|
||||
<string name="cannot_access_keychain">Impossible d\'accéder au Keystore pour enregistrer le mot de passe de la base de données</string>
|
||||
<string name="unknown_database_error_with_info">Erreur de base de données inconnue : %s</string>
|
||||
<string name="wrong_passphrase_title">Mauvaise phrase secrète !</string>
|
||||
<string name="leave_group_question">Quitter le groupe \?</string>
|
||||
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Vous ne recevrez plus de messages de ce groupe. L\'historique du chat sera conservé.</string>
|
||||
<string name="icon_descr_add_members">Inviter des membres</string>
|
||||
<string name="icon_descr_group_inactive">Groupe inactif</string>
|
||||
<string name="alert_title_group_invitation_expired">Invitation expirée !</string>
|
||||
<string name="alert_message_group_invitation_expired">L\'invitation du groupe n\'est plus valide, elle a été supprimé par l\'expéditeur.</string>
|
||||
<string name="alert_title_no_group">Groupe introuvable !</string>
|
||||
<string name="alert_title_cant_invite_contacts">Impossible d\'inviter les contacts !</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">Vous utilisez un profil incognito pour ce groupe - pour éviter de partager votre profil principal ; inviter des contacts n\'est pas possible</string>
|
||||
<string name="you_sent_group_invitation">Vous avez envoyé une invitation de groupe</string>
|
||||
<string name="rcv_group_event_member_left">a quitté</string>
|
||||
<string name="icon_descr_speaker_on">Haut-parleur ON</string>
|
||||
<string name="send_link_previews">Envoi d\'aperçus de liens</string>
|
||||
<string name="error_deleting_database">Erreur lors de la suppression de la base de données du chat</string>
|
||||
<string name="error_stopping_chat">Erreur lors de l\'arrêt du chat</string>
|
||||
<string name="error_exporting_chat_database">Erreur lors de l\'exportation de la base de données du chat</string>
|
||||
<string name="import_database_question">Importer la base de données du chat \?</string>
|
||||
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Votre base de données de chat actuelle sera SUPPRIMÉE et REMPLACÉE par celle qui a été importée.
|
||||
\nCette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irrémédiablement perdus.</string>
|
||||
<string name="enter_passphrase">Entrez la phrase secrète…</string>
|
||||
<string name="incoming_audio_call">Appel audio entrant</string>
|
||||
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g> veut se connecter à vous via</string>
|
||||
<string name="your_calls">Vos appels</string>
|
||||
<string name="connect_calls_via_relay">Se connecter via relais</string>
|
||||
<string name="call_on_lock_screen">Appels en écran verrouillé :</string>
|
||||
<string name="show_call_on_lock_screen">Montrer</string>
|
||||
<string name="no_call_on_lock_screen">Désactiver</string>
|
||||
<string name="your_ice_servers">Vos serveurs ICE</string>
|
||||
<string name="webrtc_ice_servers">Serveurs WebRTC ICE</string>
|
||||
<string name="open_simplex_chat_to_accept_call">Ouvrez <xliff:g id="appNameFull">SimpleX Chat</xliff:g> pour décrocher</string>
|
||||
<string name="status_no_e2e_encryption">sans chiffrement de bout en bout</string>
|
||||
<string name="status_contact_has_e2e_encryption">Ce contact a le chiffrement de bout en bout</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">Ce contact n\'a pas le chiffrement de bout en bout</string>
|
||||
<string name="call_connection_peer_to_peer">pair-à-pair</string>
|
||||
<string name="icon_descr_audio_off">Audio OFF</string>
|
||||
<string name="icon_descr_audio_on">Audio ON</string>
|
||||
<string name="icon_descr_speaker_off">Haut-parleur OFF</string>
|
||||
<string name="icon_descr_flip_camera">Retourner la caméra</string>
|
||||
<string name="icon_descr_call_pending_sent">Appel en suspend</string>
|
||||
<string name="icon_descr_call_missed">Appel manqué</string>
|
||||
<string name="icon_descr_call_connecting">Appel en connexion</string>
|
||||
<string name="answer_call">Répondre à l\'appel</string>
|
||||
<string name="integrity_msg_bad_hash">hash de message incorrect</string>
|
||||
<string name="integrity_msg_duplicate">message dupliqué</string>
|
||||
<string name="alert_title_skipped_messages">Messages manqués</string>
|
||||
<string name="privacy_and_security">Vie privée et sécurité</string>
|
||||
<string name="protect_app_screen">Protéger l\'écran de l\'app</string>
|
||||
<string name="auto_accept_images">Images auto-acceptées</string>
|
||||
<string name="transfer_images_faster">Transfert d\'images plus rapide</string>
|
||||
<string name="full_backup">Sauvegarde des données de l\'app</string>
|
||||
<string name="settings_section_title_you">VOUS</string>
|
||||
<string name="settings_section_title_help">AIDE</string>
|
||||
<string name="settings_section_title_support">SOUTENEZ SIMPLEX CHAT</string>
|
||||
<string name="settings_section_title_incognito">Mode Incognito</string>
|
||||
<string name="chat_is_running">Le chat est en cours d\'exécution</string>
|
||||
<string name="chat_is_stopped">Le chat est arrêté</string>
|
||||
<string name="chat_database_section">BASE DE DONNÉES DU CHAT</string>
|
||||
<string name="database_passphrase">Phrase secrète de la base de données</string>
|
||||
<string name="export_database">Exporter la base de données</string>
|
||||
<string name="stop_chat_confirmation">Arrêter</string>
|
||||
<string name="set_password_to_export">Définir la phrase secrète pour l\'export</string>
|
||||
<string name="set_password_to_export_desc">La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire. Veuillez la changer avant d\'exporter.</string>
|
||||
<string name="error_importing_database">Erreur lors de l\'importation de la base de données du chat</string>
|
||||
<string name="chat_database_imported">Base de données du chat importée</string>
|
||||
<string name="delete_chat_profile_question">Supprimer le profil du chat \?</string>
|
||||
<string name="stop_chat_to_enable_database_actions">Arrêter le chat pour agir sur la base de données.</string>
|
||||
<string name="delete_files_and_media_question">Supprimer les fichiers et médias \?</string>
|
||||
<string name="delete_files_and_media">"Supprimer les fichiers médias"</string>
|
||||
<string name="delete_files_and_media_desc">Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées.</string>
|
||||
<string name="no_received_app_files">Aucun fichier reçu ou envoyé</string>
|
||||
<string name="chat_item_ttl_month">1 mois</string>
|
||||
<string name="chat_item_ttl_seconds">%s seconde·s</string>
|
||||
<string name="delete_messages_after">Supprimer les messages après</string>
|
||||
<string name="enable_automatic_deletion_question">Activer la suppression automatique des messages \?</string>
|
||||
<string name="error_changing_message_deletion">Erreur de changement de paramètre</string>
|
||||
<string name="remove_passphrase_from_keychain">Retirer la phrase secrète du Keystore \?</string>
|
||||
<string name="notifications_will_be_hidden">Les notifications seront délivrées jusqu\'à ce que l\'application s\'arrête !</string>
|
||||
<string name="remove_passphrase">Supprimer</string>
|
||||
<string name="current_passphrase">Phrase secrète actuelle…</string>
|
||||
<string name="new_passphrase">Nouvelle phrase secrète…</string>
|
||||
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
|
||||
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
|
||||
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
|
||||
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
|
||||
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
|
||||
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
|
||||
<string name="database_will_be_encrypted">La base de données sera chiffrée.</string>
|
||||
<string name="keychain_error">Erreur de la keychain</string>
|
||||
<string name="file_with_path">Fichier : %s</string>
|
||||
<string name="database_passphrase_is_required">La phrase secrète de la base de données est nécessaire pour ouvrir le chat.</string>
|
||||
<string name="save_passphrase_and_open_chat">Sauvegarder la phrase secrète et ouvrir le chat</string>
|
||||
<string name="open_chat">Ouvrir le chat</string>
|
||||
<string name="database_backup_can_be_restored">La tentative de modification de la phrase secrète de la base de données n\'a pas abouti.</string>
|
||||
<string name="restore_database_alert_title">Restaurer la sauvegarde de la base de données \?</string>
|
||||
<string name="restore_database_alert_confirm">Restaurer</string>
|
||||
<string name="chat_is_stopped_indication">Le chat est arrêté</string>
|
||||
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Vous pouvez lancer le chat via les Paramètres / la Base de données de l\'app ou en la redémarrant.</string>
|
||||
<string name="chat_archive_header">Archives du chat</string>
|
||||
<string name="chat_archive_section">ARCHIVE DU CHAT</string>
|
||||
<string name="save_archive">Sauvegarder l\'archive</string>
|
||||
<string name="delete_archive">Supprimer l\'archive</string>
|
||||
<string name="delete_chat_archive_question">Supprimer l\'archive du chat \?</string>
|
||||
<string name="group_invitation_item_description">Invitation au groupe <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="join_group_question">Rejoindre le groupe \?</string>
|
||||
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Vous êtes invité·e dans un groupe. Rejoignez le pour vous connecter avec ses membres.</string>
|
||||
<string name="join_group_button">Rejoindre</string>
|
||||
<string name="join_group_incognito_button">Rejoindre en incognito</string>
|
||||
<string name="joining_group">Entrain de rejoindre le groupe</string>
|
||||
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Vous avez rejoint ce groupe. Connexion à l\'invitation d\'un membre du groupe.</string>
|
||||
<string name="leave_group_button">Quitter</string>
|
||||
<string name="you_are_invited_to_group">Vous êtes invité·e au groupe</string>
|
||||
<string name="group_invitation_tap_to_join">Appuyez pour rejoindre</string>
|
||||
<string name="group_invitation_tap_to_join_incognito">Appuyez pour rejoindre incognito</string>
|
||||
<string name="group_invitation_expired">Invitation au groupe expirée</string>
|
||||
<string name="rcv_group_event_member_added">a invité <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
|
||||
<string name="rcv_group_event_member_connected">est connecté·e</string>
|
||||
<string name="rcv_group_event_changed_member_role">a modifié le rôle de %s pour %s</string>
|
||||
<string name="rcv_group_event_changed_your_role">a modifié votre rôle pour %s</string>
|
||||
<string name="rcv_group_event_user_deleted">vous a retiré</string>
|
||||
<string name="rcv_group_event_group_deleted">a supprimé le groupe</string>
|
||||
<string name="rcv_group_event_updated_group_profile">mise à jour du profil de groupe</string>
|
||||
<string name="snd_group_event_changed_member_role">vous avez modifié le rôle de %s pour %s</string>
|
||||
<string name="snd_group_event_changed_role_for_yourself">vous avez modifié votre rôle pour %s</string>
|
||||
<string name="snd_group_event_user_left">vous avez quitté</string>
|
||||
<string name="snd_group_event_group_profile_updated">mise à jour du profil de groupe</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">adresse modifiée pour vous</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changement d\'adresse pour %s…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">changement d\'adresse…</string>
|
||||
<string name="group_member_role_member">membre</string>
|
||||
<string name="group_member_role_admin">admin</string>
|
||||
<string name="group_member_role_owner">propriétaire</string>
|
||||
<string name="group_member_status_removed">supprimé</string>
|
||||
<string name="group_member_status_left">a quitté</string>
|
||||
<string name="group_member_status_group_deleted">groupe supprimé</string>
|
||||
<string name="group_member_status_invited">invité·e</string>
|
||||
<string name="group_member_status_introduced">connexion (introduite)</string>
|
||||
<string name="group_member_status_intro_invitation">connexion (introduite par invitation)</string>
|
||||
<string name="group_member_status_accepted">connexion (acceptée)</string>
|
||||
<string name="group_member_status_announced">connexion (annoncée)</string>
|
||||
<string name="group_member_status_connected">connecté</string>
|
||||
<string name="group_member_status_complete">complet</string>
|
||||
<string name="group_member_status_creator">créateur</string>
|
||||
<string name="group_member_status_connecting">connexion</string>
|
||||
<string name="no_contacts_to_add">Aucun contact à ajouter</string>
|
||||
<string name="new_member_role">Nouveau rôle</string>
|
||||
<string name="delete_group_question">Supprimer le groupe \?</string>
|
||||
<string name="group_link">Lien du groupe</string>
|
||||
<string name="button_create_group_link">Créer un lien</string>
|
||||
<string name="button_edit_group_profile">Modifier le profil du groupe</string>
|
||||
<string name="remove_member_confirmation">Supprimer</string>
|
||||
<string name="member_info_section_title_member">MEMBRE</string>
|
||||
<string name="live_message">Message dynamique !</string>
|
||||
<string name="send_live_message">Envoyer un message dynamique</string>
|
||||
<string name="send_live_message_desc">Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez</string>
|
||||
<string name="send_verb">Envoyer</string>
|
||||
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour «%s». Le membre va recevoir une nouvelle invitation.</string>
|
||||
<string name="live">LIVE</string>
|
||||
<string name="button_add_members">Inviter des membres</string>
|
||||
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Vous pouvez partager un lien ou un code QR - n\'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite.</string>
|
||||
<string name="info_row_local_name">Nom local</string>
|
||||
<string name="create_group_link">Créer un lien de groupe</string>
|
||||
<string name="error_deleting_link_for_group">Erreur lors de la suppression du lien du groupe</string>
|
||||
<string name="error_creating_link_for_group">Erreur lors de la création du lien du groupe</string>
|
||||
<string name="only_group_owners_can_change_prefs">Seuls les propriétaires du groupe peuvent modifier les préférences du groupe.</string>
|
||||
<string name="section_title_for_console">POUR TERMINAL</string>
|
||||
<string name="change_member_role_question">Changer le rôle du groupe \?</string>
|
||||
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
|
||||
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
|
||||
<string name="clear_contacts_selection_button">Effacer</string>
|
||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact·s sélectionné·e·s</string>
|
||||
<string name="skip_inviting_button">Passer l’invitation de membres</string>
|
||||
<string name="select_contacts">Sélectionnez des contacts</string>
|
||||
<string name="no_contacts_selected">Aucun contact sélectionné</string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBRES</string>
|
||||
<string name="group_info_member_you">vous : <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
<string name="button_delete_group">Supprimer le groupe</string>
|
||||
<string name="delete_group_for_all_members_cannot_undo_warning">Le groupe va être supprimé pour tout les membres - impossible de revenir en arrière !</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">Le groupe va être supprimé pour vous - impossible de revenir en arrière !</string>
|
||||
<string name="button_leave_group">Quitter le groupe</string>
|
||||
<string name="delete_link_question">Supprimer le lien \?</string>
|
||||
<string name="delete_link">Supprimer le lien</string>
|
||||
<string name="all_group_members_will_remain_connected">Tous les membres du groupe resteront connectés.</string>
|
||||
<string name="icon_descr_expand_role">Étendre la sélection de rôle</string>
|
||||
<string name="invite_to_group_button">Inviter au groupe</string>
|
||||
<string name="invite_prohibited">Impossible d\'inviter le contact !</string>
|
||||
<string name="invite_prohibited_description">Vous essayez d\'inviter un contact avec lequel vous avez partagé un profil incognito à rejoindre le groupe dans lequel vous utilisez votre profil principal</string>
|
||||
<string name="info_row_database_id">ID de base de données</string>
|
||||
<string name="button_remove_member">Retirer le membre</string>
|
||||
<string name="button_send_direct_message">Envoi de message direct</string>
|
||||
<string name="member_will_be_removed_from_group_cannot_be_undone">Ce membre sera retiré du groupe - impossible de revenir en arrière !</string>
|
||||
<string name="role_in_group">Rôle</string>
|
||||
<string name="change_role">Changer le rôle</string>
|
||||
<string name="change_verb">Changer</string>
|
||||
<string name="switch_verb">Échanger</string>
|
||||
<string name="error_removing_member">Erreur lors de la suppression d\'un membre</string>
|
||||
<string name="error_changing_role">Erreur lors du changement de rôle</string>
|
||||
<string name="group_full_name_field">Nom complet du groupe :</string>
|
||||
<string name="update_network_settings_confirmation">Mise à jour</string>
|
||||
<string name="chat_preferences_on">on</string>
|
||||
<string name="chat_preferences_off">off</string>
|
||||
<string name="direct_messages">Messages dynamiques</string>
|
||||
<string name="full_deletion">Supprimer pour tous</string>
|
||||
<string name="only_you_can_delete_messages">Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer pour suppression).</string>
|
||||
<string name="conn_stats_section_title_servers">SERVEURS</string>
|
||||
<string name="receiving_via">Réception via</string>
|
||||
<string name="theme_system">Système</string>
|
||||
<string name="allow_direct_messages">Autoriser l\'envoi de messages directs aux membres.</string>
|
||||
<string name="prohibit_direct_messages">Interdire l\'envoi de messages directs aux membres.</string>
|
||||
<string name="group_members_can_delete">Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">La suppression irréversible de messages est interdite dans ce groupe.</string>
|
||||
<string name="sending_via">Envoyé via</string>
|
||||
<string name="network_status">État du réseau</string>
|
||||
<string name="switch_receiving_address">Changer d\'adresse de réception</string>
|
||||
<string name="create_secret_group_title">Créer un groupe secret</string>
|
||||
<string name="group_main_profile_sent">Votre profil de chat sera envoyé aux membres du groupe</string>
|
||||
<string name="network_option_enable_tcp_keep_alive">Activer le TCP keep-alive</string>
|
||||
<string name="network_options_save">Sauvegarder</string>
|
||||
<string name="update_network_settings_question">Mettre à jour les paramètres réseau \?</string>
|
||||
<string name="incognito">Incognito</string>
|
||||
<string name="incognito_random_profile">Votre profil aléatoire</string>
|
||||
<string name="incognito_random_profile_description">Un profil aléatoire sera envoyé à votre contact</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Un profil aléatoire sera envoyé au contact qui vous a envoyé ce lien</string>
|
||||
<string name="incognito_info_allows">Cela permet d\'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil.</string>
|
||||
<string name="incognito_info_find">Pour trouver le profil utilisé lors d\'une connexion incognito, appuyez sur le nom du contact ou du groupe en haut du chat.</string>
|
||||
<string name="theme_light">Clair</string>
|
||||
<string name="theme_dark">Sombre</string>
|
||||
<string name="theme">Thème</string>
|
||||
<string name="save_color">Sauvegarder la couleur</string>
|
||||
<string name="reset_color">Réinitialisation des couleurs</string>
|
||||
<string name="color_primary">Principale</string>
|
||||
<string name="chat_preferences_you_allow">Vous autorisez</string>
|
||||
<string name="chat_preferences_contact_allows">Votre contact autorise</string>
|
||||
<string name="chat_preferences_default">par défaut (%s)</string>
|
||||
<string name="chat_preferences_no">non</string>
|
||||
<string name="chat_preferences_always">toujours</string>
|
||||
<string name="chat_preferences">Préférences de chat</string>
|
||||
<string name="contact_preferences">Préférences de contact</string>
|
||||
<string name="group_preferences">Préférences du groupe</string>
|
||||
<string name="set_group_preferences">Définir les préférences du groupe</string>
|
||||
<string name="your_preferences">Vos préférences</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Autorise votre contact à supprimer de façon définitive des messages envoyés.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Vos contacts peuvent marquer les messages pour les supprimer ; vous pourrez les consulter.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Autorise vos contacts à envoyer des messages vocaux.</string>
|
||||
<string name="allow_voice_messages_only_if">Autoriser les messages vocaux uniquement si votre contact les autorise.</string>
|
||||
<string name="prohibit_sending_voice_messages">Interdire l\'envoi de messages vocaux.</string>
|
||||
<string name="only_you_can_send_disappearing">Seulement vous pouvez envoyer des messages éphémères.</string>
|
||||
<string name="only_you_can_send_voice">Vous seul pouvez envoyer des messages vocaux.</string>
|
||||
<string name="allow_to_delete_messages">Autoriser la suppression irréversible de messages envoyés.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Les messages éphémères sont interdits dans ce groupe.</string>
|
||||
<string name="group_members_can_send_voice">Les membres du groupe peuvent envoyer des messages vocaux.</string>
|
||||
<string name="delete_after">Supprimer après</string>
|
||||
<string name="ttl_sec">%d sec</string>
|
||||
<string name="ttl_s">%ds</string>
|
||||
<string name="ttl_min">%d min</string>
|
||||
<string name="ttl_month">%d mois</string>
|
||||
<string name="ttl_months">%d mois</string>
|
||||
<string name="ttl_m">%dm</string>
|
||||
<string name="ttl_mth">%dm</string>
|
||||
<string name="ttl_hour">%d heure</string>
|
||||
<string name="ttl_hours">%d heures</string>
|
||||
<string name="ttl_h">%dh</string>
|
||||
<string name="ttl_day">%d jour</string>
|
||||
<string name="ttl_days">%d jours</string>
|
||||
<string name="ttl_d">%dj</string>
|
||||
<string name="ttl_week">%d semaine</string>
|
||||
<string name="ttl_weeks">%d semaines</string>
|
||||
<string name="ttl_w">%dsmn</string>
|
||||
<string name="timed_messages">Messages éphémères</string>
|
||||
<string name="voice_messages">Messages vocaux</string>
|
||||
<string name="feature_enabled">activé</string>
|
||||
<string name="feature_enabled_for_you">activé pour vous</string>
|
||||
<string name="feature_enabled_for_contact">activé pour le contact</string>
|
||||
<string name="feature_off">off</string>
|
||||
<string name="feature_received_prohibited">reçu, non autorisé</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Autorise votre contact à envoyer des messages éphémères.</string>
|
||||
<string name="conn_level_desc_direct">directe</string>
|
||||
<string name="group_is_decentralized">Le groupe est entièrement décentralisé – il n\'est visible que par ses membres.</string>
|
||||
<string name="group_members_can_send_disappearing">Les membres du groupes peuvent envoyer des messages éphémères.</string>
|
||||
<string name="network_options_revert">Revenir en arrière</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Interdit l’envoi de messages éphémères.</string>
|
||||
<string name="incognito_info_protects">Le mode Incognito protège la confidentialité de votre profil principal — pour chaque nouveau contact un nouveau profil aléatoire est créé.</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">La mise à jour des ces paramètres reconnectera le client à tous les serveurs.</string>
|
||||
<string name="incognito_info_share">Lorsque vous partagez un profil incognito avec quelqu\'un, ce profil sera utilisé pour les groupes auxquels il vous invite.</string>
|
||||
<string name="chat_preferences_yes">oui</string>
|
||||
<string name="allow_disappearing_messages_only_if">Autorise les messages éphémères seulement si votre contact les autorises.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Autoriser la suppression irréversible des messages uniquement si votre contact vous l\'autorise.</string>
|
||||
<string name="only_your_contact_can_delete">Seul votre contact peut supprimer de manière irréversible des messages (vous pouvez les marquer pour suppression).</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Seulement votre contact peut envoyer des messages éphémères.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Vous et votre contact peuvent envoyer des messages éphémères.</string>
|
||||
<string name="voice_messages_are_prohibited">Les messages vocaux sont interdits dans ce groupe.</string>
|
||||
<string name="group_display_name_field">Nom affiché du groupe :</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">Le mode Incognito n\'est pas supporté ici - votre profil principal sera envoyé aux membres du groupe</string>
|
||||
<string name="conn_level_desc_indirect">indirecte (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
<string name="info_row_group">Groupe</string>
|
||||
<string name="info_row_connection">Connexion</string>
|
||||
<string name="network_option_seconds_label">sec</string>
|
||||
<string name="network_option_tcp_connection_timeout">Délai de connexion TCP</string>
|
||||
<string name="group_profile_is_stored_on_members_devices">Le profil du groupe est stocké sur les appareils des membres, pas sur les serveurs.</string>
|
||||
<string name="save_group_profile">Sauvegarder le profil du groupe</string>
|
||||
<string name="error_saving_group_profile">Erreur lors de la sauvegarde du profil de groupe</string>
|
||||
<string name="network_options_reset_to_defaults">Réinitialisation des valeurs par défaut</string>
|
||||
<string name="network_option_protocol_timeout">Délai du protocole</string>
|
||||
<string name="network_option_ping_interval">Intervalle de PING</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
|
||||
<string name="message_deletion_prohibited">La suppression irréversible de message est interdite dans ce chat.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Vous et votre contact pouvez tous deux supprimer de manière irréversible les messages envoyés.</string>
|
||||
<string name="only_your_contact_can_send_voice">Seul votre contact peut envoyer des messages vocaux.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Les messages vocaux sont interdits dans ce chat.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Les messages éphémères sont interdits dans cette discussion.</string>
|
||||
<string name="allow_to_send_voice">Autoriser l\'envoi de messages vocaux.</string>
|
||||
<string name="prohibit_sending_voice">Interdire l\'envoi de messages vocaux.</string>
|
||||
<string name="allow_to_send_disappearing">Autorise l’envoi de messages éphémères.</string>
|
||||
<string name="prohibit_sending_disappearing">Interdit l’envoi de messages éphémères.</string>
|
||||
<string name="prohibit_message_deletion">Interdire la suppression irréversible des messages.</string>
|
||||
<string name="group_members_can_send_dms">Les membres du groupe peuvent envoyer des messages directs.</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Les messages directs entre membres sont interdits dans ce groupe.</string>
|
||||
</resources>
|
||||
@@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
|
||||
<string name="thousand_abbreviation">т</string>
|
||||
|
||||
<!-- Connect via Link - MainActivity.kt -->
|
||||
<string name="connect_via_contact_link">Соединиться через ссылку-контакт?</string>
|
||||
<string name="connect_via_invitation_link">Соединиться через ссылку-приглашение?</string>
|
||||
@@ -10,7 +9,6 @@
|
||||
<string name="profile_will_be_sent_to_contact_sending_link">Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку.</string>
|
||||
<string name="you_will_join_group">Вы вступите в группу, на которую ссылается эта ссылка.</string>
|
||||
<string name="connect_via_link_verb">Соединиться</string>
|
||||
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
<string name="server_connected">соединено</string>
|
||||
<string name="server_error">ошибка</string>
|
||||
@@ -18,7 +16,6 @@
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
|
||||
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
|
||||
|
||||
<!-- Item Content - ChatModel.kt -->
|
||||
<string name="deleted_description">удалено</string>
|
||||
<string name="marked_deleted_description">помечено к удалению</string>
|
||||
@@ -27,7 +24,6 @@
|
||||
<string name="sender_you_pronoun">вы</string>
|
||||
<string name="unknown_message_format">неизвестный формат сообщения</string>
|
||||
<string name="invalid_message_format">неверный формат сообщения</string>
|
||||
|
||||
<!-- PendingContactConnection - ChatModel.kt -->
|
||||
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="display_name_connection_established">соединение установлено</string>
|
||||
@@ -41,7 +37,6 @@
|
||||
<string name="description_via_contact_address_link_incognito">инкогнито через ссылку-контакт</string>
|
||||
<string name="description_via_one_time_link">через одноразовую ссылку</string>
|
||||
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
|
||||
|
||||
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||
<string name="simplex_link_contact">SimpleX ссылка-контакт</string>
|
||||
<string name="simplex_link_invitation">SimpleX одноразовая ссылка</string>
|
||||
@@ -52,12 +47,10 @@
|
||||
<string name="simplex_link_mode_full">Полная ссылка</string>
|
||||
<string name="simplex_link_mode_browser">В браузере</string>
|
||||
<string name="simplex_link_mode_browser_warning">Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными.</string>
|
||||
|
||||
<!-- SimpleXAPI.kt -->
|
||||
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
|
||||
<string name="error_setting_network_config">Ошибка при сохранении настроек сети</string>
|
||||
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Превышено время соединения</string>
|
||||
<string name="connection_error">Ошибка соединения</string>
|
||||
@@ -90,7 +83,6 @@
|
||||
<string name="smp_server_test_secure_queue">Защита очереди</string>
|
||||
<string name="smp_server_test_delete_queue">Удаление очереди</string>
|
||||
<string name="smp_server_test_disconnect">Разрыв соединения</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||
<string name="service_notifications">Мгновенные уведомления!</string>
|
||||
@@ -106,16 +98,13 @@
|
||||
<string name="enter_passphrase_notification_desc">Для получения уведомлений, пожалуйста, введите пароль от базы данных</string>
|
||||
<string name="database_initialization_error_title">Ошибка базы данных</string>
|
||||
<string name="database_initialization_error_desc">Ошибка при инициализации базы данных. Нажмите чтобы узнать больше</string>
|
||||
|
||||
<!-- SimpleX Chat foreground Service -->
|
||||
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
|
||||
<string name="simplex_service_notification_text">Приём сообщений…</string>
|
||||
<string name="hide_notification">Скрыть</string>
|
||||
|
||||
<!-- Notification channels -->
|
||||
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
|
||||
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
|
||||
<string name="settings_notification_preview_mode_title">Показывать уведомления</string>
|
||||
@@ -136,12 +125,10 @@
|
||||
<string name="notification_preview_new_message">новое сообщение</string>
|
||||
<string name="notification_new_contact_request">Новый запрос на соединение</string>
|
||||
<string name="notification_contact_connected">Соединен(а)</string>
|
||||
|
||||
<!-- local authentication notice - SimpleXAPI.kt -->
|
||||
<string name="la_notice_title_simplex_lock">Блокировка SimpleX</string>
|
||||
<string name="la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
|
||||
<string name="la_notice_turn_on">Включить</string>
|
||||
|
||||
<!-- LocalAuthentication.kt -->
|
||||
<string name="auth_simplex_lock_turned_on">Блокировка SimpleX включена</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
|
||||
@@ -155,11 +142,9 @@
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
|
||||
<string name="auth_stop_chat">Остановить чат</string>
|
||||
<string name="auth_open_chat_console">Открыть консоль</string>
|
||||
|
||||
<!-- Chat Alerts - ChatItemView.kt -->
|
||||
<string name="message_delivery_error_title">Ошибка доставки сообщения</string>
|
||||
<string name="message_delivery_error_desc">Скорее всего, этот контакт удалил соединение с вами.</string>
|
||||
|
||||
<!-- Chat Actions - ChatItemView.kt (and general) -->
|
||||
<string name="reply_verb">Ответить</string>
|
||||
<string name="share_verb">Поделиться</string>
|
||||
@@ -175,14 +160,12 @@
|
||||
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
|
||||
<string name="for_me_only">Удалить для меня</string>
|
||||
<string name="for_everybody">Для всех</string>
|
||||
|
||||
<!-- CIMetaView.kt -->
|
||||
<string name="icon_descr_edited">отредактировано</string>
|
||||
<string name="icon_descr_sent_msg_status_sent">отправлено</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">ошибка авторизации при отправке</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">ошибка при отправке</string>
|
||||
<string name="icon_descr_received_msg_status_unread">не прочитано</string>
|
||||
|
||||
<!-- ChatListView.kt -->
|
||||
<string name="personal_welcome">Здравствуйте <xliff:g>%1$s</xliff:g>!</string>
|
||||
<string name="welcome">Здравствуйте!</string>
|
||||
@@ -195,12 +178,10 @@
|
||||
<string name="tap_to_start_new_chat">Нажмите, чтобы начать чат</string>
|
||||
<string name="chat_with_developers">Соединиться с разработчиками</string>
|
||||
<string name="you_have_no_chats">У вас нет чатов</string>
|
||||
|
||||
<!-- ShareListView.kt -->
|
||||
<string name="share_message">Отправить сообщение…</string>
|
||||
<string name="share_image">Отправить изображение…</string>
|
||||
<string name="share_file">Отправить файл…</string>
|
||||
|
||||
<!-- ComposeView.kt, helpers -->
|
||||
<string name="attach">Прикрепить</string>
|
||||
<string name="icon_descr_context">Значок контекста</string>
|
||||
@@ -210,7 +191,6 @@
|
||||
<string name="images_limit_desc">Только 10 изображений могут быть отправлены одномоментно</string>
|
||||
<string name="image_decoding_exception_title">Ошибка декодирования</string>
|
||||
<string name="image_decoding_exception_desc">Не получается декодировать изображение. Пожалуйста, попробуйте другое изображение или свяжитесь с разработчиками.</string>
|
||||
|
||||
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
|
||||
<string name="image_descr">Изображение</string>
|
||||
<string name="icon_descr_waiting_for_image">Ожидается прием изображения</string>
|
||||
@@ -219,7 +199,6 @@
|
||||
<string name="waiting_for_image">Ожидается прием изображения</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Изображение будет принято, когда ваш контакт будет в сети, подождите или проверьте позже!</string>
|
||||
<string name="image_saved">Изображение сохранено в Галерею</string>
|
||||
|
||||
<!-- Files - CIFileView.kt -->
|
||||
<string name="icon_descr_file">Файл</string>
|
||||
<string name="large_file">Большой файл!</string>
|
||||
@@ -230,15 +209,12 @@
|
||||
<string name="file_saved">Файл сохранен</string>
|
||||
<string name="file_not_found">Файл не найден</string>
|
||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||
|
||||
<!-- Voice messages -->
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="voice_message_with_duration">Голосовое сообщение (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Уведомления</string>
|
||||
|
||||
<!-- Chat Info Actions - ChatInfoView.kt -->
|
||||
<string name="delete_contact_question">Удалить контакт?</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
|
||||
@@ -250,7 +226,6 @@
|
||||
<string name="icon_descr_server_status_pending">Ожидается соединение с сервером</string>
|
||||
<string name="switch_receiving_address_question">Переключить адрес получения?</string>
|
||||
<string name="switch_receiving_address_desc">Это экспериментальная функция! Она будет работать, только если на другом клиенте установлена версия 4.2. После завершения смены адреса вы увидите сообщение — убедитесь, что вы все еще можете получать сообщения от этого контакта (или члена группы).</string>
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||
@@ -259,7 +234,6 @@
|
||||
<string name="voice_messages_prohibited">Голосовые сообщения запрещены!</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Попросите вашего контакта разрешить отправку голосовых сообщений.</string>
|
||||
<string name="only_group_owners_can_enable_voice">Только владельцы группы могут разрешить голосовые сообщения.</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Назад</string>
|
||||
<string name="cancel_verb">Отменить</string>
|
||||
@@ -269,7 +243,6 @@
|
||||
<string name="no_details">нет описания</string>
|
||||
<string name="add_contact">Одноразовая ссылка</string>
|
||||
<string name="copied">Скопировано в буфер обмена</string>
|
||||
|
||||
<!-- NewChatSheet -->
|
||||
<string name="add_contact_or_create_group">Начать новый разговор</string>
|
||||
<string name="share_one_time_link">Создать ссылку-приглашение</string>
|
||||
@@ -279,13 +252,11 @@
|
||||
<string name="to_share_with_your_contact">(чтобы отправить вашему контакту)</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(сканировать или вставить из буфера)</string>
|
||||
<string name="only_stored_on_members_devices">(хранится только у членов группы)</string>
|
||||
|
||||
<!-- GetImageView -->
|
||||
<string name="toast_permission_denied">Разрешение не получено!</string>
|
||||
<string name="use_camera_button">Камера</string>
|
||||
<string name="from_gallery_button">Галерея</string>
|
||||
<string name="choose_file">Файлы</string>
|
||||
|
||||
<!-- help - ChatHelpView.kt -->
|
||||
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
<string name="you_can_connect_to_simplex_chat_founder">Вы можете <font color="#0088ff">соединиться с разработчиками</font>, чтобы задать любые вопросы или получать уведомления о новых версиях.</string>
|
||||
@@ -298,14 +269,12 @@
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Если вы получили ссылку с приглашением из <xliff:g id="appName">SimpleX Chat</xliff:g>, вы можете открыть ее в браузере:</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 на компьютере: сосканируйте показанный QR код из приложения через <b>Сканировать QR код</b>.</string>
|
||||
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app">📱 на мобильном: намжите кнопку <b>Open in mobile app</b> на веб странице, затем нажмите <b>Соединиться</b> в приложении.</string>
|
||||
|
||||
<!-- Contact Request Alert Dialogue - CharListNavLinkView.kt -->
|
||||
<string name="accept_connection_request__question">Принять запрос на соединение?</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение.</string>
|
||||
<string name="accept_contact_button">Принять</string>
|
||||
<string name="accept_contact_incognito_button">Принять инкогнито</string>
|
||||
<string name="reject_contact_button">Отклонить</string>
|
||||
|
||||
<!-- Clear Chat - ChatListNavLinkView.kt -->
|
||||
<string name="clear_chat_question">Очистить чат?</string>
|
||||
<string name="clear_chat_warning">Все сообщения будут удалены - это действие нельзя отменить! Сообщения будут удалены только для вас.</string>
|
||||
@@ -317,29 +286,23 @@
|
||||
<string name="mark_read">Прочитано</string>
|
||||
<string name="mark_unread">Не прочитано</string>
|
||||
<string name="set_contact_name">Имя контакта</string>
|
||||
|
||||
<!-- Actions - ChatListNavLinkView.kt -->
|
||||
<string name="mute_chat">Без звука</string>
|
||||
<string name="unmute_chat">Уведомлять</string>
|
||||
|
||||
<!-- Pending contact connection alert dialogues -->
|
||||
<string name="you_invited_your_contact">Вы пригласили ваш контакт</string>
|
||||
<string name="you_accepted_connection">Вы приняли приглашение соединиться</string>
|
||||
<string name="delete_pending_connection__question">Удалить ожидаемое соединение?</string>
|
||||
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Контакт, которому вы отправили эту ссылку, не сможет соединиться!</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">Подтвержденное соединение будет отменено!</string>
|
||||
|
||||
<!-- Connection Pending Alert Dialogue - ChatListNavLinkView.kt -->
|
||||
<string name="alert_title_contact_connection_pending">Соединение еще не установлено!</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой).</string>
|
||||
|
||||
<!-- Contact Request Information - ContactRequestView.kt -->
|
||||
<string name="contact_wants_to_connect_with_you">хочет соединиться с вами!</string>
|
||||
|
||||
<!-- Image Placeholder - ChatInfoImage.kt -->
|
||||
<string name="icon_descr_profile_image_placeholder">аватар не установлен</string>
|
||||
<string name="image_descr_profile_image">аватар</string>
|
||||
|
||||
<!-- Content Descriptions -->
|
||||
<string name="icon_descr_close_button">закрыть</string>
|
||||
<string name="image_descr_link_preview">изображение превью ссылки</string>
|
||||
@@ -352,10 +315,8 @@
|
||||
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> логотип</string>
|
||||
<string name="icon_descr_email">Email</string>
|
||||
<string name="icon_descr_more_button">Больше</string>
|
||||
|
||||
<!-- Connection info - ContactConnectionInfoView.kt -->
|
||||
<string name="show_QR_code">Показать QR код</string>
|
||||
|
||||
<!-- Add Contact - AddContactView.kt -->
|
||||
<string name="invalid_QR_code">Неверный QR код</string>
|
||||
<string name="this_QR_code_is_not_a_link">Этот QR код не является ссылкой!</string>
|
||||
@@ -372,16 +333,13 @@
|
||||
<string name="share_invitation_link">Поделиться ссылкой</string>
|
||||
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
|
||||
<string name="your_profile_will_be_sent">Ваш профиль будет отправлен вашему контакту</string>
|
||||
|
||||
<!-- PasteToConnect.kt -->
|
||||
<string name="connect_button">Соединиться</string>
|
||||
<string name="paste_button">Вставить</string>
|
||||
|
||||
<!-- CreateLinkView.kt -->
|
||||
<string name="create_one_time_link">Создать одноразовую ссылку</string>
|
||||
<string name="one_time_link">Одноразовая ссылка</string>
|
||||
<string name="your_contact_address">Ваш SimpleX адрес</string>
|
||||
|
||||
<!-- settings - SettingsView.kt -->
|
||||
<string name="your_settings">Настройки</string>
|
||||
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
|
||||
@@ -450,7 +408,6 @@
|
||||
<string name="network_use_onion_hosts_no_desc_in_alert">Onion хосты не используются.</string>
|
||||
<string name="network_use_onion_hosts_required_desc_in_alert">Подключаться только к onion хостам.</string>
|
||||
<string name="appearance_settings">Интерфейс</string>
|
||||
|
||||
<!-- Address Items - UserAddressView.kt -->
|
||||
<string name="create_address">Создать адрес</string>
|
||||
<string name="delete_address__question">Удалить адрес?</string>
|
||||
@@ -458,13 +415,11 @@
|
||||
<string name="you_can_share_your_address_anybody_will_be_able_to_connect">Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились.</string>
|
||||
<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>
|
||||
@@ -477,7 +432,6 @@
|
||||
<string name="save_and_notify_contacts">Сохранить и уведомить контакты</string>
|
||||
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
|
||||
<string name="exit_without_saving">Выйти без сохранения</string>
|
||||
|
||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность.</string>
|
||||
@@ -490,7 +444,6 @@
|
||||
<string name="full_name_optional__prompt">Полное имя (не обязательно)</string>
|
||||
<string name="create_profile_button">Создать</string>
|
||||
<string name="about_simplex">О SimpleX</string>
|
||||
|
||||
<!-- markdown demo - MarkdownHelpView.kt -->
|
||||
<string name="how_to_use_markdown">Как форматировать</string>
|
||||
<string name="you_can_use_markdown_to_format_messages__prompt">Вы можете форматировать сообщения:</string>
|
||||
@@ -503,7 +456,6 @@
|
||||
<string name="connect_via_link">Соединиться через ссылку</string>
|
||||
<string name="this_string_is_not_a_connection_link">Эта строка не является ссылкой-приглашением!</string>
|
||||
<string name="you_can_also_connect_by_clicking_the_link">Вы также можете соединиться, открыв ссылку там, где вы её получили. Если ссылка откроется в браузере, нажмите кнопку <b>Открыть в приложении</b>.</string>
|
||||
|
||||
<!-- CICallStatus -->
|
||||
<string name="callstatus_calling">входящий звонок…</string>
|
||||
<string name="callstatus_missed">пропущенный звонок</string>
|
||||
@@ -513,7 +465,6 @@
|
||||
<string name="callstatus_in_progress">активный звонок</string>
|
||||
<string name="callstatus_ended">звонок завершён <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_error">ошибка звонка</string>
|
||||
|
||||
<!-- CallState -->
|
||||
<string name="callstate_starting">инициализация…</string>
|
||||
<string name="callstate_waiting_for_answer">ожидается ответ…</string>
|
||||
@@ -523,7 +474,6 @@
|
||||
<string name="callstate_connecting">соединяется…</string>
|
||||
<string name="callstate_connected">соединено</string>
|
||||
<string name="callstate_ended">завершен</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">Новое поколение приватных сообщений</string>
|
||||
<string name="privacy_redefined">Более конфиденциальный</string>
|
||||
@@ -535,7 +485,6 @@
|
||||
<string name="create_your_profile">Создать профиль</string>
|
||||
<string name="make_private_connection">Добавьте контакт</string>
|
||||
<string name="how_it_works">Как это работает</string>
|
||||
|
||||
<!-- How SimpleX Works -->
|
||||
<string name="how_simplex_works">Как <xliff:g id="appName">SimpleX</xliff:g> работает</string>
|
||||
<string name="many_people_asked_how_can_it_deliver">Много пользователей спросили: <i>как <xliff:g id="appName">SimpleX</xliff:g> доставляет сообщения без идентификаторов пользователей?</i></string>
|
||||
@@ -544,10 +493,10 @@
|
||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются <b>с двухуровневым end-to-end шифрованием</b>.</string>
|
||||
<string name="read_more_in_github">Узнайте больше из нашего GitHub репозитория.</string>
|
||||
<string name="read_more_in_github_with_link">Узнайте больше из нашего <font color="#0088ff">GitHub репозитория</font>.</string>
|
||||
|
||||
<!-- SetNotificationsMode.kt -->
|
||||
<string name="use_chat">Использовать чат</string>
|
||||
<!-- MakeConnection -->
|
||||
<string name="paste_the_link_you_received">Вставить полученную ссылку</string>
|
||||
|
||||
<!-- Call -->
|
||||
<string name="incoming_video_call">Входящий видеозвонок</string>
|
||||
<string name="incoming_audio_call">Входящий аудиозвонок</string>
|
||||
@@ -562,7 +511,6 @@
|
||||
<string name="call_already_ended">Звонок уже завершен!</string>
|
||||
<string name="icon_descr_video_call">видеозвонок</string>
|
||||
<string name="icon_descr_audio_call">аудиозвонок</string>
|
||||
|
||||
<!-- Call settings -->
|
||||
<string name="settings_audio_video_calls">Аудио- и видеозвонки</string>
|
||||
<string name="your_calls">Ваши звонки</string>
|
||||
@@ -573,12 +521,10 @@
|
||||
<string name="no_call_on_lock_screen">Выключить</string>
|
||||
<string name="your_ice_servers">Ваши ICE серверы</string>
|
||||
<string name="webrtc_ice_servers">WebRTC ICE серверы</string>
|
||||
|
||||
<!-- Call Lock Screen -->
|
||||
<string name="open_simplex_chat_to_accept_call">Откройте <xliff:g id="appNameFull">SimpleX Chat</xliff:g>\nчтобы принять звонок</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Вы можете разрешить принимать звонки на экране блокировки через Настройки.</string>
|
||||
<string name="open_verb">Открыть</string>
|
||||
|
||||
<!-- Call overlay -->
|
||||
<string name="status_e2e_encrypted">e2e зашифровано</string>
|
||||
<string name="status_no_e2e_encryption">нет e2e шифрования</string>
|
||||
@@ -594,7 +540,6 @@
|
||||
<string name="icon_descr_speaker_off">Выключить спикер</string>
|
||||
<string name="icon_descr_speaker_on">Включить спикер</string>
|
||||
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
|
||||
|
||||
<!-- Call items -->
|
||||
<string name="icon_descr_call_pending_sent">Входящий звонок</string>
|
||||
<string name="icon_descr_call_missed">Пропущенный звонок</string>
|
||||
@@ -603,7 +548,6 @@
|
||||
<string name="icon_descr_call_progress">Текущий звонок</string>
|
||||
<string name="icon_descr_call_ended">Звонок завершен</string>
|
||||
<string name="answer_call">Принять звонок</string>
|
||||
|
||||
<!-- Message integrity -->
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> пропущенных сообщений</string>
|
||||
<string name="integrity_msg_bad_hash">ошибка хэш сообщения</string>
|
||||
@@ -611,7 +555,6 @@
|
||||
<string name="integrity_msg_duplicate">повторное сообщение</string>
|
||||
<string name="alert_title_skipped_messages">Пропущенные сообщения</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения.</string>
|
||||
|
||||
<!-- Privacy settings -->
|
||||
<string name="privacy_and_security">Конфиденциальность</string>
|
||||
<string name="your_privacy">Конфиденциальность</string>
|
||||
@@ -620,7 +563,6 @@
|
||||
<string name="transfer_images_faster">Передавать изображения быстрее</string>
|
||||
<string name="send_link_previews">Отправлять картинки ссылок</string>
|
||||
<string name="full_backup">Резервная копия данных</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">ВЫ</string>
|
||||
<string name="settings_section_title_settings">НАСТРОЙКИ</string>
|
||||
@@ -637,7 +579,6 @@
|
||||
<string name="settings_section_title_messages">СООБЩЕНИЯ</string>
|
||||
<string name="settings_section_title_calls">ЗВОНКИ</string>
|
||||
<string name="settings_section_title_incognito">Режим Инкогнито</string>
|
||||
|
||||
<!-- DatabaseView.kt -->
|
||||
<string name="your_chat_database">База данных</string>
|
||||
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
|
||||
@@ -687,7 +628,6 @@
|
||||
<string name="enable_automatic_deletion_message">Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут.</string>
|
||||
<string name="delete_messages">Удалить сообщения</string>
|
||||
<string name="error_changing_message_deletion">Ошибка при изменении настройки</string>
|
||||
|
||||
<!-- DatabaseEncryptionView.kt -->
|
||||
<string name="save_passphrase_in_keychain">Сохранить пароль в Keystore</string>
|
||||
<string name="database_encrypted">База данных зашифрована!</string>
|
||||
@@ -716,7 +656,6 @@
|
||||
<string name="database_passphrase_will_be_updated">Пароль базы данных будет изменен.</string>
|
||||
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</string>
|
||||
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</string>
|
||||
|
||||
<!-- DatabaseErrorView.kt -->
|
||||
<string name="wrong_passphrase">Неправильный пароль базы данных</string>
|
||||
<string name="encrypted_database">База данных зашифрована</string>
|
||||
@@ -741,11 +680,9 @@
|
||||
<string name="restore_database_alert_confirm">Восстановить</string>
|
||||
<string name="database_restore_error">Ошибка при восстановлении базы данных</string>
|
||||
<string name="restore_passphrase_not_found_desc">Пароль не найден в Keystore, пожалуйста, введите его вручную. Это могло произойти, если вы восстановили данные приложения с помощью инструмента резервного копирования. Если это не так, пожалуйста, свяжитесь с разработчиками.</string>
|
||||
|
||||
<!-- ChatModel.chatRunning interactions -->
|
||||
<string name="chat_is_stopped_indication">Чат остановлен</string>
|
||||
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
|
||||
|
||||
<!-- ChatArchiveView.kt -->
|
||||
<string name="chat_archive_header">Архив чата</string>
|
||||
<string name="chat_archive_section">АРХИВ ЧАТА</string>
|
||||
@@ -753,7 +690,6 @@
|
||||
<string name="delete_archive">Удалить архив</string>
|
||||
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||
<string name="delete_chat_archive_question">Удалить архив чата?</string>
|
||||
|
||||
<!-- Groups -->
|
||||
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="join_group_question">Вступить в группу?</string>
|
||||
@@ -773,7 +709,6 @@
|
||||
<string name="alert_message_no_group">Эта группа больше не существует.</string>
|
||||
<string name="alert_title_cant_invite_contacts">Нельзя пригласить контакты!</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено</string>
|
||||
|
||||
<!-- CIGroupInvitationView.kt -->
|
||||
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
|
||||
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
|
||||
@@ -782,7 +717,6 @@
|
||||
<string name="you_joined_this_group">Вы вступили в эту группу</string>
|
||||
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
|
||||
<string name="group_invitation_expired">Приглашение в группу истекло</string>
|
||||
|
||||
<!-- Group event chat items -->
|
||||
<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>
|
||||
@@ -799,7 +733,6 @@
|
||||
<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>
|
||||
|
||||
<!-- Conn event chat items -->
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">поменял(а) адрес для вас</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">смена адреса…</string>
|
||||
@@ -807,12 +740,10 @@
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">смена адреса для %s…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_completed">вы поменяли адрес</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">смена адреса…</string>
|
||||
|
||||
<!-- GroupMemberRole -->
|
||||
<string name="group_member_role_member">член группы</string>
|
||||
<string name="group_member_role_admin">админ</string>
|
||||
<string name="group_member_role_owner">владелец</string>
|
||||
|
||||
<!-- GroupMemberStatus -->
|
||||
<string name="group_member_status_removed">удален(а)</string>
|
||||
<string name="group_member_status_left">покинул(а)</string>
|
||||
@@ -825,9 +756,7 @@
|
||||
<string name="group_member_status_connected">соединен(а)</string>
|
||||
<string name="group_member_status_complete">соединение завершено</string>
|
||||
<string name="group_member_status_creator">создатель</string>
|
||||
|
||||
<string name="group_member_status_connecting">соединяется</string>
|
||||
|
||||
<!-- AddGroupMembersView.kt -->
|
||||
<string name="no_contacts_to_add">Нет контактов для добавления</string>
|
||||
<string name="new_member_role">Роль члена группы</string>
|
||||
@@ -841,7 +770,6 @@
|
||||
<string name="no_contacts_selected">Контакты не выбраны</string>
|
||||
<string name="invite_prohibited">Нельзя пригласить контакт!</string>
|
||||
<string name="invite_prohibited_description">Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль</string>
|
||||
|
||||
<!-- GroupChatInfoView.kt -->
|
||||
<string name="button_add_members">Пригласить членов группы</string>
|
||||
<string name="group_info_section_title_num_members">ЧЛЕНОВ ГРУППЫ: <xliff:g id="num_members">%1$s</xliff:g></string>
|
||||
@@ -861,12 +789,10 @@
|
||||
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
|
||||
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
|
||||
<string name="only_group_owners_can_change_prefs">Только владельцы группы могут изменять предпочтения группы.</string>
|
||||
|
||||
<!-- For Console chat info section -->
|
||||
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
|
||||
<string name="info_row_local_name">Локальное имя</string>
|
||||
<string name="info_row_database_id">ID базы данных</string>
|
||||
|
||||
<!-- GroupMemberInfoView.kt -->
|
||||
<string name="button_remove_member">Удалить члена группы</string>
|
||||
<string name="button_send_direct_message">Отправить сообщение</string>
|
||||
@@ -886,14 +812,12 @@
|
||||
<string name="info_row_connection">Соединение</string>
|
||||
<string name="conn_level_desc_direct">прямое</string>
|
||||
<string name="conn_level_desc_indirect">непрямое (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
|
||||
<!-- ConnectionStats -->
|
||||
<string name="conn_stats_section_title_servers">СЕРВЕРЫ</string>
|
||||
<string name="receiving_via">Получение через</string>
|
||||
<string name="sending_via">Отправка через</string>
|
||||
<string name="network_status">Состояние сети</string>
|
||||
<string name="switch_receiving_address">Переключить адрес получения</string>
|
||||
|
||||
<!-- AddGroupView.kt -->
|
||||
<string name="create_secret_group_title">Создать скрытую группу</string>
|
||||
<string name="group_is_decentralized">Группа полностью децентрализована — она видна только членам.</string>
|
||||
@@ -901,12 +825,10 @@
|
||||
<string name="group_full_name_field">Полное имя:</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы</string>
|
||||
<string name="group_main_profile_sent">Ваш профиль чата будет отправлен членам группы</string>
|
||||
|
||||
<!-- GroupProfileView.kt -->
|
||||
<string name="group_profile_is_stored_on_members_devices">Профиль группы хранится на устройствах членов, а не на серверах.</string>
|
||||
<string name="save_group_profile">Сохранить профиль группы</string>
|
||||
<string name="error_saving_group_profile">Ошибка при сохранении профиля группы</string>
|
||||
|
||||
<!-- AdvancedNetworkSettings.kt -->
|
||||
<string name="network_options_reset_to_defaults">Сбросить настройки</string>
|
||||
<string name="network_option_seconds_label">сек</string>
|
||||
@@ -919,29 +841,24 @@
|
||||
<string name="update_network_settings_question">Обновить настройки сети?</string>
|
||||
<string name="updating_settings_will_reconnect_client_to_all_servers">Обновление настроек приведет к переподключению клиента ко всем серверам.</string>
|
||||
<string name="update_network_settings_confirmation">Обновить</string>
|
||||
|
||||
<!-- Incognito mode -->
|
||||
<string name="incognito">Инкогнито</string>
|
||||
<string name="incognito_random_profile">Случайный профиль</string>
|
||||
<string name="incognito_random_profile_description">Вашему контакту будет отправлен случайный профиль</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль</string>
|
||||
|
||||
<string name="incognito_info_protects">Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль.</string>
|
||||
<string name="incognito_info_allows">Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</string>
|
||||
<string name="incognito_info_share">Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</string>
|
||||
<string name="incognito_info_find">Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата.</string>
|
||||
|
||||
<!-- Default themes -->
|
||||
<string name="theme_system">Системная</string>
|
||||
<string name="theme_light">Светлая</string>
|
||||
<string name="theme_dark">Темная</string>
|
||||
|
||||
<!-- Appearance.kt -->
|
||||
<string name="theme">Тема</string>
|
||||
<string name="save_color">Сохранить цвет</string>
|
||||
<string name="reset_color">Сбросить цвета</string>
|
||||
<string name="color_primary">Акцент</string>
|
||||
|
||||
<!-- Preferences.kt -->
|
||||
<string name="chat_preferences_you_allow">Вы разрешаете</string>
|
||||
<string name="chat_preferences_contact_allows">Контакт разрешает</string>
|
||||
@@ -990,5 +907,61 @@
|
||||
<string name="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этой группе.</string>
|
||||
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
|
||||
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этой группе.</string>
|
||||
|
||||
</resources>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Минимальный расход батареи</b>. Вы получите уведомления только когда приложение запущено, без фонового сервиса.</string>
|
||||
<string name="onboarding_notifications_mode_title">Уведомления</string>
|
||||
<string name="onboarding_notifications_mode_off">Когда приложение запущено</string>
|
||||
<string name="onboarding_notifications_mode_periodic">Периодически</string>
|
||||
<string name="onboarding_notifications_mode_service">Мгновенно</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Больше расход батареи</b>! Фоновый сервис постоянно запущен - уведомления будут показаны как только есть новые сообщения.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Меньше расход батареи</b>. Фоновый сервис проверяет новые сообщения каждые 10 минут. Вы можете пропустить звонки и срочные сообщения.</string>
|
||||
<string name="onboarding_notifications_mode_subtitle">Можно изменить позже в настройках.</string>
|
||||
<string name="live">LIVE</string>
|
||||
<string name="send_live_message">Отправить живое сообщение</string>
|
||||
<string name="live_message">Живое сообщение!</string>
|
||||
<string name="send_verb">Отправить</string>
|
||||
<string name="scan_code_from_contacts_app">Сканируйте код безопасности из приложения контакта.</string>
|
||||
<string name="delete_after">Удалять через</string>
|
||||
<string name="ttl_sec">%d сек</string>
|
||||
<string name="ttl_s">%dс</string>
|
||||
<string name="ttl_min">%d мин</string>
|
||||
<string name="ttl_month">%d мес.</string>
|
||||
<string name="ttl_months">%d мес.</string>
|
||||
<string name="ttl_m">%dм</string>
|
||||
<string name="ttl_mth">%dмес</string>
|
||||
<string name="ttl_hour">%d час</string>
|
||||
<string name="ttl_hours">%d ч.</string>
|
||||
<string name="ttl_h">%dч</string>
|
||||
<string name="ttl_day">%d день</string>
|
||||
<string name="ttl_week">%d нед.</string>
|
||||
<string name="timed_messages">Исчезающие сообщения</string>
|
||||
<string name="view_security_code">Показать код безопасности</string>
|
||||
<string name="verify_security_code">Подтвердить код безопасности</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Вы и ваш контакт можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_you_can_send_disappearing">Только вы можете отправлять исчезающие сообщения.</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Только ваш контакт может отправлять исчезающие сообщения.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Исчезающие сообщения запрещены в этом чате.</string>
|
||||
<string name="allow_to_send_disappearing">Разрешить посылать исчезающие сообщения.</string>
|
||||
<string name="contact_developers">Пожалуйста, обновите приложение и свяжитесь с разработчиками.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Разрешить вашим контактам отправлять исчезающие сообщения.</string>
|
||||
<string name="failed_to_parse_chat_title">Не удалось открыть чат</string>
|
||||
<string name="failed_to_parse_chats_title">Не удалось открыть чаты</string>
|
||||
<string name="incorrect_code">Неправильный код безопасности!</string>
|
||||
<string name="scan_code">Сканировать код</string>
|
||||
<string name="send_live_message_desc">Отправить живое сообщение — оно будет обновляться для получателей по мере того, как вы его вводите</string>
|
||||
<string name="create_group_link">Создать ссылку группы</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Запретить отправлять исчезающие сообщения.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Исчезающие сообщения запрещены в этой группе.</string>
|
||||
<string name="ttl_w">%dнед</string>
|
||||
<string name="ttl_d">%dд</string>
|
||||
<string name="ttl_weeks">%d нед.</string>
|
||||
<string name="ttl_days">%d дней</string>
|
||||
<string name="to_verify_compare">Чтобы подтвердить безопасность end-to-end шифрования с вашим контактом сравните (или сканируйте) код на ваших устройствах.</string>
|
||||
<string name="is_verified">%s подтверждён</string>
|
||||
<string name="is_not_verified">%s не подтверждён</string>
|
||||
<string name="security_code">Код безопасности</string>
|
||||
<string name="mark_code_verified">Подтвердить</string>
|
||||
<string name="clear_verification">Сбросить подтверждение</string>
|
||||
<string name="allow_disappearing_messages_only_if">Разрешить исчезающие сообщения, только если ваш контакт разрешает их вам.</string>
|
||||
<string name="prohibit_sending_disappearing">Запретить посылать исчезающие сообщения.</string>
|
||||
<string name="group_members_can_send_disappearing">Члены группы могут посылать исчезающие сообщения.</string>
|
||||
</resources>
|
||||
@@ -27,6 +27,7 @@
|
||||
<string name="sender_you_pronoun">you</string>
|
||||
<string name="unknown_message_format">unknown message format</string>
|
||||
<string name="invalid_message_format">invalid message format</string>
|
||||
<string name="live">LIVE</string>
|
||||
|
||||
<!-- PendingContactConnection - ChatModel.kt -->
|
||||
<string name="connection_local_display_name">connection <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
@@ -57,6 +58,9 @@
|
||||
<string name="error_saving_smp_servers">Error saving SMP servers</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
|
||||
<string name="error_setting_network_config">Error updating network configuration</string>
|
||||
<string name="failed_to_parse_chat_title">Failed to load chat</string>
|
||||
<string name="failed_to_parse_chats_title">Failed to load chats</string>
|
||||
<string name="contact_developers">Please update the app and contact developers.</string>
|
||||
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Connection timeout</string>
|
||||
@@ -250,6 +254,8 @@
|
||||
<string name="icon_descr_server_status_pending">Pending</string>
|
||||
<string name="switch_receiving_address_question">Switch receiving address?</string>
|
||||
<string name="switch_receiving_address_desc">This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member).</string>
|
||||
<string name="view_security_code">View security code</string>
|
||||
<string name="verify_security_code">Verify security code</string>
|
||||
|
||||
<!-- Message Actions - SendMsgView.kt -->
|
||||
<string name="icon_descr_send_message">Send Message</string>
|
||||
@@ -259,6 +265,10 @@
|
||||
<string name="voice_messages_prohibited">Voice messages prohibited!</string>
|
||||
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
|
||||
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
|
||||
<string name="send_live_message">Send live message</string>
|
||||
<string name="live_message">Live message!</string>
|
||||
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
|
||||
<string name="send_verb">Send</string>
|
||||
|
||||
<!-- General Actions / Responses -->
|
||||
<string name="back">Back</string>
|
||||
@@ -385,6 +395,19 @@
|
||||
<string name="one_time_link">One-time invitation link</string>
|
||||
<string name="your_contact_address">Your contact address</string>
|
||||
|
||||
<!-- ScanCodeView.kt -->
|
||||
<string name="scan_code">Scan code</string>
|
||||
<string name="incorrect_code">Incorrect security code!</string>
|
||||
<string name="scan_code_from_contacts_app">Scan security code from your contact\'s app.</string>
|
||||
|
||||
<!-- VerifyCodeView.kt -->
|
||||
<string name="security_code">Security code</string>
|
||||
<string name="mark_code_verified">Mark verified</string>
|
||||
<string name="clear_verification">Clear verification</string>
|
||||
<string name="to_verify_compare">To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</string>
|
||||
<string name="is_verified">%s is verified</string>
|
||||
<string name="is_not_verified">%s is not verified</string>
|
||||
|
||||
<!-- settings - SettingsView.kt -->
|
||||
<string name="your_settings">Your settings</string>
|
||||
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
|
||||
@@ -545,6 +568,17 @@
|
||||
<string name="read_more_in_github">Read more in our GitHub repository.</string>
|
||||
<string name="read_more_in_github_with_link">Read more in our <font color="#0088ff">GitHub repository</font>.</string>
|
||||
|
||||
<!-- SetNotificationsMode.kt -->
|
||||
<string name="use_chat">Use chat</string>
|
||||
<string name="onboarding_notifications_mode_title">Private notifications</string>
|
||||
<string name="onboarding_notifications_mode_subtitle">It can be changed later via settings.</string>
|
||||
<string name="onboarding_notifications_mode_off">When app is running</string>
|
||||
<string name="onboarding_notifications_mode_periodic">Periodic</string>
|
||||
<string name="onboarding_notifications_mode_service">Instant</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Best for battery</b>. You will receive notifications only when the app is running, background service will NOT be used.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Good for battery</b>. Background service checks for new messages every 10 minutes. You may miss calls and urgent messages.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Uses more battery</b>! Background service is always running – notifications will be shown as soon as the messages are available.</string>
|
||||
|
||||
<!-- MakeConnection -->
|
||||
<string name="paste_the_link_you_received">Paste received link</string>
|
||||
|
||||
@@ -853,6 +887,7 @@
|
||||
<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="create_group_link">Create 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>
|
||||
@@ -957,6 +992,7 @@
|
||||
<string name="group_preferences">Group preferences</string>
|
||||
<string name="set_group_preferences">Set group preferences</string>
|
||||
<string name="your_preferences">Your preferences</string>
|
||||
<string name="timed_messages">Disappearing messages</string>
|
||||
<string name="direct_messages">Direct messages</string>
|
||||
<string name="full_deletion">Delete for everyone</string>
|
||||
<string name="voice_messages">Voice messages</string>
|
||||
@@ -965,12 +1001,21 @@
|
||||
<string name="feature_enabled_for_contact">enabled for contact</string>
|
||||
<string name="feature_off">off</string>
|
||||
<string name="feature_received_prohibited">received, prohibited</string>
|
||||
<string name="accept_feature">Accept</string>
|
||||
<string name="accept_feature_set_1_day">Set 1 day</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Allow your contacts to send disappearing messages.</string>
|
||||
<string name="allow_disappearing_messages_only_if">Allow disappearing messages only if your contact allows them.</string>
|
||||
<string name="prohibit_sending_disappearing_messages">Prohibit sending disappearing messages.</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Allow your contacts to irreversibly delete sent messages.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Allow irreversible message deletion only if your contact allows it to you.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">Contacts can mark messages for deletion; you will be able to view them.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
|
||||
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
|
||||
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Both you and your contact can send disappearing messages.</string>
|
||||
<string name="only_you_can_send_disappearing">Only you can send disappearing messages.</string>
|
||||
<string name="only_your_contact_can_send_disappearing">Only your contact can send disappearing messages.</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Disappearing messages are prohibited in this chat.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Both you and your contact can irreversibly delete sent messages.</string>
|
||||
<string name="only_you_can_delete_messages">Only you can irreversibly delete messages (your contact can mark them for deletion).</string>
|
||||
<string name="only_your_contact_can_delete">Only your contact can irreversibly delete messages (you can mark them for deletion).</string>
|
||||
@@ -979,17 +1024,61 @@
|
||||
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
|
||||
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
|
||||
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
|
||||
<string name="allow_to_send_disappearing">Allow to send disappearing messages.</string>
|
||||
<string name="prohibit_sending_disappearing">Prohibit sending disappearing messages.</string>
|
||||
<string name="allow_direct_messages">Allow sending direct messages to members.</string>
|
||||
<string name="prohibit_direct_messages">Prohibit sending direct messages to members.</string>
|
||||
<string name="allow_to_delete_messages">Allow to irreversibly delete sent messages.</string>
|
||||
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
|
||||
<string name="allow_to_send_voice">Allow to send voice messages.</string>
|
||||
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
|
||||
<string name="group_members_can_send_disappearing">Group members can send disappearing messages.</string>
|
||||
<string name="disappearing_messages_are_prohibited">Disappearing messages are prohibited in this group.</string>
|
||||
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Direct messages between members are prohibited in this group.</string>
|
||||
<string name="group_members_can_delete">Group members can irreversibly delete sent messages.</string>
|
||||
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this group.</string>
|
||||
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
|
||||
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this group.</string>
|
||||
<string name="delete_after">Delete after</string>
|
||||
<string name="ttl_sec">%d sec</string>
|
||||
<string name="ttl_s">%ds</string>
|
||||
<string name="ttl_min">%d min</string>
|
||||
<string name="ttl_month">%d month</string>
|
||||
<string name="ttl_months">%d months</string>
|
||||
<string name="ttl_m">%dm</string>
|
||||
<string name="ttl_mth">%dmth</string>
|
||||
<string name="ttl_hour">%d hour</string>
|
||||
<string name="ttl_hours">%d hours</string>
|
||||
<string name="ttl_h">%dh</string>
|
||||
<string name="ttl_day">%d day</string>
|
||||
<string name="ttl_days">%d days</string>
|
||||
<string name="ttl_d">%dd</string>
|
||||
<string name="ttl_week">%d week</string>
|
||||
<string name="ttl_weeks">%d weeks</string>
|
||||
<string name="ttl_w">%dw</string>
|
||||
|
||||
<!-- WhatsNewView.kt -->
|
||||
<string name="whats_new">What\'s new</string>
|
||||
<string name="new_in_version">New in %s</string>
|
||||
<string name="v4_2_security_assessment">Security assessment</string>
|
||||
<string name="v4_2_security_assessment_desc">SimpleX Chat security was audited by Trail of Bits.</string>
|
||||
<string name="v4_2_group_links">Group links</string>
|
||||
<string name="v4_2_group_links_desc">Admins can create the links to join groups.</string>
|
||||
<string name="v4_2_auto_accept_contact_requests">Auto-accept contact requests</string>
|
||||
<string name="v4_2_auto_accept_contact_requests_desc">With optional welcome message.</string>
|
||||
<string name="v4_3_voice_messages">Voice messages</string>
|
||||
<string name="v4_3_voice_messages_desc">Max 40 seconds, received instantly.</string>
|
||||
<string name="v4_3_irreversible_message_deletion">Irreversible message deletion</string>
|
||||
<string name="v4_3_irreversible_message_deletion_desc">Your contacts can allow full message deletion.</string>
|
||||
<string name="v4_3_improved_server_configuration">Improved server configuration</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">Add servers by scanning QR codes.</string>
|
||||
<string name="v4_3_improved_privacy_and_security">Improved privacy and security</string>
|
||||
<string name="v4_3_improved_privacy_and_security_desc">Hide app screen in the recent apps.</string>
|
||||
<string name="v4_4_disappearing_messages">Disappearing messages</string>
|
||||
<string name="v4_4_disappearing_messages_desc">Sent messages will be deleted after set time.</string>
|
||||
<string name="v4_4_live_messages">Live messages</string>
|
||||
<string name="v4_4_live_messages_desc">Recipients see updates as you type them.</string>
|
||||
<string name="v4_4_verify_connection_security">Verify connection security</string>
|
||||
<string name="v4_4_verify_connection_security_desc">Compare security codes with your contacts.</string>
|
||||
</resources>
|
||||
|
||||
@@ -19,6 +19,7 @@ struct ContentView: View {
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
@State private var showWhatsNew = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -61,9 +62,16 @@ struct ContentView: View {
|
||||
if (!prefLANoticeShown && prefShowLANotice) {
|
||||
prefLANoticeShown = true
|
||||
alertManager.showAlert(laNoticeAlert())
|
||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
showWhatsNew = shouldShowWhatsNew()
|
||||
}
|
||||
}
|
||||
prefShowLANotice = true
|
||||
}
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
}
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
ActiveCallView(call: call)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import UIKit
|
||||
//import UIKit
|
||||
|
||||
let s = """
|
||||
{
|
||||
@@ -15,6 +15,6 @@ let s = """
|
||||
}
|
||||
"""
|
||||
//let s = "\"2022-04-24T11:59:23.703162Z\""
|
||||
let json = getJSONDecoder()
|
||||
let d = s.data(using: .utf8)!
|
||||
print (try! json.decode(ChatInfo.self, from: d))
|
||||
//let json = getJSONDecoder()
|
||||
//let d = s.data(using: .utf8)!
|
||||
//print (try! json.decode(ChatInfo.self, from: d))
|
||||
|
||||
@@ -101,7 +101,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateContact(_ contact: Contact) {
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directContact)
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
|
||||
}
|
||||
|
||||
func updateGroup(_ groupInfo: GroupInfo) {
|
||||
@@ -289,10 +289,7 @@ final class ChatModel: ObservableObject {
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < reversedChatItems.count {
|
||||
if case .rcvNew = reversedChatItems[j].meta.itemStatus {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
}
|
||||
markChatItemRead_(j)
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
@@ -347,9 +344,19 @@ final class ChatModel: ObservableObject {
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
markChatItemRead_(i)
|
||||
}
|
||||
}
|
||||
|
||||
private func markChatItemRead_(_ i: Int) {
|
||||
let meta = reversedChatItems[i].meta
|
||||
if case .rcvNew = meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,4 +520,6 @@ final class Chat: ObservableObject, Identifiable {
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
|
||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||
|
||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
}
|
||||
|
||||
189
apps/ios/Shared/Model/ImageUtils.swift
Normal file
189
apps/ios/Shared/Model/ImageUtils.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// ImageUtils.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 24/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
func getLoadedFilePath(_ file: CIFile?) -> String? {
|
||||
if let fileName = getLoadedFileName(file) {
|
||||
return getAppFilePath(fileName).path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedFileName(_ file: CIFile?) -> String? {
|
||||
if let file = file,
|
||||
file.loaded,
|
||||
let fileName = file.filePath {
|
||||
return fileName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
do {
|
||||
let data = try Data(contentsOf: filePath)
|
||||
let img = UIImage(data: data)
|
||||
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
return img
|
||||
} catch {
|
||||
return UIImage(contentsOfFile: loadedFilePath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAnimImage(_ image: UIImage) -> String? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
return saveFile(imageData, fileName)
|
||||
}
|
||||
|
||||
func saveImage(_ uiImage: UIImage) -> String? {
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE) {
|
||||
let ext = imageHasAlpha(uiImage) ? "png" : "jpg"
|
||||
let fileName = generateNewFileName("IMG", ext)
|
||||
return saveFile(imageDataResized, fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
let size = image.size
|
||||
let side = min(size.width, size.height)
|
||||
let newSize = CGSize(width: side, height: side)
|
||||
var origin = CGPoint.zero
|
||||
if size.width > side {
|
||||
origin.x -= (size.width - side) / 2
|
||||
} else if size.height > side {
|
||||
origin.y -= (size.height - side) / 2
|
||||
}
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
|
||||
}
|
||||
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
|
||||
var img = image
|
||||
let usePng = imageHasAlpha(image)
|
||||
var data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
data = usePng ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
dataSize = data?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToDataSize final \(dataSize)")
|
||||
return data
|
||||
}
|
||||
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
var dataSize = str?.count ?? 0
|
||||
while dataSize != 0 && dataSize > maxDataSize {
|
||||
let ratio = sqrt(Double(dataSize) / Double(maxDataSize))
|
||||
let clippedRatio = min(ratio, 2.0)
|
||||
img = reduceSize(img, ratio: clippedRatio)
|
||||
str = compressImageStr(img)
|
||||
dataSize = str?.count ?? 0
|
||||
}
|
||||
logger.debug("resizeImageToStrSize final \(dataSize)")
|
||||
return str
|
||||
}
|
||||
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? {
|
||||
let ext = imageHasAlpha(image) ? "png" : "jpg"
|
||||
if let data = imageHasAlpha(image) ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
|
||||
return "data:image/\(ext);base64,\(data.base64EncodedString())"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage {
|
||||
let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio))
|
||||
let bounds = CGRect(origin: .zero, size: newSize)
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds)
|
||||
}
|
||||
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
format.opaque = !imageHasAlpha(image)
|
||||
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
||||
image.draw(in: drawIn)
|
||||
}
|
||||
}
|
||||
|
||||
func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
let alpha = img.cgImage?.alphaInfo
|
||||
return alpha == .first || alpha == .last || alpha == .premultipliedFirst || alpha == .premultipliedLast || alpha == .alphaOnly
|
||||
}
|
||||
|
||||
func saveFileFromURL(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
do {
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
savedFile = saveFile(fileData, fileName)
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
} else {
|
||||
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
|
||||
savedFile = nil
|
||||
}
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
|
||||
return fileName
|
||||
}
|
||||
|
||||
private func uniqueCombine(_ fileName: String) -> String {
|
||||
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
||||
let ns = fileName as NSString
|
||||
let name = ns.deletingPathExtension
|
||||
let ext = ns.pathExtension
|
||||
let suffix = (n == 0) ? "" : "_\(n)"
|
||||
let f = "\(name)\(suffix).\(ext)"
|
||||
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
}
|
||||
|
||||
private var tsFormatter: DateFormatter?
|
||||
|
||||
private func getTimestamp() -> String {
|
||||
var df: DateFormatter
|
||||
if let tsFormatter = tsFormatter {
|
||||
df = tsFormatter
|
||||
} else {
|
||||
df = DateFormatter()
|
||||
df.dateFormat = "yyyyMMdd_HHmmss"
|
||||
df.locale = Locale(identifier: "US")
|
||||
tsFormatter = df
|
||||
}
|
||||
return df.string(from: Date())
|
||||
}
|
||||
|
||||
func dropImagePrefix(_ s: String) -> String {
|
||||
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
|
||||
}
|
||||
|
||||
private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
@@ -219,9 +219,9 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async -> ChatItem? {
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
|
||||
let r: ChatResponse
|
||||
if type == .direct {
|
||||
var cItem: ChatItem!
|
||||
@@ -255,8 +255,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
)
|
||||
}
|
||||
|
||||
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay)
|
||||
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
|
||||
if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem }
|
||||
throw r
|
||||
}
|
||||
@@ -356,14 +356,14 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
|
||||
func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
|
||||
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
|
||||
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
|
||||
if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) {
|
||||
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
|
||||
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) }
|
||||
throw r
|
||||
}
|
||||
@@ -376,6 +376,32 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws
|
||||
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
}
|
||||
|
||||
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
|
||||
if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
|
||||
let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
|
||||
let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
|
||||
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
|
||||
logger.error("apiVerifyContact error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
|
||||
let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
|
||||
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
|
||||
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiAddContact() async -> String? {
|
||||
let r = await chatSendCmd(.addContact, bgTask: false)
|
||||
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
|
||||
@@ -782,6 +808,12 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
||||
return []
|
||||
}
|
||||
|
||||
func apiListMembersSync(_ groupId: Int64) -> [GroupMember] {
|
||||
let r = chatSendCmdSync(.apiListMembers(groupId: groupId))
|
||||
if case let .groupMembers(group) = r { return group.members }
|
||||
return []
|
||||
}
|
||||
|
||||
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
|
||||
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
|
||||
return ChatModel.shared.chats
|
||||
@@ -917,7 +949,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .contactConnectionDeleted(connection):
|
||||
m.removeChat(connection.id)
|
||||
case let .contactConnected(contact, _):
|
||||
if contact.directContact {
|
||||
if contact.directOrUsed {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
@@ -925,7 +957,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
NtfManager.shared.notifyContactConnected(contact)
|
||||
}
|
||||
case let .contactConnecting(contact):
|
||||
if contact.directContact {
|
||||
if contact.directOrUsed {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
@@ -974,7 +1006,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
if let file = cItem.file,
|
||||
let mc = cItem.content.msgContent,
|
||||
file.fileSize <= MAX_IMAGE_SIZE {
|
||||
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
|
||||
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
if (mc.isImage && acceptImages)
|
||||
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
|
||||
|
||||
@@ -32,15 +32,26 @@ struct ChatInfoToolbar: View {
|
||||
.frame(width: imageSize, height: imageSize)
|
||||
.padding(.trailing, 4)
|
||||
VStack {
|
||||
Text(cInfo.displayName).font(.headline)
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 220)
|
||||
}
|
||||
|
||||
private var contactVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtocol {
|
||||
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
@@ -18,6 +18,15 @@ func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtoco
|
||||
}
|
||||
}
|
||||
|
||||
func infoRow(_ title: Text, _ value: String) -> some View {
|
||||
HStack {
|
||||
title
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
@@ -55,8 +64,9 @@ struct ChatInfoView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@State var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
var customUserProfile: Profile?
|
||||
@Binding var customUserProfile: Profile?
|
||||
@State var localAlias: String
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@@ -89,7 +99,9 @@ struct ChatInfoView: View {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
localAliasTextEdit()
|
||||
Group {
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
@@ -100,6 +112,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
}
|
||||
|
||||
@@ -143,17 +156,23 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func contactInfoHeader() -> some View {
|
||||
private func contactInfoHeader() -> some View {
|
||||
VStack {
|
||||
let cInfo = chat.chatInfo
|
||||
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
HStack {
|
||||
if contact.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
@@ -163,7 +182,7 @@ struct ChatInfoView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
func localAliasTextEdit() -> some View {
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set contact name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
.focused($aliasTextFieldFocused)
|
||||
@@ -194,7 +213,36 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func contactPreferencesButton() -> some View {
|
||||
private func verifyCodeButton(_ code: String) -> some View {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
displayName: contact.displayName,
|
||||
connectionCode: code,
|
||||
connectionVerified: contact.verified,
|
||||
verify: { code in
|
||||
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
DispatchQueue.main.async {
|
||||
chat.chatInfo = .direct(contact: contact)
|
||||
}
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Security code")
|
||||
} label: {
|
||||
Label(
|
||||
contact.verified ? "View security code" : "Verify security code",
|
||||
systemImage: contact.verified ? "checkmark.shield" : "shield"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func contactPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
contact: $contact,
|
||||
@@ -208,7 +256,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func networkStatusRow() -> some View {
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
Image(systemName: "info.circle")
|
||||
@@ -221,14 +269,14 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func serverImage() -> some View {
|
||||
private func serverImage() -> some View {
|
||||
let status = chat.serverInfo.networkStatus
|
||||
return Image(systemName: status.imageName)
|
||||
.foregroundColor(status == .connected ? .green : .secondary)
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
|
||||
func deleteContactButton() -> some View {
|
||||
private func deleteContactButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteContactAlert
|
||||
} label: {
|
||||
@@ -237,7 +285,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func clearChatButton() -> some View {
|
||||
private func clearChatButton() -> some View {
|
||||
Button() {
|
||||
alert = .clearChatAlert
|
||||
} label: {
|
||||
@@ -323,7 +371,9 @@ struct ChatInfoView_Previews: PreviewProvider {
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
||||
contact: Contact.sampleData,
|
||||
connectionStats: Binding.constant(nil),
|
||||
localAlias: ""
|
||||
customUserProfile: Binding.constant(nil),
|
||||
localAlias: "",
|
||||
connectionCode: Binding.constant(nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
63
apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
Normal file
63
apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// Created by Avently on 19.12.2022.
|
||||
// Copyright (c) 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class AnimatedImageView: UIView {
|
||||
var image: UIImage? = nil
|
||||
var imageView: UIImageView? = nil
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
convenience init(image: UIImage) {
|
||||
self.init()
|
||||
self.image = image
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
self.addSubview(imageView!)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
imageView!.frame = bounds
|
||||
}
|
||||
|
||||
func updateImage(_ image: UIImage) {
|
||||
if let subview = self.subviews.first as? UIImageView {
|
||||
if image.imageData != subview.gifImage?.imageData {
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
self.addSubview(imageView!)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
imageView!.frame = bounds
|
||||
self.layoutSubviews()
|
||||
}
|
||||
}
|
||||
|
||||
struct SwiftyGif: UIViewRepresentable {
|
||||
private let image: UIImage
|
||||
|
||||
init(image: UIImage) {
|
||||
self.image = image
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> AnimatedImageView {
|
||||
AnimatedImageView(image: image)
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||
imageView.updateImage(image)
|
||||
imageView.imageView!.startAnimatingGif()
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ import SimpleXChat
|
||||
struct CIChatFeatureView: View {
|
||||
var chatItem: ChatItem
|
||||
var feature: Feature
|
||||
var icon: String? = nil
|
||||
var iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Image(systemName: feature.iconFilled)
|
||||
Image(systemName: icon ?? feature.iconFilled)
|
||||
.foregroundColor(iconColor)
|
||||
.scaleEffect(feature.iconScale)
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// CIFeaturePreferenceView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 21/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIFeaturePreferenceView: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var feature: ChatFeature
|
||||
var allowed: FeatureAllowed
|
||||
var param: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: feature.icon)
|
||||
.foregroundColor(.secondary)
|
||||
.scaleEffect(feature.iconScale)
|
||||
if let ct = chat.chatInfo.contact,
|
||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
|
||||
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
|
||||
.onTapGesture {
|
||||
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
|
||||
}
|
||||
} else {
|
||||
featurePreferenceView()
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
if let acceptText {
|
||||
r = r
|
||||
+ Text(acceptText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.accentColor)
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
return r.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
func allowFeatureToContact(_ contact: Contact, _ feature: ChatFeature, param: Int? = nil) {
|
||||
Task {
|
||||
do {
|
||||
let prefs = contactUserPreferencesToPreferences(contact.mergedPreferences).setAllowed(feature, param: param)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("allowFeatureToContact apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFeaturePreferenceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let content = CIContent.rcvChatPreference(feature: .timedMessages, allowed: .yes, param: 30)
|
||||
let chatItem = ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false),
|
||||
content: content,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
@@ -161,5 +161,6 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,13 +55,19 @@ struct CIImageView: View {
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : img.imageData == nil ? .infinity : maxWidth
|
||||
DispatchQueue.main.async { imgWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
} else {
|
||||
SwiftyGif(image: img)
|
||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||
.scaledToFit()
|
||||
}
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,42 +10,42 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIMetaView: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var metaColor = Color.secondary
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
if !chatItem.isDeletedContent {
|
||||
if chatItem.meta.itemEdited {
|
||||
statusImage("pencil", metaColor, 9)
|
||||
}
|
||||
|
||||
switch chatItem.meta.itemStatus {
|
||||
case .sndSent:
|
||||
statusImage("checkmark", metaColor)
|
||||
case .sndErrorAuth:
|
||||
statusImage("multiply", .red)
|
||||
case .sndError:
|
||||
statusImage("exclamationmark.triangle.fill", .yellow)
|
||||
case .rcvNew:
|
||||
statusImage("circlebadge.fill", Color.accentColor)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
chatItem.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(metaColor)
|
||||
if chatItem.isDeletedContent {
|
||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||
} else {
|
||||
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View {
|
||||
Image(systemName: systemName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(color)
|
||||
.frame(maxHeight: maxHeight)
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false) -> Text {
|
||||
var r = Text("")
|
||||
if meta.itemEdited {
|
||||
r = r + statusIconText("pencil", color)
|
||||
}
|
||||
if meta.disappearing {
|
||||
r = r + statusIconText("timer", color).font(.caption2)
|
||||
let ttl = meta.itemTimed?.ttl
|
||||
if ttl != chatTTL {
|
||||
r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color)
|
||||
}
|
||||
r = r + Text(" ")
|
||||
}
|
||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
||||
r = r + statusIconText(icon, transparent ? .clear : statusColor) + Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||
}
|
||||
return (r + meta.timestampText.foregroundColor(color)).font(.caption)
|
||||
}
|
||||
|
||||
private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
Text(Image(systemName: icon)).foregroundColor(color)
|
||||
}
|
||||
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
@@ -56,5 +56,6 @@ struct CIMetaView_Previews: PreviewProvider {
|
||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,16 +231,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
playbackState: .playing,
|
||||
playbackTime: TimeInterval(20)
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1,
|
||||
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
|
||||
|
||||
struct FramedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
@@ -31,14 +30,16 @@ struct FramedItemView: View {
|
||||
let v = ZStack(alignment: .bottomTrailing) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if chatItem.meta.itemDeleted {
|
||||
ciDeletedView()
|
||||
framedItemHeader(icon: "trash", caption: Text("marked deleted").italic())
|
||||
} else if chatItem.meta.isLive {
|
||||
framedItemHeader(caption: Text("LIVE"))
|
||||
}
|
||||
|
||||
if let qi = chatItem.quotedItem {
|
||||
ciQuoteView(qi)
|
||||
.onTapGesture {
|
||||
if let proxy = scrollProxy,
|
||||
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||
let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(ci.viewId, anchor: .bottom)
|
||||
}
|
||||
@@ -73,7 +74,7 @@ struct FramedItemView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func framedMsgContentView() -> some View {
|
||||
if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) {
|
||||
if chatItem.formattedText == nil && chatItem.file == nil && !chatItem.meta.isLive && isShortEmoji(chatItem.content.text) {
|
||||
VStack {
|
||||
emojiText(chatItem.content.text)
|
||||
Text("")
|
||||
@@ -88,7 +89,7 @@ struct FramedItemView: View {
|
||||
case let .image(text, image):
|
||||
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" {
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.preference(
|
||||
@@ -127,32 +128,33 @@ struct FramedItemView: View {
|
||||
message: err
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private func ciDeletedView() -> some View {
|
||||
|
||||
@ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> some View {
|
||||
let v = HStack(spacing: 6) {
|
||||
Image(systemName: "trash")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
Text("marked deleted")
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
caption
|
||||
.font(.caption)
|
||||
.italic()
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: msgWidth, alignment: .leading)
|
||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: msgWidth, alignment: .leading)
|
||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
||||
if let imgWidth = imgWidth, imgWidth < maxWidth {
|
||||
v.frame(maxWidth: imgWidth, alignment: .leading)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
|
||||
let v = ZStack(alignment: .topTrailing) {
|
||||
switch (qi.content) {
|
||||
@@ -222,21 +224,21 @@ struct FramedItemView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
|
||||
let rtl = isRightToLeft(chatItem.text)
|
||||
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
|
||||
let rtl = isRightToLeft(text)
|
||||
let v = MsgContentView(
|
||||
text: ci.text,
|
||||
formattedText: ci.formattedText,
|
||||
text: text,
|
||||
formattedText: text == "" ? [] : ci.formattedText,
|
||||
sender: showMember ? ci.memberDisplayName : nil,
|
||||
metaText: ci.timestampText,
|
||||
edited: ci.meta.itemEdited,
|
||||
meta: ci.meta,
|
||||
rightToLeft: rtl
|
||||
)
|
||||
.multilineTextAlignment(rtl ? .trailing : .leading)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: 0, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
.multilineTextAlignment(rtl ? .trailing : .leading)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.overlay(DetermineWidth())
|
||||
.frame(minWidth: 0, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if let imgWidth = imgWidth, imgWidth < maxWidth {
|
||||
v.frame(maxWidth: imgWidth, alignment: .leading)
|
||||
@@ -248,7 +250,7 @@ struct FramedItemView: View {
|
||||
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" {
|
||||
if text != "" || ci.meta.isLive {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
}
|
||||
@@ -270,7 +272,7 @@ private struct MetaColorPreferenceKey: PreferenceKey {
|
||||
|
||||
func onlyImage(_ ci: ChatItem) -> Bool {
|
||||
if case let .image(text, _) = ci.content.msgContent {
|
||||
return !ci.meta.itemDeleted && ci.quotedItem == nil && text == ""
|
||||
return !ci.meta.itemDeleted && !ci.meta.isLive && ci.quotedItem == nil && text == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
|
||||
struct FullScreenImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@@ -77,9 +78,14 @@ struct FullScreenImageView: View {
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
SwiftyGif(image: img)
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,28 +11,76 @@ import SimpleXChat
|
||||
|
||||
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
|
||||
private let noTyping = Text(" ")
|
||||
|
||||
private let typingIndicators: [Text] = [
|
||||
(typing(.black) + typing() + typing()),
|
||||
(typing(.bold) + typing(.black) + typing()),
|
||||
(typing() + typing(.bold) + typing(.black)),
|
||||
(typing() + typing() + typing(.bold))
|
||||
]
|
||||
|
||||
private func typing(_ w: Font.Weight = .light) -> Text {
|
||||
Text(".").fontWeight(w)
|
||||
}
|
||||
|
||||
struct MsgContentView: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var text: String
|
||||
var formattedText: [FormattedText]? = nil
|
||||
var sender: String? = nil
|
||||
var metaText: Text? = nil
|
||||
var edited = false
|
||||
var meta: CIMeta? = nil
|
||||
var rightToLeft = false
|
||||
@State private var typingIdx = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
var body: some View {
|
||||
let v = messageText(text, formattedText, sender)
|
||||
if let mt = metaText {
|
||||
return v + reserveSpaceForMeta(mt, edited)
|
||||
if meta?.isLive == true {
|
||||
msgContentView()
|
||||
.onAppear { switchTyping() }
|
||||
.onDisappear(perform: stopTyping)
|
||||
.onChange(of: meta?.isLive, perform: switchTyping)
|
||||
.onChange(of: meta?.recent, perform: switchTyping)
|
||||
} else {
|
||||
return v
|
||||
msgContentView()
|
||||
}
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ meta: Text, _ edited: Bool) -> Text {
|
||||
let reserve = rightToLeft ? "\n" : edited ? " " : " "
|
||||
return (Text(reserve) + meta)
|
||||
.font(.caption)
|
||||
.foregroundColor(.clear)
|
||||
|
||||
private func switchTyping(_: Bool? = nil) {
|
||||
if let meta = meta, meta.isLive && meta.recent {
|
||||
timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
|
||||
typingIdx = (typingIdx + 1) % typingIndicators.count
|
||||
}
|
||||
} else {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTyping() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func msgContentView() -> Text {
|
||||
var v = messageText(text, formattedText, sender)
|
||||
if let mt = meta {
|
||||
if mt.isLive {
|
||||
v = v + typingIndicator(mt.recent)
|
||||
}
|
||||
v = v + reserveSpaceForMeta(mt)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private func typingIndicator(_ recent: Bool) -> Text {
|
||||
return (recent ? typingIndicators[typingIdx] : noTyping)
|
||||
.font(.body.monospaced())
|
||||
.kerning(-2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +153,8 @@ struct MsgContentView_Previews: PreviewProvider {
|
||||
text: chatItem.text,
|
||||
formattedText: chatItem.formattedText,
|
||||
sender: chatItem.memberDisplayName,
|
||||
metaText: chatItem.timestampText
|
||||
meta: chatItem.meta
|
||||
)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ChatItemView: View {
|
||||
let ci = chatItem
|
||||
if chatItem.meta.itemDeleted && !revealed {
|
||||
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||
} else if ci.quotedItem == nil && !ci.meta.itemDeleted {
|
||||
} else if ci.quotedItem == nil && !ci.meta.itemDeleted && !ci.meta.isLive {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
@@ -62,10 +62,14 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case .sndGroupEvent: eventItemView()
|
||||
case .rcvConnEvent: eventItemView()
|
||||
case .sndConnEvent: eventItemView()
|
||||
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .rcvChatPreference(feature, allowed, param):
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||
case let .sndChatPreference(feature, _, _):
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary)
|
||||
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
}
|
||||
@@ -102,15 +106,17 @@ struct ChatItemView_Previews: PreviewProvider {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, false, false, true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, false, true), revealed: Binding.constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false))
|
||||
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
|
||||
Group{
|
||||
ChatItemView(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
@@ -158,5 +164,6 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
|
||||
private let memberImageSize: CGFloat = 34
|
||||
|
||||
@@ -23,6 +24,7 @@ struct ChatView: View {
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@State private var connectionCode: String?
|
||||
@State private var tableView: UITableView?
|
||||
@State private var loadingItems = false
|
||||
@State private var firstPage = false
|
||||
@@ -33,8 +35,7 @@ struct ChatView: View {
|
||||
@FocusState private var searchFocussed
|
||||
// opening GroupMemberInfoView on member icon
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
@State private var memberConnectionStats: ConnectionStats?
|
||||
|
||||
|
||||
var body: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return VStack(spacing: 0) {
|
||||
@@ -90,24 +91,30 @@ struct ChatView: View {
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
let (stats, profile) = try await apiContactInfo(contactId: chat.chatInfo.apiId)
|
||||
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
connectionStats = stats
|
||||
customUserProfile = profile
|
||||
connectionCode = code
|
||||
if contact.activeConn.connectionCode != ct.activeConn.connectionCode {
|
||||
chat.chatInfo = .direct(contact: ct)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiContactInfo error: \(responseError(error))")
|
||||
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
|
||||
}
|
||||
await MainActor.run { showChatInfoSheet = true }
|
||||
}
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
|
||||
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
|
||||
connectionStats = nil
|
||||
customUserProfile = nil
|
||||
connectionCode = nil
|
||||
}) {
|
||||
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias)
|
||||
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
@@ -381,29 +388,15 @@ struct ChatView: View {
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture {
|
||||
Task {
|
||||
do {
|
||||
let stats = try await apiGroupMemberInfo(member.groupId, member.groupMemberId)
|
||||
await MainActor.run { memberConnectionStats = stats }
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo error: \(responseError(error))")
|
||||
}
|
||||
await MainActor.run { selectedMember = member }
|
||||
}
|
||||
}
|
||||
.appSheet(item: $selectedMember, onDismiss: {
|
||||
selectedMember = nil
|
||||
memberConnectionStats = nil
|
||||
}) { _ in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $memberConnectionStats)
|
||||
.onTapGesture { selectedMember = member }
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
|
||||
}
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
ChatItemWithMenu(
|
||||
chat: chat,
|
||||
ci: ci,
|
||||
showMember: showMember,
|
||||
maxWidth: maxWidth,
|
||||
@@ -412,13 +405,14 @@ struct ChatView: View {
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
).padding(.leading, 8)
|
||||
)
|
||||
.padding(.leading, 8)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
ChatItemWithMenu(
|
||||
chat: chat,
|
||||
ci: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
@@ -426,12 +420,14 @@ struct ChatView: View {
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
).padding(.horizontal)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var ci: ChatItem
|
||||
var showMember: Bool = false
|
||||
var maxWidth: CGFloat
|
||||
@@ -471,8 +467,12 @@ struct ChatView: View {
|
||||
menu.append(shareUIAction())
|
||||
menu.append(copyUIAction())
|
||||
if let filePath = getLoadedFilePath(ci.file) {
|
||||
if case .image = ci.content.msgContent, let image = UIImage(contentsOfFile: filePath) {
|
||||
menu.append(saveImageAction(image))
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
if image.imageData != nil {
|
||||
menu.append(saveFileAction(filePath))
|
||||
} else {
|
||||
menu.append(saveImageAction(image))
|
||||
}
|
||||
} else {
|
||||
menu.append(saveFileAction(filePath))
|
||||
}
|
||||
@@ -600,7 +600,7 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private var broadcastDeleteButtonText: LocalizedStringKey {
|
||||
chat.chatInfo.fullDeletionAllowed ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
import PhotosUI
|
||||
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
@@ -29,8 +31,15 @@ enum VoiceMessageRecordingState {
|
||||
case finished
|
||||
}
|
||||
|
||||
struct LiveMessage {
|
||||
var chatItem: ChatItem
|
||||
var typedMsg: String
|
||||
var sentMsg: String
|
||||
}
|
||||
|
||||
struct ComposeState {
|
||||
var message: String
|
||||
var liveMessage: LiveMessage? = nil
|
||||
var preview: ComposePreview
|
||||
var contextItem: ComposeContextItem
|
||||
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||
@@ -40,11 +49,13 @@ struct ComposeState {
|
||||
|
||||
init(
|
||||
message: String = "",
|
||||
liveMessage: LiveMessage? = nil,
|
||||
preview: ComposePreview = .noPreview,
|
||||
contextItem: ComposeContextItem = .noContextItem,
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
|
||||
) {
|
||||
self.message = message
|
||||
self.liveMessage = liveMessage
|
||||
self.preview = preview
|
||||
self.contextItem = contextItem
|
||||
self.voiceMessageRecordingState = voiceMessageRecordingState
|
||||
@@ -64,12 +75,14 @@ struct ComposeState {
|
||||
|
||||
func copy(
|
||||
message: String? = nil,
|
||||
liveMessage: LiveMessage? = nil,
|
||||
preview: ComposePreview? = nil,
|
||||
contextItem: ComposeContextItem? = nil,
|
||||
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
|
||||
) -> ComposeState {
|
||||
ComposeState(
|
||||
message: message ?? self.message,
|
||||
liveMessage: liveMessage ?? self.liveMessage,
|
||||
preview: preview ?? self.preview,
|
||||
contextItem: contextItem ?? self.contextItem,
|
||||
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
|
||||
@@ -88,7 +101,7 @@ struct ComposeState {
|
||||
case .imagePreviews: return true
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty
|
||||
default: return !message.isEmpty || liveMessage != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +141,15 @@ struct ComposeState {
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentDisabled: Bool {
|
||||
if editing || liveMessage != nil { return true }
|
||||
switch preview {
|
||||
case .noPreview: return false
|
||||
case .linkPreview: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
@@ -149,6 +171,37 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
return chatItemPreview
|
||||
}
|
||||
|
||||
enum UploadContent: Equatable {
|
||||
case simpleImage(image: UIImage)
|
||||
case animatedImage(image: UIImage)
|
||||
|
||||
var uiImage: UIImage {
|
||||
switch self {
|
||||
case let .simpleImage(image): return image
|
||||
case let .animatedImage(image): return image
|
||||
}
|
||||
}
|
||||
|
||||
static func loadFromURL(url: URL) -> UploadContent? {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
if let image = UIImage(data: data) {
|
||||
try image.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
logger.log("UploadContent: added animated image")
|
||||
return .animatedImage(image: image)
|
||||
} else { return nil }
|
||||
} catch {
|
||||
do {
|
||||
if let image = try UIImage(data: Data(contentsOf: url)) {
|
||||
logger.log("UploadContent: added simple image")
|
||||
return .simpleImage(image: image)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@@ -163,7 +216,7 @@ struct ComposeView: View {
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State var chosenImages: [UIImage] = []
|
||||
@State var chosenImages: [UploadContent] = []
|
||||
@State private var showFileImporter = false
|
||||
@State var chosenFile: URL? = nil
|
||||
|
||||
@@ -174,7 +227,7 @@ struct ComposeView: View {
|
||||
// fails to stop on ComposeVoiceView.playbackMode().onDisappear,
|
||||
// this is a workaround to fire an explicit event in certain cases
|
||||
@State private var stopPlayback: Bool = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
contextItemView()
|
||||
@@ -190,7 +243,7 @@ struct ComposeView: View {
|
||||
Image(systemName: "paperclip")
|
||||
.resizable()
|
||||
}
|
||||
.disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording)
|
||||
.disabled(composeState.attachmentDisabled)
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.leading, 12)
|
||||
@@ -200,15 +253,18 @@ struct ComposeView: View {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
},
|
||||
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
|
||||
sendLiveMessage: sendLiveMessage,
|
||||
updateLiveMessage: updateLiveMessage,
|
||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
Task {
|
||||
await startVoiceMessageRecording()
|
||||
}
|
||||
},
|
||||
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
|
||||
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
@@ -233,7 +289,15 @@ struct ComposeView: View {
|
||||
}
|
||||
if UIPasteboard.general.hasImages {
|
||||
Button("Paste image") {
|
||||
chosenImages = imageList(UIPasteboard.general.image)
|
||||
UIPasteboard.general.itemProviders.forEach { p in
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
||||
chosenImages.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Choose file") {
|
||||
@@ -260,7 +324,7 @@ struct ComposeView: View {
|
||||
Task {
|
||||
var imgs: [String] = []
|
||||
for image in images {
|
||||
if let img = resizeImageToStrSize(image, maxDataSize: 14000) {
|
||||
if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) {
|
||||
imgs.append(img)
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
|
||||
@@ -307,6 +371,10 @@ struct ComposeView: View {
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
cancelVoiceMessageRecording(fileName)
|
||||
}
|
||||
if composeState.liveMessage != nil {
|
||||
sendMessage()
|
||||
resetLinkPreview()
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||
if !startingRecording {
|
||||
@@ -317,7 +385,7 @@ struct ComposeView: View {
|
||||
startingRecording = false
|
||||
}
|
||||
}
|
||||
.onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in
|
||||
.onChange(of: chat.chatInfo.featureEnabled(.voice)) { vmAllowed in
|
||||
if !vmAllowed && composeState.voicePreview,
|
||||
let fileName = composeState.voiceMessageRecordingFileName {
|
||||
cancelVoiceMessageRecording(fileName)
|
||||
@@ -325,6 +393,54 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
let sentMsg = truncateToWords(typedMsg)
|
||||
if composeState.liveMessage == nil,
|
||||
let ci = await sendMessageAsync(sentMsg, live: true) {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
if let liveMessage = composeState.liveMessage {
|
||||
if let sentMsg = liveMessageToSend(liveMessage, typedMsg),
|
||||
let ci = await sendMessageAsync(sentMsg, live: true) {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
|
||||
}
|
||||
} else if liveMessage.typedMsg != typedMsg {
|
||||
await MainActor.run {
|
||||
var lm = liveMessage
|
||||
lm.typedMsg = typedMsg
|
||||
composeState = composeState.copy(liveMessage: lm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? {
|
||||
let s = t != lm.typedMsg ? truncateToWords(t) : t
|
||||
return s != lm.sentMsg ? s : nil
|
||||
}
|
||||
|
||||
private func truncateToWords(_ s: String) -> String {
|
||||
var acc = ""
|
||||
var word = ""
|
||||
for c in s {
|
||||
if c.isLetter || c.isNumber {
|
||||
word = word + String(c)
|
||||
} else {
|
||||
acc = acc + word + String(c)
|
||||
word = ""
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
@ViewBuilder func previewView() -> some View {
|
||||
switch composeState.preview {
|
||||
case .noPreview:
|
||||
@@ -382,72 +498,55 @@ struct ComposeView: View {
|
||||
logger.debug("ChatView sendMessage")
|
||||
Task {
|
||||
logger.debug("ChatView sendMessage: in Task")
|
||||
switch composeState.contextItem {
|
||||
case let .editingItem(chatItem: ei):
|
||||
if let oldMsgContent = ei.content.msgContent {
|
||||
do {
|
||||
await sending()
|
||||
let mc = updateMsgContent(oldMsgContent)
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc
|
||||
)
|
||||
await MainActor.run {
|
||||
clearState()
|
||||
let _ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
composeState.disabled = false
|
||||
composeState.inProgress = false
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
|
||||
}
|
||||
} else {
|
||||
await MainActor.run { clearState() }
|
||||
}
|
||||
default:
|
||||
await sending()
|
||||
var quoted: Int64? = nil
|
||||
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
|
||||
quoted = quotedItem.id
|
||||
}
|
||||
_ = await sendMessageAsync(nil, live: false)
|
||||
}
|
||||
}
|
||||
|
||||
switch (composeState.preview) {
|
||||
case .noPreview:
|
||||
await send(.text(composeState.message), quoted: quoted)
|
||||
case .linkPreview:
|
||||
await send(checkLinkPreview(), quoted: quoted)
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
var text = composeState.message
|
||||
var sent = false
|
||||
for i in 0..<min(chosenImages.count, images.count) {
|
||||
if i > 0 { _ = try? await Task.sleep(nanoseconds: 100_000000) }
|
||||
if let savedFile = saveImage(chosenImages[i]) {
|
||||
await send(.image(text: text, image: images[i]), quoted: quoted, file: savedFile)
|
||||
text = ""
|
||||
quoted = nil
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
if !sent {
|
||||
await send(.text(composeState.message), quoted: quoted)
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName)
|
||||
case .filePreview:
|
||||
if let fileURL = chosenFile,
|
||||
let savedFile = saveFileFromURL(fileURL) {
|
||||
await send(.file(composeState.message), quoted: quoted, file: savedFile)
|
||||
private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? {
|
||||
var sent: ChatItem?
|
||||
let msgText = text ?? composeState.message
|
||||
if !live { await sending() }
|
||||
if case let .editingItem(ci) = composeState.contextItem {
|
||||
sent = await updateMessage(ci, live: live)
|
||||
} else if let liveMessage = composeState.liveMessage {
|
||||
sent = await updateMessage(liveMessage.chatItem, live: live)
|
||||
} else {
|
||||
var quoted: Int64? = nil
|
||||
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
|
||||
quoted = quotedItem.id
|
||||
}
|
||||
|
||||
switch (composeState.preview) {
|
||||
case .noPreview:
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live)
|
||||
case .linkPreview:
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live)
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
let last = min(chosenImages.count, images.count) - 1
|
||||
for i in 0..<last {
|
||||
if let savedFile = saveAnyImage(chosenImages[i]) {
|
||||
_ = await send(.image(text: "", image: images[i]), quoted: nil, file: savedFile)
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
if let savedFile = saveAnyImage(chosenImages[last]) {
|
||||
sent = await send(.image(text: msgText, image: images[last]), quoted: quoted, file: savedFile, live: live)
|
||||
}
|
||||
if sent == nil {
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live)
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
|
||||
case .filePreview:
|
||||
if let fileURL = chosenFile,
|
||||
let savedFile = saveFileFromURL(fileURL) {
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
|
||||
}
|
||||
}
|
||||
await MainActor.run { clearState() }
|
||||
}
|
||||
await MainActor.run { clearState(live: live) }
|
||||
return sent
|
||||
|
||||
func sending() async {
|
||||
await MainActor.run { composeState.disabled = true }
|
||||
@@ -456,17 +555,82 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil) async {
|
||||
func updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? {
|
||||
if let oldMsgContent = ei.content.msgContent {
|
||||
do {
|
||||
let mc = updateMsgContent(oldMsgContent)
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
} catch {
|
||||
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
||||
switch msgContent {
|
||||
case .text:
|
||||
return checkLinkPreview()
|
||||
case .link:
|
||||
return checkLinkPreview()
|
||||
case .image(_, let image):
|
||||
return .image(text: msgText, image: image)
|
||||
case .voice(_, let duration):
|
||||
return .voice(text: msgText, duration: duration)
|
||||
case .file:
|
||||
return .file(msgText)
|
||||
case .unknown(let type, _):
|
||||
return .unknown(type: type, text: msgText)
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? {
|
||||
if let chatItem = await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
file: file,
|
||||
quotedItemId: quoted,
|
||||
msg: mc
|
||||
msg: mc,
|
||||
live: live
|
||||
) {
|
||||
await MainActor.run {
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkLinkPreview() -> MsgContent {
|
||||
switch (composeState.preview) {
|
||||
case let .linkPreview(linkPreview: linkPreview):
|
||||
if let url = parseMessage(msgText),
|
||||
let linkPreview = linkPreview,
|
||||
url == linkPreview.uri {
|
||||
return .link(text: msgText, preview: linkPreview)
|
||||
} else {
|
||||
return .text(msgText)
|
||||
}
|
||||
default:
|
||||
return .text(msgText)
|
||||
}
|
||||
}
|
||||
|
||||
func saveAnyImage(_ img: UploadContent) -> String? {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,19 +687,7 @@ struct ComposeView: View {
|
||||
|
||||
private func allowVoiceMessagesToContact() {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
Task {
|
||||
do {
|
||||
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
|
||||
prefs.voice = Preference(allow: .yes)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
chatModel.updateContact(toContact)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
allowFeatureToContact(contact, .voice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,12 +711,14 @@ struct ComposeView: View {
|
||||
clearState()
|
||||
}
|
||||
|
||||
private func clearState() {
|
||||
composeState = ComposeState()
|
||||
linkUrl = nil
|
||||
prevLinkUrl = nil
|
||||
pendingLinkUrl = nil
|
||||
cancelledLinks = []
|
||||
private func clearState(live: Bool = false) {
|
||||
if live {
|
||||
composeState.disabled = false
|
||||
composeState.inProgress = false
|
||||
} else {
|
||||
composeState = ComposeState()
|
||||
resetLinkPreview()
|
||||
}
|
||||
chosenImages = []
|
||||
chosenFile = nil
|
||||
audioRecorder = nil
|
||||
@@ -572,23 +726,6 @@ struct ComposeView: View {
|
||||
startingRecording = false
|
||||
}
|
||||
|
||||
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
||||
switch msgContent {
|
||||
case .text:
|
||||
return checkLinkPreview()
|
||||
case .link:
|
||||
return checkLinkPreview()
|
||||
case .image(_, let image):
|
||||
return .image(text: composeState.message, image: image)
|
||||
case .voice(_, let duration):
|
||||
return .voice(text: composeState.message, duration: duration)
|
||||
case .file:
|
||||
return .file(composeState.message)
|
||||
case .unknown(let type, _):
|
||||
return .unknown(type: type, text: composeState.message)
|
||||
}
|
||||
}
|
||||
|
||||
private func showLinkPreview(_ s: String) {
|
||||
prevLinkUrl = linkUrl
|
||||
linkUrl = parseMessage(s)
|
||||
@@ -648,21 +785,6 @@ struct ComposeView: View {
|
||||
pendingLinkUrl = nil
|
||||
cancelledLinks = []
|
||||
}
|
||||
|
||||
private func checkLinkPreview() -> MsgContent {
|
||||
switch (composeState.preview) {
|
||||
case let .linkPreview(linkPreview: linkPreview):
|
||||
if let url = parseMessage(composeState.message),
|
||||
let linkPreview = linkPreview,
|
||||
url == linkPreview.uri {
|
||||
return .link(text: composeState.message, preview: linkPreview)
|
||||
} else {
|
||||
return .text(composeState.message)
|
||||
}
|
||||
default:
|
||||
return .text(composeState.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView_Previews: PreviewProvider {
|
||||
|
||||
150
apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
Normal file
150
apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// NativeTextEditor.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 15.12.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftyGif
|
||||
import SimpleXChat
|
||||
import PhotosUI
|
||||
|
||||
struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
let height: CGFloat
|
||||
let font: UIFont
|
||||
@FocusState.Binding var focused: Bool
|
||||
let alignment: TextAlignment
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField()
|
||||
field.text = text
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
field.autocapitalizationType = .sentences
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
text = newText
|
||||
if !images.isEmpty {
|
||||
onImagesAdded(images)
|
||||
}
|
||||
}
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
return field
|
||||
}
|
||||
|
||||
func updateUIView(_ field: UITextView, context: Context) {
|
||||
field.text = text
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
var onFocusChanged: (Bool) -> Void = { focused in }
|
||||
|
||||
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
||||
self.onTextChanged = onTextChanged
|
||||
}
|
||||
|
||||
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
||||
self.onFocusChanged = onFocusChanged
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
|
||||
return UIMenu(children: suggestedActions.map { elem in
|
||||
if let elem = elem as? UIMenu {
|
||||
var actions = elem.children
|
||||
// Replacing Paste action since it allows to paste animated images too
|
||||
let pasteIndex = elem.children.firstIndex { elem in elem.debugDescription.contains("Action: paste:")}
|
||||
if let pasteIndex = pasteIndex {
|
||||
let paste = actions[pasteIndex]
|
||||
actions.remove(at: pasteIndex)
|
||||
let newPaste = UIAction(title: paste.title, image: paste.image) { action in
|
||||
var images: [UploadContent] = []
|
||||
var totalImages = 0
|
||||
var processed = 0
|
||||
UIPasteboard.general.itemProviders.forEach { p in
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
totalImages += 1
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
processed += 1
|
||||
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
||||
images.append(image)
|
||||
DispatchQueue.main.sync {
|
||||
self.onTextChanged(textView.text, images)
|
||||
}
|
||||
}
|
||||
// No images were added, just paste a text then
|
||||
if processed == totalImages && images.isEmpty {
|
||||
textView.paste(UIPasteboard.general.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
actions.insert(newPaste, at: 0)
|
||||
}
|
||||
return UIMenu(title: elem.title, subtitle: elem.subtitle, image: elem.image, identifier: elem.identifier, options: elem.options, children: actions)
|
||||
} else {
|
||||
return elem
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
}
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
}
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
onFocusChanged(true)
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
onFocusChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
static var previews: some View {
|
||||
@FocusState var keyboardVisible: Bool
|
||||
return NativeTextEditor(
|
||||
text: Binding.constant("Hello, world!"),
|
||||
height: 100,
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: $keyboardVisible,
|
||||
alignment: TextAlignment.leading,
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,26 @@ import SimpleXChat
|
||||
struct SendMessageView: View {
|
||||
@Binding var composeState: ComposeState
|
||||
var sendMessage: () -> Void
|
||||
var sendLiveMessage: (() async -> Void)? = nil
|
||||
var updateLiveMessage: (() async -> Void)? = nil
|
||||
var showVoiceMessageButton: Bool = true
|
||||
var voiceMessageAllowed: Bool = true
|
||||
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||
var onImagesAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teFont: Font = .body
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@State private var sendButtonSize: CGFloat = 29
|
||||
@State private var sendButtonOpacity: CGFloat = 1
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -44,19 +51,25 @@ struct SendMessageView: View {
|
||||
.lineLimit(10)
|
||||
.font(teFont)
|
||||
.multilineTextAlignment(alignment)
|
||||
// put text on top (after NativeTextEditor) and set color to precisely align it on changes
|
||||
// .foregroundColor(.red)
|
||||
.foregroundColor(.clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.matchedGeometryEffect(id: "te", in: namespace)
|
||||
.background(GeometryReader(content: updateHeight))
|
||||
TextEditor(text: $composeState.message)
|
||||
.focused($keyboardVisible)
|
||||
.font(teFont)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.multilineTextAlignment(alignment)
|
||||
.padding(.horizontal, 5)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
height: teHeight,
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
alignment: alignment,
|
||||
onImagesAdded: onImagesAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,20 +80,26 @@ struct SendMessageView: View {
|
||||
.padding([.bottom, .trailing], 3)
|
||||
} else {
|
||||
let vmrs = composeState.voiceMessageRecordingState
|
||||
if showVoiceMessageButton,
|
||||
composeState.message.isEmpty,
|
||||
!composeState.editing,
|
||||
(composeState.noPreview && vmrs == .noRecording)
|
||||
|| (vmrs == .recording && holdingVMR) {
|
||||
if voiceMessageAllowed {
|
||||
RecordVoiceMessageButton(
|
||||
startVoiceMessageRecording: startVoiceMessageRecording,
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
holdingVMR: $holdingVMR,
|
||||
disabled: composeState.disabled
|
||||
)
|
||||
} else {
|
||||
voiceMessageNotAllowedButton()
|
||||
if showVoiceMessageButton
|
||||
&& composeState.message.isEmpty
|
||||
&& !composeState.editing
|
||||
&& composeState.liveMessage == nil
|
||||
&& ((composeState.noPreview && vmrs == .noRecording)
|
||||
|| (vmrs == .recording && holdingVMR)) {
|
||||
HStack {
|
||||
if voiceMessageAllowed {
|
||||
RecordVoiceMessageButton(
|
||||
startVoiceMessageRecording: startVoiceMessageRecording,
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
holdingVMR: $holdingVMR,
|
||||
disabled: composeState.disabled
|
||||
)
|
||||
} else {
|
||||
voiceMessageNotAllowedButton()
|
||||
}
|
||||
if let send = sendLiveMessage, let update = updateLiveMessage {
|
||||
startLiveMessageButton(send: send, update: update)
|
||||
}
|
||||
}
|
||||
} else if vmrs == .recording && !holdingVMR {
|
||||
finishVoiceMessageRecordingButton()
|
||||
@@ -97,11 +116,15 @@ struct SendMessageView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func sendMessageButton() -> some View {
|
||||
Button(action: { sendMessage() }) {
|
||||
Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
|
||||
@ViewBuilder private func sendMessageButton() -> some View {
|
||||
let v = Button(action: sendMessage) {
|
||||
Image(systemName: composeState.editing || composeState.liveMessage != nil
|
||||
? "checkmark.circle.fill"
|
||||
: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
@@ -109,7 +132,22 @@ struct SendMessageView: View {
|
||||
(!voiceMessageAllowed && composeState.voicePreview)
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
|
||||
if composeState.liveMessage == nil,
|
||||
!composeState.voicePreview && !composeState.editing,
|
||||
let send = sendLiveMessage,
|
||||
let update = updateLiveMessage {
|
||||
v.contextMenu{
|
||||
Button {
|
||||
startLiveMessage(send: send, update: update)
|
||||
} label: {
|
||||
Label("Send live message", systemImage: "bolt.fill")
|
||||
}
|
||||
}
|
||||
.padding([.bottom, .trailing], 4)
|
||||
} else {
|
||||
v.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecordVoiceMessageButton: View {
|
||||
@@ -146,7 +184,7 @@ struct SendMessageView: View {
|
||||
}
|
||||
|
||||
private func voiceMessageNotAllowedButton() -> some View {
|
||||
Button(action: {
|
||||
Button {
|
||||
switch showEnableVoiceMessagesAlert {
|
||||
case .userEnable:
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
@@ -173,7 +211,7 @@ struct SendMessageView: View {
|
||||
message: "Please check yours and your contact preferences."
|
||||
)
|
||||
}
|
||||
}) {
|
||||
} label: {
|
||||
Image(systemName: "mic")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -182,6 +220,64 @@ struct SendMessageView: View {
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func startLiveMessageButton(send: @escaping () async -> Void, update: @escaping () async -> Void) -> some View {
|
||||
return Button {
|
||||
switch composeState.preview {
|
||||
case .noPreview: startLiveMessage(send: send, update: update)
|
||||
default: ()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "bolt.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .horizontal], 4)
|
||||
}
|
||||
|
||||
private func startLiveMessage(send: @escaping () async -> Void, update: @escaping () async -> Void) {
|
||||
if liveMessageAlertShown {
|
||||
start()
|
||||
} else {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Live message!"),
|
||||
message: Text("Send a live message - it will update for the recipient(s) as you type it"),
|
||||
primaryButton: .default(Text("Send")) {
|
||||
liveMessageAlertShown = true
|
||||
start()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
|
||||
func start() {
|
||||
Task {
|
||||
await send()
|
||||
await MainActor.run { run() }
|
||||
}
|
||||
}
|
||||
|
||||
@Sendable func run() {
|
||||
Timer.scheduledTimer(withTimeInterval: 0.75, repeats: true) { t in
|
||||
withAnimation(.easeInOut(duration: 0.7)) {
|
||||
sendButtonSize = sendButtonSize == 29 ? 26 : 29
|
||||
sendButtonOpacity = sendButtonOpacity == 1 ? 0.75 : 1
|
||||
}
|
||||
if composeState.liveMessage == nil {
|
||||
t.invalidate()
|
||||
sendButtonSize = 29
|
||||
sendButtonOpacity = 1
|
||||
}
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { t in
|
||||
if composeState.liveMessage == nil { t.invalidate() }
|
||||
Task { await update() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishVoiceMessageRecordingButton() -> some View {
|
||||
Button(action: { finishVoiceMessageRecording?() }) {
|
||||
Image(systemName: "stop.fill")
|
||||
@@ -195,11 +291,11 @@ struct SendMessageView: View {
|
||||
private func updateHeight(_ g: GeometryProxy) -> Color {
|
||||
DispatchQueue.main.async {
|
||||
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
||||
teFont = isShortEmoji(composeState.message)
|
||||
? composeState.message.count < 4
|
||||
? largeEmojiFont
|
||||
: mediumEmojiFont
|
||||
: .body
|
||||
(teFont, teUiFont) = isShortEmoji(composeState.message)
|
||||
? composeState.message.count < 4
|
||||
? (largeEmojiFont, largeEmojiUIFont)
|
||||
: (mediumEmojiFont, mediumEmojiUIFont)
|
||||
: (.body, UIFont.preferredFont(forTextStyle: .body))
|
||||
}
|
||||
return Color.clear
|
||||
}
|
||||
@@ -220,6 +316,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: {},
|
||||
onImagesAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
@@ -229,6 +326,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: {},
|
||||
onImagesAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct ContactPreferencesView: View {
|
||||
|
||||
VStack {
|
||||
List {
|
||||
timedMessagesFeatureSection()
|
||||
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
||||
|
||||
@@ -48,9 +49,10 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
|
||||
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference<SimplePreference>, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
|
||||
let enabled = FeatureEnabled.enabled(
|
||||
user: Preference(allow: allowFeature.wrappedValue.allowed),
|
||||
asymmetric: feature.asymmetric,
|
||||
user: SimplePreference(allow: allowFeature.wrappedValue.allowed),
|
||||
contact: pref.contactPreference
|
||||
)
|
||||
return Section {
|
||||
@@ -61,16 +63,51 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
.frame(height: 36)
|
||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||
} header: {
|
||||
HStack {
|
||||
Image(systemName: "\(feature.icon).fill")
|
||||
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
||||
Text(feature.text)
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enabledDescription(enabled))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
header: { featureHeader(feature, enabled) }
|
||||
footer: { featureFooter(feature, enabled) }
|
||||
}
|
||||
|
||||
private func timedMessagesFeatureSection() -> some View {
|
||||
let pref = contact.mergedPreferences.timedMessages
|
||||
let enabled = FeatureEnabled.enabled(
|
||||
asymmetric: ChatFeature.timedMessages.asymmetric,
|
||||
user: TimedMessagesPreference(allow: featuresAllowed.timedMessagesAllowed ? .yes : .no),
|
||||
contact: pref.contactPreference
|
||||
)
|
||||
return Section {
|
||||
Toggle("You allow", isOn: $featuresAllowed.timedMessagesAllowed)
|
||||
.onChange(of: featuresAllowed.timedMessagesAllowed) { allow in
|
||||
if allow {
|
||||
if featuresAllowed.timedMessagesTTL == nil {
|
||||
featuresAllowed.timedMessagesTTL = 86400
|
||||
}
|
||||
} else {
|
||||
featuresAllowed.timedMessagesTTL = currentFeaturesAllowed.timedMessagesTTL
|
||||
}
|
||||
}
|
||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||
if featuresAllowed.timedMessagesAllowed {
|
||||
timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL)
|
||||
} else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always {
|
||||
infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||
}
|
||||
}
|
||||
header: { featureHeader(.timedMessages, enabled) }
|
||||
footer: { featureFooter(.timedMessages, enabled) }
|
||||
}
|
||||
|
||||
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||
HStack {
|
||||
Image(systemName: feature.iconFilled)
|
||||
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
||||
Text(feature.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||
Text(feature.enabledDescription(enabled))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
@@ -91,6 +128,18 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func timedMessagesTTLPicker(_ selection: Binding<Int?>) -> some View {
|
||||
Picker("Delete after", selection: selection) {
|
||||
let selectedTTL = selection.wrappedValue
|
||||
let ttlValues = TimedMessagesPreference.ttlValues
|
||||
let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL])
|
||||
ForEach(values, id: \.self) { ttl in
|
||||
Text(TimedMessagesPreference.ttlText(ttl))
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
|
||||
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContactPreferencesView(
|
||||
|
||||
@@ -28,4 +28,6 @@ func isShortEmoji(_ str: String) -> Bool {
|
||||
}
|
||||
|
||||
let largeEmojiFont = Font.custom("Emoji", size: 48, relativeTo: .largeTitle)
|
||||
let largeEmojiUIFont: UIFont = UIFont(name: "Emoji", size: 48) ?? UIFont.systemFont(ofSize: 48)
|
||||
let mediumEmojiFont = Font.custom("Emoji", size: 36, relativeTo: .largeTitle)
|
||||
let mediumEmojiUIFont: UIFont = UIFont(name: "Emoji", size: 36) ?? UIFont.systemFont(ofSize: 36)
|
||||
|
||||
@@ -34,10 +34,24 @@ struct AddGroupMembersView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
|
||||
if creatingGroup {
|
||||
NavigationView {
|
||||
addGroupMembersView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb?(selectedContacts) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addGroupMembersView()
|
||||
}
|
||||
}
|
||||
|
||||
let v = List {
|
||||
private func addGroupMembersView() -> some View {
|
||||
VStack {
|
||||
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
|
||||
List {
|
||||
ChatInfoToolbar(chat: chat, imageSize: 48)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -80,16 +94,6 @@ struct AddGroupMembersView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if creatingGroup {
|
||||
v.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb?(selectedContacts) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alert in
|
||||
|
||||
@@ -18,8 +18,8 @@ struct GroupChatInfoView: View {
|
||||
@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 connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum GroupChatInfoViewAlert: Identifiable {
|
||||
@@ -65,28 +65,17 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
memberView(groupInfo.membership, user: true)
|
||||
ForEach(members) { member in
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
let stats = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
await MainActor.run { connectionStats = stats }
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo error: \(responseError(error))")
|
||||
}
|
||||
await MainActor.run { selectedMember = member }
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member.groupMemberId)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
} label: { memberView(member) }
|
||||
.opacity(0)
|
||||
memberView(member)
|
||||
}
|
||||
}
|
||||
}
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
.appSheet(item: $selectedMember, onDismiss: {
|
||||
selectedMember = nil
|
||||
connectionStats = nil
|
||||
}) { _ in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
@@ -125,7 +114,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func groupInfoHeader() -> some View {
|
||||
private func groupInfoHeader() -> some View {
|
||||
VStack {
|
||||
let cInfo = chat.chatInfo
|
||||
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
||||
@@ -146,35 +135,32 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showAddMembersSheet = true
|
||||
NavigationLink {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
.onAppear {
|
||||
ChatModel.shared.groupMembers = apiListMembersSync(groupInfo.groupId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
func serverImage() -> some View {
|
||||
private func serverImage() -> some View {
|
||||
let status = chat.serverInfo.networkStatus
|
||||
return Image(systemName: status.imageName)
|
||||
.foregroundColor(status == .connected ? .green : .secondary)
|
||||
}
|
||||
|
||||
func memberView(_ member: GroupMember, user: Bool = false) -> some View {
|
||||
private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 38, height: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
VStack(alignment: .leading) {
|
||||
Text(member.chatViewName)
|
||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
||||
(member.verified ? memberVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
.foregroundColor(member.memberIncognito ? .indigo : .primary)
|
||||
let s = Text(member.memberStatus.shortText)
|
||||
(user ? Text ("you: ") + s : s)
|
||||
.lineLimit(1)
|
||||
@@ -190,17 +176,36 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
|
||||
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Group link", systemImage: "link")
|
||||
if groupLink == nil {
|
||||
Label("Create group link", systemImage: "link.badge.plus")
|
||||
} else {
|
||||
Label("Group link", systemImage: "link")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func editGroupButton() -> some View {
|
||||
private func editGroupButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupProfileView(
|
||||
groupInfo: $groupInfo,
|
||||
@@ -213,7 +218,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteGroupButton() -> some View {
|
||||
private func deleteGroupButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteGroupAlert
|
||||
} label: {
|
||||
@@ -222,7 +227,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func clearChatButton() -> some View {
|
||||
private func clearChatButton() -> some View {
|
||||
Button() {
|
||||
alert = .clearChatAlert
|
||||
} label: {
|
||||
@@ -231,7 +236,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func leaveGroupButton() -> some View {
|
||||
private func leaveGroupButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .leaveGroupAlert
|
||||
} label: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
||||
struct GroupLinkView: View {
|
||||
var groupId: Int64
|
||||
@Binding var groupLink: String?
|
||||
@State private var creatingLink = false
|
||||
@State private var alert: GroupLinkAlert?
|
||||
|
||||
private enum GroupLinkAlert: Identifiable {
|
||||
@@ -48,18 +49,17 @@ struct GroupLinkView: View {
|
||||
}
|
||||
.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") }
|
||||
Button(action: createGroupLink) {
|
||||
Label("Create link", systemImage: "link.badge.plus")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(creatingLink)
|
||||
.padding(.bottom)
|
||||
if creatingLink {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -74,9 +74,7 @@ struct GroupLinkView: View {
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteGroupLink(groupId)
|
||||
await MainActor.run {
|
||||
groupLink = nil
|
||||
}
|
||||
await MainActor.run { groupLink = nil }
|
||||
} catch let error {
|
||||
logger.error("GroupLinkView apiDeleteGroupLink: \(responseError(error))")
|
||||
}
|
||||
@@ -87,6 +85,31 @@ struct GroupLinkView: View {
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if groupLink == nil && !creatingLink {
|
||||
createGroupLink()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createGroupLink() {
|
||||
Task {
|
||||
do {
|
||||
creatingLink = true
|
||||
let link = try await apiCreateGroupLink(groupId)
|
||||
await MainActor.run {
|
||||
creatingLink = false
|
||||
groupLink = link
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
creatingLink = false
|
||||
let a = getErrorAlert(error, "Error creating group link")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ struct GroupMemberInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var groupInfo: GroupInfo
|
||||
@Binding var member: GroupMember?
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
@State var member: GroupMember
|
||||
var navigation: Bool = false
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@@ -36,76 +38,95 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if let member = member {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
if navigation {
|
||||
NavigationView { groupMemberInfoView() }
|
||||
} else {
|
||||
groupMemberInfoView()
|
||||
}
|
||||
}
|
||||
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directContact ?? false {
|
||||
Section {
|
||||
private func groupMemberInfoView() -> some View {
|
||||
VStack {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directOrUsed ?? false {
|
||||
knownDirectChatButton(chat)
|
||||
}
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
Section {
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
newDirectChatButton(contactId)
|
||||
}
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
}
|
||||
}
|
||||
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
}
|
||||
}
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear { newRole = member.memberRole }
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
member = mem
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
@@ -155,10 +176,15 @@ struct GroupMemberInfoView: View {
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
HStack {
|
||||
if mem.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
}
|
||||
Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
if mem.fullName != "" && mem.fullName != mem.displayName {
|
||||
Text(mem.fullName)
|
||||
.font(.title2)
|
||||
@@ -168,7 +194,38 @@ struct GroupMemberInfoView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
private func verifyCodeButton(_ code: String) -> some View {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
displayName: member.displayName,
|
||||
connectionCode: code,
|
||||
connectionVerified: member.verified,
|
||||
verify: { code in
|
||||
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
member.activeConn?.connectionCode = connCode
|
||||
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
|
||||
}
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Security code")
|
||||
} label: {
|
||||
Label(
|
||||
member.verified ? "View security code" : "Verify security code",
|
||||
systemImage: member.verified ? "checkmark.shield" : "shield"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .removeMemberAlert(mem: mem)
|
||||
} label: {
|
||||
@@ -230,9 +287,7 @@ struct GroupMemberInfoView: View {
|
||||
private func switchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
if let member = member {
|
||||
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
}
|
||||
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -248,8 +303,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: GroupInfo.sampleData,
|
||||
member: Binding.constant(GroupMember.sampleData),
|
||||
connectionStats: Binding.constant(nil)
|
||||
member: GroupMember.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct GroupPreferencesView: View {
|
||||
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
|
||||
VStack {
|
||||
List {
|
||||
featureSection(.timedMessages, $preferences.timedMessages.enable)
|
||||
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
@@ -35,6 +36,15 @@ struct GroupPreferencesView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: preferences.timedMessages.enable) { enable in
|
||||
if enable == .on {
|
||||
if preferences.timedMessages.ttl == nil {
|
||||
preferences.timedMessages.ttl = 86400
|
||||
}
|
||||
} else {
|
||||
preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl
|
||||
}
|
||||
}
|
||||
.modifier(BackButton {
|
||||
if currentPreferences == preferences {
|
||||
dismiss()
|
||||
@@ -55,19 +65,24 @@ struct GroupPreferencesView: View {
|
||||
Section {
|
||||
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
||||
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
||||
if (groupInfo.canEdit) {
|
||||
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
||||
if groupInfo.canEdit {
|
||||
let enable = Binding(
|
||||
get: { enableFeature.wrappedValue == .on },
|
||||
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
|
||||
)
|
||||
settingsRow(icon, color: color) {
|
||||
Picker(feature.text, selection: enableFeature) {
|
||||
ForEach(GroupFeatureEnabled.values) { enable in
|
||||
Text(enable.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
Toggle(feature.text, isOn: enable)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if timedOn {
|
||||
timedMessagesTTLPicker($preferences.timedMessages.ttl)
|
||||
}
|
||||
} else {
|
||||
settingsRow(icon, color: color) {
|
||||
infoRow(feature.text, enableFeature.wrappedValue.text)
|
||||
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
|
||||
}
|
||||
if timedOn {
|
||||
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
|
||||
60
apps/ios/Shared/Views/Chat/ScanCodeView.swift
Normal file
60
apps/ios/Shared/Views/Chat/ScanCodeView.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// ScanCodeView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CodeScanner
|
||||
|
||||
struct ScanCodeView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var connectionVerified: Bool
|
||||
var verify: (String?) async -> (Bool, String)?
|
||||
@State private var showCodeError = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.border(.gray)
|
||||
Text("Scan security code from your contact's app.")
|
||||
.padding(.top)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(isPresented: $showCodeError) {
|
||||
Alert(title: Text("Incorrect security code!"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
Task {
|
||||
if let (ok, _) = await verify(r.string) {
|
||||
await MainActor.run {
|
||||
connectionVerified = ok
|
||||
if ok {
|
||||
dismiss()
|
||||
} else {
|
||||
showCodeError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("ScanCodeView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScanCodeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanCodeView(connectionVerified: Binding.constant(true), verify: {_ in nil})
|
||||
}
|
||||
}
|
||||
126
apps/ios/Shared/Views/Chat/VerifyCodeView.swift
Normal file
126
apps/ios/Shared/Views/Chat/VerifyCodeView.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// VerifyCodeView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct VerifyCodeView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var displayName: String
|
||||
@State var connectionCode: String?
|
||||
@State var connectionVerified: Bool
|
||||
var verify: (String?) -> (Bool, String)?
|
||||
@State private var showCodeError = false
|
||||
|
||||
var body: some View {
|
||||
if let code = connectionCode {
|
||||
verifyCodeView(code)
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyCodeView(_ code: String) -> some View {
|
||||
ScrollView {
|
||||
let splitCode = splitToParts(code, length: 24)
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
HStack {
|
||||
if connectionVerified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(displayName) is verified")
|
||||
} else {
|
||||
Text("\(displayName) is not verified")
|
||||
}
|
||||
}
|
||||
.frame(height: 24)
|
||||
|
||||
QRCode(uri: code)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text(splitCode)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.body.monospaced())
|
||||
.lineLimit(20)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
|
||||
.padding(.bottom)
|
||||
|
||||
Group {
|
||||
if connectionVerified {
|
||||
Button {
|
||||
verifyCode(nil)
|
||||
} label: {
|
||||
Label("Clear verification", systemImage: "shield")
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
HStack {
|
||||
NavigationLink {
|
||||
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Scan code")
|
||||
} label: {
|
||||
Label("Scan code", systemImage: "qrcode")
|
||||
}
|
||||
.padding()
|
||||
Button {
|
||||
verifyCode(code) { verified in
|
||||
if !verified { showCodeError = true }
|
||||
}
|
||||
} label: {
|
||||
Label("Mark verified", systemImage: "checkmark.shield")
|
||||
}
|
||||
.padding()
|
||||
.alert(isPresented: $showCodeError) {
|
||||
Alert(title: Text("Incorrect security code!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showShareSheet(items: [splitCode])
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: connectionVerified) { _ in
|
||||
if connectionVerified { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) {
|
||||
if let (verified, existingCode) = verify(code) {
|
||||
connectionVerified = verified
|
||||
connectionCode = existingCode
|
||||
cb?(verified)
|
||||
}
|
||||
}
|
||||
|
||||
private func splitToParts(_ s: String, length: Int) -> String {
|
||||
if length >= s.count { return s }
|
||||
return (0 ... (s.count - 1) / length)
|
||||
.map { s.dropFirst($0 * length).prefix(length) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
struct VerifyCodeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ struct ChatHelp: View {
|
||||
@State private var showAddChat = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView { chatHelp() }
|
||||
}
|
||||
|
||||
func chatHelp() -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Thank you for installing SimpleX Chat!")
|
||||
|
||||
@@ -44,6 +48,15 @@ struct ChatHelp: View {
|
||||
Text("**Scan QR code**: to connect to your contact in person or via video call.")
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Markdown in messages")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
MarkdownHelp()
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -79,26 +79,33 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewTitle() -> some View {
|
||||
let v = Text(chat.chatInfo.chatViewName)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(1)
|
||||
.frame(alignment: .topLeading)
|
||||
switch (chat.chatInfo) {
|
||||
case .direct:
|
||||
v.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
case .group(groupInfo: let groupInfo):
|
||||
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
previewTitle(contact.verified == true ? verifiedIcon + t : t)
|
||||
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
case let .group(groupInfo):
|
||||
let v = previewTitle(t)
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
chat.chatInfo.incognito ? v.foregroundColor(.indigo) : v.foregroundColor(.accentColor)
|
||||
case .memAccepted:
|
||||
v.foregroundColor(.secondary)
|
||||
case .memInvited: v.foregroundColor(chat.chatInfo.incognito ? .indigo : .accentColor)
|
||||
case .memAccepted: v.foregroundColor(.secondary)
|
||||
default: v
|
||||
}
|
||||
default: v
|
||||
default: previewTitle(t)
|
||||
}
|
||||
}
|
||||
|
||||
private func previewTitle(_ t: Text) -> some View {
|
||||
t.lineLimit(1).frame(alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var verifiedIcon: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.foregroundColor(.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
|
||||
if let cItem = cItem {
|
||||
let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
|
||||
|
||||
@@ -8,28 +8,34 @@
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import SwiftyGif
|
||||
import SimpleXChat
|
||||
|
||||
struct LibraryImagePicker: View {
|
||||
@Binding var image: UIImage?
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
@State var images: [UIImage] = []
|
||||
@State var images: [UploadContent] = []
|
||||
|
||||
var body: some View {
|
||||
LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
.onChange(of: images) { image = $0.first }
|
||||
.onChange(of: images) { _ in
|
||||
if let img = images.first {
|
||||
image = img.uiImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = PHPickerViewController
|
||||
@Binding var images: [UIImage]
|
||||
@Binding var images: [UploadContent]
|
||||
var selectionLimit: Int
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
|
||||
class Coordinator: PHPickerViewControllerDelegate {
|
||||
let parent: LibraryImageListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker")
|
||||
var images: [UIImage] = []
|
||||
var images: [UploadContent] = []
|
||||
var imageCount: Int = 0
|
||||
|
||||
init(_ parent: LibraryImageListPicker) {
|
||||
@@ -48,7 +54,11 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
for result in results {
|
||||
logger.log("LibraryImageListPicker result")
|
||||
let p = result.itemProvider
|
||||
if p.canLoadObject(ofClass: UIImage.self) {
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
self.loadImage(object: url, error: error)
|
||||
}
|
||||
} else if p.canLoadObject(ofClass: UIImage.self) {
|
||||
p.loadObject(ofClass: UIImage.self) { image, error in
|
||||
DispatchQueue.main.async {
|
||||
self.loadImage(object: image, error: error)
|
||||
@@ -72,8 +82,10 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
if let error = error {
|
||||
logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
} else if let image = object as? UIImage {
|
||||
images.append(image)
|
||||
images.append(.simpleImage(image: image))
|
||||
logger.log("LibraryImageListPicker: added image")
|
||||
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
|
||||
images.append(image)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.imageCount -= 1
|
||||
@@ -105,20 +117,18 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
struct CameraImageListPicker: View {
|
||||
@Binding var images: [UIImage]
|
||||
@Binding var images: [UploadContent]
|
||||
@State var image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
CameraImagePicker(image: $image)
|
||||
.onChange(of: image) { images = imageList($0) }
|
||||
}
|
||||
}
|
||||
|
||||
func imageList(_ img: UIImage?) -> [UIImage] {
|
||||
if let img = img {
|
||||
return [img]
|
||||
} else {
|
||||
return []
|
||||
.onChange(of: image) { img in
|
||||
if let img = img {
|
||||
images = [UploadContent.simpleImage(image: img)]
|
||||
} else {
|
||||
images = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ struct CreateProfile: View {
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
194
apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
Normal file
194
apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
Normal file
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// WhatsNewView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 24/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private struct VersionDescription {
|
||||
var version: String
|
||||
var features: [FeatureDescription]
|
||||
}
|
||||
|
||||
private struct FeatureDescription {
|
||||
var icon: String
|
||||
var title: LocalizedStringKey
|
||||
var description: LocalizedStringKey
|
||||
}
|
||||
|
||||
private let versionDescriptions: [VersionDescription] = [
|
||||
VersionDescription(
|
||||
version: "v4.2",
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Security assessment",
|
||||
description: "SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "person.2",
|
||||
title: "Group links",
|
||||
description: "Admins can create the links to join groups."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "checkmark",
|
||||
title: "Auto-accept contact requests",
|
||||
description: "With optional welcome message."
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.3",
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "mic",
|
||||
title: "Voice messages",
|
||||
description: "Max 30 seconds, received instantly."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "trash.slash",
|
||||
title: "Irreversible message deletion",
|
||||
description: "Your contacts can allow full message deletion."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Improved server configuration",
|
||||
description: "Add servers by scanning QR codes."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "eye.slash",
|
||||
title: "Improved privacy and security",
|
||||
description: "Hide app screen in the recent apps."
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.4",
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "stopwatch",
|
||||
title: "Disappearing messages",
|
||||
description: "Sent messages will be deleted after set time."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "ellipsis.circle",
|
||||
title: "Live messages",
|
||||
description: "Recipients see updates as you type them."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Verify connection security",
|
||||
description: "Compare security codes with your contacts."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "camera",
|
||||
title: "GIFs and stickers",
|
||||
description: "Send them from gallery or custom keyboards."
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
|
||||
func setLastVersionDefault() {
|
||||
UserDefaults.standard.set(lastVersion, forKey: DEFAULT_WHATS_NEW_VERSION)
|
||||
}
|
||||
|
||||
func shouldShowWhatsNew() -> Bool {
|
||||
let v = UserDefaults.standard.string(forKey: DEFAULT_WHATS_NEW_VERSION)
|
||||
setLastVersionDefault()
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
struct WhatsNewView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var currentVersion = versionDescriptions.count - 1
|
||||
@State var currentVersionNav = versionDescriptions.count - 1
|
||||
var viaSettings = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TabView(selection: $currentVersion) {
|
||||
ForEach(0..<3) { i in
|
||||
let v = versionDescriptions[i]
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("New in \(v.version)")
|
||||
.font(.title)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
ForEach(v.features, id: \.icon) { f in
|
||||
featureDescription(f.icon, f.title, f.description)
|
||||
}
|
||||
if !viaSettings {
|
||||
Spacer()
|
||||
Button("Ok") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title3)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.tag(i)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
Spacer()
|
||||
pagination()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
Image(systemName: icon).foregroundColor(.secondary)
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
Text(description)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func pagination() -> some View {
|
||||
HStack {
|
||||
if currentVersionNav > 0 {
|
||||
let prev = currentVersionNav - 1
|
||||
Button {
|
||||
currentVersionNav = prev
|
||||
withAnimation { currentVersion = prev }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "chevron.left")
|
||||
Text(versionDescriptions[prev].version)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if currentVersionNav < versionDescriptions.count - 1 {
|
||||
let next = currentVersionNav + 1
|
||||
Button {
|
||||
currentVersionNav = next
|
||||
withAnimation { currentVersion = next }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(versionDescriptions[next].version)
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewFeaturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WhatsNewView()
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ struct TerminalView: View {
|
||||
composeState: $composeState,
|
||||
sendMessage: sendMessage,
|
||||
showVoiceMessageButton: false,
|
||||
onImagesAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -26,7 +26,6 @@ struct MarkdownHelp: View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ struct PreferencesView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
timedMessagesFeatureSection($preferences.timedMessages.allow)
|
||||
featureSection(.fullDelete, $preferences.fullDelete.allow)
|
||||
featureSection(.voice, $preferences.voice.allow)
|
||||
|
||||
@@ -40,10 +41,27 @@ struct PreferencesView: View {
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.allowDescription(allowFeature.wrappedValue))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
footer: { featureFooter(feature, allowFeature) }
|
||||
|
||||
}
|
||||
|
||||
private func timedMessagesFeatureSection(_ allowFeature: Binding<FeatureAllowed>) -> some View {
|
||||
Section {
|
||||
let allow = Binding(
|
||||
get: { allowFeature.wrappedValue == .always || allowFeature.wrappedValue == .yes },
|
||||
set: { yes, _ in allowFeature.wrappedValue = yes ? .yes : .no }
|
||||
)
|
||||
settingsRow(ChatFeature.timedMessages.icon) {
|
||||
Toggle(ChatFeature.timedMessages.text, isOn: allow)
|
||||
}
|
||||
}
|
||||
footer: { featureFooter(.timedMessages, allowFeature) }
|
||||
}
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
|
||||
Text(ChatFeature.timedMessages.allowDescription(allowFeature.wrappedValue))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
|
||||
@@ -47,7 +47,7 @@ struct ScanSMPServer: View {
|
||||
showAddressError = true
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
logger.error("ScanSMPServer.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen"
|
||||
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue"
|
||||
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
@@ -48,7 +50,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
|
||||
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
|
||||
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: "description",
|
||||
DEFAULT_PRIVACY_PROTECT_SCREEN: true,
|
||||
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
||||
DEFAULT_EXPERIMENTAL_CALLS: false,
|
||||
DEFAULT_CHAT_V3_DB_MIGRATION: "offer",
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
@@ -57,7 +59,8 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_ACCENT_COLOR_GREEN: 0.533,
|
||||
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
|
||||
DEFAULT_USER_INTERFACE_STYLE: 0,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: "scan"
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: "scan",
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false
|
||||
]
|
||||
|
||||
enum SimpleXLinkMode: String, Identifiable {
|
||||
@@ -191,6 +194,12 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("questionmark") { Text("How to use it") }
|
||||
}
|
||||
NavigationLink {
|
||||
WhatsNewView(viaSettings: true)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} label: {
|
||||
settingsRow("plus") { Text("What's new") }
|
||||
}
|
||||
NavigationLink {
|
||||
SimpleXInfo(onboarding: false)
|
||||
.navigationBarTitle("", displayMode: .inline)
|
||||
@@ -198,13 +207,14 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("info") { Text("About SimpleX Chat") }
|
||||
}
|
||||
NavigationLink {
|
||||
MarkdownHelp()
|
||||
.navigationTitle("How to use markdown")
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
} label: {
|
||||
settingsRow("textformat") { Text("Markdown in messages") }
|
||||
}
|
||||
// NavigationLink {
|
||||
// MarkdownHelp()
|
||||
// .padding()
|
||||
// .navigationTitle("How to use markdown")
|
||||
// .frame(maxHeight: .infinity, alignment: .top)
|
||||
// } label: {
|
||||
// settingsRow("textformat") { Text("Markdown in messages") }
|
||||
// }
|
||||
settingsRow("number") {
|
||||
Button("Send questions and ideas") {
|
||||
showSettings = false
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<target> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<source> </source>
|
||||
<target> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (" xml:space="preserve">
|
||||
<source> (</source>
|
||||
<target> (</target>
|
||||
@@ -57,11 +62,39 @@
|
||||
<target>%@ ist mit Ihnen verbunden!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is not verified" xml:space="preserve">
|
||||
<source>%@ is not verified</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is verified" xml:space="preserve">
|
||||
<source>%@ is verified</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ wants to connect!" xml:space="preserve">
|
||||
<source>%@ wants to connect!</source>
|
||||
<target>%@ will sich mit Ihnen verbinden!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d days" xml:space="preserve">
|
||||
<source>%d days</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d hours" xml:space="preserve">
|
||||
<source>%d hours</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d min" xml:space="preserve">
|
||||
<source>%d min</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d months" xml:space="preserve">
|
||||
<source>%d months</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d sec" xml:space="preserve">
|
||||
<source>%d sec</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target>%d übersprungene Nachricht(en)</target>
|
||||
@@ -97,11 +130,35 @@
|
||||
<target>%lld Sekunde(n)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldd" xml:space="preserve">
|
||||
<source>%lldd</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldh" xml:space="preserve">
|
||||
<source>%lldh</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldk" xml:space="preserve">
|
||||
<source>%lldk</source>
|
||||
<target>%lldk</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldm" xml:space="preserve">
|
||||
<source>%lldm</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldmth" xml:space="preserve">
|
||||
<source>%lldmth</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%llds" xml:space="preserve">
|
||||
<source>%llds</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldw" xml:space="preserve">
|
||||
<source>%lldw</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="(" xml:space="preserve">
|
||||
<source>(</source>
|
||||
<target>(</target>
|
||||
@@ -177,20 +234,33 @@
|
||||
<target>, </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="." xml:space="preserve">
|
||||
<source>.</source>
|
||||
<target>.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 day" xml:space="preserve">
|
||||
<source>1 day</source>
|
||||
<target>täglich</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 hour" xml:space="preserve">
|
||||
<source>1 hour</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 month" xml:space="preserve">
|
||||
<source>1 month</source>
|
||||
<target>monatlich</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 week" xml:space="preserve">
|
||||
<source>1 week</source>
|
||||
<target>wöchentlich</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="2 weeks" xml:space="preserve">
|
||||
<source>2 weeks</source>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="6" xml:space="preserve">
|
||||
<source>6</source>
|
||||
@@ -263,6 +333,10 @@
|
||||
<target>Füge voreingestellte Server hinzu</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve">
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<target>Füge Server hinzu…</target>
|
||||
@@ -273,6 +347,10 @@
|
||||
<target>Einem anderen Gerät hinzufügen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Admins can create the links to join groups." xml:space="preserve">
|
||||
<source>Admins can create the links to join groups.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Advanced network settings" xml:space="preserve">
|
||||
<source>Advanced network settings</source>
|
||||
<target>Erweiterte Netzwerkeinstellungen</target>
|
||||
@@ -298,6 +376,10 @@
|
||||
<target>Erlauben</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow disappearing messages only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow disappearing messages only if your contact allows it to you.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||
<target>Erlauben Sie das unwiederbringliche löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.</target>
|
||||
@@ -308,6 +390,10 @@
|
||||
<target>Erlauben Sie das Senden von Direktnachrichten an Mitglieder</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow sending disappearing messages." xml:space="preserve">
|
||||
<source>Allow sending disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow to irreversibly delete sent messages.</source>
|
||||
<target>Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.</target>
|
||||
@@ -333,6 +419,10 @@
|
||||
<target>Erlauben Sie Ihren Kontakten gesendete Nachrichten unwiederbringlich zu löschen.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send disappearing messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send voice messages.</source>
|
||||
<target>Erlauben Sie Ihren Kontakten Sprachnachrichten zu senden.</target>
|
||||
@@ -378,6 +468,10 @@
|
||||
<target>Authentifizierung nicht verfügbar</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept contact requests" xml:space="preserve">
|
||||
<source>Auto-accept contact requests</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept images" xml:space="preserve">
|
||||
<source>Auto-accept images</source>
|
||||
<target>Bilder automatisch akzeptieren</target>
|
||||
@@ -398,6 +492,10 @@
|
||||
<target>Sowohl Ihr Kontakt, als auch Sie können gesendete Nachrichten unwiederbringlich löschen.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send disappearing messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send voice messages.</source>
|
||||
<target>Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden.</target>
|
||||
@@ -543,11 +641,19 @@
|
||||
<target>Unterhaltung löschen?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Clear verification" xml:space="preserve">
|
||||
<source>Clear verification</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Colors" xml:space="preserve">
|
||||
<source>Colors</source>
|
||||
<target>Farben</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Compare security codes with your contacts." xml:space="preserve">
|
||||
<source>Compare security codes with your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Configure ICE servers" xml:space="preserve">
|
||||
<source>Configure ICE servers</source>
|
||||
<target>ICE-Server konfigurieren</target>
|
||||
@@ -608,6 +714,11 @@
|
||||
<target>Mit dem Server verbinden… (Fehler: %@)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connection" xml:space="preserve">
|
||||
<source>Connection</source>
|
||||
<target>Verbindung</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connection error" xml:space="preserve">
|
||||
<source>Connection error</source>
|
||||
<target>Verbindungsfehler</target>
|
||||
@@ -633,6 +744,11 @@
|
||||
<target>Verbindungszeitüberschreitung</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact allows" xml:space="preserve">
|
||||
<source>Contact allows</source>
|
||||
<target>Der Kontakt erlaubt</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>
|
||||
@@ -693,6 +809,10 @@
|
||||
<target>Adresse erstellen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Create group link" xml:space="preserve">
|
||||
<source>Create group link</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Create link" xml:space="preserve">
|
||||
<source>Create link</source>
|
||||
<target>Link erzeugen</target>
|
||||
@@ -743,6 +863,11 @@
|
||||
<target>Daten</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Database ID" xml:space="preserve">
|
||||
<source>Database ID</source>
|
||||
<target>Datenbank-ID</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Database encrypted!" xml:space="preserve">
|
||||
<source>Database encrypted!</source>
|
||||
<target>Datenbank verschlüsselt!</target>
|
||||
@@ -841,6 +966,10 @@
|
||||
<target>Adresse löschen?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Delete after" xml:space="preserve">
|
||||
<source>Delete after</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Delete archive" xml:space="preserve">
|
||||
<source>Delete archive</source>
|
||||
<target>Archiv löschen</target>
|
||||
@@ -1006,6 +1135,18 @@
|
||||
<target>SimpleX Sperre deaktivieren</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages" xml:space="preserve">
|
||||
<source>Disappearing messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this chat." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
<source>Disconnect</source>
|
||||
<target>Trennen</target>
|
||||
@@ -1371,6 +1512,15 @@
|
||||
<target>Vollständiger Name:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="GIFs and stickers" xml:space="preserve">
|
||||
<source>GIFs and stickers</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group" xml:space="preserve">
|
||||
<source>Group</source>
|
||||
<target>Gruppe</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group display name" xml:space="preserve">
|
||||
<source>Group display name</source>
|
||||
<target>Anzeigename der Gruppe</target>
|
||||
@@ -1406,6 +1556,10 @@
|
||||
<target>Gruppen-Link</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group links" xml:space="preserve">
|
||||
<source>Group links</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<target>Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.</target>
|
||||
@@ -1416,6 +1570,10 @@
|
||||
<target>Gruppenmitglieder können Direktnachrichten versenden.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<target>Gruppenmitglieder können Sprachnachrichten senden.</target>
|
||||
@@ -1466,6 +1624,10 @@
|
||||
<target>Verbergen</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Hide app screen in the recent apps." xml:space="preserve">
|
||||
<source>Hide app screen in the recent apps.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="How SimpleX works" xml:space="preserve">
|
||||
<source>How SimpleX works</source>
|
||||
<target>Wie SimpleX funktioniert</target>
|
||||
@@ -1486,11 +1648,6 @@
|
||||
<target>Wie man SimpleX nutzt</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="How to use markdown" xml:space="preserve">
|
||||
<source>How to use markdown</source>
|
||||
<target>Markdowns verwenden</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="How to use your servers" xml:space="preserve">
|
||||
<source>How to use your servers</source>
|
||||
<target>Wie Sie Ihre Server nutzen</target>
|
||||
@@ -1551,6 +1708,14 @@
|
||||
<target>Datenbank importieren</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Improved privacy and security" xml:space="preserve">
|
||||
<source>Improved privacy and security</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Improved server configuration" xml:space="preserve">
|
||||
<source>Improved server configuration</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Incognito" xml:space="preserve">
|
||||
<source>Incognito</source>
|
||||
<target>Inkognito</target>
|
||||
@@ -1586,6 +1751,10 @@
|
||||
<target>Eingehender Videoanruf</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Incorrect security code!" xml:space="preserve">
|
||||
<source>Incorrect security code!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
|
||||
<source>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</source>
|
||||
<target>Installieren Sie [SimpleX Chat als Terminalanwendung](https://github.com/simplex-chat/simplex-chat)</target>
|
||||
@@ -1628,6 +1797,10 @@
|
||||
<target>In Gruppe einladen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion" xml:space="preserve">
|
||||
<source>Irreversible message deletion</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<target>In diesem Chat ist das unwiederbringliche Löschen von Nachrichten untersagt.</target>
|
||||
@@ -1688,6 +1861,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Schlüsselbundfehler</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="LIVE" xml:space="preserve">
|
||||
<source>LIVE</source>
|
||||
<target>LIVE</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Large file!" xml:space="preserve">
|
||||
<source>Large file!</source>
|
||||
<target>Große Datei!</target>
|
||||
@@ -1718,6 +1896,19 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Einschränkungen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Live message!" xml:space="preserve">
|
||||
<source>Live message!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Live messages" xml:space="preserve">
|
||||
<source>Live messages</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Local name" xml:space="preserve">
|
||||
<source>Local name</source>
|
||||
<target>Lokaler Name</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Make a private connection" xml:space="preserve">
|
||||
<source>Make a private connection</source>
|
||||
<target>Stellen Sie eine private Verbindung her</target>
|
||||
@@ -1748,11 +1939,19 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Als gelesen markieren</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Mark verified" xml:space="preserve">
|
||||
<source>Mark verified</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Markdown in messages" xml:space="preserve">
|
||||
<source>Markdown in messages</source>
|
||||
<target>Markdowns in Nachrichten</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Max 30 seconds, received instantly." xml:space="preserve">
|
||||
<source>Max 30 seconds, received instantly.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Member" xml:space="preserve">
|
||||
<source>Member</source>
|
||||
<target>Mitglied</target>
|
||||
@@ -1853,6 +2052,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Neues Datenbankarchiv</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New in %@" xml:space="preserve">
|
||||
<source>New in %@</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New member role" xml:space="preserve">
|
||||
<source>New member role</source>
|
||||
<target>Neue Mitgliedsrolle</target>
|
||||
@@ -1973,6 +2176,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Nur Sie können Nachrichten unwiederbringlich löschen (Ihr Kontakt kann sie zum Löschen markieren).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send disappearing messages." xml:space="preserve">
|
||||
<source>Only you can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||
<source>Only you can send voice messages.</source>
|
||||
<target>Nur Sie können Sprachnachrichten senden.</target>
|
||||
@@ -1983,6 +2190,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Nur Ihr Kontakt kann Nachrichten unwiederbringlich löschen (Sie können sie zum Löschen markieren).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send disappearing messages." xml:space="preserve">
|
||||
<source>Only your contact can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||
<source>Only your contact can send voice messages.</source>
|
||||
<target>Nur Ihr Kontakt kann Sprachnachrichten senden.</target>
|
||||
@@ -2133,6 +2344,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Verbieten Sie das Senden von Direktnachrichten an Mitglieder</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending disappearing messages." xml:space="preserve">
|
||||
<source>Prohibit sending disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||
<source>Prohibit sending voice messages.</source>
|
||||
<target>Senden von Sprachnachrichten untersagen.</target>
|
||||
@@ -2183,6 +2398,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Empfangen über</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Recipients see updates as you type them." xml:space="preserve">
|
||||
<source>Recipients see updates as you type them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reject" xml:space="preserve">
|
||||
<source>Reject</source>
|
||||
<target>Ablehnen</target>
|
||||
@@ -2293,6 +2512,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Zurückkehren</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Role" xml:space="preserve">
|
||||
<source>Role</source>
|
||||
<target>Rolle</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Run chat" xml:space="preserve">
|
||||
<source>Run chat</source>
|
||||
<target>Chat starten</target>
|
||||
@@ -2363,6 +2587,14 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>QR-Code scannen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scan code" xml:space="preserve">
|
||||
<source>Scan code</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scan security code from your contact's app." xml:space="preserve">
|
||||
<source>Scan security code from your contact's app.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scan server QR code" xml:space="preserve">
|
||||
<source>Scan server QR code</source>
|
||||
<target>Scannen Sie den QR-Code des Servers</target>
|
||||
@@ -2378,6 +2610,22 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Sichere Warteschlange</target>
|
||||
<note>server test step</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Security assessment" xml:space="preserve">
|
||||
<source>Security assessment</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Security code" xml:space="preserve">
|
||||
<source>Security code</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send" xml:space="preserve">
|
||||
<source>Send</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send a live message - it will update for the recipient(s) as you type it" xml:space="preserve">
|
||||
<source>Send a live message - it will update for the recipient(s) as you type it</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send direct message" xml:space="preserve">
|
||||
<source>Send direct message</source>
|
||||
<target>Direktnachricht senden</target>
|
||||
@@ -2388,6 +2636,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Link-Vorschau senden</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send live message" xml:space="preserve">
|
||||
<source>Send live message</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send notifications" xml:space="preserve">
|
||||
<source>Send notifications</source>
|
||||
<target>Benachrichtigungen senden</target>
|
||||
@@ -2403,6 +2655,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Senden Sie Fragen und Ideen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send them from gallery or custom keyboards." xml:space="preserve">
|
||||
<source>Send them from gallery or custom keyboards.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sender cancelled file transfer." xml:space="preserve">
|
||||
<source>Sender cancelled file transfer.</source>
|
||||
<target>Der Absender hat die Dateiübertragung abgebrochen.</target>
|
||||
@@ -2423,6 +2679,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Datei-Ereignis wurde gesendet</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sent messages will be deleted after set time." xml:space="preserve">
|
||||
<source>Sent messages will be deleted after set time.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Server requires authorization to create queues, check password" xml:space="preserve">
|
||||
<source>Server requires authorization to create queues, check password</source>
|
||||
<target>Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort.</target>
|
||||
@@ -2493,6 +2753,10 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
|
||||
<target>Vorschau anzeigen</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
<source>SimpleX Lock</source>
|
||||
<target>SimpleX Sperre</target>
|
||||
@@ -2795,6 +3059,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt
|
||||
<target>Um sofortige Push-Benachrichtigungen zu unterstützen, muss die Chat-Datenbank migriert werden.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To verify end-to-end encryption with your contact compare (or scan) the code on your devices." xml:space="preserve">
|
||||
<source>To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Transfer images faster" xml:space="preserve">
|
||||
<source>Transfer images faster</source>
|
||||
<target>Bilder schneller übertragen</target>
|
||||
@@ -2937,6 +3205,14 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
||||
<target>Verwende SimpleX Chat Server.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Verify connection security" xml:space="preserve">
|
||||
<source>Verify connection security</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Verify security code" xml:space="preserve">
|
||||
<source>Verify security code</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Via browser" xml:space="preserve">
|
||||
<source>Via browser</source>
|
||||
<target>Über den Browser</target>
|
||||
@@ -2947,6 +3223,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
||||
<target>Videoanruf</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="View security code" xml:space="preserve">
|
||||
<source>View security code</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages" xml:space="preserve">
|
||||
<source>Voice messages</source>
|
||||
<target>Sprachnachrichten</target>
|
||||
@@ -2997,6 +3277,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
||||
<target>Begrüßungsmeldung</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="What's new" xml:space="preserve">
|
||||
<source>What's new</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="When available" xml:space="preserve">
|
||||
<source>When available</source>
|
||||
<target>Wenn verfügbar</target>
|
||||
@@ -3007,6 +3291,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
|
||||
<target>Wenn Sie ein Inkognito-Profil mit Jemandem teilen, wird dieses Profil auch für die Gruppen verwendet, für die Sie von diesem Kontakt eingeladen werden.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="With optional welcome message." xml:space="preserve">
|
||||
<source>With optional welcome message.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Wrong database passphrase" xml:space="preserve">
|
||||
<source>Wrong database passphrase</source>
|
||||
<target>Falsches Datenbank-Passwort</target>
|
||||
@@ -3249,6 +3537,10 @@ Sie können diese Verbindung abbrechen und den Kontakt entfernen (und es später
|
||||
<target>Ihr Kontakt hat eine Datei gesendet, die größer ist als die derzeit unterstützte maximale Größe (%@).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your contacts can allow full message deletion." xml:space="preserve">
|
||||
<source>Your contacts can allow full message deletion.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your current chat database will be DELETED and REPLACED with the imported one." xml:space="preserve">
|
||||
<source>Your current chat database will be DELETED and REPLACED with the imported one.</source>
|
||||
<target>Ihre aktuelle Chat-Datenbank wird GELÖSCHT und durch die Importierte ERSETZT.</target>
|
||||
@@ -3281,6 +3573,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
|
||||
<target>Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your random profile" xml:space="preserve">
|
||||
<source>Your random profile</source>
|
||||
<target>Ihr Zufallsprofil</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your server" xml:space="preserve">
|
||||
<source>Your server</source>
|
||||
<target>Ihr Server</target>
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<target> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<source> </source>
|
||||
<target> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (" xml:space="preserve">
|
||||
<source> (</source>
|
||||
<target> (</target>
|
||||
@@ -57,11 +62,46 @@
|
||||
<target>%@ is connected!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is not verified" xml:space="preserve">
|
||||
<source>%@ is not verified</source>
|
||||
<target>%@ is not verified</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is verified" xml:space="preserve">
|
||||
<source>%@ is verified</source>
|
||||
<target>%@ is verified</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ wants to connect!" xml:space="preserve">
|
||||
<source>%@ wants to connect!</source>
|
||||
<target>%@ wants to connect!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d days" xml:space="preserve">
|
||||
<source>%d days</source>
|
||||
<target>%d days</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d hours" xml:space="preserve">
|
||||
<source>%d hours</source>
|
||||
<target>%d hours</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d min" xml:space="preserve">
|
||||
<source>%d min</source>
|
||||
<target>%d min</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d months" xml:space="preserve">
|
||||
<source>%d months</source>
|
||||
<target>%d months</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d sec" xml:space="preserve">
|
||||
<source>%d sec</source>
|
||||
<target>%d sec</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target>%d skipped message(s)</target>
|
||||
@@ -97,11 +137,41 @@
|
||||
<target>%lld second(s)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldd" xml:space="preserve">
|
||||
<source>%lldd</source>
|
||||
<target>%lldd</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldh" xml:space="preserve">
|
||||
<source>%lldh</source>
|
||||
<target>%lldh</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldk" xml:space="preserve">
|
||||
<source>%lldk</source>
|
||||
<target>%lldk</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldm" xml:space="preserve">
|
||||
<source>%lldm</source>
|
||||
<target>%lldm</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldmth" xml:space="preserve">
|
||||
<source>%lldmth</source>
|
||||
<target>%lldmth</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%llds" xml:space="preserve">
|
||||
<source>%llds</source>
|
||||
<target>%llds</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldw" xml:space="preserve">
|
||||
<source>%lldw</source>
|
||||
<target>%lldw</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="(" xml:space="preserve">
|
||||
<source>(</source>
|
||||
<target>(</target>
|
||||
@@ -177,20 +247,35 @@
|
||||
<target>, </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="." xml:space="preserve">
|
||||
<source>.</source>
|
||||
<target>.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 day" xml:space="preserve">
|
||||
<source>1 day</source>
|
||||
<target>1 day</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 hour" xml:space="preserve">
|
||||
<source>1 hour</source>
|
||||
<target>1 hour</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 month" xml:space="preserve">
|
||||
<source>1 month</source>
|
||||
<target>1 month</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="1 week" xml:space="preserve">
|
||||
<source>1 week</source>
|
||||
<target>1 week</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="2 weeks" xml:space="preserve">
|
||||
<source>2 weeks</source>
|
||||
<target>2 weeks</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="6" xml:space="preserve">
|
||||
<source>6</source>
|
||||
@@ -263,6 +348,11 @@
|
||||
<target>Add preset servers</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve">
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<target>Add servers by scanning QR codes.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<target>Add server…</target>
|
||||
@@ -273,6 +363,11 @@
|
||||
<target>Add to another device</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Admins can create the links to join groups." xml:space="preserve">
|
||||
<source>Admins can create the links to join groups.</source>
|
||||
<target>Admins can create the links to join groups.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Advanced network settings" xml:space="preserve">
|
||||
<source>Advanced network settings</source>
|
||||
<target>Advanced network settings</target>
|
||||
@@ -298,6 +393,11 @@
|
||||
<target>Allow</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow disappearing messages only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow disappearing messages only if your contact allows it to you.</source>
|
||||
<target>Allow disappearing messages only if your contact allows it to you.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow irreversible message deletion only if your contact allows it to you." xml:space="preserve">
|
||||
<source>Allow irreversible message deletion only if your contact allows it to you.</source>
|
||||
<target>Allow irreversible message deletion only if your contact allows it to you.</target>
|
||||
@@ -308,6 +408,11 @@
|
||||
<target>Allow sending direct messages to members.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow sending disappearing messages." xml:space="preserve">
|
||||
<source>Allow sending disappearing messages.</source>
|
||||
<target>Allow sending disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Allow to irreversibly delete sent messages.</source>
|
||||
<target>Allow to irreversibly delete sent messages.</target>
|
||||
@@ -333,6 +438,11 @@
|
||||
<target>Allow your contacts to irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send disappearing messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send disappearing messages.</source>
|
||||
<target>Allow your contacts to send disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Allow your contacts to send voice messages." xml:space="preserve">
|
||||
<source>Allow your contacts to send voice messages.</source>
|
||||
<target>Allow your contacts to send voice messages.</target>
|
||||
@@ -378,6 +488,11 @@
|
||||
<target>Authentication unavailable</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept contact requests" xml:space="preserve">
|
||||
<source>Auto-accept contact requests</source>
|
||||
<target>Auto-accept contact requests</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Auto-accept images" xml:space="preserve">
|
||||
<source>Auto-accept images</source>
|
||||
<target>Auto-accept images</target>
|
||||
@@ -398,6 +513,11 @@
|
||||
<target>Both you and your contact can irreversibly delete sent messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send disappearing messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send disappearing messages.</source>
|
||||
<target>Both you and your contact can send disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Both you and your contact can send voice messages." xml:space="preserve">
|
||||
<source>Both you and your contact can send voice messages.</source>
|
||||
<target>Both you and your contact can send voice messages.</target>
|
||||
@@ -543,11 +663,21 @@
|
||||
<target>Clear conversation?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Clear verification" xml:space="preserve">
|
||||
<source>Clear verification</source>
|
||||
<target>Clear verification</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Colors" xml:space="preserve">
|
||||
<source>Colors</source>
|
||||
<target>Colors</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Compare security codes with your contacts." xml:space="preserve">
|
||||
<source>Compare security codes with your contacts.</source>
|
||||
<target>Compare security codes with your contacts.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Configure ICE servers" xml:space="preserve">
|
||||
<source>Configure ICE servers</source>
|
||||
<target>Configure ICE servers</target>
|
||||
@@ -608,6 +738,11 @@
|
||||
<target>Connecting to server… (error: %@)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connection" xml:space="preserve">
|
||||
<source>Connection</source>
|
||||
<target>Connection</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Connection error" xml:space="preserve">
|
||||
<source>Connection error</source>
|
||||
<target>Connection error</target>
|
||||
@@ -633,6 +768,11 @@
|
||||
<target>Connection timeout</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Contact allows" xml:space="preserve">
|
||||
<source>Contact allows</source>
|
||||
<target>Contact allows</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>
|
||||
@@ -693,6 +833,11 @@
|
||||
<target>Create address</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Create group link" xml:space="preserve">
|
||||
<source>Create group link</source>
|
||||
<target>Create group link</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>
|
||||
@@ -743,6 +888,11 @@
|
||||
<target>Data</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Database ID" xml:space="preserve">
|
||||
<source>Database ID</source>
|
||||
<target>Database ID</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Database encrypted!" xml:space="preserve">
|
||||
<source>Database encrypted!</source>
|
||||
<target>Database encrypted!</target>
|
||||
@@ -841,6 +991,11 @@
|
||||
<target>Delete address?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Delete after" xml:space="preserve">
|
||||
<source>Delete after</source>
|
||||
<target>Delete after</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Delete archive" xml:space="preserve">
|
||||
<source>Delete archive</source>
|
||||
<target>Delete archive</target>
|
||||
@@ -1006,6 +1161,21 @@
|
||||
<target>Disable SimpleX Lock</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages" xml:space="preserve">
|
||||
<source>Disappearing messages</source>
|
||||
<target>Disappearing messages</target>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this chat." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<target>Disappearing messages are prohibited in this chat.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<target>Disappearing messages are prohibited in this group.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
<source>Disconnect</source>
|
||||
<target>Disconnect</target>
|
||||
@@ -1371,6 +1541,16 @@
|
||||
<target>Full name:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="GIFs and stickers" xml:space="preserve">
|
||||
<source>GIFs and stickers</source>
|
||||
<target>GIFs and stickers</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group" xml:space="preserve">
|
||||
<source>Group</source>
|
||||
<target>Group</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group display name" xml:space="preserve">
|
||||
<source>Group display name</source>
|
||||
<target>Group display name</target>
|
||||
@@ -1406,6 +1586,11 @@
|
||||
<target>Group link</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group links" xml:space="preserve">
|
||||
<source>Group links</source>
|
||||
<target>Group links</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<target>Group members can irreversibly delete sent messages.</target>
|
||||
@@ -1416,6 +1601,11 @@
|
||||
<target>Group members can send direct messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<target>Group members can send disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<target>Group members can send voice messages.</target>
|
||||
@@ -1466,6 +1656,11 @@
|
||||
<target>Hide</target>
|
||||
<note>chat item action</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Hide app screen in the recent apps." xml:space="preserve">
|
||||
<source>Hide app screen in the recent apps.</source>
|
||||
<target>Hide app screen in the recent apps.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="How SimpleX works" xml:space="preserve">
|
||||
<source>How SimpleX works</source>
|
||||
<target>How SimpleX works</target>
|
||||
@@ -1486,11 +1681,6 @@
|
||||
<target>How to use it</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="How to use markdown" xml:space="preserve">
|
||||
<source>How to use markdown</source>
|
||||
<target>How to use markdown</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="How to use your servers" xml:space="preserve">
|
||||
<source>How to use your servers</source>
|
||||
<target>How to use your servers</target>
|
||||
@@ -1551,6 +1741,16 @@
|
||||
<target>Import database</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Improved privacy and security" xml:space="preserve">
|
||||
<source>Improved privacy and security</source>
|
||||
<target>Improved privacy and security</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Improved server configuration" xml:space="preserve">
|
||||
<source>Improved server configuration</source>
|
||||
<target>Improved server configuration</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Incognito" xml:space="preserve">
|
||||
<source>Incognito</source>
|
||||
<target>Incognito</target>
|
||||
@@ -1586,6 +1786,11 @@
|
||||
<target>Incoming video call</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Incorrect security code!" xml:space="preserve">
|
||||
<source>Incorrect security code!</source>
|
||||
<target>Incorrect security code!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" xml:space="preserve">
|
||||
<source>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</source>
|
||||
<target>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</target>
|
||||
@@ -1628,6 +1833,11 @@
|
||||
<target>Invite to group</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion" xml:space="preserve">
|
||||
<source>Irreversible message deletion</source>
|
||||
<target>Irreversible message deletion</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this chat." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<target>Irreversible message deletion is prohibited in this chat.</target>
|
||||
@@ -1688,6 +1898,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Keychain error</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="LIVE" xml:space="preserve">
|
||||
<source>LIVE</source>
|
||||
<target>LIVE</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Large file!" xml:space="preserve">
|
||||
<source>Large file!</source>
|
||||
<target>Large file!</target>
|
||||
@@ -1718,6 +1933,21 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Limitations</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Live message!" xml:space="preserve">
|
||||
<source>Live message!</source>
|
||||
<target>Live message!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Live messages" xml:space="preserve">
|
||||
<source>Live messages</source>
|
||||
<target>Live messages</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Local name" xml:space="preserve">
|
||||
<source>Local name</source>
|
||||
<target>Local name</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Make a private connection" xml:space="preserve">
|
||||
<source>Make a private connection</source>
|
||||
<target>Make a private connection</target>
|
||||
@@ -1748,11 +1978,21 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Mark read</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Mark verified" xml:space="preserve">
|
||||
<source>Mark verified</source>
|
||||
<target>Mark verified</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Markdown in messages" xml:space="preserve">
|
||||
<source>Markdown in messages</source>
|
||||
<target>Markdown in messages</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Max 30 seconds, received instantly." xml:space="preserve">
|
||||
<source>Max 30 seconds, received instantly.</source>
|
||||
<target>Max 30 seconds, received instantly.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Member" xml:space="preserve">
|
||||
<source>Member</source>
|
||||
<target>Member</target>
|
||||
@@ -1853,6 +2093,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>New database archive</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New in %@" xml:space="preserve">
|
||||
<source>New in %@</source>
|
||||
<target>New in %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="New member role" xml:space="preserve">
|
||||
<source>New member role</source>
|
||||
<target>New member role</target>
|
||||
@@ -1973,6 +2218,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Only you can irreversibly delete messages (your contact can mark them for deletion).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send disappearing messages." xml:space="preserve">
|
||||
<source>Only you can send disappearing messages.</source>
|
||||
<target>Only you can send disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only you can send voice messages." xml:space="preserve">
|
||||
<source>Only you can send voice messages.</source>
|
||||
<target>Only you can send voice messages.</target>
|
||||
@@ -1983,6 +2233,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Only your contact can irreversibly delete messages (you can mark them for deletion).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send disappearing messages." xml:space="preserve">
|
||||
<source>Only your contact can send disappearing messages.</source>
|
||||
<target>Only your contact can send disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only your contact can send voice messages." xml:space="preserve">
|
||||
<source>Only your contact can send voice messages.</source>
|
||||
<target>Only your contact can send voice messages.</target>
|
||||
@@ -2133,6 +2388,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Prohibit sending direct messages to members.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending disappearing messages." xml:space="preserve">
|
||||
<source>Prohibit sending disappearing messages.</source>
|
||||
<target>Prohibit sending disappearing messages.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
|
||||
<source>Prohibit sending voice messages.</source>
|
||||
<target>Prohibit sending voice messages.</target>
|
||||
@@ -2183,6 +2443,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Receiving via</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Recipients see updates as you type them." xml:space="preserve">
|
||||
<source>Recipients see updates as you type them.</source>
|
||||
<target>Recipients see updates as you type them.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Reject" xml:space="preserve">
|
||||
<source>Reject</source>
|
||||
<target>Reject</target>
|
||||
@@ -2293,6 +2558,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Revert</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Role" xml:space="preserve">
|
||||
<source>Role</source>
|
||||
<target>Role</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Run chat" xml:space="preserve">
|
||||
<source>Run chat</source>
|
||||
<target>Run chat</target>
|
||||
@@ -2363,6 +2633,16 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Scan QR code</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scan code" xml:space="preserve">
|
||||
<source>Scan code</source>
|
||||
<target>Scan code</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scan security code from your contact's app." xml:space="preserve">
|
||||
<source>Scan security code from your contact's app.</source>
|
||||
<target>Scan security code from your contact's app.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Scan server QR code" xml:space="preserve">
|
||||
<source>Scan server QR code</source>
|
||||
<target>Scan server QR code</target>
|
||||
@@ -2378,6 +2658,26 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Secure queue</target>
|
||||
<note>server test step</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Security assessment" xml:space="preserve">
|
||||
<source>Security assessment</source>
|
||||
<target>Security assessment</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Security code" xml:space="preserve">
|
||||
<source>Security code</source>
|
||||
<target>Security code</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send" xml:space="preserve">
|
||||
<source>Send</source>
|
||||
<target>Send</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send a live message - it will update for the recipient(s) as you type it" xml:space="preserve">
|
||||
<source>Send a live message - it will update for the recipient(s) as you type it</source>
|
||||
<target>Send a live message - it will update for the recipient(s) as you type it</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send direct message" xml:space="preserve">
|
||||
<source>Send direct message</source>
|
||||
<target>Send direct message</target>
|
||||
@@ -2388,6 +2688,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Send link previews</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send live message" xml:space="preserve">
|
||||
<source>Send live message</source>
|
||||
<target>Send live message</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send notifications" xml:space="preserve">
|
||||
<source>Send notifications</source>
|
||||
<target>Send notifications</target>
|
||||
@@ -2403,6 +2708,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Send questions and ideas</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send them from gallery or custom keyboards." xml:space="preserve">
|
||||
<source>Send them from gallery or custom keyboards.</source>
|
||||
<target>Send them from gallery or custom keyboards.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sender cancelled file transfer." xml:space="preserve">
|
||||
<source>Sender cancelled file transfer.</source>
|
||||
<target>Sender cancelled file transfer.</target>
|
||||
@@ -2423,6 +2733,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Sent file event</target>
|
||||
<note>notification</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sent messages will be deleted after set time." xml:space="preserve">
|
||||
<source>Sent messages will be deleted after set time.</source>
|
||||
<target>Sent messages will be deleted after set time.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Server requires authorization to create queues, check password" xml:space="preserve">
|
||||
<source>Server requires authorization to create queues, check password</source>
|
||||
<target>Server requires authorization to create queues, check password</target>
|
||||
@@ -2493,6 +2808,11 @@ We will be adding server redundancy to prevent lost messages.</target>
|
||||
<target>Show preview</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<target>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
<source>SimpleX Lock</source>
|
||||
<target>SimpleX Lock</target>
|
||||
@@ -2795,6 +3115,11 @@ You will be prompted to complete authentication before this feature is enabled.<
|
||||
<target>To support instant push notifications the chat database has to be migrated.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To verify end-to-end encryption with your contact compare (or scan) the code on your devices." xml:space="preserve">
|
||||
<source>To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</source>
|
||||
<target>To verify end-to-end encryption with your contact compare (or scan) the code on your devices.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Transfer images faster" xml:space="preserve">
|
||||
<source>Transfer images faster</source>
|
||||
<target>Transfer images faster</target>
|
||||
@@ -2937,6 +3262,16 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Using SimpleX Chat servers.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Verify connection security" xml:space="preserve">
|
||||
<source>Verify connection security</source>
|
||||
<target>Verify connection security</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Verify security code" xml:space="preserve">
|
||||
<source>Verify security code</source>
|
||||
<target>Verify security code</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Via browser" xml:space="preserve">
|
||||
<source>Via browser</source>
|
||||
<target>Via browser</target>
|
||||
@@ -2947,6 +3282,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Video call</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="View security code" xml:space="preserve">
|
||||
<source>View security code</source>
|
||||
<target>View security code</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages" xml:space="preserve">
|
||||
<source>Voice messages</source>
|
||||
<target>Voice messages</target>
|
||||
@@ -2997,6 +3337,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>Welcome message</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="What's new" xml:space="preserve">
|
||||
<source>What's new</source>
|
||||
<target>What's new</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="When available" xml:space="preserve">
|
||||
<source>When available</source>
|
||||
<target>When available</target>
|
||||
@@ -3007,6 +3352,11 @@ To connect, please ask your contact to create another connection link and check
|
||||
<target>When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="With optional welcome message." xml:space="preserve">
|
||||
<source>With optional welcome message.</source>
|
||||
<target>With optional welcome message.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Wrong database passphrase" xml:space="preserve">
|
||||
<source>Wrong database passphrase</source>
|
||||
<target>Wrong database passphrase</target>
|
||||
@@ -3249,6 +3599,11 @@ You can cancel this connection and remove the contact (and try later with a new
|
||||
<target>Your contact sent a file that is larger than currently supported maximum size (%@).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your contacts can allow full message deletion." xml:space="preserve">
|
||||
<source>Your contacts can allow full message deletion.</source>
|
||||
<target>Your contacts can allow full message deletion.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your current chat database will be DELETED and REPLACED with the imported one." xml:space="preserve">
|
||||
<source>Your current chat database will be DELETED and REPLACED with the imported one.</source>
|
||||
<target>Your current chat database will be DELETED and REPLACED with the imported one.</target>
|
||||
@@ -3281,6 +3636,11 @@ SimpleX servers cannot see your profile.</target>
|
||||
<target>Your profile, contacts and delivered messages are stored on your device.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your random profile" xml:space="preserve">
|
||||
<source>Your random profile</source>
|
||||
<target>Your random profile</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Your server" xml:space="preserve">
|
||||
<source>Your server</source>
|
||||
<target>Your server</target>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"locale" : "fr"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
4259
apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff
Normal file
4259
apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.000",
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.533"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user