Compare commits

..

1 Commits

Author SHA1 Message Date
Evgeny Poberezkin 41d7a47b37 core: api to load all chat items 2022-11-20 21:38:42 +00:00
157 changed files with 2837 additions and 9362 deletions
+1 -4
View File
@@ -1,7 +1,7 @@
FROM ubuntu:focal AS build
# Install curl and simplex-chat-related dependencies
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev
# Install ghcup
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
@@ -21,9 +21,6 @@ WORKDIR /project
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Adjust build
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal install
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 75
versionName "4.3"
versionCode 69
versionName "4.3-beta.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -24,8 +24,6 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:fullBackupOnly="true"
android:backupAgent="BackupAgent"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
@@ -104,9 +102,7 @@
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"
android:exported="false"
android:launchMode="singleTask"/>
android:showOnLockScreen="true"/>
<provider
android:name="androidx.core.content.FileProvider"
@@ -29,7 +29,6 @@ extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
@@ -77,11 +76,3 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
@@ -1,18 +0,0 @@
package chat.simplex.app
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import android.content.Context
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
class BackupAgent: BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput?) {
if (applicationContext
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
) {
super.onFullBackup(data)
}
}
}
@@ -3,12 +3,10 @@ package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.os.SystemClock.elapsedRealtime
import android.util.Log
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.*
@@ -75,13 +73,6 @@ class MainActivity: FragmentActivity() {
processIntent(intent, m)
processExternalIntent(intent, m)
}
if (m.controller.appPrefs.privacyProtectScreen.get()) {
Log.d(TAG, "onCreate: set FLAG_SECURE")
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
setContent {
SimpleXTheme {
Surface(
@@ -134,15 +125,7 @@ class MainActivity: FragmentActivity() {
}
override fun onBackPressed() {
if (
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
) {
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
super.onBackPressed()
}
super.onBackPressed()
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()
@@ -383,6 +366,15 @@ fun MainPage(
}
}
}
launch {
snapshotFlow { chatModel.sharedContent.value }
.distinctUntilChanged()
.filter { it != null }
.collect {
chatModel.chatId.value = null
currentChatId = null
}
}
}
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
currentChatId?.let {
@@ -32,13 +32,10 @@ external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
lateinit var chatController: ChatController
var isAppOnForeground: Boolean = false
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
@@ -98,7 +95,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
withApi {
when (event) {
Lifecycle.Event.ON_START -> {
isAppOnForeground = true
if (chatModel.chatRunning.value == true) {
kotlin.runCatching {
val chats = chatController.apiGetChats()
@@ -107,7 +103,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
@@ -119,7 +114,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
else -> isAppOnForeground = false
else -> {}
}
}
}
@@ -44,10 +44,7 @@ class ChatModel(val controller: ChatController) {
val terminalItems = mutableStateListOf<TerminalItem>()
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
val userSMPServers = mutableStateOf<(List<ServerCfg>)?>(null)
// Allows to temporary save servers that are being edited on multiple screens
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
val presetSMPServers = mutableStateOf<(List<String>)?>(null)
val userSMPServers = mutableStateOf<(List<String>)?>(null)
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
// set when app opened from external intent
@@ -79,7 +76,6 @@ class ChatModel(val controller: ChatController) {
val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
fun updateUserProfile(profile: LocalProfile) {
val user = currentUser.value
@@ -353,7 +349,6 @@ data class User(
val userContactId: Long,
val localDisplayName: String,
val profile: LocalProfile,
val fullPreferences: FullChatPreferences,
val activeUser: Boolean
): NamedChat {
override val displayName: String get() = profile.displayName
@@ -367,7 +362,6 @@ data class User(
userContactId = 1,
localDisplayName = "alice",
profile = LocalProfile.sampleData,
fullPreferences = FullChatPreferences.sampleData,
activeUser = true
)
}
@@ -392,9 +386,6 @@ interface SomeChat {
val ready: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
val voiceMessageAllowed: Boolean
val fullDeletionAllowed: Boolean
val createdAt: Instant
val updatedAt: Instant
}
@@ -445,6 +436,7 @@ data class Chat (
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
abstract val incognito: Boolean
@Serializable @SerialName("direct")
data class Direct(val contact: Contact): ChatInfo() {
@@ -454,10 +446,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contact.apiId
override val ready get() = contact.ready
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 val ntfsEnabled get() = contact.chatSettings.enableNtfs
override val incognito get() = contact.contactConnIncognito
override val createdAt get() = contact.createdAt
override val updatedAt get() = contact.updatedAt
override val displayName get() = contact.displayName
@@ -478,10 +468,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
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 val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
override val incognito get() = groupInfo.membership.memberIncognito
override val createdAt get() = groupInfo.createdAt
override val updatedAt get() = groupInfo.updatedAt
override val displayName get() = groupInfo.displayName
@@ -502,10 +490,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
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 val ntfsEnabled get() = false
override val incognito get() = false
override val createdAt get() = contactRequest.createdAt
override val updatedAt get() = contactRequest.updatedAt
override val displayName get() = contactRequest.displayName
@@ -526,10 +512,8 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = contactConnection.incognito
override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito
override val voiceMessageAllowed get() = contactConnection.voiceMessageAllowed
override val fullDeletionAllowed get() = contactConnection.fullDeletionAllowed
override val createdAt get() = contactConnection.createdAt
override val updatedAt get() = contactConnection.updatedAt
override val displayName get() = contactConnection.displayName
@@ -552,8 +536,8 @@ data class Contact(
val activeConn: Connection,
val viaGroup: Long? = null,
val chatSettings: ChatSettings,
// User applies his preferences for the contact here. Named user_preferences on the contact in DB
val userPreferences: ChatPreferences,
val mergedPreferences: ContactUserPreferences,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -563,9 +547,6 @@ data class Contact(
override val ready get() = activeConn.connStatus == ConnStatus.Ready
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 val displayName get() = localAlias.ifEmpty { profile.displayName }
override val fullName get() = profile.fullName
override val image get() = profile.image
@@ -587,8 +568,7 @@ data class Contact(
profile = LocalProfile.sampleData,
activeConn = Connection.sampleData,
chatSettings = ChatSettings(true),
userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData,
userPreferences = ChatPreferences(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -618,11 +598,12 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
}
@Serializable
data class Profile(
class Profile(
override val displayName: String,
override val fullName: String,
override val image: String? = null,
override val localAlias : String = "",
// Contact applies his preferences here
val preferences: ChatPreferences? = null
): NamedChat {
val profileViewName: String
@@ -630,7 +611,7 @@ data class Profile(
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
}
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, preferences)
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias)
companion object {
val sampleData = Profile(
@@ -647,18 +628,18 @@ class LocalProfile(
override val fullName: String,
override val image: String? = null,
override val localAlias: String,
// Contact applies his preferences here
val preferences: ChatPreferences? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, preferences)
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
companion object {
val sampleData = LocalProfile(
profileId = 1L,
displayName = "alice",
fullName = "Alice",
preferences = ChatPreferences.sampleData,
localAlias = ""
)
}
@@ -675,10 +656,10 @@ data class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
val fullGroupPreferences: FullGroupPreferences,
val membership: GroupMember,
val hostConnCustomUserProfileId: Long? = null,
val chatSettings: ChatSettings,
// val groupPreferences: GroupPreferences? = null,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -688,9 +669,6 @@ data class GroupInfo (
override val ready get() = membership.memberActive
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 val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
@@ -710,7 +688,6 @@ data class GroupInfo (
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
fullGroupPreferences = FullGroupPreferences.sampleData,
membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(true),
@@ -721,12 +698,11 @@ data class GroupInfo (
}
@Serializable
data class GroupProfile (
class GroupProfile (
override val displayName: String,
override val fullName: String,
override val image: String? = null,
override val localAlias: String = "",
val groupPreferences: GroupPreferences? = null
): NamedChat {
companion object {
val sampleData = GroupProfile(
@@ -934,9 +910,6 @@ class UserContactRequest (
override val ready get() = true
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 val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
@@ -972,9 +945,6 @@ class PendingContactConnection(
override val ready get() = false
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 val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
override val displayName: String get() {
if (localAlias.isNotEmpty()) return localAlias
@@ -994,6 +964,8 @@ class PendingContactConnection(
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
val incognito = customUserProfileId != null
val description: String get() {
val initiated = pccConnStatus.initiated
return if (initiated == null) "" else generalGetString(
@@ -1063,14 +1035,14 @@ data class ChatItem (
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val text: String get() {
val mc = content.msgContent
return when {
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(R.string.voice_message_with_duration), durationText(mc.duration))
val text: String get() =
when {
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
}
content.text == "" && file != null -> file.fileName
else -> content.text
}
}
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
@@ -1085,40 +1057,30 @@ data class ChatItem (
else -> false
}
private val showNtfDir: Boolean get() = !chatDir.sent
val showNotification: Boolean get() =
val isCall: Boolean get() =
when (content) {
is CIContent.SndMsgContent -> showNtfDir
is CIContent.RcvMsgContent -> showNtfDir
is CIContent.SndDeleted -> showNtfDir
is CIContent.RcvDeleted -> showNtfDir
is CIContent.SndCall -> showNtfDir
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
is CIContent.RcvIntegrityError -> showNtfDir
is CIContent.RcvGroupInvitation -> showNtfDir
is CIContent.SndGroupInvitation -> showNtfDir
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberConnected -> false
is RcvGroupEvent.MemberLeft -> false
is RcvGroupEvent.MemberRole -> false
is RcvGroupEvent.UserRole -> showNtfDir
is RcvGroupEvent.MemberDeleted -> false
is RcvGroupEvent.UserDeleted -> showNtfDir
is RcvGroupEvent.GroupDeleted -> showNtfDir
is RcvGroupEvent.GroupUpdated -> false
is RcvGroupEvent.InvitedViaGroupLink -> false
}
is CIContent.SndGroupEventContent -> showNtfDir
is CIContent.RcvConnEventContent -> false
is CIContent.SndConnEventContent -> showNtfDir
is CIContent.RcvChatFeature -> false
is CIContent.SndChatFeature -> showNtfDir
is CIContent.RcvGroupFeature -> false
is CIContent.SndGroupFeature -> showNtfDir
is CIContent.RcvChatFeatureRejected -> showNtfDir
is CIContent.RcvGroupFeatureRejected -> showNtfDir
is CIContent.SndCall -> true
is CIContent.RcvCall -> true
else -> false
}
val isMutedMemberEvent: Boolean get() =
when (content) {
is CIContent.RcvGroupEventContent ->
when (content.rcvGroupEvent) {
is RcvGroupEvent.GroupUpdated -> true
is RcvGroupEvent.MemberConnected -> true
is RcvGroupEvent.UserDeleted -> false
is RcvGroupEvent.GroupDeleted -> false
is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberLeft -> false
is RcvGroupEvent.MemberRole -> true
is RcvGroupEvent.UserRole -> false
is RcvGroupEvent.MemberDeleted -> false
is RcvGroupEvent.InvitedViaGroupLink -> false
}
is CIContent.SndGroupEventContent -> true
else -> false
}
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
@@ -1191,17 +1153,6 @@ data class ChatItem (
quotedItem = null,
file = null
)
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
return ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
content = content,
quotedItem = null,
file = null
)
}
}
}
@@ -1266,7 +1217,7 @@ sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus()
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
}
@@ -1298,34 +1249,22 @@ 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("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 }
override val text: String get() = when (this) {
is SndMsgContent -> msgContent.text
is RcvMsgContent -> msgContent.text
is SndDeleted -> generalGetString(R.string.deleted_description)
is RcvDeleted -> generalGetString(R.string.deleted_description)
is SndCall -> status.text(duration)
is RcvCall -> status.text(duration)
is RcvIntegrityError -> msgError.text
is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text
is RcvGroupEventContent -> rcvGroupEvent.text
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 RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
}
override val text: String get() = when(this) {
is SndMsgContent -> msgContent.text
is RcvMsgContent -> msgContent.text
is SndDeleted -> generalGetString(R.string.deleted_description)
is RcvDeleted -> generalGetString(R.string.deleted_description)
is SndCall -> status.text(duration)
is RcvCall -> status.text(duration)
is RcvIntegrityError -> msgError.text
is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text
is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text
is RcvConnEventContent -> rcvConnEvent.text
is SndConnEventContent -> sndConnEvent.text
}
}
@Serializable
@@ -1337,13 +1276,7 @@ class CIQuote (
val content: MsgContent,
val formattedText: List<FormattedText>? = null
): ItemContent {
override val text: String by lazy {
if (content.text == "" && content is MsgContent.MCVoice)
durationText(content.duration)
else
content.text
}
override val text: String get() = content.text
fun sender(membership: GroupMember?): String? = when (chatDir) {
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
@@ -1379,6 +1312,8 @@ class CIFile(
CIFileStatus.RcvComplete -> true
}
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
companion object {
fun getSample(
fileId: Long = 1,
@@ -1426,6 +1361,11 @@ sealed class MsgContent {
}
}
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
val time = String.format("%02d:%02d", duration / 60, duration % 60)
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
}
@Serializable
class CIGroupInvitation (
val groupId: Long,
@@ -1548,21 +1488,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
@Serializable
class FormattedText(val text: String, val format: Format? = null) {
// TODO make it dependent on simplexLinkMode preference
fun link(mode: SimplexLinkMode): String? = when (format) {
val link: String? = when (format) {
is Format.Uri -> text
is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri
is Format.Email -> "mailto:$text"
is Format.Phone -> "tel:$text"
else -> null
}
// TODO make it dependent on simplexLinkMode preference
fun viewText(mode: SimplexLinkMode): String =
if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text
fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List<String>): String =
"${linkType.description} (${String.format(generalGetString(R.string.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
}
@Serializable
@@ -1574,7 +1505,6 @@ sealed class Format {
@Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List<String>): Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
@@ -1586,7 +1516,6 @@ sealed class Format {
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is SimplexLink -> linkStyle
is Email -> linkStyle
is Phone -> linkStyle
}
@@ -1596,19 +1525,6 @@ sealed class Format {
}
}
@Serializable
enum class SimplexLinkType(val linkType: String) {
contact("contact"),
invitation("invitation"),
group("group");
val description: String get() = generalGetString(when (this) {
contact -> R.string.simplex_link_contact
invitation -> R.string.simplex_link_invitation
group -> R.string.simplex_link_group
})
}
@Serializable
enum class FormatColor(val color: String) {
red("red"),
@@ -1624,7 +1540,7 @@ enum class FormatColor(val color: String) {
red -> Color.Red
green -> SimplexGreen
blue -> SimplexBlue
yellow -> WarningYellow
yellow -> Color.Yellow
cyan -> Color.Cyan
magenta -> Color.Magenta
black -> MaterialTheme.colors.onBackground
@@ -1659,12 +1575,12 @@ enum class CICallStatus {
Accepted -> generalGetString(R.string.callstatus_accepted)
Negotiated -> generalGetString(R.string.callstatus_connecting)
Progress -> generalGetString(R.string.callstatus_in_progress)
Ended -> String.format(generalGetString(R.string.callstatus_ended), durationText(sec))
Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec))
Error -> generalGetString(R.string.callstatus_error)
}
}
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
}
@Serializable
sealed class MsgErrorType() {
@@ -3,11 +3,9 @@ package chat.simplex.app.model
import android.app.*
import android.content.*
import android.graphics.BitmapFactory
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.net.Uri
import android.util.Log
import android.view.Display
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
@@ -153,26 +151,19 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
fun notifyCallInvitation(invitation: RcvCallInvitation) {
if (SimplexApp.context.isAppOnForeground) return
val keyguardManager = getKeyguardManager(context)
Log.d(TAG,
"notifyCallInvitation pre-requests: device locked ${keyguardManager.isDeviceLocked}, " +
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}"
)
if (isAppOnForeground(context)) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val keyguardManager = getKeyguardManager(context)
val image = invitation.contact.image
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, LockScreenCallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSilent(false)
.setSilent(true)
} else {
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
NotificationCompat.Builder(context, CallChannel)
@@ -217,14 +208,18 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md != null) {
return if (md == null) {
if (cItem.content.text != "") {
cItem.content.text
} else {
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
}
} else {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
} else {
cItem.text
}
}
@@ -1,6 +1,8 @@
package chat.simplex.app.model
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.app.Application
import android.content.*
import android.net.Uri
@@ -10,13 +12,9 @@ import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -28,17 +26,30 @@ import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ConnectViaLinkTab
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.*
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import java.util.Date
import kotlin.concurrent.thread
typealias ChatCtrl = Long
fun isAppOnForeground(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = context.packageName
for (appProcess in appProcesses) {
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
return true
}
}
return false
}
enum class CallOnLockScreen {
DISABLE,
SHOW,
@@ -49,16 +60,6 @@ enum class CallOnLockScreen {
}
}
enum class SimplexLinkMode {
DESCRIPTION,
FULL,
BROWSER;
companion object {
val default = SimplexLinkMode.DESCRIPTION
}
}
class AppPreferences(val context: Context) {
private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
@@ -73,7 +74,7 @@ class AppPreferences(val context: Context) {
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
val webrtcPolicyRelay = mkBoolPreference(SHARED_PREFS_WEBRTC_POLICY_RELAY, true)
private val _callOnLockScreen = mkStrPreference(SHARED_PREFS_WEBRTC_CALLS_ON_LOCK_SCREEN, CallOnLockScreen.default.name)
val callOnLockScreen: SharedPreference<CallOnLockScreen> = SharedPreference(
val callOnLockScreen: Preference<CallOnLockScreen> = Preference(
get = fun(): CallOnLockScreen {
val value = _callOnLockScreen.get() ?: return CallOnLockScreen.default
return try {
@@ -87,23 +88,9 @@ class AppPreferences(val context: Context) {
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyTransferImagesInline = mkBoolPreference(SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE, false)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name)
val simplexLinkMode: SharedPreference<SimplexLinkMode> = SharedPreference(
get = fun(): SimplexLinkMode {
val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default
return try {
SimplexLinkMode.valueOf(value)
} catch (e: Error) {
SimplexLinkMode.default
}
},
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
)
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
@@ -132,33 +119,33 @@ class AppPreferences(val context: Context) {
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
Preference(
get = fun() = sharedPreferences.getInt(prefName, default),
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
)
private fun mkLongPreference(prefName: String, default: Long) =
SharedPreference(
Preference(
get = fun() = sharedPreferences.getLong(prefName, default),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference<Long> {
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference<Long> {
val d = if (networkUseSocksProxy.get()) proxyDefault else default
return SharedPreference(
return Preference(
get = fun() = sharedPreferences.getLong(prefName, d),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
}
private fun mkBoolPreference(prefName: String, default: Boolean) =
SharedPreference(
Preference(
get = fun() = sharedPreferences.getBoolean(prefName, default),
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
)
private fun mkStrPreference(prefName: String, default: String?): SharedPreference<String?> =
SharedPreference(
private fun mkStrPreference(prefName: String, default: String?): Preference<String?> =
Preference(
get = fun() = sharedPreferences.getString(prefName, default),
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
)
@@ -167,8 +154,8 @@ class AppPreferences(val context: Context) {
* Provide `[commit] = true` to save preferences right now, not after some unknown period of time.
* So in case of a crash this value will be saved 100%
* */
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference<Instant?> =
SharedPreference(
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): Preference<Instant?> =
Preference(
get = {
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
@@ -179,7 +166,7 @@ class AppPreferences(val context: Context) {
)
companion object {
internal const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode"
@@ -191,12 +178,9 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
private const val SHARED_PREFS_WEBRTC_ICE_SERVERS = "WebrtcICEServers"
private const val SHARED_PREFS_PRIVACY_PROTECT_SCREEN = "PrivacyProtectScreen"
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
internal val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
@@ -251,9 +235,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
apiSetFilesFolder(getAppFilesDirectory(appContext))
apiSetIncognito(chatModel.incognito.value)
chatModel.userAddress.value = apiGetUserAddress()
val smpServers = getUserSMPServers()
chatModel.userSMPServers.value = smpServers?.first
chatModel.presetSMPServers.value = smpServers?.second
chatModel.userSMPServers.value = getUserSMPServers()
chatModel.chatItemTTL.value = getChatItemTTL()
val chats = apiGetChats()
chatModel.updateChats(chats)
@@ -436,21 +418,21 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? {
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): AChatItem? {
val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
if (r is CR.ChatItemDeleted) return r
if (r is CR.ChatItemDeleted) return r.toChatItem
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
return null
}
private suspend fun getUserSMPServers(): Pair<List<ServerCfg>, List<String>>? {
private suspend fun getUserSMPServers(): List<String>? {
val r = sendCmd(CC.GetUserSMPServers())
if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers
if (r is CR.UserSMPServers) return r.smpServers
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun setUserSMPServers(smpServers: List<ServerCfg>): Boolean {
suspend fun setUserSMPServers(smpServers: List<String>): Boolean {
val r = sendCmd(CC.SetUserSMPServers(smpServers))
return when (r) {
is CR.CmdOk -> true
@@ -465,17 +447,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
val r = sendCmd(CC.TestSMPServer(smpServer))
return when (r) {
is CR.SmpTestResult -> r.smpTestFailure
else -> {
Log.e(TAG, "testSMPServer bad response: ${r.responseType} ${r.details}")
throw Exception("testSMPServer bad response: ${r.responseType} ${r.details}")
}
}
}
suspend fun getChatItemTTL(): ChatItemTTL {
val r = sendCmd(CC.APIGetChatItemTTL())
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
@@ -651,13 +622,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
if (r is CR.ContactPrefsUpdated) return r.toContact
Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? {
val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias))
if (r is CR.ContactAliasUpdated) return r.toContact
@@ -672,6 +636,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs))
if (r is CR.ContactPrefsUpdated) return r.toContact
Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiCreateUserAddress(): String? {
val r = sendCmd(CC.CreateMyAddress())
return when (r) {
@@ -809,13 +780,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
else -> {
if (!(networkErrorAlert(r))) {
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.FileAlreadyReceiving
) {
Log.d(TAG, "apiReceiveFile ignoring FileAlreadyReceiving error")
} else {
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
}
apiErrorAlert("apiReceiveFile", generalGetString(R.string.error_receiving_file), r)
}
null
}
@@ -959,7 +924,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
&& r.chatError.agentError.brokerErr is BrokerErrorType.TIMEOUT -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_timeout),
String.format(generalGetString(R.string.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress))
generalGetString(R.string.network_error_desc)
)
true
}
@@ -968,7 +933,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
&& r.chatError.agentError.brokerErr is BrokerErrorType.NETWORK -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_error),
String.format(generalGetString(R.string.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress))
generalGetString(R.string.network_error_desc)
)
true
}
@@ -1049,10 +1014,10 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
}
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
@@ -1070,11 +1035,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(r.chatItem)
is CR.ChatItemDeleted -> {
AudioPlayer.stop(r.deletedChatItem.chatItem)
if (r.toChatItem == null) {
chatModel.removeChatItem(r.deletedChatItem.chatInfo, r.deletedChatItem.chatItem)
val cInfo = r.toChatItem.chatInfo
val cItem = r.toChatItem.chatItem
if (cItem.meta.itemDeleted) {
chatModel.removeChatItem(cInfo, cItem)
} else {
chatModel.upsertChatItem(r.toChatItem.chatInfo, r.toChatItem.chatItem)
// currently only broadcast deletion of rcv message can be received, and only this case should happen
AudioPlayer.stop(cItem)
chatModel.upsertChatItem(cInfo, cItem)
}
}
is CR.ReceivedGroupInvitation -> {
@@ -1465,18 +1433,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
val set: (T) -> Unit
private val _state: MutableState<T> by lazy { mutableStateOf(get()) }
val state: State<T> by lazy { _state }
init {
this.set = { value ->
set(value)
_state.value = value
}
}
}
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
// ChatCommand
sealed class CC {
@@ -1508,8 +1465,7 @@ sealed class CC {
class APIDeleteGroupLink(val groupId: Long): CC()
class APIGetGroupLink(val groupId: Long): CC()
class GetUserSMPServers: CC()
class SetUserSMPServers(val smpServers: List<ServerCfg>): CC()
class TestSMPServer(val smpServer: String): CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class APISetChatItemTTL(val seconds: Long?): CC()
class APIGetChatItemTTL: CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@@ -1525,10 +1481,10 @@ sealed class CC {
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ListContacts: CC()
class ApiUpdateProfile(val profile: Profile): CC()
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
class ApiParseMarkdown(val text: String): CC()
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
@@ -1574,9 +1530,8 @@ sealed class CC {
is APICreateGroupLink -> "/_create link #$groupId"
is APIDeleteGroupLink -> "/_delete link #$groupId"
is APIGetGroupLink -> "/_get link #$groupId"
is GetUserSMPServers -> "/smp"
is SetUserSMPServers -> "/_smp ${smpServersStr(smpServers)}"
is TestSMPServer -> "/smp test $smpServer"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/ttl"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@@ -1592,10 +1547,10 @@ sealed class CC {
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ListContacts -> "/contacts"
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is ApiParseMarkdown -> "/_parse $text"
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
@@ -1644,7 +1599,6 @@ sealed class CC {
is APIGetGroupLink -> "apiGetGroupLink"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is TestSMPServer -> "testSMPServer"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
@@ -1660,10 +1614,10 @@ sealed class CC {
is ApiClearChat -> "apiClearChat"
is ListContacts -> "listContacts"
is ApiUpdateProfile -> "updateProfile"
is ApiSetContactPrefs -> "apiSetContactPrefs"
is ApiParseMarkdown -> "apiParseMarkdown"
is ApiSetContactAlias -> "apiSetContactAlias"
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
is ApiSetContactPrefs -> "apiSetContactPrefs"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
@@ -1702,7 +1656,7 @@ sealed class CC {
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
fun smpServersStr(smpServers: List<ServerCfg>) = if (smpServers.isEmpty()) "default" else json.encodeToString(SMPServersConfig(smpServers))
fun smpServersStr(smpServers: List<String>) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ";")
}
}
@@ -1733,147 +1687,6 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? =
@Serializable
class DBEncryptionConfig(val currentKey: String, val newKey: String)
@Serializable
data class SMPServersConfig(
val smpServers: List<ServerCfg>
)
@Serializable
data class ServerCfg(
val server: String,
val preset: Boolean,
val tested: Boolean? = null,
val enabled: Boolean
) {
@Transient
private val createdAt: Date = Date()
// val sendEnabled: Boolean // can we potentially want to prevent sending on the servers we use to receive?
// Even if we don't see the use case, it's probably better to allow it in the model
// In any case, "trusted/known" servers are out of scope of this change
val id: String
get() = "$server $createdAt"
val isBlank: Boolean
get() = server.isBlank()
companion object {
val empty = ServerCfg(server = "", preset = false, tested = null, enabled = true)
class SampleData(
val preset: ServerCfg,
val custom: ServerCfg,
val untested: ServerCfg
)
val sampleData = SampleData(
preset = ServerCfg(
server = "smp://abcd@smp8.simplex.im",
preset = true,
tested = true,
enabled = true
),
custom = ServerCfg(
server = "smp://abcd@smp9.simplex.im",
preset = false,
tested = false,
enabled = false
),
untested = ServerCfg(
server = "smp://abcd@smp10.simplex.im",
preset = false,
tested = null,
enabled = true
)
)
}
}
@Serializable
enum class SMPTestStep {
@SerialName("connect") Connect,
@SerialName("createQueue") CreateQueue,
@SerialName("secureQueue") SecureQueue,
@SerialName("deleteQueue") DeleteQueue,
@SerialName("disconnect") Disconnect;
val text: String get() = when (this) {
Connect -> generalGetString(R.string.smp_server_test_connect)
CreateQueue -> generalGetString(R.string.smp_server_test_create_queue)
SecureQueue -> generalGetString(R.string.smp_server_test_secure_queue)
DeleteQueue -> generalGetString(R.string.smp_server_test_delete_queue)
Disconnect -> generalGetString(R.string.smp_server_test_disconnect)
}
}
@Serializable
data class SMPTestFailure(
val testStep: SMPTestStep,
val testError: AgentErrorType
) {
override fun equals(other: Any?): Boolean {
if (other !is SMPTestFailure) return false
return other.testStep == this.testStep
}
override fun hashCode(): Int {
return testStep.hashCode()
}
val localizedDescription: String get() {
val err = String.format(generalGetString(R.string.error_smp_test_failed_at_step), testStep.text)
return when {
testError is AgentErrorType.SMP && testError.smpErr is SMPErrorType.AUTH ->
err + " " + generalGetString(R.string.error_smp_test_server_auth)
testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK ->
err + " " + generalGetString(R.string.error_smp_test_certificate)
else -> err
}
}
}
@Serializable
data class ServerAddress(
val hostnames: List<String>,
val port: String,
val keyHash: String,
val basicAuth: String = ""
) {
val uri: String
get() =
"smp://${keyHash}${if (basicAuth.isEmpty()) "" else ":$basicAuth"}@${hostnames.joinToString(",")}"
val valid: Boolean
get() = hostnames.isNotEmpty() && hostnames.toSet().size == hostnames.size
companion object {
val empty = ServerAddress(
hostnames = emptyList(),
port = "",
keyHash = "",
basicAuth = ""
)
val sampleData = ServerAddress(
hostnames = listOf("smp.simplex.im", "1234.onion"),
port = "",
keyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=",
basicAuth = "server_password"
)
fun parseServerAddress(s: String): ServerAddress? {
val parsed = chatParseServer(s)
return runCatching { json.decodeFromString(ParsedServerAddress.serializer(), parsed) }
.onFailure { Log.d(TAG, "parseServerAddress decode error: $it") }
.getOrNull()?.serverAddress
}
}
}
@Serializable
data class ParsedServerAddress (
var serverAddress: ServerAddress?,
var parseError: String
)
@Serializable
data class NetCfg(
val socksProxy: String? = null,
@@ -1952,365 +1765,36 @@ data class ChatSettings(
val enableNtfs: Boolean
)
@Serializable
data class FullChatPreferences(
val fullDelete: ChatPreference,
val voice: ChatPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
}
}
@Serializable
data class ChatPreferences(
val fullDelete: ChatPreference? = null,
val voice: ChatPreference? = null,
val voice: ChatPreference? = null
) {
companion object {
val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
val default = ChatPreferences(
voice = ChatPreference(allow = PrefAllowed.NO)
)
val empty = ChatPreferences(
voice = null
)
}
}
@Serializable
data class ChatPreference(
val allow: FeatureAllowed
val allow: PrefAllowed
)
@Serializable
data class ContactUserPreferences(
val fullDelete: ContactUserPreference,
val voice: ContactUserPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
fullDelete = fullDelete.userPreference.pref,
voice = voice.userPreference.pref
)
companion object {
val sampleData = ContactUserPreferences(
fullDelete = ContactUserPreference(
enabled = FeatureEnabled(forUser = false, forContact = false),
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)),
contactPreference = ChatPreference(allow = FeatureAllowed.NO)
),
voice = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)),
contactPreference = ChatPreference(allow = FeatureAllowed.YES)
)
)
}
}
@Serializable
data class ContactUserPreference(
val enabled: FeatureEnabled,
val userPreference: ContactUserPref,
val contactPreference: ChatPreference,
)
@Serializable
data class FeatureEnabled(
val forUser: Boolean,
val forContact: Boolean
) {
val text: String
get() = when {
forUser && forContact -> generalGetString(R.string.feature_enabled)
forUser -> generalGetString(R.string.feature_enabled_for_you)
forContact -> generalGetString(R.string.feature_enabled_for_contact)
else -> generalGetString(R.string.feature_off)
}
val iconColor: Color
get() = if (forUser) SimplexGreen else if (forContact) WarningYellow else HighOrLowlight
companion object {
fun enabled(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)
contact.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
user.allow == FeatureAllowed.NO -> FeatureEnabled(forUser = false, forContact = false)
else -> FeatureEnabled(forUser = true, forContact = true)
}
}
}
@Serializable
sealed class ContactUserPref {
abstract val pref: ChatPreference
// contact override is set
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() {
override val pref get() = preference
}
// global user default is used
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() {
override val pref get() = preference
}
}
interface Feature {
// val icon: ImageVector
val text: String
val iconFilled: ImageVector
}
@Serializable
enum class ChatFeature: Feature {
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
override val text: String
get() = when(this) {
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
}
val icon: ImageVector
get() = when(this) {
FullDelete -> Icons.Outlined.DeleteForever
Voice -> Icons.Outlined.KeyboardVoice
}
override val iconFilled: ImageVector
get() = when(this) {
FullDelete -> Icons.Filled.DeleteForever
Voice -> Icons.Filled.KeyboardVoice
}
fun allowDescription(allowed: FeatureAllowed): String =
when (this) {
FullDelete -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_irreversibly_delete)
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion)
}
Voice -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages)
FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages)
}
}
fun enabledDescription(enabled: FeatureEnabled): String =
when (this) {
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)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete)
else -> generalGetString(R.string.message_deletion_prohibited)
}
Voice -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice)
enabled.forUser -> generalGetString(R.string.only_you_can_send_voice)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_send_voice)
else -> generalGetString(R.string.voice_prohibited_in_this_chat)
}
}
}
@Serializable
enum class GroupFeature: Feature {
@SerialName("directMessages") DirectMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
override val text: String
get() = when(this) {
DirectMessages -> generalGetString(R.string.direct_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
}
val icon: ImageVector
get() = when(this) {
DirectMessages -> Icons.Outlined.SwapHorizontalCircle
FullDelete -> Icons.Outlined.DeleteForever
Voice -> Icons.Outlined.KeyboardVoice
}
override val iconFilled: ImageVector
get() = when(this) {
DirectMessages -> Icons.Filled.SwapHorizontalCircle
FullDelete -> Icons.Filled.DeleteForever
Voice -> Icons.Filled.KeyboardVoice
}
fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
if (canEdit) {
when(this) {
DirectMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_direct_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_direct_messages)
}
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
}
}
} else {
when(this) {
DirectMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_dms)
GroupFeatureEnabled.OFF -> generalGetString(R.string.direct_messages_are_prohibited_in_chat)
}
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
}
}
}
}
@Serializable
sealed class ContactFeatureAllowed {
@Serializable @SerialName("userDefault") data class UserDefault(val default: FeatureAllowed): ContactFeatureAllowed()
@Serializable @SerialName("always") object Always: ContactFeatureAllowed()
@Serializable @SerialName("yes") object Yes: ContactFeatureAllowed()
@Serializable @SerialName("no") object No: ContactFeatureAllowed()
companion object {
fun values(def: FeatureAllowed): List<ContactFeatureAllowed> = listOf(UserDefault(def), Always, Yes, No)
}
val allowed: FeatureAllowed
get() = when (this) {
is UserDefault -> this.default
is Always -> FeatureAllowed.ALWAYS
is Yes -> FeatureAllowed.YES
is No -> FeatureAllowed.NO
}
val text: String
get() = when (this) {
is UserDefault -> String.format(generalGetString(R.string.chat_preferences_default), default.text)
is Always -> generalGetString(R.string.chat_preferences_always)
is Yes -> generalGetString(R.string.chat_preferences_yes)
is No -> generalGetString(R.string.chat_preferences_no)
}
}
data class ContactFeaturesAllowed(
val fullDelete: ContactFeatureAllowed,
val voice: ContactFeatureAllowed
) {
companion object {
val sampleData = ContactFeaturesAllowed(
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES)
)
}
}
fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed =
ContactFeaturesAllowed(
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice)
)
fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed =
when (val pref = contactUserPreference.userPreference) {
is ContactUserPref.User -> ContactFeatureAllowed.UserDefault(pref.preference.allow)
is ContactUserPref.Contact -> when (pref.preference.allow) {
FeatureAllowed.ALWAYS -> ContactFeatureAllowed.Always
FeatureAllowed.YES -> ContactFeatureAllowed.Yes
FeatureAllowed.NO -> ContactFeatureAllowed.No
}
}
fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences =
ChatPreferences(
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice)
)
fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? =
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)
}
@Serializable
enum class FeatureAllowed {
enum class PrefAllowed {
@SerialName("always") ALWAYS,
@SerialName("yes") YES,
@SerialName("no") NO,
@SerialName("always") ALWAYS;
val text: String
get() = when(this) {
ALWAYS -> generalGetString(R.string.chat_preferences_always)
YES -> generalGetString(R.string.chat_preferences_yes)
NO -> generalGetString(R.string.chat_preferences_no)
}
}
@Serializable
data class FullGroupPreferences(
val directMessages: GroupPreference,
val fullDelete: GroupPreference,
val voice: GroupPreference
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(directMessages = directMessages, fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullGroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
}
}
@Serializable
data class GroupPreferences(
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))
}
}
@Serializable
data class GroupPreference(
val enable: GroupFeatureEnabled
) {
val on: Boolean get() = enable == GroupFeatureEnabled.ON
}
@Serializable
enum class GroupFeatureEnabled {
@SerialName("on") ON,
@SerialName("off") OFF;
val text: String
get() = when (this) {
ON -> generalGetString(R.string.chat_preferences_on)
OFF -> generalGetString(R.string.chat_preferences_off)
}
val iconColor: Color
get() = if (this == ON) SimplexGreen else HighOrLowlight
@SerialName("no") NO
}
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Serializable
@@ -2344,8 +1828,7 @@ sealed class CR {
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<ServerCfg>, val presetSMPServers: List<String>): CR()
@Serializable @SerialName("smpTestResult") class SmpTestResult(val smpTestFailure: SMPTestFailure? = null): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): 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()
@@ -2360,7 +1843,7 @@ sealed class CR {
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR()
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val fromContact: Contact, val toContact: Contact): CR()
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val toContact: Contact): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR()
@Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR()
@@ -2383,7 +1866,7 @@ sealed class CR {
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
@Serializable @SerialName("contactsList") class ContactsList(val contacts: List<Contact>): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
@@ -2443,7 +1926,6 @@ sealed class CR {
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
is SmpTestResult -> "smpTestResult"
is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
@@ -2538,8 +2020,7 @@ sealed class CR {
is ChatStopped -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is UserSMPServers -> "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}"
is SmpTestResult -> json.encodeToString(smpTestFailure)
is UserSMPServers -> json.encodeToString(smpServers)
is ChatItemTTL -> json.encodeToString(chatItemTTL)
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
@@ -2554,7 +2035,7 @@ sealed class CR {
is UserProfileUpdated -> json.encodeToString(toProfile)
is ContactAliasUpdated -> json.encodeToString(toContact)
is ConnectionAliasUpdated -> json.encodeToString(toConnection)
is ContactPrefsUpdated -> "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}"
is ContactPrefsUpdated -> json.encodeToString(toContact)
is ParsedMarkdown -> json.encodeToString(formattedText)
is UserContactLink -> contactLink.responseDetails
is UserContactLinkUpdated -> contactLink.responseDetails
@@ -2577,7 +2058,7 @@ sealed class CR {
is NewChatItem -> json.encodeToString(chatItem)
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser"
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
is ContactsList -> json.encodeToString(contacts)
is GroupCreated -> json.encodeToString(groupInfo)
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member"
@@ -2697,12 +2178,10 @@ sealed class ChatErrorType {
val string: String get() = when (this) {
is NoActiveUser -> "noActiveUser"
is InvalidConnReq -> "invalidConnReq"
is FileAlreadyReceiving -> "fileAlreadyReceiving"
is СommandError -> "commandError $message"
}
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType()
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
}
@@ -2751,7 +2230,7 @@ sealed class AgentErrorType {
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
}
@@ -135,7 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
@@ -123,10 +123,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
invitation,
callOnLockScreen,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
ignoreCall = { chatModel.activeCallInvitation.value = null },
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
SoundPlayer.shared.stop()
@@ -33,10 +33,7 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
IncomingCallAlertLayout(
invitation,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
ignoreCall = { chatModel.activeCallInvitation.value = null },
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
)
}
@@ -35,7 +35,7 @@ import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -62,11 +62,6 @@ fun ChatInfoView(
onLocalAliasChanged = {
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.shared.showModal(true) {
ContactPreferencesView(chatModel, chatModel.currentUser.value ?: return@showModal, contact.contactId)
}
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
switchContactAddress = {
@@ -122,7 +117,6 @@ fun ChatInfoLayout(
localAlias: String,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
openPreferences: () -> Unit,
deleteContact: () -> Unit,
clearChat: () -> Unit,
switchContactAddress: () -> Unit,
@@ -149,16 +143,13 @@ fun ChatInfoLayout(
}
}
SectionSpacer()
SectionView {
ContactPreferencesButton(openPreferences)
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchContactAddress)
SectionDivider()
if (developerTools) {
SwitchAddressButton(switchContactAddress)
SectionDivider()
}
if (connStats != null) {
SectionItemView({
AlertManager.shared.showAlertMsg(
@@ -336,15 +327,6 @@ fun SwitchAddressButton(onClick: () -> Unit) {
}
}
@Composable
private fun ContactPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.contact_preferences),
click = onClick
)
}
@Composable
fun ClearChatButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -404,7 +386,6 @@ fun PreviewChatInfoLayout() {
connStats = null,
onLocalAliasChanged = {},
customUserProfile = null,
openPreferences = {},
deleteContact = {},
clearChat = {},
switchContactAddress = {},
@@ -119,7 +119,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.chatItems,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
chatModelIncognito = chatModel.incognito.value,
back = {
hideKeyboard(view)
@@ -129,16 +128,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
info = {
hideKeyboard(view)
withApi {
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(cInfo.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)
}
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
}
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
} else if (cInfo is ChatInfo.Group) {
setGroupMembers(cInfo.groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, close)
}
@@ -166,20 +163,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
deleteMessage = { itemId, mode ->
withApi {
val cInfo = chat.chatInfo
val r = chatModel.controller.apiDeleteChatItem(
val toItem = chatModel.controller.apiDeleteChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = itemId,
mode = mode
)
if (r != null) {
val toChatItem = r.toChatItem
if (toChatItem == null) {
chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem)
} else {
chatModel.upsertChatItem(cInfo, toChatItem.chatItem)
}
}
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
}
},
receiveFile = { fileId ->
@@ -210,14 +200,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
AddGroupMembersView(groupInfo, chatModel, close)
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withBGApi {
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
@@ -250,7 +240,6 @@ fun ChatLayout(
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
back: () -> Unit,
info: () -> Unit,
@@ -300,7 +289,7 @@ fun ChatLayout(
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
)
}
@@ -458,7 +447,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
chatModelIncognito: Boolean,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
@@ -472,7 +460,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
ScrollToBottom(chat.id, listState, chatItems)
ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
@@ -510,7 +498,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -571,11 +559,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, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, 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, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -586,7 +574,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, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
@@ -605,7 +593,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
@Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
@@ -617,19 +605,6 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false to chatId
}
/*
* 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
* */
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)
}
}
}
@Composable
@@ -973,7 +948,6 @@ fun PreviewChatLayout() {
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
@@ -1031,7 +1005,6 @@ fun PreviewGroupChatLayout() {
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
chatModelIncognito = false,
back = {},
info = {},
@@ -150,7 +150,7 @@ fun ComposeView(
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>>(
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
)
@@ -167,7 +167,7 @@ fun ComposeView(
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
@@ -239,7 +239,7 @@ fun ComposeView(
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
@@ -455,29 +455,6 @@ fun ComposeView(
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
}
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)
}
}
}
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) {
@@ -494,7 +471,7 @@ fun ComposeView(
fun cancelVoice() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenAudio.value = null
chosenContent.value = emptyList()
}
fun cancelFile() {
@@ -541,9 +518,6 @@ fun ComposeView(
}
LaunchedEffect(chatModel.sharedContent.value) {
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
if (chatModel.chatId.value == null) return@LaunchedEffect
when (val shared = chatModel.sharedContent.value) {
is SharedContent.Text -> onMessageChange(shared.text)
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
@@ -575,35 +549,15 @@ fun ComposeView(
.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
}
}
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,
allowedVoiceByPrefs = allowedVoiceByPrefs,
needToAllowVoiceToContact = needToAllowVoiceToContact,
allowVoiceRecord = true,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
::onAudioAdded,
::allowVoiceToContact,
::showDisabledVoiceAlert,
textStyle
)
}
@@ -14,49 +14,42 @@ 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.durationText
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.AudioInfoUpdater
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ComposeVoiceView(
filePath: String,
recordedDurationMs: Int,
finishedRecording: Boolean,
cancelEnabled: Boolean,
cancelVoice: () -> Unit
) {
fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) {
BoxWithConstraints(Modifier
.fillMaxWidth()
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
val progress = rememberSaveable { mutableStateOf(0) }
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) {
mutableStateOf(ProgressAndDuration(durationMs = durationMs))
}
LaunchedEffect(durationMs) {
audioInfo.value = audioInfo.value.copy(durationMs = durationMs)
}
val progressBarWidth = remember { Animatable(0f) }
LaunchedEffect(recordedDurationMs, finishedRecording) {
snapshotFlow { progress.value }
LaunchedEffect(durationMs, finished) {
snapshotFlow { audioInfo.value }
.distinctUntilChanged()
.collect {
val startTime = when {
finishedRecording -> progress.value
else -> recordedDurationMs
}
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
}
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0
val new = if (audioPlaying.value || finished)
((number.toDouble() / durationMs) * maxWidth.value).dp
else
(((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp
progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec())
}
}
Spacer(
Modifier
.requiredWidth(progressBarWidth.value.dp)
.padding(top = 58.dp)
.height(3.dp)
.height(2.dp)
.background(MaterialTheme.colors.primary)
)
Row(
@@ -67,35 +60,33 @@ fun ComposeVoiceView(
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
},
enabled = finishedRecording) {
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) {
audioPlaying.value = false
}
}
val pause = {
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) {
Icon(
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight
)
}
val numberInText = remember(recordedDurationMs, progress.value) {
derivedStateOf {
when {
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
finishedRecording -> progress.value / 1000
else -> recordedDurationMs / 1000
}
}
val numberInText = remember(durationMs, audioInfo.value) {
derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 }
}
val text = "%02d:%02d".format(numberInText.value / 60, numberInText.value % 60)
Text(
durationText(numberInText.value),
text,
fontSize = 18.sp,
color = HighOrLowlight,
)
@@ -1,141 +0,0 @@
package chat.simplex.app.views.chat
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun ContactPreferencesView(
m: ChatModel,
user: User,
contactId: Long,
) {
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
val ct = contact.value ?: return
var featuresAllowed by remember(ct) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
var currentFeaturesAllowed by remember(ct) { mutableStateOf(featuresAllowed) }
ContactPreferencesLayout(
featuresAllowed,
currentFeaturesAllowed,
user,
ct,
applyPrefs = { prefs ->
featuresAllowed = prefs
},
reset = {
featuresAllowed = currentFeaturesAllowed
},
savePrefs = {
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
if (toContact != null) {
m.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
}
},
)
}
@Composable
private fun ContactPreferencesLayout(
featuresAllowed: ContactFeaturesAllowed,
currentFeaturesAllowed: ContactFeaturesAllowed,
user: User,
contact: Contact,
applyPrefs: (ContactFeaturesAllowed) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.contact_preferences))
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))
}
SectionSpacer()
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = featuresAllowed == currentFeaturesAllowed
)
}
}
@Composable
private fun FeatureSection(
feature: ChatFeature,
userDefault: FeatureAllowed,
pref: ContactUserPreference,
allowFeature: State<ContactFeatureAllowed>,
onSelected: (ContactFeatureAllowed) -> Unit
) {
val enabled = FeatureEnabled.enabled(
user = ChatPreference(allow = allowFeature.value.allowed),
contact = pref.contactPreference
)
SectionView(
feature.text.uppercase(),
icon = feature.iconFilled,
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
leadingIcon = true,
) {
SectionItemView {
ExposedDropDownSettingRow(
generalGetString(R.string.chat_preferences_you_allow),
ContactFeatureAllowed.values(userDefault).map { it to it.text },
allowFeature,
icon = null,
onSelected = onSelected
)
}
SectionDivider()
InfoRow(
generalGetString(R.string.chat_preferences_contact_allows),
pref.contactPreference.allow.text
)
}
SectionTextFooter(feature.enabledDescription(enabled))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
@@ -14,7 +14,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
@@ -52,7 +53,6 @@ fun ContextItemView(
MarkdownText(
contextItem.text, contextItem.formattedText,
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
linkMode = SimplexLinkMode.DESCRIPTION,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -17,6 +17,7 @@ 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.*
@@ -24,6 +25,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
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
@@ -46,14 +48,10 @@ import java.io.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
showVoiceRecordIcon: Boolean,
allowedVoiceByPrefs: Boolean,
needToAllowVoiceToContact: Boolean,
allowVoiceRecord: Boolean,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
allowVoiceToContact: () -> Unit,
showDisabledVoiceAlert: () -> Unit,
textStyle: MutableState<TextStyle>
) {
Column(Modifier.padding(vertical = 8.dp)) {
@@ -62,7 +60,7 @@ fun SendMsgView(
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
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && allowVoiceRecord && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
@@ -113,16 +111,6 @@ fun SendMsgView(
}
val startStopRecording: () -> Unit = {
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,
)
}
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
@@ -148,7 +136,6 @@ fun SendMsgView(
}
val cleanUp = { remove: Boolean ->
rec.stop()
AudioPlayer.stop(filePath.value)
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
@@ -161,15 +148,10 @@ fun SendMsgView(
}
}
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)
@@ -190,19 +172,9 @@ fun SendMsgView(
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
},
if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic,
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
},
tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
@@ -237,13 +209,16 @@ private fun NativeKeyboard(
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
if (cs.contextItem is ComposeContextItem.QuotedItem) {
delay(100)
showKeyboard = true
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> {
delay(100)
showKeyboard = true
}
is ComposeContextItem.EditingItem -> {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
}
}
}
@@ -325,14 +300,10 @@ fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
needToAllowVoiceToContact = false,
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -352,14 +323,10 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
needToAllowVoiceToContact = false,
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -379,14 +346,10 @@ fun PreviewSendMsgViewInProgress() {
SimpleXTheme {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
needToAllowVoiceToContact = false,
allowVoiceRecord = false,
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -31,23 +31,17 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@Composable
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
val selectedContacts = remember { mutableStateListOf<Long>() }
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
var allowModifyMembers by remember { mutableStateOf(true) }
BackHandler(onBack = close)
AddGroupMembersLayout(
groupInfo = groupInfo,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
allowModifyMembers = allowModifyMembers,
openPreferences = {
ModalManager.shared.showModal(true) {
GroupPreferencesView(chatModel, groupInfo.id)
}
},
inviteMembers = {
allowModifyMembers = false
withApi {
@@ -65,7 +59,6 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
clearSelection = { selectedContacts.clear() },
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
}
@@ -86,17 +79,14 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
creatingGroup: Boolean,
contactsToAdd: List<Contact>,
selectedContacts: List<Long>,
selectedRole: MutableState<GroupMemberRole>,
allowModifyMembers: Boolean,
openPreferences: () -> Unit,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
close: () -> Unit,
) {
Column(
Modifier
@@ -130,28 +120,18 @@ fun AddGroupMembersLayout(
}
} else {
SectionView {
if (creatingGroup) {
SectionItemView(openPreferences) {
Text(stringResource(R.string.set_group_preferences))
}
SectionDivider()
}
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
}
SectionDivider()
if (creatingGroup && selectedContacts.isEmpty()) {
SkipInvitingButton(close)
} else {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
}
SectionSpacer()
SectionView(stringResource(R.string.select_contacts)) {
SectionView {
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
SectionSpacer()
@@ -190,17 +170,6 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
)
}
@Composable
fun SkipInvitingButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.Check,
stringResource(R.string.skip_inviting_button),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
)
}
@Composable
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
Row(
@@ -319,17 +288,14 @@ fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
groupInfo = GroupInfo.sampleData,
creatingGroup = false,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
allowModifyMembers = true,
openPreferences = {},
inviteMembers = {},
clearSelection = {},
addContact = {},
removeContact = {},
close = {},
removeContact = {}
)
}
}
@@ -4,7 +4,6 @@ import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -29,7 +28,6 @@ import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
@Composable
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
@@ -49,7 +47,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
AddGroupMembersView(groupInfo, chatModel, close)
}
}
},
@@ -64,14 +62,6 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
openPreferences = {
ModalManager.shared.showModal(true) {
GroupPreferencesView(
chatModel,
chat.id
)
}
},
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
@@ -130,7 +120,6 @@ fun GroupChatInfoLayout(
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
openPreferences: () -> Unit,
deleteGroup: () -> Unit,
clearChat: () -> Unit,
leaveGroup: () -> Unit,
@@ -150,16 +139,6 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
GroupPreferencesButton(openPreferences)
}
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
SectionSpacer()
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
SectionItemView(manageGroupLink) { GroupLinkButton() }
@@ -181,6 +160,10 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
SectionDivider()
}
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
SectionDivider()
@@ -228,15 +211,6 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
}
}
@Composable
private fun GroupPreferencesButton(onClick: () -> Unit) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.group_preferences),
click = onClick
)
}
@Composable
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
Row(
@@ -312,10 +286,10 @@ fun GroupLinkButton() {
Icon(
Icons.Outlined.Link,
stringResource(R.string.group_link),
tint = HighOrLowlight
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.group_link))
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
}
}
@@ -329,10 +303,10 @@ fun EditGroupProfileButton() {
Icon(
Icons.Outlined.Edit,
stringResource(R.string.button_edit_group_profile),
tint = HighOrLowlight
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.button_edit_group_profile))
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
}
}
@@ -381,7 +355,7 @@ fun PreviewGroupChatInfoLayout() {
groupInfo = GroupInfo.sampleData,
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
)
}
}
@@ -133,12 +133,10 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
if (member.memberContactId != null && groupInfo.fullGroupPreferences.directMessages.enable == GroupFeatureEnabled.ON) {
SectionView {
OpenChatButton(openDirectChat)
}
SectionSpacer()
SectionView {
OpenChatButton(openDirectChat)
}
SectionSpacer()
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
@@ -162,8 +160,10 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
if (developerTools) {
SwitchAddressButton(switchMemberAddress)
SectionDivider()
}
if (connStats != null) {
val rcvServers = connStats.rcvServers
val sndServers = connStats.sndServers
@@ -1,132 +0,0 @@
package chat.simplex.app.views.chat.group
import InfoRow
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimplexGreen
import chat.simplex.app.views.helpers.*
@Composable
fun GroupPreferencesView(m: ChatModel, chatId: String) {
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
val gInfo = groupInfo.value ?: return
var preferences by remember(gInfo) { mutableStateOf(gInfo.fullGroupPreferences) }
var currentPreferences by remember(gInfo) { mutableStateOf(preferences) }
GroupPreferencesLayout(
preferences,
currentPreferences,
gInfo,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = {
withApi {
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
if (gInfo != null) {
m.updateGroup(gInfo)
currentPreferences = preferences
}
}
},
)
}
@Composable
private fun GroupPreferencesLayout(
preferences: FullGroupPreferences,
currentPreferences: FullGroupPreferences,
groupInfo: GroupInfo,
applyPrefs: (FullGroupPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.group_preferences))
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo) {
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
}
SectionSpacer()
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo) {
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
if (groupInfo.canEdit) {
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
}
@Composable
private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, 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
if (groupInfo.canEdit) {
SectionItemView {
ExposedDropDownSettingRow(
feature.text,
GroupFeatureEnabled.values().map { it to it.text },
enableFeature,
icon = icon,
iconTint = iconTint,
onSelected = onSelected
)
}
} else {
InfoRow(
feature.text,
enableFeature.value.text,
icon = icon,
iconTint = iconTint,
)
}
}
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
@@ -24,7 +24,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
Modifier
.padding(horizontal = 4.dp)
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
when (status) {
CICallStatus.Pending -> if (sent) {
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
@@ -38,7 +38,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
CICallStatus.Ended -> Row {
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(durationText(duration), color = HighOrLowlight)
Text(status.duration(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}
@@ -1,32 +0,0 @@
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
@Composable
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color
) {
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)
Text(
chatEventText(chatItem),
Modifier,
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
fontSize = 12.sp
)
}
}
@@ -18,42 +18,32 @@ import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CIEventView(ci: ChatItem) {
@Composable
fun chatEventTextView(text: AnnotatedString) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
Surface {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.Bottom
) {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
chatEventTextView(
buildAnnotatedString {
withChatEventStyle(this, memberDisplayName)
Text(
buildAnnotatedString {
val memberDisplayName = ci.memberDisplayName
if (memberDisplayName != null) {
withGroupEventStyle(this, memberDisplayName)
append(" ")
}.plus(chatEventText(ci))
)
} else {
chatEventTextView(chatEventText(ci))
}
}
withGroupEventStyle(this, ci.content.text)
append(" ")
withGroupEventStyle(this, ci.timestampText)
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
}
}
}
private fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) {
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
}
fun chatEventText(ci: ChatItem): AnnotatedString =
buildAnnotatedString {
withChatEventStyle(this, ci.content.text)
append(" ")
withChatEventStyle(this, ci.timestampText)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -205,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
val showMenu = remember { mutableStateOf(false) }
SimpleXTheme {
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
}
}
@@ -15,7 +15,6 @@ 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
@@ -52,7 +51,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
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)
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
}
is CIStatus.RcvNew -> {
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
@@ -88,7 +87,7 @@ fun PreviewCIMetaViewSendFailed() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError("CMD SYNTAX")
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
)
)
}
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,25 +18,26 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@Composable
fun CIVoiceView(
providedDurationSec: Int,
durationSec: Int,
file: CIFile?,
edited: Boolean,
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
longClick: () -> Unit,
metaColor: Color
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
@@ -45,27 +45,86 @@ fun CIVoiceView(
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
val play = {
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
val audioInfo = remember(file.filePath) {
file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000)
file.audioInfo
}
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) {
// If you want to preserve the position after switching a track, remove this line
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
brokenAudio = !audioPlaying.value
}
val pause = {
AudioPlayer.pause(audioPlaying, progress)
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
val text = remember {
derivedStateOf {
val time = when {
audioPlaying.value || progress.value != 0 -> progress.value
else -> duration.value
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
val text = String.format("%02d:%02d", time / 1000 / 60, time / 1000 % 60)
if (hasText) {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Text(
text,
Modifier
.padding(start = 12.dp, end = 5.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
textAlign = TextAlign.Start,
maxLines = 1
)
} else {
if (sent) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
Text(
text,
Modifier
.padding(end = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
}
} else {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
Modifier
.padding(start = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.height(56.dp))
}
}
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, play, pause, longClick)
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {})
val metaReserve = if (edited)
" "
else
@@ -75,72 +134,6 @@ fun CIVoiceView(
}
}
@Composable
private fun VoiceLayout(
file: CIFile,
ci: ChatItem,
text: State<String>,
audioPlaying: State<Boolean>,
progress: State<Int>,
duration: State<Int>,
brokenAudio: Boolean,
sent: Boolean,
hasText: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
) {
when {
hasText -> {
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
DurationText(text, PaddingValues(start = 12.dp))
}
sent -> {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci)
}
}
}
}
else -> {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Spacer(Modifier.height(56.dp))
}
}
}
}
}
@Composable
private fun DurationText(text: State<String>, padding: PaddingValues) {
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
Text(
text.value,
Modifier
.padding(padding)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
@Composable
private fun PlayPauseButton(
audioPlaying: Boolean,
@@ -151,21 +144,17 @@ private fun PlayPauseButton(
enabled: Boolean,
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
pause: () -> Unit
) {
Surface(
onClick = { if (!audioPlaying) play() else pause() },
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
color = if (sent) SentColorLight else ReceivedColorLight,
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
) {
Box(
Modifier
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
.combinedClickable(
onClick = { if (!audioPlaying) play() else pause() },
onLongClick = longClick
),
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp),
contentAlignment = Alignment.Center
) {
Icon(
@@ -184,19 +173,17 @@ private fun VoiceMsgIndicator(
audioPlaying: Boolean,
sent: Boolean,
hasText: Boolean,
progress: State<Int>?,
duration: State<Int>?,
audioInfo: State<ProgressAndDuration>?,
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
pause: () -> Unit
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
if (file != null && file.loaded && progress != null && duration != null) {
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
if (file != null && file.loaded && audioInfo != null) {
val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat()
if (hasText) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
@@ -205,13 +192,12 @@ private fun VoiceMsgIndicator(
)
}
} else {
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause)
}
} else {
if (file?.fileStatus == CIFileStatus.RcvInvitation
|| file?.fileStatus == CIFileStatus.RcvTransfer
|| file?.fileStatus == CIFileStatus.RcvAccepted
) {
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
Box(
Modifier
.size(56.dp)
@@ -221,7 +207,7 @@ private fun VoiceMsgIndicator(
ProgressIndicator()
}
} else {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {})
}
}
}
@@ -256,3 +242,26 @@ private fun ProgressIndicator() {
strokeWidth = 4.dp
)
}
@Composable
fun AudioInfoUpdater(
filePath: String?,
audioPlaying: MutableState<Boolean>,
audioInfo: MutableState<ProgressAndDuration>
) {
LaunchedEffect(filePath) {
if (filePath != null && audioInfo.value.durationMs == 0) {
audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath))
}
}
LaunchedEffect(audioPlaying.value) {
while (isActive && audioPlaying.value) {
audioInfo.value = AudioPlayer.progressAndDurationOrEnded()
if (audioInfo.value.progressMs == audioInfo.value.durationMs) {
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
delay(50)
}
}
}
@@ -26,8 +26,6 @@ import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
@Composable
fun ChatItemView(
cInfo: ChatInfo,
@@ -36,7 +34,6 @@ fun ChatItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
@@ -48,11 +45,7 @@ fun ChatItemView(
val sent = cItem.chatDir.sent
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 saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
val onLinkLongClick = { _: String -> showMenu.value = true }
Box(
modifier = Modifier
.padding(bottom = 4.dp)
@@ -65,7 +58,7 @@ fun ChatItemView(
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
}
is CIStatus.SndError -> {
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
}
else -> {}
}
@@ -75,36 +68,26 @@ fun ChatItemView(
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
generalGetString(R.string.delete_message_mark_deleted_warning)
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
if (!cItem.meta.itemDeleted) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
@@ -137,59 +120,15 @@ fun ChatItemView(
showMenu.value = false
})
}
if (cItem.meta.itemDeleted && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
Icons.Outlined.VisibilityOff,
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.reveal_verb),
Icons.Outlined.Visibility,
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
revealed.value = true
showMenu.value = false
}
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted && !revealed.value) {
MarkedDeletedItemView(cItem, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
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("") })
MsgContentItemDropdownMenu()
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
}
@@ -200,7 +139,15 @@ fun ChatItemView(
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
}
@@ -222,35 +169,11 @@ fun ChatItemView(
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
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.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)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
}
}
}
}
@Composable
fun DeleteItemAction(
cItem: ChatItem,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
@Composable
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
@@ -268,10 +191,10 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
}
}
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(R.string.delete_message__question),
text = questionText,
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
buttons = {
Row(
Modifier
@@ -312,7 +235,6 @@ fun PreviewChatItemView() {
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
@@ -331,7 +253,6 @@ fun PreviewChatItemViewDeletedContent() {
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
@@ -5,8 +5,8 @@ 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.filled.*
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -17,15 +17,15 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastMap
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.ChatItemLinkView
import chat.simplex.app.views.helpers.base64ToBitmap
import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
@@ -40,7 +40,6 @@ fun FramedItemView(
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
@@ -58,37 +57,13 @@ fun FramedItemView(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart
) {
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
qi.content.toTextWithDuration(true)
else
qi.text
MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
linkMode = linkMode
)
}
}
@Composable
fun ciDeletedView() {
Row(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.fillMaxWidth()
.padding(start = 8.dp)
.padding(end = 12.dp)
.padding(top = 6.dp)
.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
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
)
}
}
@@ -122,7 +97,7 @@ fun FramedItemView(
ciQuotedMsgView(qi)
}
Icon(
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier
.padding(top = 6.dp, end = 4.dp)
@@ -135,16 +110,7 @@ fun FramedItemView(
}
}
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (text != "") {
CIMarkdownText(ci, 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.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
.background(
@@ -158,7 +124,6 @@ fun FramedItemView(
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted) { ciDeletedView() }
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
@@ -179,33 +144,34 @@ fun FramedItemView(
if (mc.text == "") {
metaColor = Color.White
} else {
CIMarkdownText(ci, showMember, linkMode, uriHandler)
CIMarkdownText(ci, showMember, 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, mc.text != "" || ci.quotedItem != null, ci, metaColor)
if (mc.text != "") {
CIMarkdownText(ci, showMember, linkMode, uriHandler)
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCFile -> ciFileView(ci, mc.text)
is MsgContent.MCUnknown ->
if (ci.file == null) {
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
} else {
ciFileView(ci, mc.text)
is MsgContent.MCFile -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCLink -> {
ChatItemLinkView(mc.preview)
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
}
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
}
}
}
@@ -215,14 +181,13 @@ fun FramedItemView(
fun CIMarkdownText(
ci: ChatItem,
showMember: Boolean,
linkMode: SimplexLinkMode,
uriHandler: UriHandler?,
onLinkLongClick: (link: String) -> Unit = {}
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
MarkdownText(
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
metaText = ci.timestampText, edited = ci.meta.itemEdited,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
)
}
@@ -273,7 +238,6 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -290,7 +254,6 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -311,7 +274,6 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
"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.",
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -333,7 +295,6 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -355,7 +316,6 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -384,7 +344,6 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -413,7 +372,6 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -441,7 +399,6 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
quotedItem = ciQuote,
itemEdited = edited
),
linkMode = SimplexLinkMode.DESCRIPTION,
showMenu = showMenu,
receiveFile = {}
)
@@ -77,14 +77,12 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
val image = provider.getImage(index)
if (image == null) {
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
SideEffect {
scope.launch {
when (settledCurrentPage) {
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
index + 1 -> {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
scope.launch {
when (settledCurrentPage) {
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
index + 1 -> {
provider.scrollToStart()
pagerState.scrollToPage(0)
}
}
}
@@ -1,57 +0,0 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.FontStyle
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.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.generalGetString
@Composable
fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
Surface(
shape = RoundedCornerShape(18.dp),
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
buildAnnotatedString {
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
modifier = Modifier.padding(end = 8.dp)
)
CIMetaView(ci)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PreviewMarkedDeletedItemView() {
SimpleXTheme {
DeletedItemView(
ChatItem.getSampleData(itemDeleted = true)
)
}
}
@@ -1,7 +1,5 @@
package chat.simplex.app.views.chat.item
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -12,11 +10,9 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.core.text.BidiFormatter
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.detectGesture
@@ -53,7 +49,6 @@ fun MarkdownText (
uriHandler: UriHandler? = null,
senderBold: Boolean = false,
modifier: Modifier = Modifier,
linkMode: SimplexLinkMode,
onLinkLongClick: (link: String) -> Unit = {}
) {
val textLayoutDirection = remember (text) {
@@ -84,16 +79,12 @@ fun MarkdownText (
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link(linkMode)
val link = ft.link
if (link != null) {
hasLinks = true
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
} else {
ft.format.style
}
val ftStyle = ft.format.style
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
withStyle(ftStyle) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
@@ -113,15 +104,7 @@ fun MarkdownText (
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
uriHandler.openUri(annotation.item)
} catch (e: ActivityNotFoundException) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
}
}
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
@@ -33,7 +33,6 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
}
val stopped = chatModel.chatRunning.value == false
val linkMode by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
@@ -41,7 +40,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
when (chat.chatInfo) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -49,7 +48,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
@@ -587,8 +586,7 @@ fun PreviewChatListNavLinkDirect() {
),
false,
null,
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
stopped = false
)
},
click = {},
@@ -625,8 +623,7 @@ fun PreviewChatListNavLinkGroup() {
),
false,
null,
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
stopped = false
)
},
click = {},
@@ -26,7 +26,7 @@ import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
@Composable
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) {
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
val cInfo = chat.chatInfo
@Composable
@@ -83,10 +83,9 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
if (!ci.meta.itemDeleted) ci.formattedText else null,
ci.text,
ci.formattedText,
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
linkMode = linkMode,
senderBold = true,
metaText = null,
maxLines = 2,
@@ -233,6 +232,6 @@ fun ChatStatusImage(chat: Chat) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
}
}
@@ -84,7 +84,6 @@ fun DatabaseView(
chatArchiveTime,
chatLastStart,
chatDbDeleted.value,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
@@ -127,13 +126,12 @@ fun DatabaseLayout(
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: SharedPreference<Boolean>,
initialRandomDBPassphrase: Preference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
chatDbDeleted: Boolean,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
startChat: () -> Unit,
@@ -167,8 +165,6 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
@@ -687,13 +683,12 @@ fun PreviewDatabaseLayout() {
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
initialRandomDBPassphrase = Preference({ true }, {}),
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
chatDbDeleted = false,
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
startChat = {},
@@ -1,17 +1,11 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.ui.theme.DEFAULT_PADDING
class AlertManager {
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
@@ -41,26 +35,6 @@ class AlertManager {
}
}
fun showAlertDialogButtonsColumn(
title: String,
text: String? = null,
buttons: @Composable () -> Unit,
) {
showAlert {
Dialog(onDismissRequest = this::hideAlert) {
Column(Modifier.background(MaterialTheme.colors.background)) {
Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp)
if (text != null) {
Text(text)
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
buttons()
}
}
}
}
}
fun showAlertDialog(
title: String,
text: String? = null,
@@ -10,12 +10,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
@@ -30,7 +27,7 @@ fun <T> ExposedDropDownSettingRow(
onSelected: (T) -> Unit
) {
Row(
Modifier.fillMaxWidth().padding(vertical = 10.dp),
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var expanded by remember { mutableStateOf(false) }
@@ -43,7 +40,9 @@ fun <T> ExposedDropDownSettingRow(
tint = iconTint
)
}
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
Spacer(Modifier.fillMaxWidth().weight(1f))
ExposedDropdownMenuBox(
expanded = expanded,
@@ -56,10 +55,8 @@ fun <T> ExposedDropDownSettingRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
val maxWidth = with(LocalDensity.current){ 180.sp.toDp() }
Text(
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
Modifier.widthIn(max = maxWidth),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = HighOrLowlight
@@ -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,7 +3,6 @@ package chat.simplex.app.views.helpers
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
@@ -33,9 +32,11 @@ import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.json
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.PickFromGallery
import chat.simplex.app.views.newchat.ActionButton
import kotlinx.serialization.builtins.*
import kotlinx.serialization.decodeFromString
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.min
@@ -176,25 +177,6 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
try {
launch(null)
} catch (e: ActivityNotFoundException) {
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
// Means, no system camera app (Android 11+ requirement)
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
try {
// Try to open any camera just to capture an image, will not be returned like with previous intent
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
} catch (e: ActivityNotFoundException) {
// No camera apps available at all
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
}
}
}
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Uri?>,
@@ -222,7 +204,7 @@ fun GetImageBottomSheet(
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
hideBottomSheet()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
@@ -246,7 +228,7 @@ fun GetImageBottomSheet(
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launchWithFallback()
cameraLauncher.launch(null)
hideBottomSheet()
}
else -> {
@@ -1,16 +1,14 @@
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_FILESIZE_REACHED
import android.os.Build
import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import kotlinx.coroutines.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
@@ -22,6 +20,19 @@ interface Recorder {
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
}
data class ProgressAndDuration(
val progressMs: Int = 0,
val durationMs: Int = 0
) {
companion object {
val Saver
get() = Saver<MutableState<ProgressAndDuration>, Pair<Int, Int>>(
save = { it.value.progressMs to it.value.durationMs },
restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) }
)
}
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
companion object {
// Allows to stop the recorder from outside without having the recorder in a variable
@@ -47,7 +58,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(-1) // TODO set limit
rec.setMaxDuration(-1)
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")))
@@ -96,17 +107,6 @@ object AudioPlayer {
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
// In a process of making a call
RecorderNative.stopRecording?.invoke()
stop()
}
super.onPlaybackConfigChanged(configs)
}
}, null)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
@@ -116,79 +116,55 @@ object AudioPlayer {
.build()
)
}
// Filepath: String, onProgressUpdate
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
private var progressJob: Job? = null
// Filepath: String, onStop: () -> Unit
private val currentlyPlaying: MutableState<Pair<String, () -> Unit>?> = mutableStateOf(null)
enum class TrackState {
PLAYING, PAUSED, REPLACED
}
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
return null
return false
}
RecorderNative.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
stopListener()
player.reset()
// Notify prev audio listener about stop
current?.second?.invoke()
runCatching {
player.setDataSource(filePath)
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return null
return false
}
runCatching { player.prepare() }.onFailure {
// Can happen when audio file is broken
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return null
return false
}
}
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) {
// Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
break
}
delay(50)
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
}
/*
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
* the player can show position != duration even if they actually equal.
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
* */
if (isActive) {
onProgressUpdate(player.duration, TrackState.PAUSED)
}
onProgressUpdate(null, TrackState.PAUSED)
// Repeated calls to play/pause on the same track will not recompose all dependent views
if (currentlyPlaying.value?.first != filePath) {
currentlyPlaying.value = filePath to onStop
}
return player.duration
return true
}
private fun pause(): Int {
progressJob?.cancel()
progressJob = null
fun pause(): Int {
player.pause()
return player.currentPosition
}
fun stop() {
if (currentlyPlaying.value == null) return
if (!player.isPlaying) return
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke()
currentlyPlaying.value = null
player.stop()
stopListener()
}
fun stop(item: ChatItem) = stop(item.file?.fileName)
@@ -200,56 +176,13 @@ object AudioPlayer {
}
}
private fun stopListener() {
val afterCoroutineCancel: CompletionHandler = {
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
currentlyPlaying.value = null
}
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
* */
if (progressJob != null) {
progressJob?.invokeOnCompletion(afterCoroutineCancel)
} else {
afterCoroutineCancel(null)
}
progressJob?.cancel()
progressJob = null
}
fun play(
filePath: String?,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnEnd: Boolean,
) {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
if (pro == null || pro == duration.value) {
audioPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
} else if (state == TrackState.REPLACED) {
progress.value = 0
}
}
}
audioPlaying.value = realDuration != null
// Update to real duration instead of what was received in ChatInfo
realDuration?.let { duration.value = it }
}
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
pro.value = pause()
audioPlaying.value = false
}
/**
* If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example),
* it will stop immediately after start but will not change currentPosition, so it will not be equal to duration.
* However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop
* */
fun progressAndDurationOrEnded(): ProgressAndDuration =
ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration)
fun duration(filePath: String): Int {
var res = 0
@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.*
@@ -32,28 +31,6 @@ fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(),
}
}
@Composable
fun SectionView(
title: String,
icon: ImageVector,
iconTint: Color = HighOrLowlight,
leadingIcon: Boolean = false,
padding: PaddingValues = PaddingValues(),
content: (@Composable ColumnScope.() -> Unit)
) {
Column {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint)
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp)
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint)
}
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
}
}
}
@Composable
fun <T> SectionViewSelectable(
title: String?,
@@ -79,12 +56,7 @@ fun <T> SectionViewSelectable(
}
@Composable
fun SectionItemView(
click: (() -> Unit)? = null,
minHeight: Dp = 46.dp,
disabled: Boolean = false,
content: (@Composable RowScope.() -> Unit)
) {
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
val modifier = Modifier
.fillMaxWidth()
.sizeIn(minHeight = minHeight)
@@ -186,13 +158,9 @@ fun SectionSpacer() {
}
@Composable
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
fun InfoRow(title: String, value: String) {
SectionItemViewSpaceBetween {
Row {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: HighOrLowlight)
Text(title)
}
Text(title)
Text(value, color = HighOrLowlight)
}
}
@@ -12,28 +12,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.ui.theme.HighOrLowlight
@Composable
fun TextEditor(
modifier: Modifier,
text: MutableState<String>,
border: Boolean = true,
fontSize: TextUnit = 14.sp,
background: Color = MaterialTheme.colors.background,
onChange: ((String) -> Unit)? = null
) {
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
BasicTextField(
value = text.value,
onValueChange = { text.value = it; onChange?.invoke(it) },
onValueChange = { text.value = it },
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = fontSize,
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
@@ -44,17 +37,17 @@ fun TextEditor(
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(background),
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
.padding(vertical = 5.dp, horizontal = 7.dp)
) {
innerTextField()
}
@@ -1,5 +1,7 @@
package chat.simplex.app.views.helpers
import android.R.attr.factor
import android.R.color
import android.content.Context
import android.content.res.Resources
import android.graphics.*
@@ -26,6 +28,7 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.text.HtmlCompat
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
@@ -40,9 +43,6 @@ fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalSc
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
scope.launch { withContext(Dispatchers.Main, action) }
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(Dispatchers.Default).launch(block = action)
enum class KeyboardState {
Opened, Closed
}
@@ -47,7 +47,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
setGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, true, chatModel, close)
AddGroupMembersView(groupInfo, chatModel, close)
}
}
}
@@ -30,8 +30,8 @@ fun CallSettingsView(m: ChatModel,
@Composable
fun CallSettingsLayout(
webrtcPolicyRelay: SharedPreference<Boolean>,
callOnLockScreen: SharedPreference<CallOnLockScreen>,
webrtcPolicyRelay: Preference<Boolean>,
callOnLockScreen: Preference<CallOnLockScreen>,
editIceServers: () -> Unit,
) {
Column(
@@ -79,10 +79,9 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
@Composable
fun SharedPreferenceToggle(
text: String,
preference: SharedPreference<Boolean>,
preferenceState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
preference: Preference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.padding(end = 24.dp))
@@ -92,7 +91,6 @@ fun SharedPreferenceToggle(
onCheckedChange = {
preference.set(it)
prefState.value = it
onChange?.invoke(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
@@ -108,7 +106,7 @@ fun SharedPreferenceToggleWithIcon(
icon: ImageVector,
stopped: Boolean = false,
onClickInfo: () -> Unit,
preference: SharedPreference<Boolean>,
preference: Preference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
@@ -137,7 +135,7 @@ fun SharedPreferenceToggleWithIcon(
}
@Composable
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
@@ -3,9 +3,7 @@ package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import java.security.KeyStore
@@ -33,7 +31,7 @@ internal class Cryptor {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
return String(cipher.doFinal(data))
}
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
@@ -32,10 +32,6 @@ fun NetworkAndServersView(
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
LaunchedEffect(Unit) {
chatModel.userSMPServersUnsaved.value = null
}
NetworkAndServersLayout(
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
@@ -116,7 +112,7 @@ fun NetworkAndServersView(
) {
AppBarTitle(stringResource(R.string.network_and_servers))
SectionView(generalGetString(R.string.settings_section_title_messages)) {
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showSettingsModal { SMPServersView(it) })
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
SectionDivider()
SectionItemView {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
@@ -1,109 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.helpers.*
@Composable
fun PreferencesView(m: ChatModel, user: User) {
var preferences by remember { mutableStateOf(user.fullPreferences) }
var currentPreferences by remember { mutableStateOf(preferences) }
PreferencesLayout(
preferences,
currentPreferences,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = {
withApi {
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
if (updatedProfile != null) {
val updatedUser = user.copy(
profile = updatedProfile.toLocalProfile(user.profile.profileId),
fullPreferences = preferences
)
currentPreferences = preferences
m.currentUser.value = updatedUser
}
}
},
)
}
@Composable
private fun PreferencesLayout(
preferences: FullChatPreferences,
currentPreferences: FullChatPreferences,
applyPrefs: (FullChatPreferences) -> Unit,
reset: () -> Unit,
savePrefs: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.your_preferences))
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
}
SectionSpacer()
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
}
SectionSpacer()
ResetSaveButtons(
reset = reset,
save = savePrefs,
disabled = preferences == currentPreferences
)
}
}
@Composable
private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
SectionView {
SectionItemView {
ExposedDropDownSettingRow(
feature.text,
FeatureAllowed.values().map { it to it.text },
allowFeature,
icon = feature.icon,
onSelected = onSelected
)
}
}
SectionTextFooter(feature.allowDescription(allowFeature.value))
}
@Composable
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
SectionView {
SectionItemView(reset, disabled = disabled) {
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
SectionDivider()
SectionItemView(save, disabled = disabled) {
Text(stringResource(R.string.save_and_notify_contacts), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
}
@@ -1,88 +1,39 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.view.WindowManager
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.AppBarTitle
@Composable
fun PrivacySettingsView(
chatModel: ChatModel,
setPerformLA: (Boolean) -> Unit
) {
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
AppBarTitle(stringResource(R.string.your_privacy))
SectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
SectionDivider()
val context = LocalContext.current
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
if (on) {
(context as? FragmentActivity)?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_chats)) {
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SectionDivider()
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
simplexLinkMode.set(it)
chatModel.simplexLinkMode.value = it
}) }
}
if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) {
SectionTextFooter(stringResource(R.string.simplex_link_mode_browser_warning))
}
}
}
@Composable
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
val values = remember {
SimplexLinkMode.values().map {
when (it) {
SimplexLinkMode.DESCRIPTION -> it to generalGetString(R.string.simplex_link_mode_description)
SimplexLinkMode.FULL -> it to generalGetString(R.string.simplex_link_mode_full)
SimplexLinkMode.BROWSER -> it to generalGetString(R.string.simplex_link_mode_browser)
if (chatModel.controller.appPrefs.developerTools.get()) {
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
SectionDivider()
}
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
}
}
ExposedDropDownSettingRow(
generalGetString(R.string.simplex_link_mode),
values,
simplexLinkModeState,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = onSelected
)
}
@@ -1,200 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionView
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
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.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
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.TAG
import chat.simplex.app.model.*
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable
fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
var testing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
SMPServerLayout(
testing,
server,
testServer = {
testing = true
scope.launch {
val res = testServerConnection(server, m)
if (isActive) {
onUpdate(res.first)
testing = false
}
}
},
onUpdate,
onDelete
)
if (testing) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
@Composable
private fun SMPServerLayout(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(if (server.preset) R.string.smp_servers_preset_server else R.string.smp_servers_your_server))
if (server.preset) {
PresetServer(testing, server, testServer, onUpdate, onDelete)
} else {
CustomServer(testing, server, testServer, onUpdate, onDelete)
}
}
}
@Composable
private fun PresetServer(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
SectionView(stringResource(R.string.smp_servers_preset_address).uppercase()) {
SelectionContainer {
Text(
server.server,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = HighOrLowlight
)
)
}
}
SectionSpacer()
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
}
@Composable
private fun CustomServer(
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
val serverAddress = remember { mutableStateOf(server.server) }
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
SectionView(
stringResource(R.string.smp_servers_your_server_address).uppercase(),
icon = Icons.Outlined.ErrorOutline,
iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent,
) {
val testedPreviously = remember { mutableMapOf<String, Boolean?>() }
TextEditor(
Modifier.height(144.dp),
text = serverAddress,
border = false,
fontSize = 16.sp,
background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background
) {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
}
}
SectionSpacer()
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
SectionSpacer()
if (valid.value) {
SectionView(stringResource(R.string.smp_servers_add_to_another_device).uppercase()) {
QRCode(serverAddress.value, Modifier.aspectRatio(1f))
}
}
}
@Composable
private fun UseServerSection(
valid: Boolean,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
SectionView(stringResource(R.string.smp_servers_use_server).uppercase()) {
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
Text(stringResource(R.string.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else HighOrLowlight)
ShowTestStatus(server)
}
SectionDivider()
SectionItemView {
val enabled = rememberUpdatedState(server.enabled)
PreferenceToggle(stringResource(R.string.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) }
}
SectionDivider()
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(R.string.smp_servers_delete_server), color = if (testing) HighOrLowlight else MaterialTheme.colors.error)
}
}
}
@Composable
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
when (server.tested) {
true -> Icon(Icons.Outlined.Check, null, modifier, tint = SimplexGreen)
false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error)
else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent)
}
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, SMPTestFailure?> =
try {
val r = m.controller.testSMPServer(server.server)
server.copy(tested = r == null) to r
} catch (e: Exception) {
Log.e(TAG, "testServerConnection ${e.stackTraceToString()}")
server.copy(tested = false) to null
}
fun serverHostname(srv: String): String =
parseServerAddress(srv)?.hostnames?.firstOrNull() ?: srv
@@ -0,0 +1,258 @@
package chat.simplex.app.views.usersettings
import SectionItemViewSpaceBetween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
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.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun SMPServersView(chatModel: ChatModel) {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
if (r) {
chatModel.userSMPServers.value = smpServers
if (smpServers.isEmpty()) {
isUserSMPServers = false
editSMPServers = true
} else {
editSMPServers = false
}
}
}
}
SMPServersLayout(
isUserSMPServers = isUserSMPServers,
editSMPServers = editSMPServers,
userSMPServersStr = userSMPServersStr,
isUserSMPServersOnOff = { switch ->
if (switch) {
isUserSMPServers = true
} else {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.use_simplex_chat_servers__question),
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
confirmText = generalGetString(R.string.confirm_verb),
onConfirm = {
saveSMPServers(listOf())
isUserSMPServers = false
userSMPServersStr.value = ""
}
)
} else {
isUserSMPServers = false
userSMPServersStr.value = ""
}
}
}
},
cancelEdit = {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
isUserSMPServers = userSMPServers.isNotEmpty()
editSMPServers = !isUserSMPServers
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
}
},
saveSMPServers = { saveSMPServers(it) },
editOn = { editSMPServers = true },
)
}
}
@Composable
fun SMPServersLayout(
isUserSMPServers: Boolean,
editSMPServers: Boolean,
userSMPServersStr: MutableState<String>,
isUserSMPServersOnOff: (Boolean) -> Unit,
cancelEdit: () -> Unit,
saveSMPServers: (List<String>) -> Unit,
editOn: () -> Unit,
) {
Column {
AppBarTitle(stringResource(R.string.your_SMP_servers))
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SectionItemViewSpaceBetween(padding = PaddingValues()) {
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
if (!isUserSMPServers) {
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
} else {
Text(stringResource(R.string.enter_one_SMP_server_per_line))
if (editSMPServers) {
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
stringResource(R.string.cancel_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.save_servers_button),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
val servers = userSMPServersStr.value.split("\n")
saveSMPServers(servers)
})
)
}
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
} else {
Surface(
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
SelectionContainer(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
userSMPServersStr.value,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
)
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
stringResource(R.string.edit_verb),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
)
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
}
}
}
}
}
@Composable
private fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
) {
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
Icon(
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutDefaultServers() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
userSMPServersStr = remember { mutableStateOf("") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOn() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = true,
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOff() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = false,
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
isUserSMPServersOnOff = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@@ -1,319 +0,0 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.launch
@Composable
fun SMPServersView(m: ChatModel) {
var servers by remember {
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
}
val testing = rememberSaveable { mutableStateOf(false) }
val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } }
val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
val saveDisabled = remember {
derivedStateOf {
servers.isEmpty() ||
servers == m.userSMPServers.value ||
testing.value ||
!servers.all { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
} ||
allServersDisabled.value
}
}
fun showServer(server: ServerCfg) {
ModalManager.shared.showModalCloseable(true) { close ->
var old by remember { mutableStateOf(server) }
val index = servers.indexOf(old)
SMPServerView(
m,
old,
onUpdate = { updated ->
val newServers = ArrayList(servers)
newServers.removeAt(index)
newServers.add(index, updated)
old = updated
servers = newServers
m.userSMPServersUnsaved.value = servers
},
onDelete = {
val newServers = ArrayList(servers)
newServers.removeAt(index)
servers = newServers
m.userSMPServersUnsaved.value = servers
close()
})
}
}
val scope = rememberCoroutineScope()
SMPServersLayout(
testing = testing.value,
servers = servers,
serversUnchanged = serversUnchanged.value,
saveDisabled = saveDisabled.value,
allServersDisabled = allServersDisabled.value,
addServer = {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(R.string.smp_servers_add),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
servers = servers + ServerCfg.empty
// No saving until something will be changed on the next screen to prevent blank servers on the list
showServer(servers.last())
}) {
Text(stringResource(R.string.smp_servers_enter_manually))
}
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.shared.showModalCloseable { close ->
ScanSMPServer {
close()
servers = servers + it
m.userSMPServersUnsaved.value = servers
}
}
}
) {
Text(stringResource(R.string.smp_servers_scan_qr))
}
val hasAllPresets = hasAllPresets(servers, m)
if (!hasAllPresets) {
SectionItemView({
AlertManager.shared.hideAlert()
servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset }
}) {
Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground)
}
}
}
}
)
},
testServers = {
scope.launch {
testServers(testing, servers, m) {
servers = it
m.userSMPServersUnsaved.value = servers
}
}
},
resetServers = {
servers = m.userSMPServers.value ?: emptyList()
m.userSMPServersUnsaved.value = null
},
saveSMPServers = {
saveSMPServers(servers, m)
},
showServer = ::showServer,
)
if (testing.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
@Composable
private fun SMPServersLayout(
testing: Boolean,
servers: List<ServerCfg>,
serversUnchanged: Boolean,
saveDisabled: Boolean,
allServersDisabled: Boolean,
addServer: () -> Unit,
testServers: () -> Unit,
resetServers: () -> Unit,
saveSMPServers: () -> Unit,
showServer: (ServerCfg) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
) {
AppBarTitle(stringResource(R.string.your_SMP_servers))
SectionView(stringResource(R.string.smp_servers).uppercase()) {
for (srv in servers) {
SectionItemView({ showServer(srv) }, disabled = testing) {
SmpServerView(srv, servers, testing)
}
SectionDivider()
}
SettingsActionItem(
Icons.Outlined.Add,
stringResource(R.string.smp_servers_add),
addServer,
disabled = testing,
textColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary,
iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary
)
}
SectionSpacer()
SectionView {
SectionItemView(resetServers, disabled = serversUnchanged) {
Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
val testServersDisabled = testing || allServersDisabled
SectionItemView(testServers, disabled = testServersDisabled) {
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
SectionItemView(saveSMPServers, disabled = saveDisabled) {
Text(stringResource(R.string.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
}
}
SectionSpacer()
SectionView {
HowToButton()
}
}
}
@Composable
private fun SmpServerView(srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer()
!srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = if (disabled) HighOrLowlight else MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = HighOrLowlight)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
Icons.Outlined.OpenInNew,
stringResource(R.string.how_to_use_your_servers),
{ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(Icons.Outlined.ErrorOutline, null, tint = MaterialTheme.colors.error)
}
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
address.hostnames.all { host ->
srv.id == s.id || !srv.server.contains(host)
}
}
private fun hasAllPresets(servers: List<ServerCfg>, m: ChatModel): Boolean =
m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true
private fun addAllPresets(servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
val toAdd = ArrayList<ServerCfg>()
for (srv in m.presetSMPServers.value ?: emptyList()) {
if (!hasPreset(srv, servers)) {
toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true))
}
}
return toAdd
}
private fun hasPreset(srv: String, servers: List<ServerCfg>): Boolean =
servers.any { it.server == srv }
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
val resetStatus = resetTestStatus(servers)
onUpdated(resetStatus)
testing.value = true
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
testing.value = false
if (fs.isNotEmpty()) {
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.smp_servers_test_failed),
text = generalGetString(R.string.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
val copy = ArrayList(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
copy.removeAt(index)
copy.add(index, server.copy(tested = null))
}
}
return copy
}
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, SMPTestFailure> {
val fs: MutableMap<String, SMPTestFailure> = mutableMapOf()
val updatedServers = ArrayList<ServerCfg>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
val (updatedServer, f) = testServerConnection(server, m)
updatedServers.removeAt(index)
updatedServers.add(index, updatedServer)
// toList() is important. Otherwise, Compose will not redraw the screen after first update
onUpdated(updatedServers.toList())
if (f != null) {
fs[serverHostname(updatedServer.server)] = f
}
}
}
return fs
}
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
withApi {
if (m.controller.setUserSMPServers(servers)) {
m.userSMPServers.value = servers
m.userSMPServersUnsaved.value = null
}
}
}
@@ -1,55 +0,0 @@
package chat.simplex.app.views.usersettings
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.app.model.ServerCfg
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 ScanSMPServer(onNext: (ServerCfg) -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanSMPServerLayout(onNext)
}
@Composable
private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(R.string.smp_servers_scan_qr), false)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
.padding(bottom = 12.dp)
) {
QRCodeScanner { text ->
val res = parseServerAddress(text)
if (res != null) {
onNext(ServerCfg(text, false, null, true))
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.smp_servers_invalid_address),
text = generalGetString(R.string.smp_servers_check_address)
)
}
}
}
}
}
@@ -80,8 +80,8 @@ fun SettingsLayout(
stopped: Boolean,
encrypted: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: SharedPreference<Boolean>,
developerTools: SharedPreference<Boolean>,
incognitoPref: Preference<Boolean>,
developerTools: Preference<Boolean>,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@@ -116,22 +116,20 @@ fun SettingsLayout(
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
SectionDivider()
ChatPreferencesItem(showSettingsModal)
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionSpacer()
SectionView(stringResource(R.string.settings_section_title_settings)) {
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
SectionDivider()
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
SectionDivider()
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
}
SectionSpacer()
@@ -177,7 +175,7 @@ fun SettingsLayout(
@Composable
fun SettingsIncognitoActionItem(
incognitoPref: SharedPreference<Boolean>,
incognitoPref: Preference<Boolean>,
incognito: MutableState<Boolean>,
stopped: Boolean,
onClickInfo: () -> Unit,
@@ -239,20 +237,6 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable fun ChatPreferencesItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.chat_preferences),
click = {
withApi {
showSettingsModal {
PreferencesView(it, it.currentUser.value ?: return@showSettingsModal)
}()
}
}
)
}
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -381,18 +365,12 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
}
@Composable
fun SettingsPreferenceItem(
icon: ImageVector,
text: String,
pref: SharedPreference<Boolean>,
prefState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
SectionItemView {
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
SharedPreferenceToggle(text, pref, prefState, onChange)
SharedPreferenceToggle(text, pref, prefState)
}
}
}
@@ -404,7 +382,7 @@ fun SettingsPreferenceItemWithInfo(
text: String,
stopped: Boolean,
onClickInfo: () -> Unit,
pref: SharedPreference<Boolean>,
pref: Preference<Boolean>,
prefState: MutableState<Boolean>? = null
) {
SectionItemView(onClickInfo) {
@@ -416,43 +394,21 @@ fun SettingsPreferenceItemWithInfo(
}
}
@Composable
fun PreferenceToggle(
text: String,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = checked,
onCheckedChange = onChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
)
)
}
}
@Composable
fun PreferenceToggleWithIcon(
text: String,
icon: ImageVector? = null,
iconColor: Color? = HighOrLowlight,
icon: ImageVector,
iconColor: Color = HighOrLowlight,
checked: Boolean,
onChange: (Boolean) -> Unit = {},
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(
icon,
null,
tint = iconColor ?: HighOrLowlight
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
Icon(
icon,
null,
tint = iconColor
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
@@ -482,8 +438,8 @@ fun PreviewSettingsLayout() {
stopped = false,
encrypted = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = SharedPreference({ false }, {}),
developerTools = SharedPreference({ false }, {}),
incognitoPref = Preference({ false }, {}),
developerTools = Preference({ false }, {}),
userDisplayName = "Alice",
setPerformLA = {},
showModal = { {} },
@@ -21,7 +21,6 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">Gelöscht</string>
<string name="marked_deleted_description">als gelöscht markiert</string>
<string name="sending_files_not_yet_supported">Das Senden von Dateien wird noch nicht unterstützt</string>
<string name="receiving_files_not_yet_supported">Der Empfang von Dateien wird noch nicht unterstützt</string>
<string name="sender_you_pronoun">Meine Daten</string>
@@ -42,17 +41,6 @@
<string name="description_via_one_time_link">über einen Einmal-Link</string>
<string name="description_via_one_time_link_incognito">Inkognito über einen Einmal-Link</string>
<!-- FormattedText, SimpleX links - ChatModel.kt -->
<string name="simplex_link_contact">SimpleX Kontaktadressen-Link</string>
<string name="simplex_link_invitation">SimpleX Einmal-Link</string>
<string name="simplex_link_group">SimpleX Gruppen-Link</string>
<string name="simplex_link_connection">über <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">SimpleX-Links</string>
<string name="simplex_link_mode_description">Beschreibung</string>
<string name="simplex_link_mode_full">Vollständiger Link</string>
<string name="simplex_link_mode_browser">Über den Browser</string>
<string name="simplex_link_mode_browser_warning">Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein.</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Fehler beim Speichern der SMP-Server</string>
<string name="ensure_smp_server_address_are_correct_format_and_unique">Stellen Sie sicher, dass die SMP-Server Adressen das richtige Format haben, zeilenweise angeordnet und nicht kopiert sind.</string>
@@ -61,7 +49,7 @@
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Verbindungszeitüberschreitung</string>
<string name="connection_error">Verbindungsfehler</string>
<string name="network_error_desc">Bitte überprüfen Sie Ihre Netzwerkverbindung mit <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> und versuchen Sie es erneut.</string>
<string name="network_error_desc">Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.</string>
<string name="error_sending_message">Fehler beim Senden der Nachricht</string>
<string name="error_adding_members">Fehler beim Hinzufügen von Mitgliedern</string>
<string name="error_joining_group">Fehler beim Beitritt zur Gruppe</string>
@@ -82,14 +70,6 @@
<string name="error_deleting_contact_request">Fehler beim Löschen der Kontaktanfrage</string>
<string name="error_deleting_pending_contact_connection">Fehler beim Löschen der anstehenden Kontaktaufnahme</string>
<string name="error_changing_address">Fehler beim Wechseln der Adresse</string>
<string name="error_smp_test_failed_at_step">Der Test ist beim Schritt %s fehlgeschlagen.</string>
<string name="error_smp_test_server_auth">Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort.</string>
<string name="error_smp_test_certificate">Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig.</string>
<string name="smp_server_test_connect">Verbinde</string>
<string name="smp_server_test_create_queue">Erzeuge Warteschlange</string>
<string name="smp_server_test_secure_queue">Sichere Warteschlange</string>
<string name="smp_server_test_delete_queue">Lösche Warteschlange</string>
<string name="smp_server_test_disconnect">Trenne Verbindung</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Sofortige Benachrichtigungen</string>
@@ -168,13 +148,9 @@
<string name="save_verb">Speichern</string>
<string name="edit_verb">Bearbeiten</string>
<string name="delete_verb">Löschen</string>
<string name="reveal_verb">Aufdecken</string>
<string name="hide_verb">Verbergen</string>
<string name="allow_verb">Erlauben</string>
<string name="delete_message__question">Die Nachricht löschen?</string>
<string name="delete_message_cannot_be_undone_warning">Nachricht wird gelöscht - dies kann nicht rückgängig gemacht werden!</string>
<string name="delete_message_mark_deleted_warning">Die Nachricht wird zum Löschen markiert. Der/die Empfänger kann/können diese Nachricht aufdecken.</string>
<string name="for_me_only">Für mich löschen</string>
<string name="for_me_only">Nur für mich</string>
<string name="for_everybody">Für alle</string>
<!-- CIMetaView.kt -->
@@ -233,9 +209,8 @@
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
<!-- Voice messages -->
<string name="voice_message">Sprachnachricht</string>
<string name="voice_message_with_duration">Sprachnachricht (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Sprachnachricht…</string>
<string name="voice_message">***Voice message</string>
<string name="voice_message_send_text">***Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Benachrichtigungen</string>
@@ -254,18 +229,12 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Nachricht senden</string>
<string name="icon_descr_record_voice_message">Nehme Sprachnachricht auf</string>
<string name="allow_voice_messages_question">Sprachnachrichten erlauben?</string>
<string name="you_need_to_allow_to_send_voice">Sie müssen Ihrem Kontakt das Senden von Sprachnachrichten erlauben, damit Sie sie senden können.</string>
<string name="voice_messages_prohibited">Sprachnachrichten unzulässig!</string>
<string name="ask_your_contact_to_enable_voice">Bitten Sie Ihren Kontakt darum, das Senden von Sprachnachrichten zu aktivieren.</string>
<string name="only_group_owners_can_enable_voice">Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden.</string>
<string name="icon_descr_record_voice_message">***Record voice message</string>
<!-- General Actions / Responses -->
<string name="back">Zurück</string>
<string name="cancel_verb">Abbrechen</string>
<string name="confirm_verb">Bestätigen</string>
<string name="reset_verb">Zurücksetzen</string>
<string name="ok">OK</string>
<string name="no_details">Keine Details</string>
<string name="add_contact">Kontakt hinzufügen</string>
@@ -399,34 +368,17 @@
<string name="chat_lock">SimpleX Sperre</string>
<string name="chat_console">Chat Konsole</string>
<string name="smp_servers">SMP-Server</string>
<string name="smp_servers_preset_address">Voreingestellte Serveradresse</string>
<string name="smp_servers_preset_add">Füge voreingestellte Server hinzu</string>
<string name="smp_servers_add">Füge Server hinzu…</string>
<string name="smp_servers_test_server">Teste Server</string>
<string name="smp_servers_test_servers">Teste alle Server</string>
<string name="smp_servers_save">Sichere alle Server</string>
<string name="smp_servers_test_failed">Server Test ist fehlgeschlagen!</string>
<string name="smp_servers_test_some_failed">Einige Server haben den Test nicht bestanden:</string>
<string name="smp_servers_scan_qr">Scannen Sie den QR-Code des Servers</string>
<string name="smp_servers_enter_manually">Geben Sie den Server manuell ein</string>
<string name="smp_servers_preset_server">Voreingestellter Server</string>
<string name="smp_servers_your_server">Ihr Server</string>
<string name="smp_servers_your_server_address">Ihre Serveradresse</string>
<string name="smp_servers_use_server">Server nutzen</string>
<string name="smp_servers_use_server_for_new_conn">Für neue Verbindungen nutzen</string>
<string name="smp_servers_add_to_another_device">Einem anderen Gerät hinzufügen</string>
<string name="smp_servers_invalid_address">Ungültige Serveradresse!</string>
<string name="smp_servers_check_address">Überprüfen Sie die Serveradresse und versuchen Sie es nochmal.</string>
<string name="smp_servers_delete_server">Server löschen</string>
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
<string name="star_on_github">Stern auf GitHub vergeben</string>
<string name="contribute">Unterstützen Sie uns</string>
<string name="rate_the_app">Bewerten Sie die App</string>
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
<string name="your_SMP_servers">Ihre SMP-Server</string>
<string name="configure_SMP_servers">SMP-Server konfigurieren</string>
<string name="using_simplex_chat_servers">Verwendung von <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Servern.</string>
<string name="enter_one_SMP_server_per_line">SMP-Server (einer pro Zeile)</string>
<string name="how_to">Anleitung</string>
<string name="how_to_use_your_servers">Wie Sie Ihre Server nutzen</string>
<string name="saved_ICE_servers_will_be_removed">Gespeicherte WebRTC ICE-Server werden entfernt.</string>
<string name="your_ICE_servers">Ihre ICE-Server</string>
<string name="configure_ICE_servers">Konfigurieren Sie ICE-Server</string>
@@ -476,9 +428,7 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\n<xliff:g id="appName">SimpleX</xliff:g>-Server können Ihr Profil nicht sehen.</string>
<string name="edit_image">Bild bearbeiten</string>
<string name="delete_image">Bild löschen</string>
<string name="save_and_notify_contact">Speichern (und Kontakt benachrichtigen)</string>
<string name="save_and_notify_contacts">Speichern (und Kontakte benachrichtigen)</string>
<string name="save_and_notify_group_members">Speichern (und Gruppenmitglieder benachrichtigen)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
@@ -537,12 +487,12 @@
<!-- How SimpleX Works -->
<string name="how_simplex_works">Wie <xliff:g id="appName">SimpleX</xliff:g> funktioniert</string>
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wie kann <xliff:g id="appName">SimpleX</xliff:g> Nachrichten zustellen, wenn es keine Benutzerkennungen gibt?</i></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzerkennungen, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie können selbst festlegen, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b> wollen.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern die Benutzerprofile, Kontakte, Gruppen und Nachrichten, welche über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
<string name="read_more_in_github">Erfahren Sie in unserem GitHub-Repository mehr dazu.</string>
<string name="read_more_in_github_with_link">Erfahren Sie in unserem <font color="#0088ff">GitHub-Repository</font> mehr dazu.</string>
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wenn <xliff:g id="appName">SimpleX</xliff:g> keine Benutzerkennungen hat, wie kann es dann Nachrichten zustellen?</i></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzer-IDs, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie legen fest, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b>.</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
<string name="read_more_in_github">Erfahren Sie mehr dazu in unserem GitHub-Repository.</string>
<string name="read_more_in_github_with_link">Erfahren Sie mehr dazu in unserem <font color="#0088ff">GitHub-Repository</font>.</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Fügen Sie den erhaltenen Link ein</string>
@@ -614,11 +564,9 @@
<!-- Privacy settings -->
<string name="privacy_and_security">Datenschutz &amp; Sicherheit</string>
<string name="your_privacy">Meine Privatsphäre</string>
<string name="protect_app_screen">App-Bildschirm schützen</string>
<string name="auto_accept_images">Bilder automatisch akzeptieren</string>
<string name="transfer_images_faster">Bilder schneller übertragen</string>
<string name="transfer_images_faster">Bilder schneller übertragen (BETA)</string>
<string name="send_link_previews">Link-Vorschau senden</string>
<string name="full_backup">App-Datensicherung</string>
<!-- Settings sections -->
<string name="settings_section_title_you">MEINE DATEN</string>
@@ -739,7 +687,7 @@
<string name="restore_database_alert_desc">Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="restore_database_alert_confirm">Wiederherstellen</string>
<string name="database_restore_error">Fehler bei der Wiederherstellung der Datenbank</string>
<string name="restore_passphrase_not_found_desc">Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Das kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Bitte nehmen Sie Kontakt mit den Entwicklern auf, wenn das nicht der Fall ist.</string>
<string name="restore_passphrase_not_found_desc">***Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Chat wurde beendet</string>
@@ -832,8 +780,6 @@
<string name="new_member_role">Neue Mitgliedsrolle</string>
<string name="icon_descr_expand_role">Rollenauswahl erweitern</string>
<string name="invite_to_group_button">In Gruppe einladen</string>
<string name="skip_inviting_button">Mitgliedereinladungen überspringen</string>
<string name="select_contacts">Kontakte auswählen</string>
<string name="icon_descr_contact_checked">Kontakt geprüft</string>
<string name="clear_contacts_selection_button">Löschen</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> Kontakt(e) ausgewählt</string>
@@ -859,7 +805,6 @@
<string name="all_group_members_will_remain_connected">Alle Gruppenmitglieder bleiben verbunden.</string>
<string name="error_creating_link_for_group">Fehler beim Erzeugen des Gruppen-Links</string>
<string name="error_deleting_link_for_group">Fehler beim Löschen des Gruppen-Links</string>
<string name="only_group_owners_can_change_prefs">Gruppenpräferenzen können nur von Gruppen-Eigentümern geändert werden.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FÜR KONSOLE</string>
@@ -891,7 +836,7 @@
<string name="receiving_via">Empfangen über</string>
<string name="sending_via">Senden über</string>
<string name="network_status">Netzwerkstatus</string>
<string name="switch_receiving_address">Wechseln der Empfängeradresse</string>
<string name="switch_receiving_address">Wechseln der Empfängeradresse (BETA)</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Geheime Gruppe erstellen</string>
@@ -941,54 +886,4 @@
<string name="save_color">Farbe speichern</string>
<string name="reset_color">Farben zurücksetzen</string>
<string name="color_primary">Akzent</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">Sie erlauben</string>
<string name="chat_preferences_contact_allows">Der Kontakt erlaubt</string>
<string name="chat_preferences_default">Voreinstellung (%s)</string>
<string name="chat_preferences_yes">Ja</string>
<string name="chat_preferences_no">Nein</string>
<string name="chat_preferences_always">Immer</string>
<string name="chat_preferences_on">Ein</string>
<string name="chat_preferences_off">Aus</string>
<string name="chat_preferences">Chat Präferenzen</string>
<string name="contact_preferences">Kontakt Präferenzen</string>
<string name="group_preferences">Gruppen Präferenzen</string>
<string name="set_group_preferences">Gruppenpräferenzen einstellen</string>
<string name="your_preferences">Ihre Präferenzen</string>
<string name="direct_messages">Direkte Nachrichten</string>
<string name="full_deletion">Für Alle löschen</string>
<string name="voice_messages">Sprachnachrichten</string>
<string name="feature_enabled">aktiviert</string>
<string name="feature_enabled_for_you">Für Sie aktiviert</string>
<string name="feature_enabled_for_contact">FÜr Kontakt aktiviert</string>
<string name="feature_off">Aus</string>
<string name="feature_received_prohibited">empfangen, nicht erlaubt</string>
<string name="allow_your_contacts_irreversibly_delete">Erlauben Sie Ihren Kontakten gesendete Nachrichten unwiederbringlich zu löschen.</string>
<string name="allow_irreversible_message_deletion_only_if">Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.</string>
<string name="contacts_can_mark_messages_for_deletion">Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen.</string>
<string name="allow_your_contacts_to_send_voice_messages">Erlauben Sie Ihre Kontakten Sprachnachrichten zu senden.</string>
<string name="allow_voice_messages_only_if">Erlauben Sie Sprachnachrichten nur dann, wenn Ihr Kontakt diese ebenfalls erlaubt.</string>
<string name="prohibit_sending_voice_messages">Das Senden von Sprachnachrichten verbieten.</string>
<string name="both_you_and_your_contacts_can_delete">Sowohl Ihr Kontakt, als auch Sie können Nachrichten unwiederbringlich löschen.</string>
<string name="only_you_can_delete_messages">Nur Sie können Nachrichten unwiederbringlich löschen (Ihr Kontakt kann sie zum Löschen markieren).</string>
<string name="only_your_contact_can_delete">Nur Ihr Kontakt kann Nachrichten unwiederbringlich löschen (Sie können sie zum Löschen markieren).</string>
<string name="message_deletion_prohibited">In dieser Gruppe ist das unwiederbringliche Löschen von Nachrichten nicht erlaubt.</string>
<string name="both_you_and_your_contact_can_send_voice">Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden.</string>
<string name="only_you_can_send_voice">Nur Sie können Sprachnachrichten senden.</string>
<string name="only_your_contact_can_send_voice">Nur Ihr Kontakt kann Sprachnachrichten senden.</string>
<string name="voice_prohibited_in_this_chat">In diesem Chat sind Sprachnachrichten untersagt.</string>
<string name="allow_direct_messages">Das Senden von Direktnachrichten an Mitglieder erlauben.</string>
<string name="prohibit_direct_messages">Das Senden von Direktnachrichten an Mitglieder verbieten.</string>
<string name="allow_to_delete_messages">Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.</string>
<string name="prohibit_message_deletion">Unwiederbringliches Löschen von Nachrichten verbieten.</string>
<string name="allow_to_send_voice">Senden von Sprachnachrichten erlauben.</string>
<string name="prohibit_sending_voice">Senden von Sprachnachrichten untersagen.</string>
<string name="group_members_can_send_dms">Gruppenmitglieder können Direktnachrichten versenden.</string>
<string name="direct_messages_are_prohibited_in_chat">In dieser Gruppe sind Direktnachrichten zwischen Mitgliedern nicht möglich.</string>
<string name="group_members_can_delete">Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.</string>
<string name="message_deletion_prohibited_in_chat">In dieser Gruppe ist das unwiederbringliche Löschen von Nachrichten verboten.</string>
<string name="group_members_can_send_voice">Gruppenmitglieder können Sprachnachrichten senden.</string>
<string name="voice_messages_are_prohibited">In dieser Gruppe sind Sprachnachrichten untersagt.</string>
</resources>
@@ -21,7 +21,6 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">удалено</string>
<string name="marked_deleted_description">помечено к удалению</string>
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
<string name="sender_you_pronoun">вы</string>
@@ -42,17 +41,6 @@
<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>
<string name="simplex_link_group">SimpleX ссылка группы</string>
<string name="simplex_link_connection">через <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
<string name="simplex_link_mode">SimpleX ссылки</string>
<string name="simplex_link_mode_description">Описание</string>
<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>
@@ -61,7 +49,7 @@
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Превышено время соединения</string>
<string name="connection_error">Ошибка соединения</string>
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сервером <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> и попробуйте еще раз.</string>
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</string>
<string name="error_sending_message">Ошибка при отправке сообщения</string>
<string name="error_adding_members">Ошибка при добавлении членов группы</string>
<string name="error_joining_group">Ошибка при вступлении в группу</string>
@@ -82,14 +70,6 @@
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
<string name="error_changing_address">Ошибка при изменении адреса</string>
<string name="error_smp_test_failed_at_step">Ошибка теста на шаге %s.</string>
<string name="error_smp_test_server_auth">Сервер требует авторизации для создания очередей, проверьте пароль</string>
<string name="error_smp_test_certificate">Возможно, хэш сертификата в адресе сервера неверный</string>
<string name="smp_server_test_connect">Соединение</string>
<string name="smp_server_test_create_queue">Создание очереди</string>
<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>
@@ -168,13 +148,9 @@
<string name="save_verb">Сохранить</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="reveal_verb">Показать</string>
<string name="hide_verb">Спрятать</string>
<string name="allow_verb">Разрешить</string>
<string name="delete_message__question">Удалить сообщение?</string>
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено – это действие нельзя отменить!</string>
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
<string name="for_me_only">Удалить для меня</string>
<string name="for_me_only">Только для меня</string>
<string name="for_everybody">Для всех</string>
<!-- CIMetaView.kt -->
@@ -234,7 +210,6 @@
<!-- 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 -->
@@ -255,17 +230,11 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string>
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
<string name="allow_voice_messages_question">Разрешить голосовые сообщения?</string>
<string name="you_need_to_allow_to_send_voice">Чтобы включить отправку голосовых сообщений, разрешите их вашему контакту.</string>
<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>
<string name="confirm_verb">Подтвердить</string>
<string name="reset_verb">Сбросить</string>
<string name="ok">OK</string>
<string name="no_details">нет описания</string>
<string name="add_contact">Одноразовая ссылка</string>
@@ -396,34 +365,17 @@
<string name="chat_lock">Блокировка SimpleX</string>
<string name="chat_console">Консоль</string>
<string name="smp_servers">SMP серверы</string>
<string name="smp_servers_preset_address">Адрес сервера по умолчанию</string>
<string name="smp_servers_preset_add">Добавить серверы по умолчанию</string>
<string name="smp_servers_add">Добавить сервер…</string>
<string name="smp_servers_test_server">Тестировать сервер</string>
<string name="smp_servers_test_servers">Тестировать серверы</string>
<string name="smp_servers_save">Сохранить серверы</string>
<string name="smp_servers_test_failed">Ошибка теста сервера!</string>
<string name="smp_servers_test_some_failed">Серверы не прошли тест:</string>
<string name="smp_servers_scan_qr">Сканировать QR код сервера</string>
<string name="smp_servers_enter_manually">Ввести сервер вручную</string>
<string name="smp_servers_preset_server">Сервер по умолчанию</string>
<string name="smp_servers_your_server">Ваш сервер</string>
<string name="smp_servers_your_server_address">Адрес вашего сервера</string>
<string name="smp_servers_use_server">Использовать сервер</string>
<string name="smp_servers_use_server_for_new_conn">Использовать для новых соединений</string>
<string name="smp_servers_add_to_another_device">Добавить на другое устройство</string>
<string name="smp_servers_invalid_address">Ошибка в адресе сервера!</string>
<string name="smp_servers_check_address">Проверьте адрес сервера и попробуйте снова.</string>
<string name="smp_servers_delete_server">Удалить сервер</string>
<string name="install_simplex_chat_for_terminal"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</string>
<string name="star_on_github">Поставить звездочку в GitHub</string>
<string name="contribute">Внести свой вклад</string>
<string name="rate_the_app">Оценить приложение</string>
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
<string name="saved_SMP_servers_will_be_removed">Сохраненные SMP серверы будут удалены.</string>
<string name="your_SMP_servers">Ваши SMP серверы</string>
<string name="configure_SMP_servers">Настройка SMP серверов</string>
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
<string name="how_to">Инфо</string>
<string name="how_to_use_your_servers">Как использовать серверы</string>
<string name="saved_ICE_servers_will_be_removed">Сохраненные WebRTC ICE серверы будут удалены.</string>
<string name="your_ICE_servers">Ваши ICE серверы</string>
<string name="configure_ICE_servers">Настройка ICE серверов</string>
@@ -473,9 +425,7 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
<string name="edit_image">Поменять аватар</string>
<string name="delete_image">Удалить аватар</string>
<string name="save_and_notify_contact">Сохранить (и уведомить контакт)</string>
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
<string name="save_and_notify_group_members">Сохранить (и уведомить членов группы)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
@@ -614,11 +564,9 @@
<!-- Privacy settings -->
<string name="privacy_and_security">Конфиденциальность</string>
<string name="your_privacy">Конфиденциальность</string>
<string name="protect_app_screen">Защитить экран приложения</string>
<string name="auto_accept_images">Автоприем изображений</string>
<string name="transfer_images_faster">Передавать изображения быстрее</string>
<string name="transfer_images_faster">Передавать изображения быстрее (BETA)</string>
<string name="send_link_previews">Отправлять картинки ссылок</string>
<string name="full_backup">Резервная копия данных</string>
<!-- Settings sections -->
<string name="settings_section_title_you">ВЫ</string>
@@ -832,8 +780,6 @@
<string name="new_member_role">Роль члена группы</string>
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
<string name="invite_to_group_button">Пригласить в группу</string>
<string name="skip_inviting_button">Не приглашать членов</string>
<string name="select_contacts">Выберите контакты</string>
<string name="icon_descr_contact_checked">Контакт выбран</string>
<string name="clear_contacts_selection_button">Очистить</string>
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
@@ -859,7 +805,6 @@
<string name="all_group_members_will_remain_connected">Все члены группы, которые соединились через эту ссылку, останутся в группе.</string>
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
<string name="only_group_owners_can_change_prefs">Только владельцы группы могут изменять предпочтения группы.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
@@ -891,7 +836,7 @@
<string name="receiving_via">Получение через</string>
<string name="sending_via">Отправка через</string>
<string name="network_status">Состояние сети</string>
<string name="switch_receiving_address">Переключить адрес получения</string>
<string name="switch_receiving_address">Переключить адрес получения (BETA)</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Создать скрытую группу</string>
@@ -940,54 +885,4 @@
<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>
<string name="chat_preferences_default">по умолчанию (%s)</string>
<string name="chat_preferences_yes">да</string>
<string name="chat_preferences_no">нет</string>
<string name="chat_preferences_always">всегда</string>
<string name="chat_preferences_on">да</string>
<string name="chat_preferences_off">нет</string>
<string name="chat_preferences">Предпочтения</string>
<string name="contact_preferences">Предпочтения контакта</string>
<string name="group_preferences">Предпочтения группы</string>
<string name="set_group_preferences">Предпочтения группы</string>
<string name="your_preferences">Ваши предпочтения</string>
<string name="direct_messages">Прямые сообщения</string>
<string name="full_deletion">Удаление для всех</string>
<string name="voice_messages">Голосовые сообщения</string>
<string name="feature_enabled">включено</string>
<string name="feature_enabled_for_you">включено для вас</string>
<string name="feature_enabled_for_contact">включено для контакта</string>
<string name="feature_off">выключено</string>
<string name="feature_received_prohibited">получено, не разрешено</string>
<string name="allow_your_contacts_irreversibly_delete">Разрешить вашим контактам необратимо удалять отправленные сообщения.</string>
<string name="allow_irreversible_message_deletion_only_if">Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</string>
<string name="contacts_can_mark_messages_for_deletion">Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</string>
<string name="allow_your_contacts_to_send_voice_messages">Разрешить вашим контактам отправлять голосовые сообщения.</string>
<string name="allow_voice_messages_only_if">Разрешить голосовые сообщения, только если их разрешает ваш контакт.</string>
<string name="prohibit_sending_voice_messages">Запретить отправлять голосовые сообщений.</string>
<string name="both_you_and_your_contacts_can_delete">Вы и ваш контакт можете необратимо удалять отправленные сообщения.</string>
<string name="only_you_can_delete_messages">Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</string>
<string name="only_your_contact_can_delete">Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</string>
<string name="message_deletion_prohibited">Необратимое удаление сообщений запрещено в этой группе.</string>
<string name="both_you_and_your_contact_can_send_voice">Вы и ваш контакт можете отправлять голосовые сообщения.</string>
<string name="only_you_can_send_voice">Только вы можете отправлять голосовые сообщения.</string>
<string name="only_your_contact_can_send_voice">Только ваш контакт может отправлять голосовые сообщения.</string>
<string name="voice_prohibited_in_this_chat">Голосовые сообщения запрещены в этом чате.</string>
<string name="allow_direct_messages">Разрешить посылать прямые сообщения членам группы.</string>
<string name="prohibit_direct_messages">Запретить посылать прямые сообщения членам группы.</string>
<string name="allow_to_delete_messages">Разрешить необратимо удалять отправленные сообщения.</string>
<string name="prohibit_message_deletion">Запретить необратимое удаление сообщений.</string>
<string name="allow_to_send_voice">Разрешить отправлять голосовые сообщения.</string>
<string name="prohibit_sending_voice">Запретить отправлять голосовые сообщений.</string>
<string name="group_members_can_send_dms">Члены группы могут посылать прямые сообщения.</string>
<string name="direct_messages_are_prohibited_in_chat">Прямые сообщения между членами группы запрещены.</string>
<string name="group_members_can_delete">Члены группы могут необратимо удалять отправленные сообщения.</string>
<string name="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этой группе.</string>
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этой группе.</string>
</resources>
@@ -21,7 +21,6 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">deleted</string>
<string name="marked_deleted_description">marked deleted</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="sender_you_pronoun">you</string>
@@ -42,17 +41,6 @@
<string name="description_via_one_time_link">via one-time link</string>
<string name="description_via_one_time_link_incognito">incognito via one-time link</string>
<!-- FormattedText, SimpleX links - ChatModel.kt -->
<string name="simplex_link_contact">SimpleX contact address</string>
<string name="simplex_link_invitation">SimpleX one-time invitation</string>
<string name="simplex_link_group">SimpleX group link</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">SimpleX links</string>
<string name="simplex_link_mode_description">Description</string>
<string name="simplex_link_mode_full">Full link</string>
<string name="simplex_link_mode_browser">Via browser</string>
<string name="simplex_link_mode_browser_warning">Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.</string>
<!-- SimpleXAPI.kt -->
<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>
@@ -61,7 +49,7 @@
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
<string name="connection_error">Connection error</string>
<string name="network_error_desc">Please check your network connection with <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> and try again.</string>
<string name="network_error_desc">Please check your network connection and try again.</string>
<string name="error_sending_message">Error sending message</string>
<string name="error_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
@@ -82,14 +70,6 @@
<string name="error_deleting_contact_request">Error deleting contact request</string>
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<string name="error_changing_address">Error changing address</string>
<string name="error_smp_test_failed_at_step">Test failed at step %s.</string>
<string name="error_smp_test_server_auth">Server requires authorization to create queues, check password</string>
<string name="error_smp_test_certificate">Possibly, certificate fingerprint in server address is incorrect</string>
<string name="smp_server_test_connect">Connect</string>
<string name="smp_server_test_create_queue">Create queue</string>
<string name="smp_server_test_secure_queue">Secure queue</string>
<string name="smp_server_test_delete_queue">Delete queue</string>
<string name="smp_server_test_disconnect">Disconnect</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Instant notifications</string>
@@ -168,13 +148,9 @@
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="delete_verb">Delete</string>
<string name="reveal_verb">Reveal</string>
<string name="hide_verb">Hide</string>
<string name="allow_verb">Allow</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
<string name="for_me_only">Delete for me</string>
<string name="for_me_only">For me only</string>
<string name="for_everybody">For everyone</string>
<!-- CIMetaView.kt -->
@@ -234,7 +210,6 @@
<!-- Voice messages -->
<string name="voice_message">Voice message</string>
<string name="voice_message_with_duration">Voice message (<xliff:g id="duration">%1$s</xliff:g>)</string>
<string name="voice_message_send_text">Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt -->
@@ -255,17 +230,11 @@
<!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string>
<string name="icon_descr_record_voice_message">Record voice message</string>
<string name="allow_voice_messages_question">Allow voice messages?</string>
<string name="you_need_to_allow_to_send_voice">You need to allow your contact to send voice messages to be able to send them.</string>
<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>
<!-- General Actions / Responses -->
<string name="back">Back</string>
<string name="cancel_verb">Cancel</string>
<string name="confirm_verb">Confirm</string>
<string name="reset_verb">Reset</string>
<string name="ok">OK</string>
<string name="no_details">no details</string>
<string name="add_contact">One-time invitation link</string>
@@ -399,34 +368,17 @@
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="smp_servers">SMP servers</string>
<string name="smp_servers_preset_address">Preset server address</string>
<string name="smp_servers_preset_add">Add preset servers</string>
<string name="smp_servers_add">Add server…</string>
<string name="smp_servers_test_server">Test server</string>
<string name="smp_servers_test_servers">Test servers</string>
<string name="smp_servers_save">Save servers</string>
<string name="smp_servers_test_failed">Server test failed!</string>
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
<string name="smp_servers_scan_qr">Scan server QR code</string>
<string name="smp_servers_enter_manually">Enter server manually</string>
<string name="smp_servers_preset_server">Preset server</string>
<string name="smp_servers_your_server">Your server</string>
<string name="smp_servers_your_server_address">Your server address</string>
<string name="smp_servers_use_server">Use server</string>
<string name="smp_servers_use_server_for_new_conn">Use for new connections</string>
<string name="smp_servers_add_to_another_device">Add to another device</string>
<string name="smp_servers_invalid_address">Invalid server address!</string>
<string name="smp_servers_check_address">Check server address and try again.</string>
<string name="smp_servers_delete_server">Delete server</string>
<string name="install_simplex_chat_for_terminal">Install <xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</string>
<string name="star_on_github">Star on GitHub</string>
<string name="contribute">Contribute</string>
<string name="rate_the_app">Rate the app</string>
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
<string name="saved_SMP_servers_will_be_removed">Saved SMP servers will be removed.</string>
<string name="your_SMP_servers">Your SMP servers</string>
<string name="configure_SMP_servers">Configure SMP servers</string>
<string name="using_simplex_chat_servers">Using <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers.</string>
<string name="enter_one_SMP_server_per_line">SMP servers (one per line)</string>
<string name="how_to">How to</string>
<string name="how_to_use_your_servers">How to use your servers</string>
<string name="saved_ICE_servers_will_be_removed">Saved WebRTC ICE servers will be removed.</string>
<string name="your_ICE_servers">Your ICE servers</string>
<string name="configure_ICE_servers">Configure ICE servers</string>
@@ -476,9 +428,7 @@
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>
<string name="delete_image">Delete image</string>
<string name="save_and_notify_contact">Save (and notify contact)</string>
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
<string name="save_and_notify_group_members">Save (and notify group members)</string>
<!-- Welcome Prompts - WelcomeView.kt -->
<string name="you_control_your_chat">You control your chat!</string>
@@ -614,11 +564,9 @@
<!-- Privacy settings -->
<string name="privacy_and_security">Privacy &amp; security</string>
<string name="your_privacy">Your privacy</string>
<string name="protect_app_screen">Protect app screen</string>
<string name="auto_accept_images">Auto-accept images</string>
<string name="transfer_images_faster">Transfer images faster</string>
<string name="transfer_images_faster">Transfer images faster (BETA)</string>
<string name="send_link_previews">Send link previews</string>
<string name="full_backup">App data backup</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
@@ -832,8 +780,6 @@
<string name="new_member_role">New member role</string>
<string name="icon_descr_expand_role">Expand role selection</string>
<string name="invite_to_group_button">Invite to group</string>
<string name="skip_inviting_button">Skip inviting members</string>
<string name="select_contacts">Select contacts</string>
<string name="icon_descr_contact_checked">Contact checked</string>
<string name="clear_contacts_selection_button">Clear</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
@@ -859,7 +805,6 @@
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
<string name="error_creating_link_for_group">Error creating group link</string>
<string name="error_deleting_link_for_group">Error deleting group link</string>
<string name="only_group_owners_can_change_prefs">Only group owners can change group preferences.</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -891,7 +836,7 @@
<string name="receiving_via">Receiving via</string>
<string name="sending_via">Sending via</string>
<string name="network_status">Network status</string>
<string name="switch_receiving_address">Switch receiving address</string>
<string name="switch_receiving_address">Switch receiving address (BETA)</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Create secret group</string>
@@ -941,54 +886,4 @@
<string name="save_color">Save color</string>
<string name="reset_color">Reset colors</string>
<string name="color_primary">Accent</string>
<!-- Preferences.kt -->
<string name="chat_preferences_you_allow">You allow</string>
<string name="chat_preferences_contact_allows">Contact allows</string>
<string name="chat_preferences_default">default (%s)</string>
<string name="chat_preferences_yes">yes</string>
<string name="chat_preferences_no">no</string>
<string name="chat_preferences_always">always</string>
<string name="chat_preferences_on">on</string>
<string name="chat_preferences_off">off</string>
<string name="chat_preferences">Chat preferences</string>
<string name="contact_preferences">Contact preferences</string>
<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="direct_messages">Direct messages</string>
<string name="full_deletion">Delete for everyone</string>
<string name="voice_messages">Voice messages</string>
<string name="feature_enabled">enabled</string>
<string name="feature_enabled_for_you">enabled for you</string>
<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="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_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>
<string name="message_deletion_prohibited">Irreversible message deletion is prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_send_voice">Both you and your contact can send voice messages.</string>
<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_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_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>
</resources>
+13 -21
View File
@@ -17,8 +17,6 @@ struct ContentView: View {
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
@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
var body: some View {
ZStack {
@@ -31,7 +29,7 @@ struct ContentView: View {
} else if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView().privacySensitive(protectScreen)
mainView()
} else {
OnboardingView(onboarding: step)
}
@@ -48,15 +46,9 @@ struct ContentView: View {
ZStack(alignment: .top) {
ChatListView()
.onAppear {
NtfManager.shared.requestAuthorization(
onDeny: {
if (!notificationAlertShown) {
notificationAlertShown = true
alertManager.showAlert(notificationAlert())
}
},
onAuthorized: { notificationAlertShown = false }
)
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice) {
prefLANoticeShown = true
@@ -76,9 +68,9 @@ struct ContentView: View {
userAuthorized = true
} else {
dismissAllSheets(animated: false) {
chatModel.chatId = nil
justAuthenticate()
}
chatModel.chatId = nil
}
}
@@ -124,14 +116,14 @@ struct ContentView: View {
func notificationAlert() -> Alert {
Alert(
title: Text("Notifications are disabled!"),
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
}
}
-141
View File
@@ -1,141 +0,0 @@
//
// AudioRecPlay.swift
// SimpleX (iOS)
//
// Created by Evgeny on 19/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import AVFoundation
import SwiftUI
import SimpleXChat
class AudioRecorder {
var onTimer: ((TimeInterval) -> Void)?
var onFinishRecording: (() -> Void)?
var audioRecorder: AVAudioRecorder?
var recordingTimer: Timer?
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishRecording: @escaping (() -> Void)) {
self.onTimer = onTimer
self.onFinishRecording = onFinishRecording
}
enum StartError {
case permission
case error(String)
}
func start(fileName: String) async -> StartError? {
let av = AVAudioSession.sharedInstance()
if !(await checkPermission()) { return .permission }
do {
try av.setCategory(AVAudioSession.Category.playAndRecord, options: .defaultToSpeaker)
try av.setActive(true)
let settings: [String : Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 12000,
AVEncoderBitRateKey: 12000,
AVNumberOfChannelsKey: 1
]
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
await MainActor.run {
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
guard let time = self.audioRecorder?.currentTime else { return }
self.onTimer?(time)
if time >= MAX_VOICE_MESSAGE_LENGTH {
self.stop()
self.onFinishRecording?()
}
}
}
return nil
} catch let error {
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
return .error(error.localizedDescription)
}
}
func stop() {
if let recorder = audioRecorder {
recorder.stop()
}
audioRecorder = nil
if let timer = recordingTimer {
timer.invalidate()
}
recordingTimer = nil
}
private func checkPermission() async -> Bool {
let av = AVAudioSession.sharedInstance()
switch av.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
return await withCheckedContinuation { cont in
DispatchQueue.main.async {
av.requestRecordPermission { allowed in
cont.resume(returning: allowed)
}
}
}
@unknown default: return false
}
}
}
class AudioPlayer: NSObject, AVAudioPlayerDelegate {
var onTimer: ((TimeInterval) -> Void)?
var onFinishPlayback: (() -> Void)?
var audioPlayer: AVAudioPlayer?
var playbackTimer: Timer?
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishPlayback: @escaping (() -> Void)) {
self.onTimer = onTimer
self.onFinishPlayback = onFinishPlayback
}
func start(fileName: String) {
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
audioPlayer?.play()
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
if self.audioPlayer?.isPlaying ?? false {
guard let time = self.audioPlayer?.currentTime else { return }
self.onTimer?(time)
}
}
}
func pause() {
audioPlayer?.pause()
}
func play() {
audioPlayer?.play()
}
func stop() {
if let player = audioPlayer {
player.stop()
}
audioPlayer = nil
if let timer = playbackTimer {
timer.invalidate()
}
playbackTimer = nil
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stop()
self.onFinishPlayback?()
}
}
+2 -7
View File
@@ -31,8 +31,7 @@ final class ChatModel: ObservableObject {
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: UserContactLink?
@Published var userSMPServers: [ServerCfg]?
@Published var presetSMPServers: [String]?
@Published var userSMPServers: [String]?
@Published var chatItemTTL: ChatItemTTL = .none
@Published var appOpenUrl: URL?
@Published var deviceToken: DeviceToken?
@@ -52,8 +51,6 @@ final class ChatModel: ObservableObject {
@Published var showCallView = false
// currently showing QR code
@Published var connReqInv: String?
// audio recording and playback
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
var callWebView: WKWebView?
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -222,10 +219,8 @@ final class ChatModel: ObservableObject {
withAnimation(.default) {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
// arrives earlier than the response from API, and item remains without tick
if case .sndNew = cItem.meta.itemStatus {
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
self.reversedChatItems[i].meta = ci.meta
}
}
return false
+3 -4
View File
@@ -165,23 +165,22 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
])
}
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
logger.debug("NtfManager.requestAuthorization")
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .denied:
denied?()
if let handler = handler { handler() }
return
case .authorized:
self.granted = true
authorized?()
default:
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error = error {
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
} else {
self.granted = granted
authorized?()
}
}
}
+28 -38
View File
@@ -261,9 +261,9 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent
throw r
}
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
throw r
}
@@ -309,19 +309,19 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
func getUserSMPServers() throws -> [String] {
let r = chatSendCmdSync(.getUserSMPServers)
if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) }
if case let .userSMPServers(smpServers, _) = r { return smpServers.map { $0.server } }
throw r
}
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
func setUserSMPServers(smpServers: [String]) async throws {
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
}
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
if case let .smpTestResult(testFailure) = r {
if case let .sMPTestResult(testFailure) = r {
if let t = testFailure {
return .failure(t)
}
@@ -587,15 +587,10 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
)
} else if !networkErrorAlert(r) {
logger.error("apiReceiveFile error: \(String(describing: r))")
switch r {
case .chatCmdError(.error(.fileAlreadyReceiving)):
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
default:
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
}
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
}
return nil
}
@@ -603,16 +598,16 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
func networkErrorAlert(_ r: ChatResponse) -> Bool {
let am = AlertManager.shared
switch r {
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
am.showAlertMsg(
title: "Connection timeout",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
message: "Please check your network connection and try again."
)
return true
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
am.showAlertMsg(
title: "Connection error",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
message: "Please check your network connection and try again."
)
return true
default:
@@ -848,7 +843,7 @@ func startChat() throws {
let justStarted = try apiStartChat()
if justStarted {
m.userAddress = try apiGetUserAddress()
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
m.userSMPServers = try getUserSMPServers()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
m.chats = chats.map { Chat.init($0) }
@@ -974,28 +969,20 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.addChatItem(cInfo, cItem)
if case .image = cItem.content.msgContent,
let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
}
} else if case .voice = cItem.content.msgContent, // TODO check inlineFileMode != IFMSent
let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND,
file.fileSize <= maxImageSize,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
}
}
if cItem.showNotification {
if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
case let .chatItemStatusUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
var res = false
if !cItem.isDeletedContent {
if !cItem.isDeletedContent() {
res = m.upsertChatItem(cInfo, cItem)
}
if res {
@@ -1010,11 +997,14 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .chatItemUpdated(aChatItem):
chatItemSimpleUpdate(aChatItem)
case let .chatItemDeleted(deletedChatItem, toChatItem, _):
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
case let .chatItemDeleted(_, toChatItem):
let cInfo = toChatItem.chatInfo
let cItem = toChatItem.chatItem
if cItem.meta.itemDeleted {
m.removeChatItem(cInfo, cItem)
} else {
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = m.upsertChatItem(cInfo, cItem)
}
case let .receivedGroupInvitation(groupInfo, _, _):
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
@@ -1052,9 +1042,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .sndFileComplete(aChatItem, _):
chatItemSimpleUpdate(aChatItem)
let cItem = aChatItem.chatItem
let mc = cItem.content.msgContent
if aChatItem.chatInfo.chatType == .direct,
case .file = mc,
let mc = cItem.content.msgContent,
mc.isFile(),
let fileName = cItem.file?.filePath {
removeFile(fileName)
}
@@ -1146,7 +1136,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
m.updateContact(contact)
var err: String
switch chatError {
case .errorAgent(agentError: .BROKER(_, .NETWORK)): err = "network"
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
}
+1 -1
View File
@@ -20,7 +20,7 @@ struct SimpleXApp: App {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var userAuthorized: Bool?
@State private var doAuthenticate = false
@State private var enteredBackground: TimeInterval? = nil
@State private var enteredBackground: Double? = nil
init() {
hs_init(0, nil)
@@ -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()
@@ -108,8 +108,10 @@ struct ChatInfoView: View {
.onTapGesture {
alert = .networkStatusAlert
}
Button("Change receiving address") {
alert = .switchAddressAlert
if developerTools {
Button("Change receiving address (BETA)") {
alert = .switchAddressAlert
}
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
@@ -54,7 +54,7 @@ struct CICallItemView: View {
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack {
Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(.secondary)
Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
}
}
@@ -1,34 +0,0 @@
//
// CIChatFeatureView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 21/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIChatFeatureView: View {
var chatItem: ChatItem
var feature: Feature
var iconColor: Color
var body: some View {
HStack(alignment: .bottom, spacing: 4) {
Image(systemName: feature.iconFilled)
.foregroundColor(iconColor)
chatEventText(chatItem)
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled)
}
}
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
}
}
@@ -20,27 +20,27 @@ struct CIEventView: View {
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ chatEventText(chatItem)
+ eventText()
} else {
chatEventText(chatItem)
eventText()
}
}
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled)
}
}
func chatEventText(_ ci: ChatItem) -> Text {
Text(ci.content.text)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ ci.timestampText
.font(.caption)
.foregroundColor(Color.secondary)
.fontWeight(.light)
func eventText() -> Text {
Text(chatItem.content.text)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
+ Text(" ")
+ chatItem.timestampText
.font(.caption)
.foregroundColor(Color.secondary)
.fontWeight(.light)
}
}
struct CIEventView_Previews: PreviewProvider {
@@ -50,7 +50,7 @@ struct CIFileView: View {
func fileSizeValid() -> Bool {
if let file = file {
return file.fileSize <= MAX_FILE_SIZE
return file.fileSize <= maxFileSize
}
return false
}
@@ -66,7 +66,7 @@ struct CIFileView: View {
await receiveFile(fileId: file.fileId)
}
} else {
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
@@ -79,7 +79,7 @@ struct CIFileView: View {
)
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let filePath = getLoadedFilePath(file) {
if let filePath = getLoadedFilePath(file){
let url = URL(fileURLWithPath: filePath)
showShareSheet(items: [url])
}
@@ -148,17 +148,17 @@ struct CIFileView_Previews: PreviewProvider {
quotedItem: nil,
file: nil
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
Group{
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -15,7 +15,7 @@ struct CIMetaView: View {
var body: some View {
HStack(alignment: .center, spacing: 4) {
if !chatItem.isDeletedContent {
if !chatItem.isDeletedContent() {
if chatItem.meta.itemEdited {
statusImage("pencil", metaColor, 9)
}
@@ -50,7 +50,7 @@ struct CIMetaView: View {
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
Group {
return Group {
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
@@ -1,246 +0,0 @@
//
// CIVoiceView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 22.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
var body: some View {
VStack (
alignment: chatItem.chatDir.sent ? .trailing : .leading,
spacing: 6
) {
HStack {
if chatItem.chatDir.sent {
playerTime()
.frame(width: 50, alignment: .leading)
player()
} else {
player()
playerTime()
.frame(width: 50, alignment: .leading)
}
}
CIMetaView(chatItem: chatItem)
.padding(.leading, chatItem.chatDir.sent ? 0 : 12)
.padding(.trailing, chatItem.chatDir.sent ? 12 : 0)
}
.padding(.bottom, 8)
}
private func player() -> some View {
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: true,
playbackState: $playbackState,
playbackTime: $playbackTime
)
}
private func playerTime() -> some View {
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(.secondary)
}
}
struct VoiceMessagePlayerTime: View {
var recordingTime: TimeInterval
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
var body: some View {
switch playbackState {
case .noPlayback:
Text(voiceMessageTime(recordingTime))
case .playing:
Text(voiceMessageTime_(playbackTime))
case .paused:
Text(voiceMessageTime_(playbackTime))
}
}
}
struct VoiceMessagePlayer: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var recordingFile: CIFile?
var recordingTime: TimeInterval
var showBackground: Bool
@State private var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
var body: some View {
ZStack {
if let recordingFile = recordingFile {
switch recordingFile.fileStatus {
case .sndStored: playbackButton()
case .sndTransfer: playbackButton()
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
case .rcvInvitation: loadingIcon()
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
}
.onDisappear {
audioPlayer?.stop()
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
}
@ViewBuilder private func playbackButton() -> some View {
switch playbackState {
case .noPlayback:
Button {
if let recordingFileName = getLoadedFileName(recordingFile) {
startPlayback(recordingFileName)
}
} label: {
playPauseIcon("play.fill")
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
ZStack {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
private struct ProgressCircle: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
var body: some View {
Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke(
Color.accentColor,
style: StrokeStyle(lineWidth: 3)
)
.rotationEffect(.degrees(-90))
.animation(.linear, value: progress)
}
}
private func loadingIcon() -> some View {
ProgressView()
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingFileName: String) {
startingPlayback = true
chatModel.stopPreviousRecPlay.toggle()
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
playbackState = .playing
}
}
struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
)
let voiceMessageWtFile = ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
content: .rcvMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: nil
)
Group {
CIVoiceView(
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
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))
}
}
@@ -1,76 +0,0 @@
//
// FramedCIVoiceView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 22.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SwiftUI
import SimpleXChat
struct FramedCIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
var body: some View {
HStack {
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: false,
playbackState: $playbackState,
playbackTime: $playbackTime
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
playbackState: $playbackState,
playbackTime: $playbackTime
)
.foregroundColor(.secondary)
.frame(width: 50, alignment: .leading)
}
.padding(.top, 6)
.padding(.leading, 6)
.padding(.trailing, 12)
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
}
}
struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
)
let voiceMessageWithQuote: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
file: CIFile.getSample(fileStatus: .sndComplete)
)
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))
}
}
File diff suppressed because one or more lines are too long
@@ -1,44 +0,0 @@
//
// MarkedDeletedItemView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 30.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct MarkedDeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var showMember = false
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
if showMember, let member = chatItem.memberDisplayName {
Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
}
Text("marked deleted")
.font(.caption)
.foregroundColor(.secondary)
.italic()
CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled)
}
}
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false))
}
.previewLayout(.fixed(width: 360, height: 200))
}
}
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let linkColor = Color(uiColor: uiLinkColor)
struct MsgContentView: View {
var text: String
@@ -69,14 +70,6 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
switch privacySimplexLinkModeDefault.get() {
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
case .full: return linkText(t, simplexUri, preview, prefix: "")
case .browser: return trustedUri
? linkText(t, t, preview, prefix: "")
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
}
case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
@@ -85,19 +78,16 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
}
}
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
private func linkText(_ s: String, _ link: String,
_ preview: Bool, prefix: String) -> Text {
preview
? Text(s).foregroundColor(color).underline(color: color)
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
: Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + link) as Any,
.foregroundColor: uiColor as Any
.foregroundColor: uiLinkColor as Any
]))).underline()
}
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
}
struct MsgContentView_Previews: PreviewProvider {
static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
+16 -104
View File
@@ -15,42 +15,11 @@ struct ChatItemView: View {
var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted && !revealed {
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
} else if ci.quotedItem == nil && !ci.meta.itemDeleted {
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 {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration)
} else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
} else {
framedItemView()
}
}
private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
}
}
struct ChatItemContentView<Content: View>: View {
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember: Bool
var msgContentView: () -> Content
var body: some View {
switch chatItem.content {
case .sndMsgContent: msgContentView()
case .rcvMsgContent: msgContentView()
case .sndMsgContent: contentItemView()
case .rcvMsgContent: contentItemView()
case .sndDeleted: deletedItemView()
case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration)
@@ -62,12 +31,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 .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
}
}
@ViewBuilder private func contentItemView() -> some View {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
}
}
@@ -86,76 +57,17 @@ struct ChatItemContentView<Content: View>: View {
private func eventItemView() -> some View {
CIEventView(chatItem: chatItem)
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
}
}
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
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))
}
.previewLayout(.fixed(width: 360, height: 70))
}
}
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false))
Group{
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, true, false, false),
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, true, false, false),
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, true, false, false),
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, true, false, false),
content: ciFeatureContent,
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 70))
}
+135 -230
View File
@@ -34,7 +34,7 @@ struct ChatView: View {
// 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) {
@@ -48,9 +48,9 @@ struct ChatView: View {
floatingButtons(proxy)
}
}
Spacer(minLength: 0)
ComposeView(
chat: chat,
composeState: $composeState,
@@ -103,7 +103,7 @@ struct ChatView: View {
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
}) {
@@ -121,7 +121,7 @@ struct ChatView: View {
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet) {
.sheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
}
}
@@ -152,7 +152,7 @@ struct ChatView: View {
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
@@ -170,16 +170,16 @@ struct ChatView: View {
}
}
}
private func searchToolbar() -> some View {
HStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
.focused($searchFocussed)
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
Button {
searchText = ""
} label: {
@@ -190,7 +190,7 @@ struct ChatView: View {
.foregroundColor(.secondary)
.background(Color(.secondarySystemBackground))
.cornerRadius(10.0)
Button ("Cancel") {
searchText = ""
searchMode = false
@@ -204,37 +204,37 @@ struct ChatView: View {
.padding(.horizontal)
.padding(.vertical, 8)
}
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
let maxWidth =
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
LazyVStack(spacing: 5) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
chatItemView(ci, maxWidth)
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
itemsInView.insert(ci.viewId)
loadChatItems(cInfo, ci, proxy)
if ci.isRcvNew() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
Task {
await apiMarkChatItemRead(cInfo, ci)
NtfManager.shared.decNtfBadgeCount()
}
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
itemsInView.insert(ci.viewId)
loadChatItems(cInfo, ci, proxy)
if ci.isRcvNew() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
Task {
await apiMarkChatItemRead(cInfo, ci)
NtfManager.shared.decNtfBadgeCount()
}
}
}
}
.onDisappear {
itemsInView.remove(ci.viewId)
}
}
.onDisappear {
itemsInView.remove(ci.viewId)
}
}
}
}
@@ -258,7 +258,7 @@ struct ChatView: View {
}
.scaleEffect(x: 1, y: -1, anchor: .center)
}
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
return VStack {
@@ -300,7 +300,7 @@ struct ChatView: View {
}
.padding()
}
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
ZStack {
Circle()
@@ -309,7 +309,7 @@ struct ChatView: View {
content()
}
}
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
Button {
CallController.shared.startCall(contact, media)
@@ -317,7 +317,7 @@ struct ChatView: View {
Image(systemName: imageName)
}
}
private func searchButton() -> some View {
Button {
searchMode = true
@@ -327,7 +327,7 @@ struct ChatView: View {
Label("Search", systemImage: "magnifyingglass")
}
}
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
@@ -343,7 +343,7 @@ struct ChatView: View {
Image(systemName: "person.crop.circle.badge.plus")
}
}
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
if loadingItems || firstPage { return }
@@ -371,7 +371,7 @@ struct ChatView: View {
}
}
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
@@ -392,7 +392,7 @@ struct ChatView: View {
await MainActor.run { selectedMember = member }
}
}
.appSheet(item: $selectedMember, onDismiss: {
.sheet(item: $selectedMember, onDismiss: {
selectedMember = nil
memberConnectionStats = nil
}) { _ in
@@ -402,208 +402,117 @@ struct ChatView: View {
Rectangle().fill(.clear)
.frame(width: memberImageSize, height: memberImageSize)
}
ChatItemWithMenu(
chat: chat,
ci: ci,
showMember: showMember,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
).padding(.leading, 8)
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
}
.padding(.trailing)
.padding(.leading, 12)
} else {
ChatItemWithMenu(
chat: chat,
ci: ci,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
).padding(.horizontal)
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
}
}
private struct ChatItemWithMenu: View {
var chat: Chat
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState
@Binding var showDeleteMessage: Bool
@State private var revealed = false
var body: some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
.uiKitContextMenu(actions: menu())
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
}
if let di = deletingItem, di.meta.editable {
Button(broadcastDeleteButtonText, role: .destructive) {
deleteMessage(.cidmBroadcast)
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
var menu: [UIAction] = []
if ci.isMsgContent() {
menu.append(contentsOf: [
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
) { _ in
withAnimation {
if composeState.editing() {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
}
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
}
private func menu() -> [UIAction] {
var menu: [UIAction] = []
if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed {
if !ci.meta.itemDeleted {
menu.append(replyUIAction())
}
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))
},
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
},
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
} else {
menu.append(saveFileAction(filePath))
UIPasteboard.general.string = ci.content.text
}
}
if ci.meta.editable && !mc.isVoice {
menu.append(editAction())
}
if revealed {
menu.append(hideUIAction())
}
menu.append(deleteUIAction())
} else if ci.meta.itemDeleted {
menu.append(revealUIAction())
menu.append(deleteUIAction())
} else if ci.isDeletedContent {
menu.append(deleteUIAction())
}
return menu
}
private func replyUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
) { _ in
withAnimation {
if composeState.editing {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
])
if case .image = ci.content.msgContent,
let image = getLoadedImage(ci.file) {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
)
}
if ci.meta.editable {
menu.append(
UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil")
) { _ in
withAnimation {
composeState = ComposeState(editingItem: ci)
}
}
)
}
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
}
}
private func shareUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
)
} else if ci.isDeletedContent() {
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
showShareSheet(items: shareItems)
}
}
private func copyUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
} else {
UIPasteboard.general.string = ci.content.text
}
}
}
private func saveImageAction(_ image: UIImage) -> UIAction {
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
private func saveFileAction(_ filePath: String) -> UIAction {
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
let fileURL = URL(fileURLWithPath: filePath)
showShareSheet(items: [fileURL])
}
}
private func editAction() -> UIAction {
UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil")
) { _ in
withAnimation {
composeState = ComposeState(editingItem: ci)
}
}
)
}
private func hideUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Hide", comment: "chat item action"),
image: UIImage(systemName: "eye.slash")
) { _ in
withAnimation {
revealed = false
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
.uiKitContextMenu(actions: menu)
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
}
if let di = deletingItem, di.meta.editable {
Button("Delete for everyone",role: .destructive) {
deleteMessage(.cidmBroadcast)
}
}
}
}
private func deleteUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
}
private func revealUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Reveal", comment: "chat item action"),
image: UIImage(systemName: "eye")
) { _ in
withAnimation {
revealed = true
}
}
}
private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.fullDeletionAllowed ? "Delete for everyone" : "Mark deleted for everyone"
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
}
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
switch (prevItem?.chatDir) {
case .groupSnd: return true
@@ -611,26 +520,26 @@ struct ChatView: View {
default: return false
}
}
private func scrollToBottom(_ proxy: ScrollViewProxy) {
if let ci = chatModel.reversedChatItems.first {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
private func scrollUp(_ proxy: ScrollViewProxy) {
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
let (deletedItem, toItem) = try await apiDeleteChatItem(
let toItem = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
@@ -638,11 +547,7 @@ struct ChatView: View {
)
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
} else {
chatModel.removeChatItem(chat.chatInfo, deletedItem)
}
let _ = chatModel.removeChatItem(chat.chatInfo, toItem)
}
}
} catch {
@@ -13,7 +13,6 @@ enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
case imagePreviews(imagePreviews: [String])
case voicePreview(recordingFileName: String, duration: Int)
case filePreview(fileName: String)
}
@@ -23,17 +22,10 @@ enum ComposeContextItem {
case editingItem(chatItem: ChatItem)
}
enum VoiceMessageRecordingState {
case noRecording
case recording
case finished
}
struct ComposeState {
var message: String
var preview: ComposePreview
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
var disabled = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
@@ -41,91 +33,66 @@ struct ComposeState {
init(
message: String = "",
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem,
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
contextItem: ComposeContextItem = .noContextItem
) {
self.message = message
self.preview = preview
self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState
}
init(editingItem: ChatItem) {
self.message = editingItem.content.text
self.preview = chatItemPreview(chatItem: editingItem)
self.contextItem = .editingItem(chatItem: editingItem)
if let emc = editingItem.content.msgContent,
case .voice = emc {
self.voiceMessageRecordingState = .finished
} else {
self.voiceMessageRecordingState = .noRecording
}
}
func copy(
message: String? = nil,
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil,
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
contextItem: ComposeContextItem? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem,
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
contextItem: contextItem ?? self.contextItem
)
}
var editing: Bool {
func editing() -> Bool {
switch contextItem {
case .editingItem: return true
default: return false
}
}
var sendEnabled: Bool {
func sendEnabled() -> Bool {
switch preview {
case .imagePreviews: return true
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
default: return !message.isEmpty
case .imagePreviews:
return true
case .filePreview:
return true
default:
return !message.isEmpty
}
}
var linkPreviewAllowed: Bool {
func linkPreviewAllowed() -> Bool {
switch preview {
case .imagePreviews: return false
case .voicePreview: return false
case .filePreview: return false
default: return useLinkPreviews
case .imagePreviews:
return false
case .filePreview:
return false
default:
return useLinkPreviews
}
}
var linkPreview: LinkPreview? {
func linkPreview() -> LinkPreview? {
switch preview {
case let .linkPreview(linkPreview): return linkPreview
default: return nil
}
}
var voiceMessageRecordingFileName: String? {
switch preview {
case let .voicePreview(recordingFileName: recordingFileName, _): return recordingFileName
default: return nil
}
}
var noPreview: Bool {
switch preview {
case .noPreview: return true
default: return false
}
}
var voicePreview: Bool {
switch preview {
case .voicePreview: return true
default: return false
case let .linkPreview(linkPreview):
return linkPreview
default:
return nil
}
}
}
@@ -137,10 +104,8 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
chatItemPreview = .noPreview
case let .link(_, preview: preview):
chatItemPreview = .linkPreview(linkPreview: preview)
case let .image(_, image):
case let .image(_, image: image):
chatItemPreview = .imagePreviews(imagePreviews: [image])
case let .voice(_, duration):
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
case .file:
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
default:
@@ -151,7 +116,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat
let chat: Chat
@Binding var composeState: ComposeState
@FocusState.Binding var keyboardVisible: Bool
@@ -166,21 +131,12 @@ struct ComposeView: View {
@State var chosenImages: [UIImage] = []
@State private var showFileImporter = false
@State var chosenFile: URL? = nil
@State private var audioRecorder: AudioRecorder?
@State private var voiceMessageRecordingTime: TimeInterval?
@State private var startingRecording: Bool = false
// for some reason voice message preview playback occasionally
// 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()
switch (composeState.editing, composeState.preview) {
switch (composeState.editing(), composeState.preview) {
case (true, .filePreview): EmptyView()
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
default: previewView()
}
HStack (alignment: .bottom) {
@@ -190,7 +146,7 @@ struct ComposeView: View {
Image(systemName: "paperclip")
.resizable()
}
.disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording)
.disabled(composeState.editing())
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.leading, 12)
@@ -200,15 +156,6 @@ struct ComposeView: View {
sendMessage()
resetLinkPreview()
},
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: {
Task {
await startVoiceMessageRecording()
}
},
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
keyboardVisible: $keyboardVisible
)
.padding(.trailing, 12)
@@ -216,7 +163,7 @@ struct ComposeView: View {
}
}
.onChange(of: composeState.message) { _ in
if composeState.linkPreviewAllowed {
if composeState.linkPreviewAllowed() {
if composeState.message.count > 0 {
showLinkPreview(composeState.message)
} else {
@@ -288,11 +235,11 @@ struct ComposeView: View {
}
fileURL.stopAccessingSecurityScopedResource()
if let fileSize = fileSize,
fileSize <= MAX_FILE_SIZE {
fileSize <= maxFileSize {
chosenFile = fileURL
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
} else {
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
@@ -303,26 +250,6 @@ struct ComposeView: View {
}
}
}
.onDisappear {
if let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingRecording {
if composeState.voiceMessageRecordingState == .recording {
finishVoiceMessageRecording()
}
} else {
startingRecording = false
}
}
.onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in
if !vmAllowed && composeState.voicePreview,
let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
}
}
}
@ViewBuilder func previewView() -> some View {
@@ -338,16 +265,7 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
chosenImages = []
},
cancelEnabled: !composeState.editing)
case let .voicePreview(recordingFileName, _):
ComposeVoiceView(
recordingFileName: recordingFileName,
recordingTime: $voiceMessageRecordingTime,
recordingState: $composeState.voiceMessageRecordingState,
cancelVoiceMessage: { cancelVoiceMessageRecording($0) },
cancelEnabled: !composeState.editing,
stopPlayback: $stopPlayback
)
cancelEnabled: !composeState.editing())
case let .filePreview(fileName: fileName):
ComposeFileView(
fileName: fileName,
@@ -355,7 +273,7 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
chosenFile = nil
},
cancelEnabled: !composeState.editing)
cancelEnabled: !composeState.editing())
}
}
@@ -436,9 +354,6 @@ struct ComposeView: View {
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) {
@@ -471,94 +386,6 @@ struct ComposeView: View {
}
}
private func startVoiceMessageRecording() async {
startingRecording = true
chatModel.stopPreviousRecPlay.toggle()
let fileName = generateNewFileName("voice", "m4a")
audioRecorder = AudioRecorder(
onTimer: { voiceMessageRecordingTime = $0 },
onFinishRecording: {
updateComposeVMRFinished()
if let fileSize = fileSize(getAppFilePath(fileName)) {
logger.debug("onFinishRecording recording file size = \(fileSize)")
}
}
)
if let recStartError = await audioRecorder?.start(fileName: fileName) {
switch recStartError {
case .permission:
AlertManager.shared.showAlert(Alert(
title: Text("No permission to record voice message"),
message: Text("To record voice message please grant permission to use Microphone."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
))
case let .error(error):
AlertManager.shared.showAlertMsg(
title: "Unable to record voice message",
message: "Error: \(error)"
)
}
} else {
composeState = composeState.copy(
preview: .voicePreview(recordingFileName: fileName, duration: 0),
voiceMessageRecordingState: .recording
)
}
}
private func finishVoiceMessageRecording() {
audioRecorder?.stop()
audioRecorder = nil
updateComposeVMRFinished()
if let fileName = composeState.voiceMessageRecordingFileName,
let fileSize = fileSize(getAppFilePath(fileName)) {
logger.debug("finishVoiceMessageRecording recording file size = \(fileSize)")
}
}
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))")
}
}
}
}
// ? maybe we shouldn't have duration in ComposePreview.voicePreview
private func updateComposeVMRFinished() {
var preview = composeState.preview
if let recordingFileName = composeState.voiceMessageRecordingFileName,
let recordingTime = voiceMessageRecordingTime {
preview = .voicePreview(recordingFileName: recordingFileName, duration: Int(recordingTime.rounded()))
}
composeState = composeState.copy(
preview: preview,
voiceMessageRecordingState: .finished
)
}
private func cancelVoiceMessageRecording(_ fileName: String) {
stopPlayback.toggle()
audioRecorder?.stop()
removeFile(fileName)
clearState()
}
private func clearState() {
composeState = ComposeState()
linkUrl = nil
@@ -567,9 +394,6 @@ struct ComposeView: View {
cancelledLinks = []
chosenImages = []
chosenFile = nil
audioRecorder = nil
voiceMessageRecordingTime = nil
startingRecording = false
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
@@ -580,8 +404,6 @@ struct ComposeView: View {
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, _):
@@ -593,7 +415,7 @@ struct ComposeView: View {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
if prevLinkUrl == url {
loadLinkPreview(url)
@@ -622,7 +444,7 @@ struct ComposeView: View {
}
private func cancelLinkPreview() {
if let uri = composeState.linkPreview?.uri.absoluteString {
if let uri = composeState.linkPreview()?.uri.absoluteString {
cancelledLinks.insert(uri)
}
pendingLinkUrl = nil
@@ -677,13 +499,11 @@ struct ComposeView_Previews: PreviewProvider {
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.environmentObject(ChatModel())
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.environmentObject(ChatModel())
}
}
}
@@ -1,195 +0,0 @@
//
// ComposeVoiceView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 21.11.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum VoiceMessagePlaybackState {
case noPlayback
case playing
case paused
}
func voiceMessageTime(_ time: TimeInterval) -> String {
let min = Int(time / 60)
let sec = Int(time.truncatingRemainder(dividingBy: 60))
return String(format: "%02d:%02d", min, sec)
}
func voiceMessageTime_(_ time: TimeInterval?) -> String {
return voiceMessageTime(time ?? TimeInterval(0))
}
struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
var recordingFileName: String
@Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState
let cancelVoiceMessage: ((String) -> Void)
let cancelEnabled: Bool
@Binding var stopPlayback: Bool // value is not taken into account, only the fact it switches
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
private static let previewHeight: CGFloat = 50
var body: some View {
ZStack {
if recordingState != .finished {
recordingMode()
} else {
playbackMode()
}
}
.padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
private func recordingMode() -> some View {
ZStack {
HStack(alignment: .center, spacing: 8) {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
Text(voiceMessageTime_(recordingTime))
Spacer()
if cancelEnabled {
cancelButton()
}
}
.padding(.trailing, 12)
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
}
}
private func playbackMode() -> some View {
ZStack {
HStack(alignment: .center, spacing: 8) {
switch playbackState {
case .noPlayback:
Button {
startPlayback()
} label: {
playPauseIcon("play.fill")
}
Text(voiceMessageTime_(recordingTime))
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill")
}
Text(voiceMessageTime_(playbackTime))
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill")
}
Text(voiceMessageTime_(playbackTime))
}
Spacer()
if cancelEnabled {
cancelButton()
}
}
.padding(.trailing, 12)
if let recordingLength = recordingTime {
ProgressBar(length: recordingLength, progress: $playbackTime)
}
}
.onChange(of: stopPlayback) { _ in
audioPlayer?.stop()
}
.onDisappear {
audioPlayer?.stop()
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, 12)
}
private func cancelButton() -> some View {
Button {
audioPlayer?.stop()
cancelVoiceMessage(recordingFileName)
} label: {
Image(systemName: "multiply")
}
}
private struct ProgressBar: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
var body: some View {
GeometryReader { geometry in
ZStack {
Rectangle()
.fill(Color.accentColor)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 3)
.animation(.linear, value: progress)
}
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
}
}
}
private func startPlayback() {
startingPlayback = true
chatModel.stopPreviousRecPlay.toggle()
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = recordingTime // animate progress bar to the end
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
playbackState = .playing
}
}
struct ComposeVoiceView_Previews: PreviewProvider {
static var previews: some View {
ComposeVoiceView(
recordingFileName: "voice.m4a",
recordingTime: Binding.constant(TimeInterval(20)),
recordingState: Binding.constant(VoiceMessageRecordingState.recording),
cancelVoiceMessage: { _ in },
cancelEnabled: true,
stopPlayback: Binding.constant(false)
)
.environmentObject(ChatModel())
}
}
@@ -12,13 +12,6 @@ import SimpleXChat
struct SendMessageView: View {
@Binding var composeState: ComposeState
var sendMessage: () -> Void
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
@State private var holdingVMR = false
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
@State private var teHeight: CGFloat = 42
@@ -30,34 +23,24 @@ struct SendMessageView: View {
ZStack {
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview {
Text("Voice message…")
.font(teFont.italic())
.multilineTextAlignment(.leading)
.foregroundColor(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
} else {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.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)
}
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.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)
}
if (composeState.inProgress) {
@@ -66,27 +49,14 @@ struct SendMessageView: View {
.frame(width: 31, height: 31, alignment: .center)
.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()
}
} else if vmrs == .recording && !holdingVMR {
finishVoiceMessageRecordingButton()
} else {
sendMessageButton()
Button(action: { sendMessage() }) {
Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
}
.disabled(!composeState.sendEnabled() || composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
}
@@ -97,109 +67,14 @@ 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")
.resizable()
.foregroundColor(.accentColor)
}
.disabled(
!composeState.sendEnabled ||
composeState.disabled ||
(!voiceMessageAllowed && composeState.voicePreview)
)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
private struct RecordVoiceMessageButton: View {
var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)?
@Binding var holdingVMR: Bool
var disabled: Bool
@State private var pressed: TimeInterval? = nil
var body: some View {
Button(action: {}) {
Image(systemName: "mic.fill")
.foregroundColor(.accentColor)
}
.disabled(disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
._onButtonGesture { down in
if down {
holdingVMR = true
pressed = ProcessInfo.processInfo.systemUptime
startVoiceMessageRecording?()
} else {
let now = ProcessInfo.processInfo.systemUptime
if let pressed = pressed,
now - pressed >= 1 {
finishVoiceMessageRecording?()
}
holdingVMR = false
pressed = nil
}
} perform: {}
}
}
private func voiceMessageNotAllowedButton() -> some View {
Button(action: {
switch showEnableVoiceMessagesAlert {
case .userEnable:
AlertManager.shared.showAlert(Alert(
title: Text("Allow voice messages?"),
message: Text("You need to allow your contact to send voice messages to be able to send them."),
primaryButton: .default(Text("Allow")) {
allowVoiceMessagesToContact?()
},
secondaryButton: .cancel()
))
case .askContact:
AlertManager.shared.showAlertMsg(
title: "Voice messages prohibited!",
message: "Please ask your contact to enable sending voice messages."
)
case .groupOwnerCan:
AlertManager.shared.showAlertMsg(
title: "Voice messages prohibited!",
message: "Only group owners can enable voice messages."
)
case .other:
AlertManager.shared.showAlertMsg(
title: "Voice messages prohibited!",
message: "Please check yours and your contact preferences."
)
}
}) {
Image(systemName: "mic")
.foregroundColor(.secondary)
}
.disabled(composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
private func finishVoiceMessageRecordingButton() -> some View {
Button(action: { finishVoiceMessageRecording?() }) {
Image(systemName: "stop.fill")
.foregroundColor(.accentColor)
}
.disabled(composeState.disabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
private func updateHeight(_ g: GeometryProxy) -> Color {
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
? largeEmojiFont
: mediumEmojiFont
: .body
}
return Color.clear
}
@@ -10,12 +10,10 @@ import SwiftUI
import SimpleXChat
struct ContactPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
@State private var showSaveDialogue = false
var body: some View {
let user: User = chatModel.currentUser!
@@ -27,28 +25,14 @@ struct ContactPreferencesView: View {
Section {
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
Button("Save and notify contact") { savePreferences() }
Button("Save (and notify contact)") { savePreferences() }
}
.disabled(currentFeaturesAllowed == featuresAllowed)
}
}
.modifier(BackButton {
if currentFeaturesAllowed == featuresAllowed {
dismiss()
} else {
showSaveDialogue = true
}
})
.confirmationDialog("Save preferences?", isPresented: $showSaveDialogue) {
Button("Save and notify contact") {
savePreferences()
dismiss()
}
Button("Exit without saving") { dismiss() }
}
}
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
private func featureSection(_ feature: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
let enabled = FeatureEnabled.enabled(
user: Preference(allow: allowFeature.wrappedValue.allowed),
contact: pref.contactPreference
@@ -13,8 +13,8 @@ struct AddGroupMembersView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var chat: Chat
@State var groupInfo: GroupInfo
var creatingGroup: Bool = false
var groupInfo: GroupInfo
var showSkip: Bool = false
var showFooterCounter: Bool = true
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
@State private var selectedContacts = Set<Int64>()
@@ -52,9 +52,6 @@ struct AddGroupMembersView: View {
} else {
let count = selectedContacts.count
Section {
if creatingGroup {
groupPreferencesButton($groupInfo, true)
}
rolePicker()
inviteMembersButton()
.disabled(count < 1)
@@ -81,10 +78,14 @@ struct AddGroupMembersView: View {
}
}
if creatingGroup {
if (showSkip) {
v.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb?(selectedContacts) }
if showSkip {
Button ("Skip") {
if let cb = addedMembersCb { cb(selectedContacts) }
}
}
}
}
} else {
@@ -141,7 +142,6 @@ struct AddGroupMembersView: View {
}
}
}
.frame(height: 36)
}
private func contactCheckView(_ contact: Contact) -> some View {
@@ -45,7 +45,7 @@ struct GroupChatInfoView: View {
if groupInfo.canEdit {
editGroupButton()
}
groupPreferencesButton($groupInfo)
groupPreferencesButton()
} header: {
Text("")
} footer: {
@@ -78,10 +78,10 @@ struct GroupChatInfoView: View {
} label: { memberView(member) }
}
}
.appSheet(isPresented: $showAddMembersSheet) {
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
.appSheet(item: $selectedMember, onDismiss: {
.sheet(item: $selectedMember, onDismiss: {
selectedMember = nil
connectionStats = nil
}) { _ in
@@ -200,6 +200,20 @@ struct GroupChatInfoView: View {
}
}
func groupPreferencesButton() -> some View {
NavigationLink {
GroupPreferencesView(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
currentPreferences: groupInfo.fullGroupPreferences
)
.navigationBarTitle("Group preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Group preferences", systemImage: "switch.2")
}
}
func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
@@ -296,25 +310,6 @@ struct GroupChatInfoView: View {
}
}
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
NavigationLink {
GroupPreferencesView(
groupInfo: groupInfo,
preferences: groupInfo.wrappedValue.fullGroupPreferences,
currentPreferences: groupInfo.wrappedValue.fullGroupPreferences,
creatingGroup: creatingGroup
)
.navigationBarTitle("Group preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
if creatingGroup {
Text("Set group preferences")
} else {
Label("Group preferences", systemImage: "switch.2")
}
}
}
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
@@ -16,7 +16,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable {
case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
@@ -55,7 +55,7 @@ struct GroupLinkView: View {
} 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)
alert = .error(title: a.title, error: "\(a.message)")
}
}
} label: { Label("Create link", systemImage: "link.badge.plus") }
@@ -84,7 +84,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return Alert(title: Text(title), message: Text("\(error)"))
}
}
}
@@ -42,7 +42,7 @@ struct GroupMemberInfoView: View {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if let contactId = member.memberContactId, groupInfo.fullGroupPreferences.directMessages.enable == .on {
if let contactId = member.memberContactId {
Section {
openDirectChatButton(contactId)
}
@@ -70,8 +70,10 @@ struct GroupMemberInfoView: View {
Section("Servers") {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
if developerTools {
Button("Change receiving address (BETA)") {
alert = .switchAddressAlert
}
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
@@ -129,9 +131,7 @@ struct GroupMemberInfoView: View {
}
if let chat = chat {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
@@ -10,53 +10,32 @@ import SwiftUI
import SimpleXChat
struct GroupPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
let creatingGroup: Bool
@State private var showSaveDialogue = false
var body: some View {
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
VStack {
List {
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.voice, $preferences.voice.enable)
if groupInfo.canEdit {
Section {
Button("Reset") { preferences = currentPreferences }
Button(saveText) { savePreferences() }
Button("Save (and notify group members)") { savePreferences() }
}
.disabled(currentPreferences == preferences)
}
}
}
.modifier(BackButton {
if currentPreferences == preferences {
dismiss()
} else {
showSaveDialogue = true
}
})
.confirmationDialog("Save preferences?", isPresented: $showSaveDialogue) {
Button(saveText) {
savePreferences()
dismiss()
}
Button("Exit without saving") { dismiss() }
}
}
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
private func featureSection(_ feature: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
if (groupInfo.canEdit) {
settingsRow(icon, color: color) {
settingsRow(feature.icon) {
Picker(feature.text, selection: enableFeature) {
ForEach(GroupFeatureEnabled.values) { enable in
Text(enable.text)
@@ -66,12 +45,12 @@ struct GroupPreferencesView: View {
}
}
else {
settingsRow(icon, color: color) {
settingsRow(feature.icon) {
infoRow(feature.text, enableFeature.wrappedValue.text)
}
}
} footer: {
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.frame(height: 36, alignment: .topLeading)
}
}
@@ -99,8 +78,7 @@ struct GroupPreferencesView_Previews: PreviewProvider {
GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData),
preferences: FullGroupPreferences.sampleData,
currentPreferences: FullGroupPreferences.sampleData,
creatingGroup: false
currentPreferences: FullGroupPreferences.sampleData
)
}
}
@@ -402,10 +402,10 @@ struct ErrorAlert {
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
switch error as? ChatResponse {
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection and try again.")
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection and try again.")
default:
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
}
@@ -101,10 +101,8 @@ struct ChatPreviewView: View {
@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")
let itemFormattedText = !cItem.meta.itemDeleted ? cItem.formattedText : nil
ZStack(alignment: .topTrailing) {
(itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true))
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
@@ -206,10 +204,6 @@ struct ChatPreviewView_Previews: PreviewProvider {
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false)]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
@@ -66,7 +66,7 @@ struct ContactConnectionView: View {
Spacer()
}
.frame(maxHeight: .infinity)
.appSheet(isPresented: $showContactConnectionInfo) {
.sheet(isPresented: $showContactConnectionInfo) {
ContactConnectionInfo(contactConnection: contactConnection)
}
}
@@ -17,7 +17,7 @@ enum DatabaseEncryptionAlert: Identifiable {
case changeDatabaseKey
case databaseEncrypted
case currentPassphraseError
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
@@ -155,7 +155,7 @@ struct DatabaseEncryptionView: View {
if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
await operationEnded(.currentPassphraseError)
} else {
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
await operationEnded(.error(title: "Error encrypting database", error: responseError(error)))
}
}
}
@@ -232,7 +232,7 @@ struct DatabaseEncryptionView: View {
message: Text("Please enter correct current passphrase.")
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return Alert(title: Text(title), message: Text("\(error)"))
}
}
@@ -289,7 +289,7 @@ struct DatabaseView: View {
}
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return Alert(title: Text(title), message: Text("\(error)"))
}
}
@@ -1,66 +0,0 @@
//
// AppSheet.swift
// SimpleX (iOS)
//
// Created by Evgeny on 24/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private struct SheetIsPresented<C>: ViewModifier where C: View {
var isPresented: Binding<Bool>
var onDismiss: (() -> Void)?
var sheetContent: () -> C
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
content.sheet(isPresented: isPresented, onDismiss: onDismiss) {
sheetContent().modifier(PrivacySensitive())
}
}
}
private struct SheetForItem<T, C>: ViewModifier where T: Identifiable, C: View {
var item: Binding<T?>
var onDismiss: (() -> Void)?
var sheetContent: (T) -> C
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
content.sheet(item: item, onDismiss: onDismiss) { it in
sheetContent(it).modifier(PrivacySensitive())
}
}
}
private struct PrivacySensitive: ViewModifier {
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
@Environment(\.scenePhase) var scenePhase
func body(content: Content) -> some View {
if case .active = scenePhase {
content
} else {
content.privacySensitive(protectScreen).redacted(reason: .privacy)
}
}
}
extension View {
func appSheet<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View where Content: View {
modifier(SheetIsPresented(isPresented: isPresented, onDismiss: onDismiss, sheetContent: content))
}
func appSheet<T, Content>(
item: Binding<T?>,
onDismiss: (() -> Void)? = nil,
content: @escaping (T) -> Content
) -> some View where T: Identifiable, Content: View {
modifier(SheetForItem(item: item, onDismiss: onDismiss, sheetContent: content))
}
}
@@ -27,7 +27,7 @@ struct AddGroupView: View {
AddGroupMembersView(
chat: chat,
groupInfo: groupInfo,
creatingGroup: true,
showSkip: true,
showFooterCounter: false
) { _ in
dismiss()
+18 -37
View File
@@ -9,25 +9,6 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
struct MutableQRCode: View {
@Binding var uri: String
@State private var image: UIImage?
var body: some View {
ZStack {
if let image = image {
qrCodeImage(image)
}
}
.onAppear {
image = generateImage(uri)
}
.onChange(of: uri) { _ in
image = generateImage(uri)
}
}
}
struct QRCode: View {
let uri: String
@State private var image: UIImage?
@@ -35,32 +16,32 @@ struct QRCode: View {
var body: some View {
ZStack {
if let image = image {
qrCodeImage(image)
Image(uiImage: image)
.resizable()
.interpolation(.none)
.aspectRatio(1, contentMode: .fit)
.textSelection(.enabled)
}
}
.onAppear {
image = image ?? generateImage(uri)
generateImage()
}
}
}
private func qrCodeImage(_ image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.interpolation(.none)
.aspectRatio(1, contentMode: .fit)
.textSelection(.enabled)
}
private func generateImage() {
guard image == nil else { return }
private func generateImage(_ uri: String) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
if let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgImage)
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
guard
let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent)
else { return }
self.image = UIImage(cgImage: cgImage)
}
return nil
}
struct QRCode_Previews: PreviewProvider {
-1
View File
@@ -86,7 +86,6 @@ struct TerminalView: View {
SendMessageView(
composeState: $composeState,
sendMessage: sendMessage,
showVoiceMessageButton: false,
keyboardVisible: $keyboardVisible
)
.padding(.horizontal, 12)
@@ -34,12 +34,19 @@ struct NetworkAndServers: View {
List {
Section {
NavigationLink {
SMPServersView()
SMPServers()
.navigationTitle("Your SMP servers")
} label: {
Text("SMP servers")
}
NavigationLink {
SMPServersView()
.navigationTitle("Your SMP servers")
} label: {
Text("SMP servers (new)")
}
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
@@ -30,7 +30,7 @@ struct PreferencesView: View {
}
}
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
private func featureSection(_ feature: Feature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
Section {
settingsRow(feature.icon) {
Picker(feature.text, selection: allowFeature) {
@@ -50,7 +50,7 @@ struct PreferencesView: View {
Task {
do {
var p = fromLocalProfile(profile)
p.preferences = fullPreferencesToPreferences(preferences)
p.preferences = toPreferences(preferences)
if let newProfile = try await apiUpdateProfile(profile: p) {
await MainActor.run {
if let profileId = chatModel.currentUser?.profile.profileId {
@@ -14,49 +14,28 @@ struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE, store: groupDefaults) private var transferImagesInline = false
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
var body: some View {
VStack {
List {
Section("Device") {
SimplexLockSetting()
settingsRow("eye.slash") {
Toggle("Protect app screen", isOn: $protectScreen)
}
}
Section {
Section("Chats") {
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("photo.on.rectangle") {
Toggle("Transfer images faster", isOn: $transferImagesInline)
if developerTools {
settingsRow("photo.on.rectangle") {
Toggle("Transfer images faster (BETA)", isOn: $transferImagesInline)
}
}
settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews)
}
settingsRow("link") {
Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach(SimpleXLinkMode.values) { mode in
Text(mode.text)
}
}
}
.frame(height: 36)
.onChange(of: simplexLinkMode) { mode in
privacySimplexLinkModeDefault.set(mode)
}
} header: {
Text("Chats")
} footer: {
if case .browser = simplexLinkMode {
Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.")
}
}
}
}

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