Merge branch 'master' into master-ios
This commit is contained in:
@@ -35,6 +35,7 @@ import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
|
||||
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
@@ -179,6 +180,7 @@ class MainActivity: FragmentActivity() {
|
||||
generalGetString(R.string.auth_log_in_using_credential)
|
||||
else
|
||||
generalGetString(R.string.auth_unlock),
|
||||
selfDestruct = true,
|
||||
this@MainActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
@@ -248,7 +250,7 @@ class MainActivity: FragmentActivity() {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_enable_simplex_lock),
|
||||
generalGetString(R.string.auth_confirm_credential),
|
||||
activity,
|
||||
activity = activity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
@@ -289,7 +291,8 @@ class MainActivity: FragmentActivity() {
|
||||
appPrefs.performLA.set(false)
|
||||
laPasscodeNotSetAlert()
|
||||
},
|
||||
close)
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +317,7 @@ class MainActivity: FragmentActivity() {
|
||||
generalGetString(R.string.auth_confirm_credential)
|
||||
else
|
||||
"",
|
||||
activity,
|
||||
activity = activity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
@@ -350,14 +353,17 @@ class MainActivity: FragmentActivity() {
|
||||
generalGetString(R.string.auth_confirm_credential)
|
||||
else
|
||||
generalGetString(R.string.auth_disable_simplex_lock),
|
||||
activity,
|
||||
activity = activity,
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
val selfDestructPref = m.controller.appPrefs.selfDestruct
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
ksAppPassword.remove()
|
||||
selfDestructPref.set(false)
|
||||
ksSelfDestructPassword.remove()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
|
||||
@@ -42,7 +42,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
@@ -65,25 +65,25 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
} else if (startChat) {
|
||||
// If we migrated successfully means previous re-encryption process on database level finished successfully too
|
||||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
withApi {
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
chatModel.currentUser.value = null
|
||||
chatModel.users.clear()
|
||||
} else {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
} else {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
} else {
|
||||
savedOnboardingStage
|
||||
}
|
||||
chatController.startChat(user)
|
||||
// Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
savedOnboardingStage
|
||||
}
|
||||
chatController.startChat(user)
|
||||
// Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||
SimplexService.start(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
context = this
|
||||
initChatController()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||
runMigrations()
|
||||
runBlocking {
|
||||
initChatController()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp)
|
||||
runMigrations()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
|
||||
@@ -41,7 +41,6 @@ class ChatModel(val controller: ChatController) {
|
||||
val chatDbChanged = mutableStateOf<Boolean>(false)
|
||||
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
|
||||
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
|
||||
val chatDbDeleted = mutableStateOf(false)
|
||||
val chats = mutableStateListOf<Chat>()
|
||||
// map of connections network statuses, key is agent connection id
|
||||
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
|
||||
|
||||
@@ -231,6 +231,10 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
manager.cancel(CallNotificationId)
|
||||
}
|
||||
|
||||
fun cancelAllNotifications() {
|
||||
manager.cancelAll()
|
||||
}
|
||||
|
||||
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
|
||||
@@ -148,8 +148,12 @@ class AppPreferences(val context: Context) {
|
||||
val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null)
|
||||
val encryptedAppPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE, null)
|
||||
val initializationVectorAppPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE, null)
|
||||
val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null)
|
||||
val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null)
|
||||
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
|
||||
val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
|
||||
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
|
||||
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
|
||||
|
||||
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
|
||||
val systemDarkTheme = mkStrPreference(SHARED_PREFS_SYSTEM_DARK_THEME, DefaultTheme.SIMPLEX.name)
|
||||
@@ -274,8 +278,12 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTED_APP_PASSPHRASE = "EncryptedAppPassphrase"
|
||||
private const val SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE = "InitializationVectorAppPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase"
|
||||
private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase"
|
||||
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
|
||||
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
|
||||
private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct"
|
||||
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
|
||||
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
|
||||
private const val SHARED_PREFS_SYSTEM_DARK_THEME = "SystemDarkTheme"
|
||||
private const val SHARED_PREFS_THEMES = "Themes"
|
||||
@@ -434,8 +442,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateActiveUser(p: Profile): User? {
|
||||
val r = sendCmd(CC.CreateActiveUser(p))
|
||||
suspend fun apiCreateActiveUser(p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? {
|
||||
val r = sendCmd(CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp))
|
||||
if (r is CR.ActiveUser) return r.user
|
||||
else if (
|
||||
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName ||
|
||||
@@ -1853,7 +1861,7 @@ class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
|
||||
sealed class CC {
|
||||
class Console(val cmd: String): CC()
|
||||
class ShowActiveUser: CC()
|
||||
class CreateActiveUser(val profile: Profile): CC()
|
||||
class CreateActiveUser(val profile: Profile?, val sameServers: Boolean, val pastTimestamp: Boolean): CC()
|
||||
class ListUsers: CC()
|
||||
class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC()
|
||||
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
|
||||
@@ -1938,7 +1946,10 @@ sealed class CC {
|
||||
val cmdString: String get() = when (this) {
|
||||
is Console -> cmd
|
||||
is ShowActiveUser -> "/u"
|
||||
is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}"
|
||||
is CreateActiveUser -> {
|
||||
val user = NewUser(profile, sameServers = sameServers, pastTimestamp = pastTimestamp)
|
||||
"/_create user ${json.encodeToString(user)}"
|
||||
}
|
||||
is ListUsers -> "/users"
|
||||
is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}"
|
||||
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
|
||||
@@ -2144,6 +2155,13 @@ sealed class CC {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NewUser(
|
||||
val profile: Profile?,
|
||||
val sameServers: Boolean,
|
||||
val pastTimestamp: Boolean
|
||||
)
|
||||
|
||||
sealed class ChatPagination {
|
||||
class Last(val count: Int): ChatPagination()
|
||||
class After(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
|
||||
@@ -351,7 +351,10 @@ fun ChatLayout(
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = { floatingButton.value() },
|
||||
) { contentPadding ->
|
||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||
BoxWithConstraints(Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
ChatItemsList(
|
||||
chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
@@ -615,12 +618,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
}
|
||||
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
|
||||
Row(Modifier.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp).then(swipeableModifier)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
@@ -643,7 +647,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
}
|
||||
}
|
||||
@@ -651,8 +655,8 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||
|
||||
@@ -686,11 +686,22 @@ fun ComposeView(
|
||||
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
when {
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||
else -> previewView()
|
||||
if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) {
|
||||
contextItemView()
|
||||
when {
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||
else -> previewView()
|
||||
}
|
||||
} else {
|
||||
Box {
|
||||
Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) {
|
||||
contextItemView()
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomStart)) {
|
||||
previewView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
|
||||
@@ -6,6 +6,10 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -25,99 +29,135 @@ fun ComposeVoiceView(
|
||||
cancelEnabled: Boolean,
|
||||
cancelVoice: () -> Unit
|
||||
) {
|
||||
BoxWithConstraints(Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||
val progress = rememberSaveable { mutableStateOf(0) }
|
||||
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||
val progressBarWidth = remember { Animatable(0f) }
|
||||
LaunchedEffect(recordedDurationMs, finishedRecording) {
|
||||
snapshotFlow { progress.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
|
||||
}
|
||||
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
val progress = rememberSaveable { mutableStateOf(0) }
|
||||
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
Box {
|
||||
Box(
|
||||
Modifier
|
||||
.requiredWidth(progressBarWidth.value.dp)
|
||||
.padding(top = 58.dp)
|
||||
.height(3.dp)
|
||||
.background(MaterialTheme.colors.primary)
|
||||
)
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(sentColor),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.fillMaxWidth().padding(top = 22.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!audioPlaying.value) {
|
||||
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
|
||||
} else {
|
||||
AudioPlayer.pause(audioPlaying, progress)
|
||||
}
|
||||
},
|
||||
enabled = finishedRecording) {
|
||||
Icon(
|
||||
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||
derivedStateOf {
|
||||
when {
|
||||
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||
finishedRecording -> progress.value / 1000
|
||||
else -> recordedDurationMs / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
durationText(numberInText.value),
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.background(sentColor)
|
||||
.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
AudioPlayer.stop(filePath)
|
||||
cancelVoice()
|
||||
if (!audioPlaying.value) {
|
||||
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
|
||||
} else {
|
||||
AudioPlayer.pause(audioPlaying, progress)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(0.dp)
|
||||
enabled = finishedRecording
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||
derivedStateOf {
|
||||
when {
|
||||
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||
finishedRecording -> progress.value / 1000
|
||||
else -> recordedDurationMs / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
durationText(numberInText.value),
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
AudioPlayer.stop(filePath)
|
||||
cancelVoice()
|
||||
},
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finishedRecording) {
|
||||
FinishedRecordingSlider(sentColor, progress, duration, filePath)
|
||||
} else {
|
||||
RecordingInProgressSlider(recordedDurationMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FinishedRecordingSlider(backgroundColor: Color, progress: MutableState<Int>, duration: MutableState<Int>, filePath: String) {
|
||||
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
|
||||
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
|
||||
val primary = MaterialTheme.colors.primary
|
||||
val inactiveTrackColor = MaterialTheme.colors.primary.mixWith(
|
||||
backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha),
|
||||
0.24f)
|
||||
Slider(
|
||||
progress.value.toFloat(),
|
||||
onValueChange = { AudioPlayer.seekTo(it.toInt(), progress, filePath) },
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
||||
drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
||||
},
|
||||
colors = SliderDefaults.colors(inactiveTrackColor = inactiveTrackColor),
|
||||
valueRange = 0f..duration.value.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecordingInProgressSlider(recordedDurationMs: Int) {
|
||||
val thumbPosition = remember { Animatable(0f) }
|
||||
val recDuration = rememberUpdatedState(recordedDurationMs)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { recDuration.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
thumbPosition.animateTo(it.toFloat(), audioProgressBarAnimationSpec())
|
||||
}
|
||||
}
|
||||
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
|
||||
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
|
||||
val primary = MaterialTheme.colors.primary
|
||||
val inactiveTrackColor = Color.Transparent
|
||||
Slider(
|
||||
thumbPosition.value,
|
||||
onValueChange = {},
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
||||
},
|
||||
colors = SliderDefaults.colors(disabledInactiveTrackColor = inactiveTrackColor, disabledActiveTrackColor = primary, thumbColor = Color.Transparent, disabledThumbColor = Color.Transparent),
|
||||
enabled = false,
|
||||
valueRange = 0f..MAX_VOICE_MILLIS_FOR_SENDING.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewComposeAudioView() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
@@ -9,20 +10,19 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
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.platform.*
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
||||
|
||||
@@ -38,7 +38,7 @@ fun CIVoiceView(
|
||||
longClick: () -> Unit,
|
||||
) {
|
||||
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 = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (file != null) {
|
||||
@@ -64,7 +64,9 @@ fun CIVoiceView(
|
||||
durationText(time / 1000)
|
||||
}
|
||||
}
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) {
|
||||
AudioPlayer.seekTo(it, progress, filePath)
|
||||
}
|
||||
} else {
|
||||
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||
val metaReserve = if (edited)
|
||||
@@ -90,18 +92,67 @@ private fun VoiceLayout(
|
||||
timedMessagesTTL: Int?,
|
||||
play: () -> Unit,
|
||||
pause: () -> Unit,
|
||||
longClick: () -> Unit
|
||||
longClick: () -> Unit,
|
||||
onProgressChanged: (Int) -> Unit,
|
||||
|
||||
) {
|
||||
@Composable
|
||||
fun RowScope.Slider(backgroundColor: Color, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
var movedManuallyTo by rememberSaveable(file.fileId) { mutableStateOf(-1) }
|
||||
if (audioPlaying.value || progress.value > 0 || movedManuallyTo == progress.value) {
|
||||
val dp4 = with(LocalDensity.current) { 4.dp.toPx() }
|
||||
val dp10 = with(LocalDensity.current) { 10.dp.toPx() }
|
||||
val primary = MaterialTheme.colors.primary
|
||||
val inactiveTrackColor =
|
||||
MaterialTheme.colors.primary.mixWith(
|
||||
backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha),
|
||||
0.24f)
|
||||
val width = with(LocalDensity.current) { LocalView.current.width.toDp() }
|
||||
val colors = SliderDefaults.colors(
|
||||
inactiveTrackColor = inactiveTrackColor
|
||||
)
|
||||
Slider(
|
||||
progress.value.toFloat(),
|
||||
onValueChange = {
|
||||
onProgressChanged(it.toInt())
|
||||
movedManuallyTo = it.toInt()
|
||||
},
|
||||
Modifier
|
||||
.size(width, 48.dp)
|
||||
.weight(1f)
|
||||
.padding(padding)
|
||||
.drawBehind {
|
||||
drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
||||
drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4))
|
||||
},
|
||||
valueRange = 0f..duration.value.toFloat(),
|
||||
colors = colors
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { audioPlaying.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
movedManuallyTo = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
hasText -> {
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||
Spacer(Modifier.width(6.dp))
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||
DurationText(text, PaddingValues(start = 12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DurationText(text, PaddingValues(start = 12.dp))
|
||||
Slider(if (ci.chatDir.sent) sentColor else receivedColor)
|
||||
}
|
||||
}
|
||||
sent -> {
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) {
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp))
|
||||
DurationText(text, PaddingValues(end = 12.dp))
|
||||
}
|
||||
Column {
|
||||
@@ -120,8 +171,9 @@ private fun VoiceLayout(
|
||||
CIMetaView(ci, timedMessagesTTL)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
|
||||
DurationText(text, PaddingValues(start = 12.dp))
|
||||
Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp))
|
||||
Spacer(Modifier.height(56.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ fun DatabaseView(
|
||||
importArchiveAlert(m, context, uri, appFilesCountAndSize, progressIndicator)
|
||||
}
|
||||
}
|
||||
val chatDbDeleted = remember { m.chatDbDeleted }
|
||||
LaunchedEffect(m.chatRunning) {
|
||||
runChat.value = m.chatRunning.value ?: true
|
||||
}
|
||||
@@ -83,7 +82,6 @@ fun DatabaseView(
|
||||
chatArchiveName,
|
||||
chatArchiveTime,
|
||||
chatLastStart,
|
||||
chatDbDeleted.value,
|
||||
m.controller.appPrefs.privacyFullBackup,
|
||||
appFilesCountAndSize,
|
||||
chatItemTTL,
|
||||
@@ -134,7 +132,6 @@ fun DatabaseLayout(
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatLastStart: MutableState<Instant?>,
|
||||
chatDbDeleted: Boolean,
|
||||
privacyFullBackup: SharedPreference<Boolean>,
|
||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||
chatItemTTL: MutableState<ChatItemTTL>,
|
||||
@@ -173,7 +170,7 @@ fun DatabaseLayout(
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
SectionView(stringResource(R.string.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
||||
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
@@ -330,7 +327,6 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
|
||||
fun RunChatSetting(
|
||||
runChat: Boolean,
|
||||
stopped: Boolean,
|
||||
chatDbDeleted: Boolean,
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit
|
||||
) {
|
||||
@@ -341,7 +337,6 @@ fun RunChatSetting(
|
||||
iconColor = if (stopped) Color.Red else MaterialTheme.colors.primary,
|
||||
) {
|
||||
DefaultSwitch(
|
||||
enabled = !chatDbDeleted,
|
||||
checked = runChat,
|
||||
onCheckedChange = { runChatSwitch ->
|
||||
if (runChatSwitch) {
|
||||
@@ -371,9 +366,14 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
|
||||
ModalManager.shared.closeModals()
|
||||
return@withApi
|
||||
}
|
||||
m.controller.apiStartChat()
|
||||
runChat.value = true
|
||||
m.chatRunning.value = true
|
||||
if (m.currentUser.value == null) {
|
||||
ModalManager.shared.closeModals()
|
||||
return@withApi
|
||||
} else {
|
||||
m.controller.apiStartChat()
|
||||
runChat.value = true
|
||||
m.chatRunning.value = true
|
||||
}
|
||||
val ts = Clock.System.now()
|
||||
m.controller.appPrefs.chatLastStart.set(ts)
|
||||
chatLastStart.value = ts
|
||||
@@ -410,7 +410,7 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context:
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_stop_chat),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
activity = context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success, is LAResult.Unavailable -> {
|
||||
@@ -433,18 +433,28 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context:
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiStopChat()
|
||||
runChat.value = false
|
||||
m.chatRunning.value = false
|
||||
SimplexService.safeStopService(context)
|
||||
stopChatAsync(m)
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
} catch (e: Error) {
|
||||
runChat.value = true
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_stopping_chat), e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopChatAsync(m: ChatModel) {
|
||||
m.controller.apiStopChat()
|
||||
m.chatRunning.value = false
|
||||
}
|
||||
|
||||
suspend fun deleteChatAsync(m: ChatModel) {
|
||||
m.controller.apiDeleteStorage()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||
}
|
||||
|
||||
private fun exportArchive(
|
||||
context: Context,
|
||||
m: ChatModel,
|
||||
@@ -619,10 +629,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
||||
progressIndicator.value = true
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiDeleteStorage()
|
||||
m.chatDbDeleted.value = true
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||
deleteChatAsync(m)
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||
}
|
||||
@@ -717,7 +724,6 @@ fun PreviewDatabaseLayout() {
|
||||
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) },
|
||||
|
||||
@@ -18,9 +18,11 @@ object DatabaseUtils {
|
||||
|
||||
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
||||
private const val APP_PASSWORD_ALIAS: String = "appPassword"
|
||||
private const val SELF_DESTRUCT_PASSWORD_ALIAS: String = "selfDestructPassword"
|
||||
|
||||
val ksDatabasePassword = KeyStoreItem(DATABASE_PASSWORD_ALIAS, appPreferences.encryptedDBPassphrase, appPreferences.initializationVectorDBPassphrase)
|
||||
val ksAppPassword = KeyStoreItem(APP_PASSWORD_ALIAS, appPreferences.encryptedAppPassphrase, appPreferences.initializationVectorAppPassphrase)
|
||||
val ksSelfDestructPassword = KeyStoreItem(SELF_DESTRUCT_PASSWORD_ALIAS, appPreferences.encryptedSelfDestructPassphrase, appPreferences.initializationVectorSelfDestructPassphrase)
|
||||
|
||||
class KeyStoreItem(private val alias: String, val passphrase: SharedPreference<String?>, val initVector: SharedPreference<String?>) {
|
||||
fun get(): String? {
|
||||
|
||||
@@ -28,16 +28,18 @@ data class LocalAuthRequest (
|
||||
val title: String?,
|
||||
val reason: String,
|
||||
val password: String,
|
||||
val selfDestruct: Boolean,
|
||||
val completed: (LAResult) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "") { }
|
||||
val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "", selfDestruct = false) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean = false,
|
||||
activity: FragmentActivity,
|
||||
usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(),
|
||||
completed: (LAResult) -> Unit
|
||||
@@ -59,7 +61,7 @@ fun authenticate(
|
||||
completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password) {
|
||||
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && SimplexApp.context.chatModel.controller.appPrefs.selfDestruct.get()) {
|
||||
close()
|
||||
completed(it)
|
||||
})
|
||||
|
||||
@@ -78,8 +78,9 @@ class ModalManager {
|
||||
}
|
||||
|
||||
fun closeModals() {
|
||||
while (modalCount.value > 0) closeModal()
|
||||
passcodeView.value = null
|
||||
modalViews.clear()
|
||||
toRemove.clear()
|
||||
modalCount.value = 0
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
|
||||
@@ -274,6 +274,13 @@ object AudioPlayer {
|
||||
audioPlaying.value = false
|
||||
}
|
||||
|
||||
fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
|
||||
pro.value = ms
|
||||
if (this.currentlyPlaying.value?.first == filePath) {
|
||||
player.seekTo(ms)
|
||||
}
|
||||
}
|
||||
|
||||
fun duration(filePath: String): Int? {
|
||||
var res: Int? = null
|
||||
kotlin.runCatching {
|
||||
|
||||
@@ -23,9 +23,11 @@ import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.*
|
||||
@@ -33,6 +35,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.graphics.ColorUtils
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
@@ -587,6 +590,9 @@ fun Color.darker(factor: Float = 0.1f): Color =
|
||||
fun Color.lighter(factor: Float = 0.1f): Color =
|
||||
Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha)
|
||||
|
||||
fun Color.mixWith(color: Color, alpha: Float): Color =
|
||||
Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha))
|
||||
|
||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
package chat.simplex.app.views.localauth
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.database.deleteChatAsync
|
||||
import chat.simplex.app.views.database.stopChatAsync
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
|
||||
val passcode = rememberSaveable { mutableStateOf("") }
|
||||
PasscodeView(passcode, authRequest.title ?: stringResource(R.string.la_enter_app_passcode), authRequest.reason, stringResource(R.string.submit_passcode),
|
||||
submit = {
|
||||
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
|
||||
authRequest.completed(r)
|
||||
val sdPassword = ksSelfDestructPassword.get()
|
||||
if (sdPassword == passcode.value && authRequest.selfDestruct) {
|
||||
deleteStorageAndRestart(m, sdPassword) { r ->
|
||||
authRequest.completed(r)
|
||||
}
|
||||
} else {
|
||||
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
|
||||
authRequest.completed(r)
|
||||
}
|
||||
},
|
||||
cancel = {
|
||||
authRequest.completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
|
||||
})
|
||||
}
|
||||
|
||||
private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) {
|
||||
withBGApi {
|
||||
try {
|
||||
stopChatAsync(m)
|
||||
deleteChatAsync(m)
|
||||
ksAppPassword.set(password)
|
||||
ksSelfDestructPassword.remove()
|
||||
m.controller.ntfManager.cancelAllNotifications()
|
||||
val selfDestructPref = m.controller.appPrefs.selfDestruct
|
||||
val displayNamePref = m.controller.appPrefs.selfDestructDisplayName
|
||||
val displayName = displayNamePref.get()
|
||||
selfDestructPref.set(false)
|
||||
displayNamePref.set(null)
|
||||
m.chatDbChanged.value = true
|
||||
m.chatDbStatus.value = null
|
||||
try {
|
||||
SimplexApp.context.initChatController(startChat = true)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||
}
|
||||
m.chatDbChanged.value = false
|
||||
if (m.currentUser.value != null) {
|
||||
return@withBGApi
|
||||
}
|
||||
var profile: Profile? = null
|
||||
if (!displayName.isNullOrEmpty()) {
|
||||
profile = Profile(displayName = displayName, fullName = "")
|
||||
}
|
||||
val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true)
|
||||
m.currentUser.value = createdUser
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
|
||||
m.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
if (createdUser != null) {
|
||||
m.controller.startChat(createdUser)
|
||||
}
|
||||
ModalManager.shared.closeModals()
|
||||
AlertManager.shared.hideAlert()
|
||||
completed(LAResult.Success)
|
||||
} catch (e: Exception) {
|
||||
completed(LAResult.Error(generalGetString(R.string.incorrect_passcode)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun SetAppPasscodeView(
|
||||
passcodeKeychain: DatabaseUtils.KeyStoreItem = ksAppPassword,
|
||||
title: String = generalGetString(R.string.new_passcode),
|
||||
reason: String? = null,
|
||||
submit: () -> Unit,
|
||||
cancel: () -> Unit,
|
||||
close: () -> Unit
|
||||
@@ -23,7 +27,7 @@ fun SetAppPasscodeView(
|
||||
close()
|
||||
cancel()
|
||||
}
|
||||
PasscodeView(passcode, title = title, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) {
|
||||
PasscodeView(passcode, title = title, reason = reason, submitLabel = submitLabel, submitEnabled = submitEnabled, submit = submit) {
|
||||
close()
|
||||
cancel()
|
||||
}
|
||||
@@ -36,7 +40,7 @@ fun SetAppPasscodeView(
|
||||
submitEnabled = { pwd -> pwd == enteredPassword }
|
||||
) {
|
||||
if (passcode.value == enteredPassword) {
|
||||
ksAppPassword.set(passcode.value)
|
||||
passcodeKeychain.set(passcode.value)
|
||||
enteredPassword = ""
|
||||
passcode.value = ""
|
||||
close()
|
||||
@@ -44,7 +48,7 @@ fun SetAppPasscodeView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SetPasswordView(generalGetString(R.string.new_passcode), generalGetString(R.string.save_verb)) {
|
||||
SetPasswordView(title, generalGetString(R.string.save_verb)) {
|
||||
enteredPassword = passcode.value
|
||||
passcode.value = ""
|
||||
confirming = true
|
||||
|
||||
@@ -126,6 +126,30 @@ fun SharedPreferenceToggleWithIcon(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPreferenceToggleWithIcon(
|
||||
text: String,
|
||||
icon: Painter,
|
||||
onClickInfo: () -> Unit,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 4.dp))
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
Modifier.clickable(onClick = onClickInfo),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
DefaultSwitch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -6,9 +6,8 @@ import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -17,12 +16,18 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
|
||||
enum class LAMode {
|
||||
SYSTEM,
|
||||
@@ -111,6 +116,9 @@ fun SimplexLockView(
|
||||
val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay }
|
||||
val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } }
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val selfDestructPref = remember { chatModel.controller.appPrefs.selfDestruct }
|
||||
val selfDestructDisplayName = remember { mutableStateOf(chatModel.controller.appPrefs.selfDestructDisplayName.get() ?: "") }
|
||||
val selfDestructDisplayNamePref = remember { chatModel.controller.appPrefs.selfDestructDisplayName }
|
||||
|
||||
fun resetLAEnabled(onOff: Boolean) {
|
||||
chatModel.controller.appPrefs.performLA.set(onOff)
|
||||
@@ -123,6 +131,11 @@ fun SimplexLockView(
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
|
||||
fun resetSelfDestruct() {
|
||||
selfDestructPref.set(false)
|
||||
ksSelfDestructPassword.remove()
|
||||
}
|
||||
|
||||
fun toggleLAMode(toLAMode: LAMode) {
|
||||
authenticate(
|
||||
if (toLAMode == LAMode.SYSTEM) {
|
||||
@@ -130,7 +143,7 @@ fun SimplexLockView(
|
||||
} else {
|
||||
generalGetString(R.string.chat_lock)
|
||||
},
|
||||
generalGetString(R.string.change_lock_mode), activity
|
||||
generalGetString(R.string.change_lock_mode), activity = activity
|
||||
) { laResult ->
|
||||
when (laResult) {
|
||||
is LAResult.Error -> {
|
||||
@@ -140,16 +153,15 @@ fun SimplexLockView(
|
||||
LAResult.Success -> {
|
||||
when (toLAMode) {
|
||||
LAMode.SYSTEM -> {
|
||||
authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity, toLAMode) { laResult ->
|
||||
authenticate(generalGetString(R.string.auth_enable_simplex_lock), promptSubtitle = "", activity = activity, usingLAMode = toLAMode) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
currentLAMode.set(toLAMode)
|
||||
ksAppPassword.remove()
|
||||
resetSelfDestruct()
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Unavailable, is LAResult.Error -> {
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable, is LAResult.Error -> laFailedAlert()
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
}
|
||||
}
|
||||
@@ -164,7 +176,7 @@ fun SimplexLockView(
|
||||
passcodeAlert(generalGetString(R.string.passcode_set))
|
||||
},
|
||||
cancel = {},
|
||||
close
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -176,8 +188,27 @@ fun SimplexLockView(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelfDestruct(selfDestruct: SharedPreference<Boolean>) {
|
||||
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.change_self_destruct_mode), activity = activity) { laResult ->
|
||||
when (laResult) {
|
||||
is LAResult.Error -> laFailedAlert()
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
LAResult.Success -> {
|
||||
if (!selfDestruct.get()) {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
EnableSelfDestruct(selfDestruct, close)
|
||||
}
|
||||
} else {
|
||||
resetSelfDestruct()
|
||||
}
|
||||
}
|
||||
is LAResult.Unavailable -> disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeLAPassword() {
|
||||
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity) { laResult ->
|
||||
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.la_change_app_passcode), activity = activity) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
@@ -187,7 +218,32 @@ fun SimplexLockView(
|
||||
passcodeAlert(generalGetString(R.string.passcode_changed))
|
||||
}, cancel = {
|
||||
passcodeAlert(generalGetString(R.string.passcode_not_changed))
|
||||
}, close
|
||||
}, close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is LAResult.Error -> laFailedAlert()
|
||||
is LAResult.Failed -> {}
|
||||
is LAResult.Unavailable -> disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeSelfDestructPassword() {
|
||||
authenticate(generalGetString(R.string.la_current_app_passcode), generalGetString(R.string.change_self_destruct_passcode), activity = activity) { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
passcodeKeychain = ksSelfDestructPassword,
|
||||
submit = {
|
||||
selfDestructPasscodeAlert(generalGetString(R.string.self_destruct_passcode_changed))
|
||||
}, cancel = {
|
||||
passcodeAlert(generalGetString(R.string.passcode_not_changed))
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -223,7 +279,8 @@ fun SimplexLockView(
|
||||
},
|
||||
cancel = {
|
||||
resetLAEnabled(false)
|
||||
}, close
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -250,11 +307,85 @@ fun SimplexLockView(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (performLA.value && laMode.value == LAMode.PASSCODE) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(stringResource(R.string.self_destruct_passcode).uppercase()) {
|
||||
val openInfo = {
|
||||
ModalManager.shared.showModal {
|
||||
SelfDestructInfoView()
|
||||
}
|
||||
}
|
||||
SettingsActionItemWithContent(null, null, click = openInfo) {
|
||||
SharedPreferenceToggleWithIcon(
|
||||
stringResource(R.string.enable_self_destruct),
|
||||
painterResource(R.drawable.ic_info),
|
||||
openInfo,
|
||||
remember { selfDestructPref.state }.value
|
||||
) {
|
||||
toggleSelfDestruct(selfDestructPref)
|
||||
}
|
||||
}
|
||||
|
||||
if (remember { selfDestructPref.state }.value) {
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
Text(
|
||||
stringResource(R.string.self_destruct_new_display_name),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(selfDestructDisplayName, "", ::isValidDisplayName)
|
||||
LaunchedEffect(selfDestructDisplayName.value) {
|
||||
val new = selfDestructDisplayName.value
|
||||
if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) {
|
||||
selfDestructDisplayNamePref.set(new)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionItemView({ changeSelfDestructPassword() }) {
|
||||
Text(stringResource(R.string.change_self_destruct_passcode))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelfDestructInfoView() {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.self_destruct), withPadding = false)
|
||||
ReadableText(stringResource(R.string.if_you_enter_self_destruct_code))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
TextListItem("1.", stringResource(R.string.all_app_data_will_be_cleared))
|
||||
TextListItem("2.", stringResource(R.string.app_passcode_replaced_with_self_destruct))
|
||||
TextListItem("3.", stringResource(R.string.empty_chat_profile_is_created))
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnableSelfDestruct(
|
||||
selfDestruct: SharedPreference<Boolean>,
|
||||
close: () -> Unit
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
passcodeKeychain = ksSelfDestructPassword, title = generalGetString(R.string.set_passcode), reason = generalGetString(R.string.enabled_self_destruct_passcode),
|
||||
submit = {
|
||||
selfDestruct.set(true)
|
||||
selfDestructPasscodeAlert(generalGetString(R.string.self_destruct_passcode_enabled))
|
||||
},
|
||||
cancel = {},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnableLock(performLA: MutableState<Boolean>, onCheckedChange: (Boolean) -> Unit) {
|
||||
SectionItemView {
|
||||
@@ -300,6 +431,14 @@ private fun LockDelaySelector(state: State<Int>, onSelected: (Int) -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextListItem(n: String, text: String) {
|
||||
Box {
|
||||
Text(n)
|
||||
Text(text, Modifier.padding(start = 20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun laDelayText(t: Int): String {
|
||||
val m = t / 60
|
||||
val s = t % 60
|
||||
@@ -319,3 +458,7 @@ private fun passcodeAlert(title: String) {
|
||||
text = generalGetString(R.string.la_please_remember_to_store_password)
|
||||
)
|
||||
}
|
||||
|
||||
private fun selfDestructPasscodeAlert(title: String) {
|
||||
AlertManager.shared.showAlertMsg(title, generalGetString(R.string.if_you_enter_passcode_data_removed))
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ private fun runAuth(title: String, desc: String, context: Context, onFinish: (su
|
||||
authenticate(
|
||||
title,
|
||||
desc,
|
||||
context as FragmentActivity,
|
||||
activity = context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable)
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@
|
||||
<string name="lock_mode">Lock mode</string>
|
||||
<string name="lock_after">Lock after</string>
|
||||
<string name="submit_passcode">Submit</string>
|
||||
<string name="confirm_passcode">Confirm Passcode</string>
|
||||
<string name="confirm_passcode">Confirm passcode</string>
|
||||
<string name="incorrect_passcode">Incorrect passcode</string>
|
||||
<string name="new_passcode">New Passcode</string>
|
||||
<string name="authentication_cancelled">Authentication cancelled</string>
|
||||
@@ -818,6 +818,21 @@
|
||||
<string name="passcode_changed">Passcode changed!</string>
|
||||
<string name="passcode_not_changed">Passcode not changed!</string>
|
||||
<string name="change_lock_mode">Change lock mode</string>
|
||||
<string name="self_destruct">Self-destruct</string>
|
||||
<string name="enabled_self_destruct_passcode">Enable self-destruct passcode</string>
|
||||
<string name="change_self_destruct_mode">Change self-destruct mode</string>
|
||||
<string name="change_self_destruct_passcode">Change self-destruct passcode</string>
|
||||
<string name="self_destruct_passcode_enabled">Self-destruct passcode enabled!</string>
|
||||
<string name="self_destruct_passcode_changed">Self-destruct passcode changed!</string>
|
||||
<string name="self_destruct_passcode">Self-destruct passcode</string>
|
||||
<string name="enable_self_destruct">Enable self-destruct</string>
|
||||
<string name="self_destruct_new_display_name">New display name:</string>
|
||||
<string name="if_you_enter_self_destruct_code">If you enter your self-destruct passcode while opening the app:</string>
|
||||
<string name="all_app_data_will_be_cleared">All app data is deleted.</string>
|
||||
<string name="app_passcode_replaced_with_self_destruct">App passcode is replaced with self-destruct passcode.</string>
|
||||
<string name="empty_chat_profile_is_created">An empty chat profile with the provided name is created, and the app opens as usual.</string>
|
||||
<string name="if_you_enter_passcode_data_removed">If you enter this passcode when opening the app, all app data will be irreversibly removed!</string>
|
||||
<string name="set_passcode">Set passcode</string>
|
||||
|
||||
<!-- Settings sections -->
|
||||
<string name="settings_section_title_you">YOU</string>
|
||||
|
||||
@@ -125,8 +125,8 @@ func apiGetActiveUser() throws -> User? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateActiveUser(_ p: Profile?) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p))
|
||||
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp))
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
@@ -295,6 +295,12 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetChatItemInfo(itemId: Int64) async throws -> ChatItemInfo {
|
||||
let r = await chatSendCmd(.apiGetChatItemInfo(itemId: itemId))
|
||||
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
|
||||
|
||||
@@ -327,7 +327,11 @@ func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorSch
|
||||
}
|
||||
|
||||
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
||||
ci.chatDir.sent
|
||||
ciDirFrameColor(chatItemSent: ci.chatDir.sent, colorScheme: colorScheme)
|
||||
}
|
||||
|
||||
func ciDirFrameColor(chatItemSent: Bool, colorScheme: ColorScheme) -> Color {
|
||||
chatItemSent
|
||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
}
|
||||
|
||||
147
apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
Normal file
147
apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// ChatItemInfoView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 09.05.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemInfoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItemSent: Bool
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
var body: some View {
|
||||
if let chatItemInfo = chatItemInfo {
|
||||
NavigationView {
|
||||
itemInfoView(chatItemInfo)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showShareSheet(items: [itemInfoShareText(chatItemInfo)]) } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No message details")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemInfoView(_ chatItemInfo: ChatItemInfo) -> some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Message details")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(chatItemInfo.chatItemId)")
|
||||
}
|
||||
infoRow("Sent at", localTimestamp(chatItemInfo.itemTs))
|
||||
if !chatItemSent {
|
||||
infoRow("Received at", localTimestamp(chatItemInfo.createdAt))
|
||||
}
|
||||
|
||||
if !chatItemInfo.itemVersions.isEmpty {
|
||||
Divider()
|
||||
.padding(.top)
|
||||
|
||||
Text("Edit history")
|
||||
.font(.title)
|
||||
.padding(.bottom, 4)
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(Array(chatItemInfo.itemVersions.enumerated()), id: \.element.chatItemVersionId) { index, itemVersion in
|
||||
itemVersionView(itemVersion, maxWidth, current: index == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
let uiMenu: Binding<UIMenu> = Binding(
|
||||
get: { UIMenu(title: "", children: itemVersionMenu(itemVersion)) },
|
||||
set: { _ in }
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
messageText(itemVersion.msgContent.text, parseSimpleXMarkdown(itemVersion.msgContent.text), nil)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(ciDirFrameColor(chatItemSent: chatItemSent, colorScheme: colorScheme))
|
||||
.cornerRadius(18)
|
||||
.uiKitContextMenu(menu: uiMenu)
|
||||
Text(
|
||||
localTimestamp(itemVersion.itemVersionTs)
|
||||
+ (current
|
||||
? (" (" + NSLocalizedString("Current", comment: "designation of the current version of the message") + ")")
|
||||
: "")
|
||||
)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
func itemVersionMenu(_ itemVersion: ChatItemVersion) -> [UIAction] {[
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.up")
|
||||
) { _ in
|
||||
showShareSheet(items: [itemVersion.msgContent.text])
|
||||
},
|
||||
UIAction(
|
||||
title: NSLocalizedString("Copy", comment: "chat item action"),
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
UIPasteboard.general.string = itemVersion.msgContent.text
|
||||
}
|
||||
]}
|
||||
|
||||
func itemInfoShareText(_ chatItemInfo: ChatItemInfo) -> String {
|
||||
var shareText = ""
|
||||
let nl = "\n"
|
||||
shareText += "Message details" + nl + nl
|
||||
if developerTools {
|
||||
shareText += "Database ID: \(chatItemInfo.chatItemId)" + nl
|
||||
}
|
||||
shareText += "Sent at: \(localTimestamp(chatItemInfo.itemTs))" + nl
|
||||
if !chatItemSent {
|
||||
shareText += "Received at: \(localTimestamp(chatItemInfo.createdAt))" + nl
|
||||
}
|
||||
if !chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += nl + "Edit history" + nl + nl
|
||||
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
||||
shareText += localTimestamp(itemVersion.itemVersionTs) + (index == 0 ? " (Current)" : "") + ":" + nl
|
||||
shareText += itemVersion.msgContent.text + nl + nl
|
||||
}
|
||||
}
|
||||
return shareText.trimmingCharacters(in: .newlines)
|
||||
}
|
||||
}
|
||||
|
||||
func localTimestamp(_ date: Date) -> String {
|
||||
let localDateFormatter = DateFormatter()
|
||||
localDateFormatter.dateStyle = .medium
|
||||
localDateFormatter.timeStyle = .medium
|
||||
return localDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
struct ChatItemInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatItemInfoView(chatItemSent: true, chatItemInfo: Binding.constant(nil))
|
||||
}
|
||||
}
|
||||
@@ -445,6 +445,8 @@ struct ChatView: View {
|
||||
@Binding var showDeleteMessage: Bool
|
||||
|
||||
@State private var revealed = false
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
|
||||
var body: some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
@@ -467,6 +469,11 @@ struct ChatView: View {
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
|
||||
chatItemInfo = nil
|
||||
}) {
|
||||
ChatItemInfoView(chatItemSent: ci.chatDir.sent, chatItemInfo: $chatItemInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(live: Bool) -> [UIAction] {
|
||||
@@ -491,6 +498,7 @@ struct ChatView: View {
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
menu.append(editAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
@@ -589,6 +597,25 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func viewInfoUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("View details", comment: "chat item action"),
|
||||
image: UIImage(systemName: "info")
|
||||
) { _ in
|
||||
Task {
|
||||
do {
|
||||
let ciInfo = try await apiGetChatItemInfo(itemId: ci.id)
|
||||
await MainActor.run {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
}
|
||||
await MainActor.run { showChatItemInfoSheet = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction {
|
||||
return UIAction(
|
||||
title: cancelAction.uiAction,
|
||||
|
||||
@@ -104,31 +104,33 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
if textView.markedTextRange == nil {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
}
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
|
||||
@@ -58,7 +58,7 @@ struct LocalAuthView: View {
|
||||
if let displayName = displayName, displayName != "" {
|
||||
profile = Profile(displayName: displayName, fullName: "")
|
||||
}
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
m.currentUser = try apiCreateActiveUser(profile, pastTimestamp: true)
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
try startChat()
|
||||
|
||||
@@ -120,8 +120,8 @@ struct SimplexLockView: View {
|
||||
case laUnavailableTurningOffAlert
|
||||
case laPasscodeSetAlert
|
||||
case laPasscodeChangedAlert
|
||||
case laSeldDestructPasscodeSetAlert
|
||||
case laSeldDestructPasscodeChangedAlert
|
||||
case laSelfDestructPasscodeSetAlert
|
||||
case laSelfDestructPasscodeChangedAlert
|
||||
case laPasscodeNotChangedAlert
|
||||
|
||||
var id: Self { self }
|
||||
@@ -238,8 +238,8 @@ struct SimplexLockView: View {
|
||||
case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert()
|
||||
case .laPasscodeSetAlert: return passcodeAlert("Passcode set!")
|
||||
case .laPasscodeChangedAlert: return passcodeAlert("Passcode changed!")
|
||||
case .laSeldDestructPasscodeSetAlert: return selfDestructPasscodeAlert("Self-destruct passcode enabled!")
|
||||
case .laSeldDestructPasscodeChangedAlert: return selfDestructPasscodeAlert("Self-destruct passcode changed!")
|
||||
case .laSelfDestructPasscodeSetAlert: return selfDestructPasscodeAlert("Self-destruct passcode enabled!")
|
||||
case .laSelfDestructPasscodeChangedAlert: return selfDestructPasscodeAlert("Self-destruct passcode changed!")
|
||||
case .laPasscodeNotChangedAlert: return mkAlert(title: "Passcode not changed!")
|
||||
}
|
||||
}
|
||||
@@ -272,13 +272,13 @@ struct SimplexLockView: View {
|
||||
case .enableSelfDestruct:
|
||||
SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, title: "Set passcode", reason: NSLocalizedString("Enable self-destruct passcode", comment: "set passcode view")) {
|
||||
updateSelfDestruct()
|
||||
showLAAlert(.laSeldDestructPasscodeSetAlert)
|
||||
showLAAlert(.laSelfDestructPasscodeSetAlert)
|
||||
} cancel: {
|
||||
revertSelfDestruct()
|
||||
}
|
||||
case .changeSelfDestructPasscode:
|
||||
SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, reason: NSLocalizedString("Change self-destruct passcode", comment: "set passcode view")) {
|
||||
showLAAlert(.laSeldDestructPasscodeChangedAlert)
|
||||
showLAAlert(.laSelfDestructPasscodeChangedAlert)
|
||||
} cancel: {
|
||||
showLAAlert(.laPasscodeNotChangedAlert)
|
||||
}
|
||||
@@ -296,17 +296,17 @@ struct SimplexLockView: View {
|
||||
|
||||
private func selfDestructInfoView() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Self-desctruct")
|
||||
Text("Self-destruct")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("If you enter your self-desctruct passcode while opening the app:")
|
||||
Text("If you enter your self-destruct passcode while opening the app:")
|
||||
VStack(spacing: 8) {
|
||||
textListItem("1.", "All app data is deleted.")
|
||||
textListItem("2.", "App passcode is replaced with self-desctruct passcode.")
|
||||
textListItem("2.", "App passcode is replaced with self-destruct passcode.")
|
||||
textListItem("3.", "An empty chat profile with the provided name is created, and the app opens as usual.")
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,7 @@ struct SimplexLockView: View {
|
||||
switch laResult {
|
||||
case .success:
|
||||
_ = kcAppPassword.remove()
|
||||
resetSelfDestruct()
|
||||
laAlert = .laTurnedOnAlert
|
||||
case .failed, .unavailable:
|
||||
currentLAMode = .passcode
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
|
||||
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
|
||||
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
|
||||
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
|
||||
@@ -442,6 +443,7 @@
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
|
||||
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
|
||||
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
|
||||
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
|
||||
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
|
||||
@@ -548,6 +550,7 @@
|
||||
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
|
||||
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */,
|
||||
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
|
||||
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
@@ -1058,6 +1061,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||
|
||||
@@ -14,7 +14,7 @@ let jsonEncoder = getJSONEncoder()
|
||||
|
||||
public enum ChatCommand {
|
||||
case showActiveUser
|
||||
case createActiveUser(profile: Profile?)
|
||||
case createActiveUser(profile: Profile?, sameServers: Bool, pastTimestamp: Bool)
|
||||
case listUsers
|
||||
case apiSetActiveUser(userId: Int64, viewPwd: String?)
|
||||
case apiHideUser(userId: Int64, viewPwd: String)
|
||||
@@ -36,6 +36,7 @@ public enum ChatCommand {
|
||||
case apiStorageEncryption(config: DBEncryptionConfig)
|
||||
case apiGetChats(userId: Int64)
|
||||
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
|
||||
case apiGetChatItemInfo(itemId: Int64)
|
||||
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool)
|
||||
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
|
||||
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
|
||||
@@ -110,11 +111,9 @@ public enum ChatCommand {
|
||||
get {
|
||||
switch self {
|
||||
case .showActiveUser: return "/u"
|
||||
case let .createActiveUser(profile):
|
||||
if let profile = profile {
|
||||
return "/create user \(profile.displayName) \(profile.fullName)"
|
||||
}
|
||||
return "/create user"
|
||||
case let .createActiveUser(profile, sameServers, pastTimestamp):
|
||||
let user = NewUser(profile: profile, sameServers: sameServers, pastTimestamp: pastTimestamp)
|
||||
return "/_create user \(encodeJSON(user))"
|
||||
case .listUsers: return "/users"
|
||||
case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))"
|
||||
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
|
||||
@@ -141,6 +140,7 @@ public enum ChatCommand {
|
||||
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
|
||||
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
|
||||
(search == "" ? "" : " search=\(search)")
|
||||
case let .apiGetChatItemInfo(itemId): return "/_get item info \(itemId)"
|
||||
case let .apiSendMessage(type, id, file, quotedItemId, mc, live):
|
||||
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
|
||||
return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)"
|
||||
@@ -247,6 +247,7 @@ public enum ChatCommand {
|
||||
case .apiStorageEncryption: return "apiStorageEncryption"
|
||||
case .apiGetChats: return "apiGetChats"
|
||||
case .apiGetChat: return "apiGetChat"
|
||||
case .apiGetChatItemInfo: return "apiGetChatItemInfo"
|
||||
case .apiSendMessage: return "apiSendMessage"
|
||||
case .apiUpdateChatItem: return "apiUpdateChatItem"
|
||||
case .apiDeleteChatItem: return "apiDeleteChatItem"
|
||||
@@ -385,6 +386,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatSuspended
|
||||
case apiChats(user: User, chats: [ChatData])
|
||||
case apiChat(user: User, chat: ChatData)
|
||||
case chatItemInfo(user: User, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case userProtoServers(user: User, servers: UserProtoServers)
|
||||
case serverTestResult(user: User, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
case chatItemTTL(user: User, chatItemTTL: Int64?)
|
||||
@@ -501,6 +503,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatSuspended: return "chatSuspended"
|
||||
case .apiChats: return "apiChats"
|
||||
case .apiChat: return "apiChat"
|
||||
case .chatItemInfo: return "chatItemInfo"
|
||||
case .userProtoServers: return "userProtoServers"
|
||||
case .serverTestResult: return "serverTestResult"
|
||||
case .chatItemTTL: return "chatItemTTL"
|
||||
@@ -616,6 +619,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatSuspended: return noDetails
|
||||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
||||
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
|
||||
@@ -729,6 +733,12 @@ public enum ChatResponse: Decodable, Error {
|
||||
}
|
||||
}
|
||||
|
||||
struct NewUser: Encodable {
|
||||
var profile: Profile?
|
||||
var sameServers: Bool
|
||||
var pastTimestamp: Bool
|
||||
}
|
||||
|
||||
public enum ChatPagination {
|
||||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
|
||||
@@ -2857,3 +2857,18 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
|
||||
return lhs.comparisonValue < rhs.comparisonValue
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatItemInfo: Decodable {
|
||||
public var chatItemId: Int64
|
||||
public var itemTs: Date
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
public var itemVersions: [ChatItemVersion]
|
||||
}
|
||||
|
||||
public struct ChatItemVersion: Decodable {
|
||||
public var chatItemVersionId: Int64
|
||||
public var msgContent: MsgContent
|
||||
public var itemVersionTs: Date
|
||||
public var createdAt: Date
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 8954f39425d971025e5e3df1a1628281dab61a3c
|
||||
tag: ce64c91d5a6e4cbc6cfae6b4a5b312224931f10c
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
25
docs/THEMES.md
Normal file
25
docs/THEMES.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# App color themes
|
||||
|
||||
Mobile apps allow to configure, export and import color themes. Currently this is only supported in Android app.
|
||||
|
||||
You can contribute your themes to the repository by creating a pull request.
|
||||
|
||||
## How to contribute a theme
|
||||
|
||||
1. Once you have configured your theme in the app, export it to a file and give it a descriptive name – e.g., `example.theme`
|
||||
|
||||
2. Export your app database, and import a [sample chat database](./themes/simplex-chat.sample.zip).
|
||||
|
||||
3. Make three screenshots - the list of conversations with opened profile picker, conversation and privacy settings.
|
||||
|
||||
4. Create PR that includes these files and amends this THEMES.md file, following the example below.
|
||||
|
||||
5. Restore your database from the backup.
|
||||
|
||||
## Color themes
|
||||
|
||||
### SimpleX - included dark blue theme
|
||||
|
||||
Download [SimpleX theme](./themes/example.theme)
|
||||
|
||||
<img src="./themes/example-chats.png" width="240"> <img src="./themes/example-conversation.png" width="240"> <img src="./themes/example-settings.png" width="240">
|
||||
BIN
docs/themes/example-chats.png
vendored
Normal file
BIN
docs/themes/example-chats.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
BIN
docs/themes/example-conversation.png
vendored
Normal file
BIN
docs/themes/example-conversation.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/themes/example-settings.png
vendored
Normal file
BIN
docs/themes/example-settings.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
11
docs/themes/example.theme
vendored
Normal file
11
docs/themes/example.theme
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
base: "SIMPLEX"
|
||||
colors:
|
||||
accent: "#ff70f0f9"
|
||||
accentVariant: "#ff1298a5"
|
||||
secondary: "#ff8b8786"
|
||||
secondaryVariant: "#ff2c464d"
|
||||
background: "#ff111528"
|
||||
menus: "#ff121c37"
|
||||
title: "#ff267be5"
|
||||
sentMessage: "#1e45b8ff"
|
||||
receivedMessage: "#20b1b0b5"
|
||||
BIN
docs/themes/simplex-chat.sample.zip
vendored
Normal file
BIN
docs/themes/simplex-chat.sample.zip
vendored
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."8954f39425d971025e5e3df1a1628281dab61a3c" = "0m670s43m1ym51firdzxj77k49bi8qq5gwxc7w50nv8r2yl62ckd";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."ce64c91d5a6e4cbc6cfae6b4a5b312224931f10c" = "0iz4hyh1s469vd9nry7wbxbqhc7p1z3qvlyawfayzna1jm0c1mcq";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
|
||||
|
||||
@@ -215,23 +215,25 @@ cfgServers = \case
|
||||
startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ())
|
||||
startChatController subConns enableExpireCIs startXFTPWorkers = do
|
||||
asks smpAgent >>= resumeAgentClient
|
||||
users <- fromRight [] <$> runExceptT (withStore' getUsers)
|
||||
restoreCalls
|
||||
users <- timeItToView "startChatController, getUsers" $ fromRight [] <$> runExceptT (withStore' getUsers)
|
||||
timeItToView "startChatController, restoreCalls" $ restoreCalls
|
||||
s <- asks agentAsync
|
||||
readTVarIO s >>= maybe (start s users) (pure . fst)
|
||||
where
|
||||
start s users = do
|
||||
a1 <- async $ race_ notificationSubscriber agentSubscriber
|
||||
a1 <- timeItToView "startChatController, a1" $ async $ race_ notificationSubscriber agentSubscriber
|
||||
a2 <-
|
||||
if subConns
|
||||
then Just <$> async (subscribeUsers users)
|
||||
else pure Nothing
|
||||
timeItToView "startChatController, a2" $
|
||||
if subConns
|
||||
then Just <$> async (subscribeUsers users)
|
||||
else pure Nothing
|
||||
atomically . writeTVar s $ Just (a1, a2)
|
||||
when startXFTPWorkers $ do
|
||||
startXFTP
|
||||
void $ forkIO $ startFilesToReceive users
|
||||
startCleanupManager
|
||||
when enableExpireCIs $ startExpireCIs users
|
||||
timeItToView "startChatController, startXFTP" $ startXFTP
|
||||
timeItToView "startChatController, forkIO startFilesToReceive" $ void $ forkIO $ startFilesToReceive users
|
||||
timeItToView "startChatController, startCleanupManager" $ startCleanupManager
|
||||
when enableExpireCIs $
|
||||
timeItToView "startChatController, startExpireCIs" $ startExpireCIs users
|
||||
pure a1
|
||||
startXFTP = do
|
||||
tmp <- readTVarIO =<< asks tempDirectory
|
||||
@@ -311,7 +313,7 @@ execChatCommand s = do
|
||||
parseChatCommand :: ByteString -> Either String ChatCommand
|
||||
parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace
|
||||
|
||||
toView :: ChatMonad m => ChatResponse -> m ()
|
||||
toView :: ChatMonad' m => ChatResponse -> m ()
|
||||
toView event = do
|
||||
q <- asks outputQ
|
||||
atomically $ writeTBQueue q (Nothing, event)
|
||||
@@ -354,7 +356,7 @@ processChatCommand = \case
|
||||
storeServers user servers =
|
||||
unless (null servers) $
|
||||
withStore $ \db -> overwriteProtocolServers db user servers
|
||||
coupleDaysAgo t = (`addUTCTime` t) . fromInteger . (+ (2 * day)) <$> randomRIO (0, day)
|
||||
coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day)
|
||||
day = 86400
|
||||
ListUsers -> CRUsersList <$> withStore' getUsersInfo
|
||||
APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do
|
||||
@@ -470,7 +472,7 @@ processChatCommand = \case
|
||||
let CIMeta {itemTs, createdAt, updatedAt} = meta
|
||||
ciInfo = ChatItemInfo {chatItemId = itemId, itemTs, createdAt, updatedAt, itemVersions}
|
||||
pure $ CRChatItemInfo user chatItem ciInfo
|
||||
APISendMessage (ChatRef cType chatId) live (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
|
||||
APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
|
||||
CTDirect -> do
|
||||
ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId
|
||||
assertDirectAllowed user MDSnd ct XMsgNew_
|
||||
@@ -479,7 +481,7 @@ processChatCommand = \case
|
||||
then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice))
|
||||
else do
|
||||
(fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct
|
||||
timed_ <- sndContactCITimed live ct
|
||||
timed_ <- sndContactCITimed live ct itemTTL
|
||||
(msgContainer, quotedItem_) <- prepareMsg fInv_ timed_
|
||||
(msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer)
|
||||
case ft_ of
|
||||
@@ -539,7 +541,7 @@ processChatCommand = \case
|
||||
then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFVoice))
|
||||
else do
|
||||
(fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms)
|
||||
timed_ <- sndGroupCITimed live gInfo
|
||||
timed_ <- sndGroupCITimed live gInfo itemTTL
|
||||
(msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership
|
||||
msg@SndMessage {sharedMsgId} <- sendGroupMessage user gInfo ms (XMsgNew msgContainer)
|
||||
mapM_ (sendGroupFileInline ms sharedMsgId) ft_
|
||||
@@ -651,14 +653,19 @@ processChatCommand = \case
|
||||
CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do
|
||||
case (ciContent, itemSharedMsgId) of
|
||||
(CISndMsgContent oldMC, Just itemSharedMId) -> do
|
||||
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
|
||||
ci' <- withStore' $ \db -> do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
|
||||
updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId
|
||||
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
|
||||
setActive $ ActiveC c
|
||||
pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci')
|
||||
let changed = mc /= oldMC
|
||||
if changed || fromMaybe False itemLive
|
||||
then do
|
||||
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
|
||||
ci' <- withStore' $ \db -> do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
when changed $
|
||||
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
|
||||
updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId
|
||||
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
|
||||
setActive $ ActiveC c
|
||||
pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci')
|
||||
else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci)
|
||||
_ -> throwChatError CEInvalidChatItemUpdate
|
||||
CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
|
||||
CTGroup -> do
|
||||
@@ -669,14 +676,19 @@ processChatCommand = \case
|
||||
CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do
|
||||
case (ciContent, itemSharedMsgId) of
|
||||
(CISndMsgContent oldMC, Just itemSharedMId) -> do
|
||||
SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
|
||||
ci' <- withStore' $ \db -> do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
|
||||
updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId
|
||||
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
|
||||
setActive $ ActiveG gName
|
||||
pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci')
|
||||
let changed = mc /= oldMC
|
||||
if changed || fromMaybe False itemLive
|
||||
then do
|
||||
SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
|
||||
ci' <- withStore' $ \db -> do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
when changed $
|
||||
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
|
||||
updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId
|
||||
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
|
||||
setActive $ ActiveG gName
|
||||
pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci')
|
||||
else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
|
||||
_ -> throwChatError CEInvalidChatItemUpdate
|
||||
CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
|
||||
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
|
||||
@@ -1199,7 +1211,7 @@ processChatCommand = \case
|
||||
contactId <- withStore $ \db -> getContactIdByName db user cName
|
||||
quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg
|
||||
let mc = MCText msg
|
||||
processChatCommand . APISendMessage (ChatRef CTDirect contactId) False $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||
processChatCommand . APISendMessage (ChatRef CTDirect contactId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||
DeleteMessage chatName deletedMsg -> withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg
|
||||
@@ -1397,7 +1409,7 @@ processChatCommand = \case
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
quotedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId cName quotedMsg
|
||||
let mc = MCText msg
|
||||
processChatCommand . APISendMessage (ChatRef CTGroup groupId) False $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||
processChatCommand . APISendMessage (ChatRef CTGroup groupId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc
|
||||
LastChats count_ -> withUser' $ \user -> do
|
||||
chats <- withStore' $ \db -> getChatPreviews db user False
|
||||
pure $ CRChats $ maybe id take count_ chats
|
||||
@@ -1429,7 +1441,7 @@ processChatCommand = \case
|
||||
asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_
|
||||
SendFile chatName f -> withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCFile "")
|
||||
processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "")
|
||||
SendImage chatName f -> withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
filePath <- toFSFilePath f
|
||||
@@ -1437,7 +1449,7 @@ processChatCommand = \case
|
||||
fileSize <- getFileSize filePath
|
||||
unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath}
|
||||
-- TODO include file description for preview
|
||||
processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview)
|
||||
processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview)
|
||||
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
|
||||
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
|
||||
SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO"
|
||||
@@ -1777,17 +1789,18 @@ processChatCommand = \case
|
||||
sendTextMessage chatName msg live = withUser $ \user -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
let mc = MCText msg
|
||||
processChatCommand . APISendMessage chatRef live $ ComposedMessage Nothing Nothing mc
|
||||
sndContactCITimed :: Bool -> Contact -> m (Maybe CITimed)
|
||||
sndContactCITimed live = mapM (sndCITimed_ live) . contactTimedTTL
|
||||
sndGroupCITimed :: Bool -> GroupInfo -> m (Maybe CITimed)
|
||||
sndGroupCITimed live = mapM (sndCITimed_ live) . groupTimedTTL
|
||||
sndCITimed_ :: Bool -> Int -> m CITimed
|
||||
sndCITimed_ live ttl =
|
||||
CITimed ttl
|
||||
<$> if live
|
||||
then pure Nothing
|
||||
else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime
|
||||
processChatCommand . APISendMessage chatRef live Nothing $ ComposedMessage Nothing Nothing mc
|
||||
sndContactCITimed :: Bool -> Contact -> Maybe Int -> m (Maybe CITimed)
|
||||
sndContactCITimed live = sndCITimed_ live . contactTimedTTL
|
||||
sndGroupCITimed :: Bool -> GroupInfo -> Maybe Int -> m (Maybe CITimed)
|
||||
sndGroupCITimed live = sndCITimed_ live . groupTimedTTL
|
||||
sndCITimed_ :: Bool -> Maybe (Maybe Int) -> Maybe Int -> m (Maybe CITimed)
|
||||
sndCITimed_ live chatTTL itemTTL =
|
||||
forM (chatTTL >>= (itemTTL <|>)) $ \ttl ->
|
||||
CITimed ttl
|
||||
<$> if live
|
||||
then pure Nothing
|
||||
else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime
|
||||
drgRandomBytes :: Int -> m ByteString
|
||||
drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n)
|
||||
privateGetUser :: UserId -> m User
|
||||
@@ -3324,12 +3337,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
updateRcvChatItem = do
|
||||
cci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId
|
||||
case cci of
|
||||
CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent oldMC} -> do
|
||||
ci' <- withStore' $ \db -> do
|
||||
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
|
||||
updateDirectChatItem' db user contactId ci content live $ Just msgId
|
||||
toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci')
|
||||
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
|
||||
CChatItem SMDRcv ci@ChatItem {meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> do
|
||||
let changed = mc /= oldMC
|
||||
if changed || fromMaybe False itemLive
|
||||
then do
|
||||
ci' <- withStore' $ \db -> do
|
||||
when changed $
|
||||
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
|
||||
updateDirectChatItem' db user contactId ci content live $ Just msgId
|
||||
toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci')
|
||||
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
|
||||
else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
|
||||
_ -> messageError "x.msg.update: contact attempted invalid message update"
|
||||
|
||||
messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m ()
|
||||
@@ -3393,16 +3411,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
updateRcvChatItem = do
|
||||
cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId
|
||||
case cci of
|
||||
CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', content = CIRcvMsgContent oldMC} -> do
|
||||
CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} ->
|
||||
if sameMemberId memberId m'
|
||||
then do
|
||||
ci' <- withStore' $ \db -> do
|
||||
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
|
||||
updateGroupChatItem db user groupId ci content live $ Just msgId
|
||||
toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci')
|
||||
setActive $ ActiveG g
|
||||
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
|
||||
else messageError "x.msg.update: group member attempted to update a message of another member" -- shouldn't happen now that query includes group member id
|
||||
let changed = mc /= oldMC
|
||||
if changed || fromMaybe False itemLive
|
||||
then do
|
||||
ci' <- withStore' $ \db -> do
|
||||
when changed $
|
||||
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
|
||||
updateGroupChatItem db user groupId ci content live $ Just msgId
|
||||
toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci')
|
||||
setActive $ ActiveG g
|
||||
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
|
||||
else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci)
|
||||
else messageError "x.msg.update: group member attempted to update a message of another member"
|
||||
_ -> messageError "x.msg.update: group member attempted invalid message update"
|
||||
|
||||
groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> m ()
|
||||
@@ -4629,7 +4652,7 @@ chatCommandP =
|
||||
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
|
||||
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
|
||||
"/_get item info " *> (APIGetChatItemInfo <$> A.decimal),
|
||||
"/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
|
||||
"/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
|
||||
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
|
||||
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
|
||||
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal),
|
||||
@@ -4823,6 +4846,7 @@ chatCommandP =
|
||||
quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space
|
||||
refChar c = c > ' ' && c /= '#' && c /= '@'
|
||||
liveMessageP = " live=" *> onOffP <|> pure False
|
||||
sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing
|
||||
onOffP = ("on" $> True) <|> ("off" $> False)
|
||||
profileNames = (,) <$> displayName <*> fullNameP
|
||||
newUserP = do
|
||||
@@ -4902,3 +4926,15 @@ chatCommandP =
|
||||
adminContactReq :: ConnReqContact
|
||||
adminContactReq =
|
||||
either error id $ strDecode "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
|
||||
timeItToView :: ChatMonad' m => String -> m a -> m a
|
||||
timeItToView s action = do
|
||||
t1 <- liftIO getCurrentTime
|
||||
a <- action
|
||||
t2 <- liftIO getCurrentTime
|
||||
let diff = diffInMillis t2 t1
|
||||
toView $ CRTimedAction s diff
|
||||
pure a
|
||||
|
||||
diffInMillis :: UTCTime -> UTCTime -> Int64
|
||||
diffInMillis a b = (`div` 1000000000) $ diffInPicos a b
|
||||
|
||||
@@ -214,7 +214,7 @@ data ChatCommand
|
||||
| APIGetChat ChatRef ChatPagination (Maybe String)
|
||||
| APIGetChatItems ChatPagination (Maybe String)
|
||||
| APIGetChatItemInfo ChatItemId
|
||||
| APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, composedMessage :: ComposedMessage}
|
||||
| APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage}
|
||||
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
|
||||
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
|
||||
| APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId
|
||||
@@ -397,6 +397,7 @@ data ChatResponse
|
||||
| CRNewChatItem {user :: User, chatItem :: AChatItem}
|
||||
| CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem}
|
||||
| CRChatItemUpdated {user :: User, chatItem :: AChatItem}
|
||||
| CRChatItemNotChanged {user :: User, chatItem :: AChatItem}
|
||||
| CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool}
|
||||
| CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId}
|
||||
| CRBroadcastSent User MsgContent Int ZonedTime
|
||||
@@ -519,6 +520,7 @@ data ChatResponse
|
||||
| CRMessageError {user :: User, severity :: Text, errorMessage :: Text}
|
||||
| CRChatCmdError {user_ :: Maybe User, chatError :: ChatError}
|
||||
| CRChatError {user_ :: Maybe User, chatError :: ChatError}
|
||||
| CRTimedAction {action :: String, durationMilliseconds :: Int64}
|
||||
deriving (Show, Generic)
|
||||
|
||||
logResponseToFile :: ChatResponse -> Bool
|
||||
|
||||
@@ -344,7 +344,7 @@ instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOpt
|
||||
|
||||
data CITimed = CITimed
|
||||
{ ttl :: Int, -- seconds
|
||||
deleteAt :: Maybe UTCTime
|
||||
deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
@@ -353,16 +353,16 @@ instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
ttl' :: CITimed -> Int
|
||||
ttl' CITimed {ttl} = ttl
|
||||
|
||||
contactTimedTTL :: Contact -> Maybe Int
|
||||
contactTimedTTL :: Contact -> Maybe (Maybe Int)
|
||||
contactTimedTTL Contact {mergedPreferences = ContactUserPreferences {timedMessages = ContactUserPreference {enabled, userPreference}}}
|
||||
| forUser enabled && forContact enabled = ttl
|
||||
| forUser enabled && forContact enabled = Just ttl
|
||||
| otherwise = Nothing
|
||||
where
|
||||
TimedMessagesPreference {ttl} = preference (userPreference :: ContactUserPref TimedMessagesPreference)
|
||||
|
||||
groupTimedTTL :: GroupInfo -> Maybe Int
|
||||
groupTimedTTL :: GroupInfo -> Maybe (Maybe Int)
|
||||
groupTimedTTL GroupInfo {fullGroupPreferences = FullGroupPreferences {timedMessages = TimedMessagesGroupPreference {enable, ttl}}}
|
||||
| enable == FEOn = Just ttl
|
||||
| enable == FEOn = Just $ Just ttl
|
||||
| otherwise = Nothing
|
||||
|
||||
rcvContactCITimed :: Contact -> Maybe Int -> Maybe CITimed
|
||||
@@ -371,7 +371,7 @@ rcvContactCITimed = rcvCITimed_ . contactTimedTTL
|
||||
rcvGroupCITimed :: GroupInfo -> Maybe Int -> Maybe CITimed
|
||||
rcvGroupCITimed = rcvCITimed_ . groupTimedTTL
|
||||
|
||||
rcvCITimed_ :: Maybe Int -> Maybe Int -> Maybe CITimed
|
||||
rcvCITimed_ :: Maybe (Maybe Int) -> Maybe Int -> Maybe CITimed
|
||||
rcvCITimed_ chatTTL itemTTL = (`CITimed` Nothing) <$> (chatTTL >> itemTTL)
|
||||
|
||||
data CIQuote (c :: ChatType) = CIQuote
|
||||
|
||||
@@ -89,6 +89,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case
|
||||
CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId]
|
||||
CRChatItemStatusUpdated u _ -> ttyUser u []
|
||||
CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts
|
||||
CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci
|
||||
CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView
|
||||
CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"]
|
||||
CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t
|
||||
@@ -246,6 +247,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case
|
||||
CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning]
|
||||
CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e
|
||||
CRChatError u e -> ttyUser' u $ viewChatError logLevel e
|
||||
CRTimedAction _ _ -> []
|
||||
where
|
||||
ttyUser :: User -> [StyledString] -> [StyledString]
|
||||
ttyUser user@User {showNtfs, activeUser} ss
|
||||
@@ -477,6 +479,11 @@ hideLive :: CIMeta с d -> [StyledString] -> [StyledString]
|
||||
hideLive CIMeta {itemLive = Just True} _ = []
|
||||
hideLive _ s = s
|
||||
|
||||
viewItemNotChanged :: AChatItem -> [StyledString]
|
||||
viewItemNotChanged (AChatItem _ msgDir _ _) = case msgDir of
|
||||
SMDSnd -> ["message didn't change"]
|
||||
SMDRcv -> []
|
||||
|
||||
viewItemDelete :: ChatInfo c -> ChatItem c d -> Maybe AChatItem -> Bool -> Bool -> CurrentTime -> Bool -> [StyledString]
|
||||
viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} toItem byUser timed ts testView
|
||||
| timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView]
|
||||
|
||||
@@ -49,7 +49,7 @@ extra-deps:
|
||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||
# - ../simplexmq
|
||||
- github: simplex-chat/simplexmq
|
||||
commit: 8954f39425d971025e5e3df1a1628281dab61a3c
|
||||
commit: ce64c91d5a6e4cbc6cfae6b4a5b312224931f10c
|
||||
- github: kazu-yamamoto/http2
|
||||
commit: b5a1b7200cf5bc7044af34ba325284271f6dff25
|
||||
# - ../direct-sqlcipher
|
||||
|
||||
@@ -34,6 +34,7 @@ chatDirectTests = do
|
||||
it "direct message edit history" testDirectMessageEditHistory
|
||||
it "direct message delete" testDirectMessageDelete
|
||||
it "direct live message" testDirectLiveMessage
|
||||
it "direct timed message" testDirectTimedMessage
|
||||
it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact
|
||||
it "should send multiline message" testMultilineMessage
|
||||
describe "SMP servers" $ do
|
||||
@@ -228,6 +229,9 @@ testDirectMessageUpdate =
|
||||
alice #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((1, "hello 🙂"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))])
|
||||
bob #$> ("/_get chat @2 count=100", chat', chatFeatures' <> [((0, "hello 🙂"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))])
|
||||
|
||||
alice ##> ("/_update item @2 " <> itemId 1 <> " text hello 🙂")
|
||||
alice <## "message didn't change"
|
||||
|
||||
alice ##> ("/_update item @2 " <> itemId 1 <> " text hey 👋")
|
||||
alice <# "@bob [edited] hey 👋"
|
||||
bob <# "alice> [edited] hey 👋"
|
||||
@@ -440,6 +444,32 @@ testDirectLiveMessage =
|
||||
bob .<## ": hello 2"
|
||||
bob .<## ":"
|
||||
|
||||
testDirectTimedMessage :: HasCallStack => FilePath -> IO ()
|
||||
testDirectTimedMessage =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
connectUsers alice bob
|
||||
|
||||
alice ##> "/_send @2 ttl=1 text hello!"
|
||||
alice <# "@bob hello!"
|
||||
bob <# "alice> hello!"
|
||||
alice <## "timed message deleted: hello!"
|
||||
bob <## "timed message deleted: hello!"
|
||||
|
||||
alice ##> "/_send @2 live=off ttl=1 text hey"
|
||||
alice <# "@bob hey"
|
||||
bob <# "alice> hey"
|
||||
alice <## "timed message deleted: hey"
|
||||
bob <## "timed message deleted: hey"
|
||||
|
||||
alice ##> "/_send @2 ttl=default text hello"
|
||||
alice <# "@bob hello"
|
||||
bob <# "alice> hello"
|
||||
|
||||
alice ##> "/_send @2 live=off text hi"
|
||||
alice <# "@bob hi"
|
||||
bob <# "alice> hi"
|
||||
|
||||
testRepeatAuthErrorsDisableContact :: HasCallStack => FilePath -> IO ()
|
||||
testRepeatAuthErrorsDisableContact =
|
||||
testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
|
||||
@@ -820,6 +820,9 @@ testGroupMessageUpdate =
|
||||
(cath <# "#team alice> hello!")
|
||||
|
||||
msgItemId1 <- lastItemId alice
|
||||
alice ##> ("/_update item #1 " <> msgItemId1 <> " text hello!")
|
||||
alice <## "message didn't change"
|
||||
|
||||
alice ##> ("/_update item #1 " <> msgItemId1 <> " text hey 👋")
|
||||
alice <# "#team [edited] hey 👋"
|
||||
concurrently_
|
||||
@@ -1044,6 +1047,7 @@ testGroupLiveMessage =
|
||||
bob <# "#team alice> [LIVE ended] hello there"
|
||||
cath <# "#team alice> [LIVE ended] hello there"
|
||||
-- empty live message is also sent instantly
|
||||
threadDelay 1000000
|
||||
alice `send` "/live #team"
|
||||
msgItemId2 <- lastItemId alice
|
||||
bob <#. "#team alice> [LIVE started]"
|
||||
@@ -1058,13 +1062,13 @@ testGroupLiveMessage =
|
||||
alice <## "message history:"
|
||||
alice .<## ": hello 2"
|
||||
alice .<## ":"
|
||||
-- bobItemId <- lastItemId bob
|
||||
-- bob ##> ("/_get item info " <> bobItemId)
|
||||
-- bob <##. "sent at: "
|
||||
-- bob <##. "received at: "
|
||||
-- bob <## "message history:"
|
||||
-- bob .<## ": hello 2"
|
||||
-- bob .<## ":"
|
||||
bobItemId <- lastItemId bob
|
||||
bob ##> ("/_get item info " <> bobItemId)
|
||||
bob <##. "sent at: "
|
||||
bob <##. "received at: "
|
||||
bob <## "message history:"
|
||||
bob .<## ": hello 2"
|
||||
bob .<## ":"
|
||||
|
||||
testUpdateGroupProfile :: HasCallStack => FilePath -> IO ()
|
||||
testUpdateGroupProfile =
|
||||
|
||||
@@ -15,7 +15,7 @@ mobileTests :: SpecWith FilePath
|
||||
mobileTests = do
|
||||
describe "mobile API" $ do
|
||||
it "start new chat without user" testChatApiNoUser
|
||||
it "start new chat with existing user" testChatApi
|
||||
xit "start new chat with existing user" testChatApi
|
||||
|
||||
noActiveUser :: String
|
||||
#if defined(darwin_HOST_OS) && defined(swiftJSON)
|
||||
|
||||
Reference in New Issue
Block a user