Compare commits

...

12 Commits

Author SHA1 Message Date
Avently
e83454a558 changes in script 2023-07-27 20:13:24 +07:00
Avently
ea1aaf6a0a desktop: preparing cloud Ubuntu distribution and building 2023-07-27 19:42:52 +07:00
Stanislav Dmitrenko
976fc68cc3 desktop: parsing html into annotated string (#2786) 2023-07-27 12:33:04 +01:00
Stanislav Dmitrenko
7c7e931aa9 desktop: bound long click to right click too (#2783)
* desktop: bound long click to right click too

* changes
2023-07-27 09:57:21 +01:00
Stanislav Dmitrenko
e8e619effa desktop: small fixes in initial setup (#2781)
* desktop: small fixes in initial setup

* small changes

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-07-27 08:47:11 +01:00
Stanislav Dmitrenko
bd0139eaab desktop: Linux lib path (#2780) 2023-07-26 20:13:06 +01:00
Stanislav Dmitrenko
e9f77e1064 desktop: development alerts (#2777)
* desktop: development alerts

* strings

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-07-26 14:31:21 +01:00
Stanislav Dmitrenko
677b75f368 multiplatform: possible race in ChatList (#2757)
* multiplatform: possible race in ChatList

* more changes

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-07-26 13:27:54 +01:00
Stanislav Dmitrenko
92d13591f3 desktop: change libs path (#2776)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-07-26 12:44:33 +01:00
Stanislav Dmitrenko
6be8476f90 desktop: decoding utf8 (#2774) 2023-07-26 12:38:49 +01:00
spaced4ndy
ae9b83515c core: group snd status (#2763)
* core: group snd status

* schema, implementation

* refactor direct, tests

* configure, tests

* item info

* refactor

* refactor

* remove do

* rename

* remove receipts on events

* refactor

* refactor

* refactor

* refactor

* tests

* rename tests

* aggregates

* fix name

* refactor

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-07-26 14:49:35 +04:00
Stanislav Dmitrenko
26a233ab1a desktop: adapted UI (#2755)
* desktop: adapted UI

* more changes

* divider fix

* do not close screens on non-desktop in terminal view

* background click to close views and small changes

* dark theme detection on supported OSes

* fix text color after theme change

* placement of desktop text field

* marked as @Composable

* padding of text view

* window sizes

* screen layout

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-07-26 09:35:29 +01:00
105 changed files with 1403 additions and 523 deletions

View File

@@ -127,7 +127,7 @@ public enum ChatCommand {
case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))"
case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings):
let umrs = userMsgReceiptSettings
return "/_set receipts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))"
case let .apiMuteUser(userId): return "/_mute user \(userId)"

View File

@@ -55,6 +55,7 @@ allprojects {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
maven("https://jitpack.io")
}
}

View File

@@ -94,6 +94,8 @@ kotlin {
val desktopMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.1")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
implementation("org.slf4j:slf4j-simple:2.0.7")
}
}
val desktopTest by getting

View File

@@ -25,7 +25,7 @@ val defaultLocale: Locale = Locale.getDefault()
lateinit var androidAppContext: Context
lateinit var mainActivity: WeakReference<FragmentActivity>
actual fun initHaskell() {
fun initHaskell() {
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {

View File

@@ -48,4 +48,6 @@ actual fun screenOrientation(): ScreenOrientation = when (mainActivity.get()?.re
@Composable
actual fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun desktopExpandWindowToWidth(width: Dp) {}
actual fun isRtl(text: CharSequence): Boolean = BidiFormatter.getInstance().isRtl(text)

View File

@@ -0,0 +1,6 @@
package chat.simplex.common.ui.theme
import androidx.compose.runtime.Composable
@Composable
actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()

View File

@@ -35,7 +35,7 @@ actual fun SimpleAndAnimatedImageView(
ImageView(imagePainter) {
hideKeyboard(view)
if (getLoadedFilePath(file) != null) {
ModalManager.shared.showCustomModal(animated = false) { close ->
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
}
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
actual interface DefaultExposedDropdownMenuBoxScope {
@Composable
actual fun DefaultExposedDropdownMenu(

View File

@@ -69,7 +69,7 @@ actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatMod
changeIcon = ::setAppIcon,
showSettingsModal = showSettingsModal,
editColor = { name, initialColor ->
ModalManager.shared.showModalCloseable { close ->
ModalManager.start.showModalCloseable { close ->
ColorEditor(name, initialColor, close)
}
},

View File

@@ -19,6 +19,8 @@ project("app")
if(UNIX AND NOT APPLE)
set(OS_LIB_PATH "linux")
set(OS_LIB_EXT "so")
# Makes ld search libs in the same dir as libapp-lib, not in system dirs
set(CMAKE_BUILD_RPATH "$ORIGIN")
elseif(WIN32)
set(OS_LIB_PATH "windows")
set(OS_LIB_EXT "dll")
@@ -37,9 +39,6 @@ else()
set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
endif()
# Makes ld search libs in the same dir as libapp-lib, not in system dirs
#set(CMAKE_BUILD_RPATH "$ORIGIN")
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.

View File

@@ -1,4 +1,5 @@
#include <jni.h>
#include <string.h>
// from the RTS
void hs_init(int * argc, char **argv[]);
@@ -30,13 +31,35 @@ extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
// As a reference: https://stackoverflow.com/a/60002045
jstring correct_string_utf8(JNIEnv *env, char *string) {
jobject bb = (*env)->NewDirectByteBuffer(env, (void *)string, strlen(string));
jclass cls_charset = (*env)->FindClass(env, "java/nio/charset/Charset");
jmethodID mid_charset_forName = (*env)->GetStaticMethodID(env, cls_charset, "forName", "(Ljava/lang/String;)Ljava/nio/charset/Charset;");
jobject charset = (*env)->CallStaticObjectMethod(env, cls_charset, mid_charset_forName, (*env)->NewStringUTF(env, "UTF-8"));
jmethodID mid_decode = (*env)->GetMethodID(env, cls_charset, "decode", "(Ljava/nio/ByteBuffer;)Ljava/nio/CharBuffer;");
jobject cb = (*env)->CallObjectMethod(env, charset, mid_decode, bb);
jclass cls_char_buffer = (*env)->FindClass(env, "java/nio/CharBuffer");
jmethodID mid_to_string = (*env)->GetMethodID(env, cls_char_buffer, "toString", "()Ljava/lang/String;");
jstring res = (*env)->CallObjectMethod(env, cb, mid_to_string);
(*env)->DeleteLocalRef(env, bb);
(*env)->DeleteLocalRef(env, charset);
(*env)->DeleteLocalRef(env, cb);
return res;
}
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
jlong _ctrl = (jlong) 0;
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
jstring res = correct_string_utf8(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
@@ -56,25 +79,25 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass cla
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, jclass clazz, jlong controller, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
jstring res = correct_string_utf8(env, chat_send_cmd((void*)controller, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
return correct_string_utf8(env, chat_recv_msg((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, jclass clazz, jlong controller, jint wait) {
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
return correct_string_utf8(env, chat_recv_msg_wait((void*)controller, wait));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
jstring res = correct_string_utf8(env, chat_parse_markdown(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
@@ -82,7 +105,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, jclass c
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
jstring res = correct_string_utf8(env, chat_parse_server(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
@@ -91,7 +114,7 @@ JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) {
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
jstring res = correct_string_utf8(env, chat_password_hash(_pwd, _salt));
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;

View File

@@ -1,35 +1,46 @@
package chat.simplex.common
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.SplashView
import chat.simplex.common.views.call.ActiveCallView
import chat.simplex.common.views.call.IncomingCallAlertView
import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.ChatListView
import chat.simplex.common.views.chatlist.ShareListView
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.localauth.VerticalDivider
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState,
val switchingUsers: MutableState<Boolean>
)
@Composable
fun AppScreen() {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
@@ -66,7 +77,7 @@ fun MainScreen() {
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value) {
ModalManager.shared.closeModals()
ModalManager.closeAllModalsEverywhere()
chatModel.clearOverlays.value = false
}
}
@@ -105,61 +116,30 @@ fun MainScreen() {
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
showAdvertiseLAAlert = true
BoxWithConstraints {
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
Modifier
.graphicsLayer {
translationX = -offset.value.dp.toPx()
}
) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
}
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
}
}
}
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val scaffoldState = rememberScaffoldState()
val switchingUsers = rememberSaveable { mutableStateOf(false) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsers) }
if (appPlatform.isAndroid) {
AndroidScreen(settingsState)
} else {
DesktopScreen(settingsState)
}
}
}
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
onboarding == OnboardingStage.Step1_SimpleXInfo -> {
SimpleXInfo(chatModel, onboarding = true)
if (appPlatform.isDesktop) {
ModalManager.fullscreen.showInView()
}
}
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
ModalManager.shared.showInView()
if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView()
}
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
@@ -178,7 +158,7 @@ fun MainScreen() {
} else if (chatModel.showCallView.value) {
ActiveCallView()
}
ModalManager.shared.showPasscodeInView()
ModalManager.fullscreen.showPasscodeInView()
val invitation = chatModel.activeCallInvitation.value
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
AlertManager.shared.showInView()
@@ -200,6 +180,141 @@ fun MainScreen() {
}
}
@Composable
fun AndroidScreen(settingsState: SettingsViewState) {
BoxWithConstraints {
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
Modifier
.graphicsLayer {
translationX = -offset.value.dp.toPx()
}
) {
StartPartOfScreen(settingsState)
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
}
}
}
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
}
}
@Composable
fun StartPartOfScreen(settingsState: SettingsViewState) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, settingsState, stopped)
}
}
@Composable
fun CenterPartOfScreen() {
val currentChatId by remember { ChatModel.chatId }
LaunchedEffect(Unit) {
snapshotFlow { currentChatId }
.distinctUntilChanged()
.collect {
if (it != null) {
ModalManager.center.closeModals()
}
}
}
when (val id = currentChatId) {
null -> {
if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) {
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
Text(stringResource(MR.strings.no_selected_chat))
}
} else {
ModalManager.center.showInView()
}
}
else -> ChatView(id, chatModel) {}
}
}
@Composable
fun EndPartOfScreen() {
ModalManager.end.showInView()
}
@Composable
fun DesktopScreen(settingsState: SettingsViewState) {
Box {
// 56.dp is a size of unused space of settings drawer
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH + 56.dp)) {
StartPartOfScreen(settingsState)
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
ModalManager.start.showInView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH).clipToBounds()) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState, switchingUsers ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
scope.launch { settingsState.scaffoldState.drawerState.close() }
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
UserPicker(chatModel, userPickerState, switchingUsers) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
ModalManager.fullscreen.showInView()
ModalManager.fullscreen.showPasscodeInView()
}
}
@Composable
fun InitializationView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {

View File

@@ -6,8 +6,7 @@ import androidx.compose.material.Surface
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import chat.simplex.common.model.*
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.localauth.SetAppPasscodeView
import chat.simplex.common.views.usersettings.*
@@ -41,17 +40,25 @@ object AppLock {
text = generalGetString(MR.strings.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
confirmText = generalGetString(MR.strings.la_notice_turn_on),
onConfirm = {
laNoticeShown.set(true)
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
showChooseLAMode(laNoticeShown)
if (appPlatform.isAndroid) {
showChooseLAMode()
} else {
AlertManager.shared.hideAlert()
setPasscode()
}
}
},
onDismiss = {
AlertManager.shared.hideAlert()
}
)
}
}
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>) {
private fun showChooseLAMode() {
Log.d(TAG, "showLANotice")
laNoticeShown.set(true)
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.la_lock_mode),
text = null,
@@ -71,7 +78,7 @@ object AppLock {
private fun initialEnableLA() {
val m = ChatModel
val appPrefs = ChatController.appPrefs
appPrefs.laMode.set(LAMode.SYSTEM)
appPrefs.laMode.set(LAMode.default)
authenticate(
generalGetString(MR.strings.auth_enable_simplex_lock),
generalGetString(MR.strings.auth_confirm_credential),
@@ -100,7 +107,7 @@ object AppLock {
private fun setPasscode() {
val appPrefs = ChatController.appPrefs
ModalManager.shared.showCustomModal { close ->
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
submit = {

View File

@@ -130,10 +130,11 @@ object ChatModel {
}
}
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
// toList() here is to prevent ConcurrentModificationException that is rarely happens but happens
fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
fun updateChatInfo(cInfo: ChatInfo) {
@@ -437,7 +438,7 @@ object ChatModel {
val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return
if (info.contactConnection.connReqInv == connReqInv.value) {
connReqInv.value = null
ModalManager.shared.closeModals()
ModalManager.center.closeModals()
}
}

View File

@@ -72,7 +72,7 @@ class AppPreferences {
set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) }
)
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.SYSTEM) { LAMode.values().firstOrNull { it.name == this } }
val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.default) { LAMode.values().firstOrNull { it.name == this } }
val laLockDelay = mkIntPreference(SHARED_PREFS_LA_LOCK_DELAY, 30)
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
@@ -1882,7 +1882,7 @@ sealed class CC {
is SetAllContactReceipts -> "/set receipts all ${onOff(enable)}"
is ApiSetUserContactReceipts -> {
val mrs = userMsgReceiptSettings
"/_set receipts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
"/_set receipts contacts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
}
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}"

View File

@@ -2,7 +2,7 @@ package com.sd.lib.compose.wheel_picker
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import chat.simplex.common.ui.theme.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue

View File

@@ -10,6 +10,9 @@ enum class AppPlatform {
val isAndroid: Boolean
get() = this == ANDROID
val isDesktop: Boolean
get() = this == DESKTOP
}
expect val appPlatform: AppPlatform
@@ -19,8 +22,6 @@ val appVersionInfo: Pair<String, Int?> = if (appPlatform == AppPlatform.ANDROID)
else
BuildConfigCommon.DESKTOP_VERSION_NAME to null
expect fun initHaskell()
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()

View File

@@ -28,4 +28,6 @@ expect fun screenOrientation(): ScreenOrientation
@Composable
expect fun screenWidth(): Dp
expect fun desktopExpandWindowToWidth(width: Dp)
expect fun isRtl(text: CharSequence): Boolean

View File

@@ -1,7 +1,6 @@
package chat.simplex.common.ui.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -191,6 +190,11 @@ val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
val DEFAULT_BOTTOM_PADDING = 48.dp
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
val DEFAULT_START_MODAL_WIDTH = 388.dp
val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp
val DEFAULT_END_MODAL_WIDTH = 388.dp
val DEFAULT_MAX_IMAGE_WIDTH = 500.dp
val DarkColorPalette = darkColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexBlue,
@@ -255,6 +259,15 @@ val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow
@Composable
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
expect fun isSystemInDarkTheme(): Boolean
fun reactOnDarkThemeChanges(isDark: Boolean) {
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == isDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, isDark)
}
}
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LaunchedEffect(darkTheme) {
@@ -264,10 +277,7 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
}
val systemDark = isSystemInDarkTheme()
LaunchedEffect(systemDark) {
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
}
reactOnDarkThemeChanges(systemDark)
}
val theme by CurrentColors.collectAsState()
MaterialTheme(

View File

@@ -24,6 +24,12 @@ import chat.simplex.common.platform.*
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
val close = {
close()
if (appPlatform.isDesktop) {
ModalManager.center.closeModals()
}
}
BackHandler(onBack = {
close()
})
@@ -126,7 +132,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
modifier = Modifier
.fillMaxWidth()
.clickable {
ModalManager.shared.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
}

View File

@@ -3,6 +3,7 @@ package chat.simplex.common.views.call
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import kotlinx.datetime.Clock
import kotlin.time.Duration.Companion.minutes
@@ -24,6 +25,9 @@ class CallManager(val chatModel: ChatModel) {
}
fun acceptIncomingCall(invitation: RcvCallInvitation) {
if (appPlatform.isDesktop) {
return showInDevelopingAlert()
}
val call = chatModel.activeCall.value
if (call == null) {
justAcceptIncomingCall(invitation = invitation)

View File

@@ -81,7 +81,7 @@ fun ChatInfoView(
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.shared.showCustomModal { close ->
ModalManager.end.showCustomModal { close ->
val user = chatModel.currentUser.value
if (user != null) {
ContactPreferencesView(chatModel, user, contact.contactId, close)
@@ -136,7 +136,7 @@ fun ChatInfoView(
})
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
ModalManager.end.showModalCloseable { close ->
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
VerifyCodeView(
ct.displayName,

View File

@@ -70,6 +70,7 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
Box(
Modifier.clip(RoundedCornerShape(18.dp)).background(itemColor).padding(bottom = 3.dp)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
.onRightClick { showMenu.value = true }
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
TextBubble(text, ciVersion.formattedText, sender = null, showMenu)
@@ -116,6 +117,7 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
Box(
Modifier.clip(RoundedCornerShape(18.dp)).background(quoteColor).padding(bottom = 3.dp)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
.onRightClick { showMenu.value = true }
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu)

View File

@@ -34,6 +34,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@@ -139,7 +140,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
@@ -149,7 +151,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
var groupLink = link?.first
var groupLinkMemberRole = link?.second
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
groupLink = it.first;
groupLinkMemberRole = it.second
@@ -174,7 +177,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
member to null
}
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
@@ -233,7 +237,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
joinGroup = { groupId ->
withApi { chatModel.controller.apiJoinGroup(groupId) }
},
startCall = { media ->
startCall = out@ { media ->
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
}
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
@@ -314,7 +321,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
withApi {
val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id)
if (ciInfo != null) {
ModalManager.shared.showModal(endButtons = { ShareButton {
ModalManager.end.closeModals()
ModalManager.end.showModal(endButtons = { ShareButton {
clipboard.shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
} }) {
ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
@@ -326,8 +334,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
hideKeyboard(view)
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
},
@@ -462,7 +471,9 @@ fun ChatInfoToolbar(
showSearch = false
}
}
BackHandler(onBack = onBackClicked)
if (appPlatform.isAndroid) {
BackHandler(onBack = onBackClicked)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
menuItems.add {
@@ -520,7 +531,7 @@ fun ChatInfoToolbar(
}
DefaultTopAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
navigationButton = { if (appPlatform.isAndroid || showSearch) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chat.chatInfo) },
onTitleClick = info,
showSearch = showSearch,

View File

@@ -170,7 +170,7 @@ fun ComposeView(
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val processPickedMedia = { uris: List<URI>, text: String? ->
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
@@ -655,7 +655,11 @@ fun ComposeView(
} else {
showChooseAttachment
}
IconButton(attachmentClicked, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
IconButton(
attachmentClicked,
Modifier.padding(bottom = if (appPlatform.isAndroid) 0.dp else 7.dp),
enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value
) {
Icon(
painterResource(MR.images.ic_attach_file_filled_500),
contentDescription = stringResource(MR.strings.attach),

View File

@@ -24,6 +24,7 @@ import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatItem
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.compose.painterResource
@@ -83,7 +84,7 @@ fun SendMsgView(
if (showDeleteTextButton.value) {
DeleteTextButton(composeState)
}
Box(Modifier.align(Alignment.BottomEnd)) {
Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else 5.dp)) {
val sendButtonSize = remember { Animatable(36f) }
val sendButtonAlpha = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
@@ -320,7 +321,10 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = {
val startRecording: () -> Unit = out@ {
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
}
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
@@ -446,7 +450,8 @@ private fun SendMsgButton(
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
),
)
.onRightClick { onLongClick?.invoke() },
contentAlignment = Alignment.Center
) {
Icon(

View File

@@ -113,7 +113,7 @@ private fun VerifyCodeLayout(
} else {
if (appPlatform.isAndroid) {
SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) {
ModalManager.shared.showModal {
ModalManager.end.showModal {
ScanCodeView(verifyCode) { }
}
}

View File

@@ -50,7 +50,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
allowModifyMembers = allowModifyMembers,
searchText,
openPreferences = {
ModalManager.shared.showCustomModal { close ->
ModalManager.end.showCustomModal { close ->
GroupPreferencesView(chatModel, groupInfo.id, close)
}
},

View File

@@ -54,7 +54,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
@@ -73,7 +73,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
} else {
member to null
}
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
ModalManager.end.showModalCloseable(true) { closeCurrent ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
@@ -84,13 +84,13 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
}
},
editGroupProfile = {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
ModalManager.end.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
addOrEditWelcomeMessage = {
ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
},
openPreferences = {
ModalManager.shared.showCustomModal { close ->
ModalManager.end.showCustomModal { close ->
GroupPreferencesView(
chatModel,
chat.id,
@@ -102,7 +102,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = {
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
ModalManager.end.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
}
)
}

View File

@@ -149,7 +149,7 @@ fun GroupMemberInfoView(
})
},
verifyClicked = {
ModalManager.shared.showModalCloseable { close ->
ModalManager.end.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,

View File

@@ -1,7 +1,6 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
@@ -20,6 +19,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import java.io.File
@@ -85,7 +85,7 @@ fun CIImageView(
@Composable
fun imageViewFullWidth(): Dp {
val approximatePadding = 100.dp
return with(LocalDensity.current) { minOf(1000.dp, LocalWindowWidth() - approximatePadding) }
return with(LocalDensity.current) { minOf(DEFAULT_MAX_IMAGE_WIDTH, LocalWindowWidth() - approximatePadding) }
}
@Composable
@@ -93,14 +93,15 @@ fun CIImageView(
Image(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp)
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
),
)
.onRightClick { showMenu.value = true },
contentScale = ContentScale.FillWidth,
)
}
@@ -110,14 +111,15 @@ fun CIImageView(
Image(
painter,
contentDescription = stringResource(MR.strings.image_descr),
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp)
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
),
)
.onRightClick { showMenu.value = true },
contentScale = ContentScale.FillWidth,
)
}

View File

@@ -21,7 +21,11 @@ import chat.simplex.res.MR
@Composable
fun CIInvalidJSONView(json: String) {
Row(Modifier
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
.clickable {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModal(true) { InvalidJSONView(json) }
}
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(stringResource(MR.strings.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)

View File

@@ -44,7 +44,7 @@ fun CIVideoView(
val view = LocalMultiplatformView()
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
hideKeyboard(view)
ModalManager.shared.showCustomModal(animated = false) { close ->
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
}
})
@@ -113,7 +113,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
}
Box {
val windowWidth = LocalWindowWidth()
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
PlayerView(
player,
width,
@@ -142,7 +142,8 @@ private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit,
Box(
Modifier
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.onRightClick { onLongClick.invoke() },
contentAlignment = Alignment.Center
) {
Icon(
@@ -202,7 +203,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
@Composable
private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
val windowWidth = LocalWindowWidth()
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
Image(
preview,
contentDescription = stringResource(MR.strings.video_descr),
@@ -211,7 +212,8 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onC
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
),
)
.onRightClick { showMenu.value = true },
contentScale = ContentScale.FillWidth,
)
}
@@ -311,5 +313,5 @@ private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
private fun videoViewFullWidth(windowWidth: Dp): Dp {
val approximatePadding = 100.dp
return minOf(1000.dp, windowWidth - approximatePadding)
return minOf(DEFAULT_MAX_IMAGE_WIDTH, windowWidth - approximatePadding)
}

View File

@@ -222,7 +222,8 @@ private fun PlayPauseButton(
.combinedClickable(
onClick = { if (!audioPlaying) play() else pause() },
onLongClick = longClick
),
)
.onRightClick { longClick() },
contentAlignment = Alignment.Center
) {
Icon(

View File

@@ -111,7 +111,8 @@ fun ChatItemView(
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
.onRightClick { showMenu.value = true },
) {
@Composable
fun framedItemView() {

View File

@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
@@ -23,10 +22,8 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.res.MR
import kotlinx.datetime.Clock
import kotlin.math.min
@Composable
@@ -110,6 +107,7 @@ fun FramedItemView(
onLongClick = { showMenu.value = true },
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
)
.onRightClick { showMenu.value = true }
) {
when (qi.content) {
is MsgContent.MCImage -> {

View File

@@ -24,7 +24,7 @@ import chat.simplex.common.views.chat.item.InvalidJSONView
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.ntfManager
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@@ -73,7 +73,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = {
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close ->
ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
}
},
@@ -87,7 +89,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
InvalidDataView()
},
click = {
ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
ModalManager.end.closeModals()
ModalManager.center.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
},
dropdownMenuItems = null,
showMenu,
@@ -342,7 +345,9 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
stringResource(MR.strings.set_contact_name),
painterResource(MR.images.ic_edit),
onClick = {
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close ->
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
}
showMenu.value = false
@@ -613,7 +618,9 @@ fun ChatListNavLinkLayout(
stopped: Boolean
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
if (!stopped) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
Box(modifier) {
Row(
modifier = Modifier

View File

@@ -8,16 +8,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -28,15 +28,13 @@ import chat.simplex.common.views.usersettings.simplexTeamUri
import chat.simplex.common.platform.*
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.net.URI
@Composable
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
@@ -47,7 +45,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
@@ -60,13 +58,13 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
connectIfOpenedViaUri(url, chatModel)
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
val switchingUsers = rememberSaveable { mutableStateOf(false) }
Scaffold(topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
val (userPickerState, scaffoldState, switchingUsers ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA) },
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
floatingActionButton = {
if (searchInList.isEmpty()) {
@@ -76,7 +74,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp),
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -91,7 +89,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
}
}
) {
Box(Modifier.padding(it)) {
Box(Modifier.padding(it).padding(end = endPadding)) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -112,8 +110,10 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
if (searchInList.isEmpty()) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
UserPicker(chatModel, userPickerState, switchingUsers) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsers) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
}
if (switchingUsers.value) {
Box(
@@ -344,7 +344,11 @@ private fun ChatList(chatModel: ChatModel, search: String) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search) } }
val allChats = remember { chatModel.chats }
// In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side
// which is related to [derivedStateOf]. Using safe alternative instead
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val chats = filteredChats(showUnreadAndFavorites, search, allChats.toList())
LazyColumn(
modifier = Modifier.fillMaxWidth(),
listState
@@ -360,13 +364,12 @@ private fun ChatList(chatModel: ChatModel, search: String) {
}
}
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String): List<Chat> {
val chatModel = ChatModel
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String, chats: List<Chat>): List<Chat> {
val s = searchText.trim().lowercase()
return if (s.isEmpty() && !showUnreadAndFavorites)
chatModel.chats
chats
else {
chatModel.chats.filter { chat ->
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
filtered(chat)

View File

@@ -13,20 +13,24 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.SettingsViewState
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.Chat
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val switchingUsers = rememberSaveable { mutableStateOf(false) }
val (userPickerState, scaffoldState, switchingUsers) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Scaffold(
Modifier.padding(end = endPadding),
scaffoldState = scaffoldState,
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
) {
Box(Modifier.padding(it)) {
@@ -42,9 +46,11 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
}
}
}
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
})
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
})
}
}
@Composable

View File

@@ -41,6 +41,11 @@ fun UserPicker(
) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
if (newChat.isVisible()) {
BackHandler {
userPickerState.value = AnimatedViewState.HIDING
}
}
val users by remember {
derivedStateOf {
chatModel.users
@@ -121,6 +126,7 @@ fun UserPicker(
delay(500)
switchingUsers.value = true
}
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.userId, null)
job.cancel()
switchingUsers.value = false
@@ -159,6 +165,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
interactionSource = remember { MutableInteractionSource() },
indication = if (!u.activeUser) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(padding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically

View File

@@ -83,7 +83,7 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
m.controller.appPrefs.chatArchiveTime.set(null)
ModalManager.shared.closeModal()
ModalManager.start.closeModal()
} else {
Log.e(TAG, "deleteArchiveAlert delete() error")
}

View File

@@ -357,11 +357,11 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
}
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
/** Hide current view and show [DatabaseErrorView] */
ModalManager.shared.closeModals()
ModalManager.closeAllModalsEverywhere()
return@withApi
}
if (m.currentUser.value == null) {
ModalManager.shared.closeModals()
ModalManager.closeAllModalsEverywhere()
return@withApi
} else {
m.controller.apiStartChat()

View File

@@ -16,7 +16,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.*
@Composable
fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> Unit = {}) {
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) {
Column(
Modifier
.fillMaxWidth()
@@ -32,7 +32,11 @@ fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> U
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
NavigationButtonBack(onButtonClicked = close)
if (showClose) {
NavigationButtonBack(onButtonClicked = close)
} else {
Spacer(Modifier)
}
Row {
endButtons()
}

View File

@@ -11,6 +11,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
expect fun Modifier.onRightClick(action: () -> Unit): Modifier
expect interface DefaultExposedDropdownMenuBoxScope {
@Composable
open fun DefaultExposedDropdownMenu(

View File

@@ -45,7 +45,7 @@ fun authenticateWithPasscode(
completed: (LAResult) -> Unit
) {
val password = DatabaseUtils.ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(MR.strings.la_no_app_password)))
ModalManager.shared.showPasscodeCustomModal { close ->
ModalManager.fullscreen.showPasscodeCustomModal { close ->
BackHandler {
close()
completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled)))

View File

@@ -8,43 +8,51 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.themedBackground
import chat.simplex.common.ui.theme.*
import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun ModalView(
close: () -> Unit,
showClose: Boolean = true,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier,
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
BackHandler(onBack = close)
if (showClose) {
BackHandler(onBack = close)
}
Surface(Modifier.fillMaxSize()) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(close, endButtons)
CloseSheetBar(close, showClose, endButtons)
Box(modifier) { content() }
}
}
}
class ModalManager {
enum class ModalPlacement {
START, CENTER, END, FULLSCREEN
}
class ModalManager(private val placement: ModalPlacement? = null) {
private val modalViews = arrayListOf<Pair<Boolean, (@Composable (close: () -> Unit) -> Unit)>>()
private val modalCount = mutableStateOf(0)
private val toRemove = mutableSetOf<Int>()
private var oldViewChanging = AtomicBoolean(false)
private var passcodeView: MutableState<(@Composable (close: () -> Unit) -> Unit)?> = mutableStateOf(null)
fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
showCustomModal { close ->
ModalView(close, endButtons = endButtons, content = content)
ModalView(close, showClose = showClose, endButtons = endButtons, content = content)
}
}
fun showModalCloseable(settings: Boolean = false, content: @Composable (close: () -> Unit) -> Unit) {
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, content: @Composable (close: () -> Unit) -> Unit) {
showCustomModal { close ->
ModalView(close, content = { content(close) })
ModalView(close, showClose = showClose, content = { content(close) })
}
}
@@ -55,8 +63,17 @@ class ModalManager {
if (toRemove.isNotEmpty()) {
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
}
modalViews.add(animated to modal)
// Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0)
// to prevent unneeded animation on different situations
val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START)
modalViews.add(anim to modal)
modalCount.value = modalViews.size - toRemove.size
if (placement == ModalPlacement.CENTER) {
ChatModel.chatId.value = null
} else if (placement == ModalPlacement.END) {
desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH)
}
}
fun showPasscodeCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
@@ -146,6 +163,17 @@ private fun <T> animationSpec() = tween<T>(durationMillis = 250, easing = FastOu
// private fun <T> animationSpecFromEnd() = tween<T>(durationMillis = 100, easing = FastOutSlowInEasing)
companion object {
val shared = ModalManager()
private val shared = ModalManager()
val start = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.START)
val center = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.CENTER)
val end = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.END)
val fullscreen = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.FULLSCREEN)
fun closeAllModalsEverywhere() {
start.closeModals()
center.closeModals()
end.closeModals()
fullscreen.closeModals()
}
}
}

View File

@@ -1,5 +1,4 @@
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
@@ -15,8 +14,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.screenWidth
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.ValueTitleDesc
import chat.simplex.common.views.helpers.ValueTitle
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
import chat.simplex.res.MR
@@ -132,7 +130,9 @@ fun SectionItemViewSpaceBetween(
.fillMaxWidth()
.sizeIn(minHeight = minHeight)
Row(
if (click == null || disabled) modifier.padding(padding) else modifier.combinedClickable(onClick = click, onLongClick = onLongClick).padding(padding),
if (click == null || disabled) modifier.padding(padding) else modifier
.combinedClickable(onClick = click, onLongClick = onLongClick).padding(padding)
.onRightClick { onLongClick?.invoke() },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -70,7 +70,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (
if (createdUser != null) {
m.controller.startChat(createdUser)
}
ModalManager.shared.closeModals()
ModalManager.fullscreen.closeModals()
AlertManager.shared.hideAlert()
completed(LAResult.Success)
} catch (e: Exception) {

View File

@@ -30,7 +30,7 @@ fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
connIncognito = connIncognito,
share = { clipboard.shareText(connReqInvitation) },
learnMore = {
ModalManager.shared.showModal {
ModalManager.center.showModal {
Column(
Modifier
.fillMaxHeight()

View File

@@ -45,7 +45,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
chatModel.chatId.value = groupInfo.id
setGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, true, chatModel, close)
}
}

View File

@@ -42,7 +42,7 @@ fun ContactConnectionInfoView(
**/
DisposableEffect(Unit) {
onDispose {
if (!ModalManager.shared.hasModalsOpen()) {
if (!ModalManager.center.hasModalsOpen()) {
chatModel.connReqInv.value = null
}
}
@@ -57,7 +57,7 @@ fun ContactConnectionInfoView(
onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) },
share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) },
learnMore = {
ModalManager.shared.showModal {
ModalManager.center.showModal {
Column(
Modifier
.fillMaxHeight()

View File

@@ -35,7 +35,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) {
**/
DisposableEffect(Unit) {
onDispose {
if (!ModalManager.shared.hasModalsOpen()) {
if (!ModalManager.center.hasModalsOpen()) {
m.connReqInv.value = null
}
}

View File

@@ -40,15 +40,18 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
stopped,
addContact = {
closeNewChatSheet(false)
ModalManager.shared.showModal { CreateLinkView(chatModel, CreateLinkTab.ONE_TIME) }
ModalManager.center.closeModals()
ModalManager.center.showModal { CreateLinkView(chatModel, CreateLinkTab.ONE_TIME) }
},
connectViaLink = {
closeNewChatSheet(false)
ModalManager.shared.showModalCloseable { close -> ConnectViaLinkView(chatModel, close) }
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) }
ModalManager.center.closeModals()
ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, close) }
},
closeNewChatSheet,
)
@@ -93,10 +96,12 @@ private fun NewChatSheetLayout(
}
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
Column(
Modifier
.fillMaxSize()
.padding(end = endPadding)
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else 0, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) }
.drawBehind { drawRect(animatedColor.value) },

View File

@@ -4,9 +4,11 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import boofcv.alg.drawing.FiducialImageEngine
import boofcv.alg.fiducial.qrcode.*
@@ -25,7 +27,7 @@ fun QRCode(
) {
val scope = rememberCoroutineScope()
BoxWithConstraints {
BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
val maxWidthInPx = with(LocalDensity.current) { maxWidth.roundToPx() }
val qr = remember(maxWidthInPx, connReq, tintColor, withLogo) {
qrCodeBitmap(connReq, maxWidthInPx).replaceColor(Color.Black.toArgb(), tintColor.toArgb())
@@ -34,7 +36,9 @@ fun QRCode(
Image(
bitmap = qr,
contentDescription = stringResource(MR.strings.image_descr_qr_code),
modifier
Modifier
.widthIn(max = 500.dp)
.then(modifier)
.clickable {
scope.launch {
val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb())

View File

@@ -57,8 +57,13 @@ fun CreateSimpleXAddress(m: ChatModel) {
}
},
nextStep = {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
m.onboardingStage.value = OnboardingStage.Step4_SetNotificationsMode
val next = if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
m.controller.appPrefs.onboardingStage.set(next)
m.onboardingStage.value = next
},
)

View File

@@ -42,7 +42,7 @@ fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = n
if (onboardingStage != null) {
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() })
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() })
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}

View File

@@ -26,7 +26,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
SimpleXInfoLayout(
user = chatModel.currentUser.value,
onboardingStage = if (onboarding) chatModel.onboardingStage else null,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } },
)
}
@@ -41,6 +41,7 @@ fun SimpleXInfoLayout(
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = DEFAULT_PADDING , end = DEFAULT_PADDING, top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp), contentAlignment = Alignment.Center) {
SimpleXLogo()
@@ -48,9 +49,11 @@ fun SimpleXInfoLayout(
Text(stringResource(MR.strings.next_generation_of_private_messaging), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = 48.dp).padding(horizontal = 36.dp), textAlign = TextAlign.Center)
InfoRow(painterResource(MR.images.privacy), MR.strings.privacy_redefined, MR.strings.first_platform_without_user_ids, width = 80.dp)
InfoRow(painterResource(MR.images.shield), MR.strings.immune_to_spam_and_abuse, MR.strings.people_can_connect_only_via_links_you_share)
InfoRow(painterResource(if (isInDarkTheme()) MR.images.decentralized_light else MR.images.decentralized), MR.strings.decentralized, MR.strings.opensource_protocol_and_code_anybody_can_run_servers)
Column {
InfoRow(painterResource(MR.images.privacy), MR.strings.privacy_redefined, MR.strings.first_platform_without_user_ids, width = 80.dp)
InfoRow(painterResource(MR.images.shield), MR.strings.immune_to_spam_and_abuse, MR.strings.people_can_connect_only_via_links_you_share)
InfoRow(painterResource(if (isInDarkTheme()) MR.images.decentralized_light else MR.images.decentralized), MR.strings.decentralized, MR.strings.opensource_protocol_and_code_anybody_can_run_servers)
}
Spacer(Modifier.fillMaxHeight().weight(1f))

View File

@@ -41,7 +41,7 @@ object AppearanceScope {
) {
val currentTheme by CurrentColors.collectAsState()
SectionView(stringResource(MR.strings.settings_section_title_themes)) {
val darkTheme = isSystemInDarkTheme()
val darkTheme = chat.simplex.common.ui.theme.isSystemInDarkTheme()
val state = remember { derivedStateOf { currentTheme.name } }
ThemeSelector(state) {
ThemeManager.applyTheme(it, darkTheme)
@@ -226,7 +226,7 @@ object AppearanceScope {
@Composable
private fun ThemeSelector(state: State<String>, onSelected: (String) -> Unit) {
val darkTheme = isSystemInDarkTheme()
val darkTheme = chat.simplex.common.ui.theme.isSystemInDarkTheme()
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third }) }
ExposedDropDownSettingRow(
generalGetString(MR.strings.theme),

View File

@@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.views.TerminalView
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
@@ -41,3 +42,10 @@ fun DeveloperView(
SectionBottomSpacer()
}
}
fun showInDevelopingAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.in_developing_title),
text = generalGetString(MR.strings.in_developing_desc)
)
}

View File

@@ -32,7 +32,7 @@ fun NotificationsSettingsView(
notificationsMode = remember { chatModel.controller.appPrefs.notificationsMode.state },
notificationPreviewMode = chatModel.notificationPreviewMode,
showPage = { page ->
ModalManager.shared.showModalCloseable(true) {
ModalManager.start.showModalCloseable(true) {
when (page) {
CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.controller.appPrefs.notificationsMode.state) { changeNotificationsMode(it, chatModel) }
CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected)

View File

@@ -278,7 +278,7 @@ fun SimplexLockView(
}
}
LAMode.PASSCODE -> {
ModalManager.shared.showCustomModal { close ->
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
submit = {
@@ -306,7 +306,7 @@ fun SimplexLockView(
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
LAResult.Success -> {
if (!selfDestruct.get()) {
ModalManager.shared.showCustomModal { close ->
ModalManager.fullscreen.showCustomModal { close ->
EnableSelfDestruct(selfDestruct, close)
}
} else {
@@ -322,7 +322,7 @@ fun SimplexLockView(
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode)) { laResult ->
when (laResult) {
LAResult.Success -> {
ModalManager.shared.showCustomModal { close ->
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
submit = {
@@ -345,7 +345,7 @@ fun SimplexLockView(
authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode)) { laResult ->
when (laResult) {
LAResult.Success -> {
ModalManager.shared.showCustomModal { close ->
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
passcodeKeychain = ksSelfDestructPassword,
@@ -380,7 +380,7 @@ fun SimplexLockView(
setPerformLA(true)
}
LAMode.PASSCODE -> {
ModalManager.shared.showCustomModal { close ->
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
SetAppPasscodeView(
submit = {
@@ -427,7 +427,7 @@ fun SimplexLockView(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.self_destruct_passcode).uppercase()) {
val openInfo = {
ModalManager.shared.showModal {
ModalManager.start.showModal {
SelfDestructInfoView()
}
}

View File

@@ -62,7 +62,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: ()
}
fun showServer(server: ServerCfg) {
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.start.showModalCloseable(true) { close ->
var old by remember { mutableStateOf(server) }
val index = servers.indexOf(old)
ProtocolServerView(
@@ -117,7 +117,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: ()
if (appPlatform.isAndroid) {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.shared.showModalCloseable { close ->
ModalManager.start.showModalCloseable { close ->
ScanProtocolServer {
close()
servers = servers + it

View File

@@ -11,10 +11,10 @@ import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import chat.simplex.common.platform.TAG
import chat.simplex.common.platform.Log
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
@@ -73,8 +73,9 @@ private fun SetDeliveryReceiptsLayout(
skip: () -> Unit,
userCount: Int,
) {
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING),
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING, end = endPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.delivery_receipts_title))

View File

@@ -23,16 +23,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appVersionInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
@@ -48,10 +49,10 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
chatModel.controller.appPrefs.incognito,
user.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
showSettingsModalWithSearch = { modalView ->
ModalManager.shared.showCustomModal { close ->
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
@@ -61,12 +62,12 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
content = { modalView(chatModel, search) })
}
},
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.shared.showModal { VersionInfoView(info) }
ModalManager.start.showModal { VersionInfoView(info) }
}
}
},
@@ -75,7 +76,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
block()
} else {
var autoShow = true
ModalManager.shared.showModalCloseable { close ->
ModalManager.fullscreen.showModalCloseable { close ->
val onFinishAuth = { success: Boolean ->
if (success) {
close()
@@ -104,6 +105,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
}
}
},
drawerState = drawerState,
)
}
}
@@ -125,15 +127,25 @@ fun SettingsLayout(
showSettingsModalWithSearch: (@Composable (ChatModel, MutableState<String>) -> Unit) -> Unit,
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit,
drawerState: DrawerState,
) {
val scope = rememberCoroutineScope()
val closeSettings: () -> Unit = { scope.launch { drawerState.close() } }
if (drawerState.isOpen) {
BackHandler {
closeSettings()
}
}
val theme = CurrentColors.collectAsState()
val uriHandler = LocalUriHandler.current
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).themedBackground(theme.value.base)) {
Box(Modifier.fillMaxSize()) {
Column(
Modifier
.fillMaxSize()
.padding(top = DEFAULT_PADDING)
.verticalScroll(rememberScrollState())
.themedBackground(theme.value.base)
.padding(top = if (appPlatform.isAndroid) DEFAULT_PADDING else DEFAULT_PADDING * 3)
) {
AppBarTitle(stringResource(MR.strings.your_settings))
@@ -178,6 +190,17 @@ fun SettingsLayout(
SettingsSectionApp(showSettingsModal, showCustomModal, showVersion, withAuth)
SectionBottomSpacer()
}
if (appPlatform.isDesktop) {
Box(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
.background(if (isInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(start = 4.dp, top = 8.dp)
) {
NavigationButtonBack(closeSettings)
}
}
}
}
@@ -510,6 +533,7 @@ fun PreviewSettingsLayout() {
showCustomModal = { {} },
showVersion = {},
withAuth = { _, _, _ -> },
drawerState = DrawerState(DrawerValue.Closed),
)
}
}

View File

@@ -85,7 +85,7 @@ fun UserAddressView(
}
},
learnMore = {
ModalManager.shared.showModal {
ModalManager.start.showModal {
Column(
Modifier
.fillMaxHeight()

View File

@@ -30,6 +30,7 @@ import chat.simplex.common.views.database.PassphraseField
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.CreateProfile
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.delay
@@ -47,11 +48,15 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
visibleUsersCount = visibleUsersCount(m),
addUser = {
ModalManager.shared.showModalCloseable { close ->
ModalManager.center.showModalCloseable { close ->
CreateProfile(m, close)
}
},
activateUser = { user ->
if (appPlatform.isDesktop) {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
}
withBGApi {
m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
}
@@ -99,7 +104,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
},
unhideUser = { user ->
if (passwordEntryRequired(user, searchTextOrPassword.value)) {
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.start.showModalCloseable(true) { close ->
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
withBGApi {
setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) }
@@ -122,7 +127,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } }
},
showHiddenProfile = { user ->
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.start.showModalCloseable(true) { close ->
HiddenProfileView(m, user) {
profileHidden.value = true
withBGApi {
@@ -328,7 +333,7 @@ private fun passwordEntryRequired(user: User, searchTextOrPassword: String): Boo
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, searchTextOrPassword: String) {
if (passwordEntryRequired(user, searchTextOrPassword)) {
ModalManager.shared.showModalCloseable(true) { close ->
ModalManager.start.showModalCloseable(true) { close ->
ProfileActionView(UserProfileAction.DELETE, user) { pwd ->
withBGApi {
doRemoveUser(m, user, users, delSMPQueues, pwd)

View File

@@ -267,6 +267,9 @@
<string name="you_have_no_chats">You have no chats</string>
<string name="no_filtered_chats">No filtered chats</string>
<!-- ChatView.kt -->
<string name="no_selected_chat">No selected chat</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
<string name="share_image">Share media…</string>
@@ -1523,4 +1526,8 @@
<string name="delivery_receipts_are_disabled">Delivery receipts are disabled!</string>
<string name="you_can_enable_delivery_receipts_later_alert">You can enable them later via app Privacy &amp; Security settings.</string>
<string name="error_enabling_delivery_receipts">Error enabling delivery receipts!</string>
<!-- Under development -->
<string name="in_developing_title">Coming soon!</string>
<string name="in_developing_desc">This feature is not yet supported. Try the next release.</string>
</resources>

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.desktopPlatform
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.FileDialogChooser
import kotlinx.coroutines.*
@@ -24,7 +25,11 @@ import java.io.File
val simplexWindowState = SimplexWindowState()
fun showApp() = application {
val windowState = rememberWindowState(placement = WindowPlacement.Floating)
// For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366,
// it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here
val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp
val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp)
simplexWindowState.windowState = windowState
Window(state = windowState, onCloseRequest = ::exitApplication, onKeyEvent = {
if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) {
simplexWindowState.backstack.lastOrNull()?.invoke() != null
@@ -108,6 +113,7 @@ fun showApp() = application {
}
class SimplexWindowState {
lateinit var windowState: WindowState
val backstack = mutableStateListOf<() -> Unit>()
val openDialog = DialogState<File?>()
val openMultipleDialog = DialogState<List<File>>()
@@ -144,6 +150,3 @@ fun AppPreview() {
AppScreen()
}
}
/** Needed for [chat.simplex.common.platform.Files] to get path to jar file */
class DesktopApp()

View File

@@ -1,12 +1,8 @@
package chat.simplex.common.platform
import chat.simplex.common.DesktopApp
import chat.simplex.common.model.*
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.helpers.withBGApi
import java.io.*
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import java.util.*
actual val appPlatform = AppPlatform.DESKTOP
@@ -39,29 +35,3 @@ private fun applyAppLocale() {
if (lang == null || lang == Locale.getDefault().language) return
Locale.setDefault(Locale.forLanguageTag(lang))
}
@Suppress("UnsafeDynamicallyLoadedCode")
actual fun initHaskell() {
val libApp = "libapp-lib.${desktopPlatform.libExtension}"
val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs")
copyResources(desktopPlatform.libPath, libsTmpDir.toPath())
System.load(File(libsTmpDir, libApp).absolutePath)
libsTmpDir.deleteRecursively()
initHS()
}
private fun copyResources(from: String, to: Path) {
val resource = DesktopApp::class.java.getResource("")!!.toURI()
val fileSystem = FileSystems.newFileSystem(resource, emptyMap<String, String>())
val resPath = fileSystem.getPath(from)
Files.walkFileTree(resPath, object: SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.createDirectories(to.resolve(resPath.relativize(dir).toString()))
return FileVisitResult.CONTINUE
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.copy(file, to.resolve(resPath.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING)
return FileVisitResult.CONTINUE
}
})
}

View File

@@ -7,15 +7,6 @@ import chat.simplex.res.MR
import java.io.*
import java.net.URI
private fun applicationParentPath(): String = try {
DesktopApp::class.java.protectionDomain!!.codeSource.location.toURI().path
.replaceAfterLast("/", "")
.replaceAfterLast(File.separator, "")
.replace("/", File.separator)
} catch (e: Exception) {
"./"
}
actual val dataDir: File = File(desktopPlatform.dataPath)
actual val tmpDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex").also { it.deleteOnExit() }
actual val filesDir: File = File(dataDir.absolutePath + File.separator + "simplex_v1_files")

View File

@@ -14,6 +14,8 @@ enum class DesktopPlatform(val libPath: String, val libExtension: String, val co
WINDOWS_X86_64("/libs/windows-x86_64", "dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"),
MAC_X86_64("/libs/mac-x86_64", "dylib", unixConfigPath, unixDataPath),
MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath);
fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64
}
private fun detectDesktopPlatform(): DesktopPlatform {

View File

@@ -38,7 +38,7 @@ actual fun PlatformTextField(
val cs = composeState.value
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
val padding = PaddingValues(12.dp, 12.dp, 45.dp, 0.dp)
LaunchedEffect(cs.contextItem) {
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
// In replying state
@@ -74,14 +74,10 @@ actual fun PlatformTextField(
CompositionLocalProvider(
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
) {
Box(
Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.padding(top = 4.dp)
.padding(bottom = 6.dp)
) {
Column(Modifier.weight(1f).padding(start = 12.dp, end = 32.dp)) {
Spacer(Modifier.height(8.dp))
innerTextField()
Spacer(Modifier.height(10.dp))
}
}
}

View File

@@ -2,6 +2,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import chat.simplex.common.model.ChatItem
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import kotlinx.coroutines.CoroutineScope
actual class RecorderNative: RecorderInterface {
@@ -18,7 +19,7 @@ actual class RecorderNative: RecorderInterface {
actual object AudioPlayer: AudioPlayerInterface {
override fun play(filePath: String?, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) {
/*LALAL*/
showInDevelopingAlert()
}
override fun stop() {

View File

@@ -4,8 +4,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import chat.simplex.common.simplexWindowState
import com.russhwolf.settings.*
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.desc.desc
@@ -49,6 +49,11 @@ actual fun screenWidth(): Dp {
return width*/
}// LALAL java.awt.Desktop.getDesktop()
actual fun desktopExpandWindowToWidth(width: Dp) {
if (simplexWindowState.windowState.size.width >= width) return
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = width)
}
actual fun isRtl(text: CharSequence): Boolean {
if (text.isEmpty()) return false
return text.any { char ->

View File

@@ -1,7 +1,9 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import java.net.URI
actual class VideoPlayer: VideoPlayerInterface {
@@ -12,39 +14,40 @@ actual class VideoPlayer: VideoPlayerInterface {
defaultPreview: ImageBitmap,
defaultDuration: Long,
soundEnabled: Boolean
): VideoPlayer = VideoPlayer()
actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean { TODO() }
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) { TODO() }
): VideoPlayer = VideoPlayer().also {
it.preview.value = defaultPreview
it.duration.value = defaultDuration
it.soundEnabled.value = soundEnabled
}
actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean { /*TODO*/ return false }
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) { /*TODO*/ }
actual fun stopAll() { /*LALAL*/ }
actual fun releaseAll() { /*LALAL*/ }
}
override val soundEnabled: MutableState<Boolean>
get() = TODO("Not yet implemented")
override val brokenVideo: MutableState<Boolean>
get() = TODO("Not yet implemented")
override val videoPlaying: MutableState<Boolean>
get() = TODO("Not yet implemented")
override val progress: MutableState<Long>
get() = TODO("Not yet implemented")
override val duration: MutableState<Long>
get() = TODO("Not yet implemented")
override val preview: MutableState<ImageBitmap>
get() = TODO("Not yet implemented")
override val soundEnabled: MutableState<Boolean> = mutableStateOf(false)
override val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
override val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
override val progress: MutableState<Long> = mutableStateOf(0L)
override val duration: MutableState<Long> = mutableStateOf(0L)
override val preview: MutableState<ImageBitmap> = mutableStateOf(ImageBitmap(0, 0))
override fun stop() {
TODO("Not yet implemented")
/*TODO*/
}
override fun play(resetOnEnd: Boolean) {
TODO("Not yet implemented")
if (appPlatform.isDesktop) {
showInDevelopingAlert()
}
}
override fun enableSound(enable: Boolean): Boolean {
TODO("Not yet implemented")
/*TODO*/
return false
}
override fun release(remove: Boolean) {
TODO("Not yet implemented")
/*TODO*/
}
}

View File

@@ -0,0 +1,10 @@
package chat.simplex.common.ui.theme
import com.jthemedetecor.OsThemeDetector
private val detector: OsThemeDetector = OsThemeDetector.getDetector()
.apply {
registerListener(::reactOnDarkThemeChanges)
}
actual fun isSystemInDarkTheme(): Boolean = detector.isDark

View File

@@ -20,7 +20,7 @@ actual fun SimpleAndAnimatedImageView(
// LALAL make it animated too
ImageView(imageBitmap.toAwtImage().toPainter()) {
if (getLoadedFilePath(file) != null) {
ModalManager.shared.showCustomModal(animated = false) { close ->
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
}
}

View File

@@ -1,6 +1,6 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.DropdownMenu
@@ -9,7 +9,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlin.math.exp
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() }
actual interface DefaultExposedDropdownMenuBoxScope {
@Composable

View File

@@ -1,22 +1,88 @@
package chat.simplex.common.views.helpers
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import chat.simplex.common.model.CIFile
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import java.io.File
import java.net.URI
import java.util.*
import javax.imageio.ImageIO
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.path.toPath
// LALAL MAKE REALLY ANNOTATED STRING FROM HTML
actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString {
return AnnotatedString(text)
private val bStyle = SpanStyle(fontWeight = FontWeight.Bold)
private val iStyle = SpanStyle(fontStyle = FontStyle.Italic)
private fun fontStyle(color: String) =
SpanStyle(color = Color(color.replace("#", "ff").toLongOrNull(16) ?: Color.White.toArgb().toLong()))
actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString = try {
buildAnnotatedString {
fun String.substringSafe(start: Int, len: Int): String =
if (start < 0 || start >= this.length || start + len < 0 || start + len > this.length) ""
else substring(start, start + len)
var skipTil = 0
for (outerI in text.indices) {
if (skipTil > outerI) continue
if (text[outerI] == '<') {
for (innerI in outerI + 1 until text.length) {
when {
text.substringSafe(innerI, 2) == "b>" -> {
val textStart = innerI + 2
for (insideTagI in textStart until text.length) {
if (text[insideTagI] == '<') {
withStyle(bStyle) { append(text.substring(textStart, insideTagI)) }
skipTil = insideTagI + 4
break
}
}
break
}
text.substringSafe(innerI, 2) == "i>" -> {
val textStart = innerI + 2
for (insideTagI in textStart until text.length) {
if (text[insideTagI] == '<') {
withStyle(iStyle) { append(text.substring(textStart, insideTagI)) }
skipTil = insideTagI + 4
break
}
}
break
}
text.substringSafe(innerI, 4) == "font" -> {
var textStart = innerI + 5
var color = "#000000"
for (i in textStart until text.length) {
if (text[i] == '#') {
color = text.substring(i, i + 7)
textStart = i + 9
break
}
}
for (insideTagI in textStart until text.length) {
if (text[insideTagI] == '<') {
withStyle(fontStyle(color)) { append(text.substring(textStart, insideTagI)) }
skipTil = insideTagI + 7
break
}
}
break
}
}
if (skipTil > innerI) continue
}
} else {
append(text[outerI])
}
}
}
} catch (e: Exception) {
AnnotatedString(text)
}
actual fun getAppFileUri(fileName: String): URI =

View File

@@ -29,7 +29,7 @@ actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatMod
m.controller.appPrefs.systemDarkTheme,
showSettingsModal = showSettingsModal,
editColor = { name, initialColor ->
ModalManager.shared.showModalCloseable { close ->
ModalManager.start.showModalCloseable { close ->
ColorEditor(name, initialColor, close)
}
},

View File

@@ -30,7 +30,7 @@ kotlin {
compose {
desktop {
application {
mainClass = "MainKt"
mainClass = "chat.simplex.desktop.MainKt"
nativeDistributions {
modules("jdk.zipfs")
//includeAllModules = true
@@ -40,19 +40,19 @@ compose {
//, TargetFormat.AppImage // Gradle doesn't sync on Mac with it
)
linux {
iconFile.set(project.file("../common/src/commonMain/resources/distribute/simplex.png"))
iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.png"))
appCategory = "Messenger"
}
windows {
// LALAL
iconFile.set(project.file("../common/src/commonMain/resources/distribute/simplex.ico"))
iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.ico"))
console = true
perUserInstall = true
dirChooser = true
}
macOS {
// LALAL
//iconFile.set(project.file("../common/src/commonMain/resources/distribute/simplex.icns"))
//iconFile.set(project.file("../desktop/src/jvmMain/resources/distribute/simplex.icns"))
appCategory = "public.app-category.social-networking"
bundleID = "chat.simplex.app"
}
@@ -116,7 +116,7 @@ afterEvaluate {
doLast {
copy {
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps")
into("../common/src/commonMain/resources/libs/linux-x86_64")
into("src/jvmMain/resources/libs/linux-x86_64")
include("*.so")
eachFile {
path = name
@@ -126,7 +126,7 @@ afterEvaluate {
}
copy {
from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps")
into("../common/src/commonMain/resources/libs/linux-aarch64")
into("src/jvmMain/resources/libs/linux-aarch64")
include("*.so")
eachFile {
path = name
@@ -136,7 +136,7 @@ afterEvaluate {
}
copy {
from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
into("../common/src/commonMain/resources/libs/windows-x86_64")
into("src/jvmMain/resources/libs/windows-x86_64")
include("*.dll")
eachFile {
path = name
@@ -146,7 +146,7 @@ afterEvaluate {
}
copy {
from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps")
into("../common/src/commonMain/resources/libs/mac-x86_64")
into("src/jvmMain/resources/libs/mac-x86_64")
include("*.dylib")
eachFile {
path = name
@@ -156,7 +156,7 @@ afterEvaluate {
}
copy {
from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps")
into("../common/src/commonMain/resources/libs/mac-aarch64")
into("src/jvmMain/resources/libs/mac-aarch64")
include("*.dylib")
eachFile {
path = name

View File

@@ -1,9 +0,0 @@
import chat.simplex.common.platform.*
import chat.simplex.common.showApp
fun main() {
initHaskell()
initApp()
tmpDir.deleteRecursively()
return showApp()
}

View File

@@ -0,0 +1,40 @@
package chat.simplex.desktop
import chat.simplex.common.platform.*
import chat.simplex.common.showApp
import java.io.File
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
fun main() {
initHaskell()
initApp()
tmpDir.deleteRecursively()
return showApp()
}
@Suppress("UnsafeDynamicallyLoadedCode")
private fun initHaskell() {
val libApp = "libapp-lib.${desktopPlatform.libExtension}"
val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs")
copyResources(desktopPlatform.libPath, libsTmpDir.toPath())
System.load(File(libsTmpDir, libApp).absolutePath)
libsTmpDir.deleteRecursively()
initHS()
}
private fun copyResources(from: String, to: Path) {
val resource = Class.forName("chat.simplex.desktop.MainKt").getResource("")!!.toURI()
val fileSystem = FileSystems.newFileSystem(resource, emptyMap<String, String>())
val resPath = fileSystem.getPath(from)
Files.walkFileTree(resPath, object: SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.createDirectories(to.resolve(resPath.relativize(dir).toString()))
return FileVisitResult.CONTINUE
}
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.copy(file, to.resolve(resPath.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING)
return FileVisitResult.CONTINUE
}
})
}

View File

@@ -2,6 +2,6 @@
Type=Application
Name=SimpleX Chat
Comment=Private and secure open-source messenger - no user IDs (not even random numbers).
Exec=simplex
Icon=simplex.png
Categories=Messenger;
Exec=/opt/simplex/bin/simplex
Icon=/opt/simplex/lib/simplex
Categories=Network;Chat;

View File

@@ -17,7 +17,7 @@ ldd libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so | grep "ghc" | cut -d' ' -f
cd -
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
rm -rf apps/multiplatform/common/src/commonMain/resources/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/

View File

@@ -64,7 +64,7 @@ rm deps/`basename $LIB`
cd -
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
rm -rf apps/multiplatform/common/src/commonMain/resources/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/

View File

@@ -0,0 +1,20 @@
#!/bin/bash
sudo apt install git openjdk-17-jdk make cmake gcc g++ libssl-dev zlib1g zlib1g-dev pkg-config build-essential curl libffi-dev libgmp-dev libgmp10 libncurses-dev libncurses5 libtinfo5
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
ghcup install ghc 8.10.7
ghcup set ghc 8.10.7
cabal update
git clone https://github.com/simplex-chat/simplex-chat -b master simplex
cd simplex
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
scripts/desktop/build-lib-linux.sh
cd apps/multiplatform
sed -i 's|":android", ||' settings.gradle.kts
./gradlew packageDeb
#sudo dpkg -i ./release/main/deb/simpl*.deb
#sudo ln -s /opt/simplex/bin/simplex /usr/bin/simplex

View File

@@ -105,6 +105,7 @@ library
Simplex.Chat.Migrations.M20230618_favorite_chats
Simplex.Chat.Migrations.M20230621_chat_item_moderations
Simplex.Chat.Migrations.M20230705_delivery_receipts
Simplex.Chat.Migrations.M20230721_group_snd_item_statuses
Simplex.Chat.Mobile
Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Options

View File

@@ -159,6 +159,9 @@ maxMsgReactions = 3
fixedImagePreview :: ImageData
fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="
smallGroupsRcptsMemLimit :: Int
smallGroupsRcptsMemLimit = 20
logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
@@ -397,6 +400,12 @@ processChatCommand = \case
withStore' $ \db -> updateUserContactReceipts db user' settings
ok user
SetUserContactReceipts settings -> withUser $ \User {userId} -> processChatCommand $ APISetUserContactReceipts userId settings
APISetUserGroupReceipts userId' settings -> withUser $ \user -> do
user' <- privateGetUser userId'
validateUserPassword user user' Nothing
withStore' $ \db -> updateUserGroupReceipts db user' settings
ok user
SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand $ APISetUserGroupReceipts userId settings
APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do
user' <- privateGetUser userId'
case viewPwdHash user' of
@@ -494,10 +503,16 @@ processChatCommand = \case
chatItems <- withStore $ \db -> getAllChatItems db user pagination search
pure $ CRChatItems user chatItems
APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do
(aci@(AChatItem _ _ _ ci), versions) <- withStore $ \db ->
(aci@(AChatItem cType dir _ ci), versions) <- withStore $ \db ->
(,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId)
let itemVersions = if null versions then maybeToList $ mkItemVersion ci else versions
pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions}
memberDeliveryStatuses <- case (cType, dir) of
(SCTGroup, SMDSnd) -> do
withStore' (`getGroupSndStatuses` itemId) >>= \case
[] -> pure Nothing
memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses
_ -> pure Nothing
pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses}
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
@@ -572,9 +587,12 @@ processChatCommand = \case
(fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms)
timed_ <- sndGroupCITimed live gInfo itemTTL
(msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership
msg@SndMessage {sharedMsgId} <- sendGroupMessage user gInfo ms (XMsgNew msgContainer)
(msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer)
mapM_ (sendGroupFileInline ms sharedMsgId) ft_
ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live
withStore' $ \db ->
forM_ sentToMembers $ \GroupMember {groupMemberId} ->
createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew
forM_ (timed_ >>= timedDeleteAt') $
startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci)
setActive $ ActiveG gName
@@ -708,7 +726,7 @@ processChatCommand = \case
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))
(SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
ci' <- withStore' $ \db -> do
currentTs <- liftIO getCurrentTime
when changed $
@@ -742,7 +760,7 @@ processChatCommand = \case
(CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime
(CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do
assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier
SndMessage {msgId} <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId Nothing
(SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId Nothing
delGroupChatItem user gInfo ci msgId Nothing
(CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
@@ -754,7 +772,7 @@ processChatCommand = \case
(CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do
when (groupMemberId /= mId) $ throwChatError CEInvalidChatItemDelete
assertUserGroupRole gInfo $ max GRAdmin memberRole
SndMessage {msgId} <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId
(SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId
delGroupChatItem user gInfo ci msgId (Just membership)
(_, _) -> throwChatError CEInvalidChatItemDelete
APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of
@@ -786,7 +804,7 @@ processChatCommand = \case
let GroupMember {memberId = itemMemberId} = chatItemMember g ci
rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True
checkReactionAllowed rs
SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add)
(SndMessage {msgId}, _) <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add)
createdAt <- liftIO getCurrentTime
reactions <- withStore' $ \db -> do
setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt
@@ -1409,7 +1427,7 @@ processChatCommand = \case
(Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
_ -> do
msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole
(msg, _) <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent)
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole}
@@ -1425,7 +1443,7 @@ processChatCommand = \case
deleteMemberConnection user m
withStore' $ \db -> deleteGroupMember db user m
_ -> do
msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId
(msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile))
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
deleteMemberConnection user m
@@ -1435,7 +1453,7 @@ processChatCommand = \case
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId
withChatLock "leaveGroup" . procCmd $ do
msg <- sendGroupMessage user gInfo members XGrpLeave
(msg, _) <- sendGroupMessage user gInfo members XGrpLeave
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
-- TODO delete direct connections that were unused
@@ -1823,7 +1841,7 @@ processChatCommand = \case
runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p} ms) p' = do
assertUserGroupRole g GROwner
g' <- withStore $ \db -> updateGroupProfile db user g p'
msg <- sendGroupMessage user g' ms (XGrpInfo p')
(msg, _) <- sendGroupMessage user g' ms (XGrpInfo p')
let cd = CDGroupSnd g'
unless (sameGroupProfileInfo p p') $ do
ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p')
@@ -2871,12 +2889,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
SENT msgId -> do
sentMsgDeliveryEvent conn msgId
checkSndInlineFTComplete conn msgId
withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId msgId) >>= \case
Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _}}) -> pure ()
Just (CChatItem SMDSnd ci) -> do
chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId (chatItemId' ci) CISSndSent
toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
_ -> pure ()
updateDirectItemStatus ct conn msgId $ CISSndSent SSPComplete
SWITCH qd phase cStats -> do
toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats)
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
@@ -2917,10 +2930,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} ->
when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId
MERR msgId err -> do
chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId
forM_ chatItemId_ $ \chatItemId -> do
chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId chatItemId (agentErrToItemStatus err)
toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
updateDirectItemStatus ct conn msgId $ agentErrToItemStatus err
toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity)
incAuthErrCounter connEntity conn err
ERR err -> do
@@ -3066,7 +3076,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
XGrpInfo p' -> xGrpInfo gInfo m p' msg msgMeta
BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta
_ -> messageError $ "unsupported message: " <> T.pack (show event)
pure False -- no receipts in group now $ hasDeliveryReceipt $ toCMEventTag event
currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo
let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo
pure $
fromMaybe (sendRcptsSmallGroups user) sendRcpts
&& hasDeliveryReceipt (toCMEventTag event)
&& currentMemCount <= smallGroupsRcptsMemLimit
where
canSend a
| memberRole (m :: GroupMember) <= GRObserver = messageError "member is not allowed to send messages"
@@ -3077,6 +3092,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
SENT msgId -> do
sentMsgDeliveryEvent conn msgId
checkSndInlineFTComplete conn msgId
updateGroupItemStatus gInfo m conn msgId $ CISSndSent SSPComplete
SWITCH qd phase cStats -> do
toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats)
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
@@ -3113,7 +3129,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
-- [async agent commands] continuation on receiving OK
withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} ->
when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId
MERR _ err -> do
MERR msgId err -> do
chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId
forM_ chatItemId_ $ \itemId -> do
let GroupMember {groupMemberId} = m
updateGroupMemSndStatus itemId groupMemberId $ agentErrToItemStatus err
-- group errors are silenced to reduce load on UI event log
-- toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity)
incAuthErrCounter connEntity conn err
@@ -3368,7 +3388,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
-- 1) retry processing several times
-- 2) stabilize database
-- 3) show screen of death to the user asking to restart
-- TODO send receipt depending on contact/group settings
tryChatError action >>= \case
Right withRcpt -> ack $ if withRcpt then Just "" else Nothing
Left e -> ack Nothing >> throwError e
@@ -4295,21 +4314,52 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'
directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m ()
directMsgReceived ct@Contact {contactId} Connection {connId} msgMeta msgRcpts = do
directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do
withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus
withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId agentMsgId) >>= \case
Just (CChatItem SMDSnd ci) -> do
chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId (chatItemId' ci) $ CISSndRcvd msgRcptStatus
toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
_ -> pure ()
updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete
groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m ()
groupMsgReceived gInfo m Connection {connId} msgMeta msgRcpts = do
groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do
checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta
forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} ->
forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do
withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus
updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete
updateDirectItemStatus :: Contact -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> m ()
updateDirectItemStatus ct@Contact {contactId} Connection {connId} msgId newStatus =
withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId msgId) >>= \case
Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ _}}) -> pure ()
Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}})
| itemStatus == newStatus -> pure ()
| otherwise -> do
chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId itemId newStatus
toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
_ -> pure ()
updateGroupMemSndStatus :: ChatItemId -> GroupMemberId -> CIStatus 'MDSnd -> m Bool
updateGroupMemSndStatus itemId groupMemberId newStatus =
runExceptT (withStore $ \db -> getGroupSndStatus db itemId groupMemberId) >>= \case
Right (CISSndRcvd _ _) -> pure False
Right memStatus
| memStatus == newStatus -> pure False
| otherwise -> withStore' (\db -> updateGroupSndStatus db itemId groupMemberId newStatus) $> True
_ -> pure False
updateGroupItemStatus :: GroupInfo -> GroupMember -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> m ()
updateGroupItemStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus =
withStore' (\db -> getGroupChatItemByAgentMsgId db user groupId connId msgId) >>= \case
Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure ()
Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) -> do
memStatusChanged <- updateGroupMemSndStatus itemId groupMemberId newMemStatus
when memStatusChanged $ do
memStatusCounts <- withStore' (`getGroupSndStatusCounts` itemId)
let newStatus = membersGroupItemStatus memStatusCounts
when (newStatus /= itemStatus) $ do
chatItem <- withStore $ \db -> updateGroupChatItemStatus db user groupId itemId newStatus
toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem)
_ -> pure ()
parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p)
parseFileDescription =
@@ -4525,26 +4575,33 @@ deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do
(Just $ "createSndMsgDelivery, sndMsgDelivery: " <> show sndMsgDelivery <> ", msgId: " <> show msgId <> ", cmEventTag: " <> show cmEventTag <> ", msgDeliveryStatus: MDSSndAgent")
$ \db -> createSndMsgDelivery db sndMsgDelivery msgId
sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m SndMessage
sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember])
sendGroupMessage user GroupInfo {groupId} members chatMsgEvent =
sendGroupMessage' user members chatMsgEvent groupId Nothing $ pure ()
sendGroupMessage' :: (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m SndMessage
sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m (SndMessage, [GroupMember])
sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do
msg <- createSndMessage chatMsgEvent (GroupId groupId)
-- TODO collect failed deliveries into a single error
forM_ (filter memberCurrent members) $ \m ->
messageMember m msg `catchChatError` (toView . CRChatError (Just user))
pure msg
rs <- forM (filter memberCurrent members) $ \m ->
messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing)
let sentToMembers = catMaybes rs
pure (msg, sentToMembers)
where
messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember)
messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of
Nothing -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_
Nothing -> do
withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_
pure $ Just m
Just conn@Connection {connStatus}
| connDisabled conn || connStatus == ConnDeleted -> pure ()
| connDisabled conn || connStatus == ConnDeleted -> pure Nothing
| connStatus == ConnSndReady || connStatus == ConnReady -> do
let tag = toCMEventTag chatMsgEvent
deliverMessage conn tag msgBody msgId >> postDeliver
| otherwise -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_
pure $ Just m
| otherwise -> do
withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_
pure $ Just m
sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m ()
sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do
@@ -4926,8 +4983,10 @@ chatCommandP =
"/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)),
("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)),
"/set receipts all " *> (SetAllContactReceipts <$> onOffP),
"/_set receipts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings),
"/set receipts " *> (SetUserContactReceipts <$> receiptSettings),
"/_set receipts contacts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings),
"/set receipts contacts " *> (SetUserContactReceipts <$> receiptSettings),
"/_set receipts groups " *> (APISetUserGroupReceipts <$> A.decimal <* A.space <*> receiptSettings),
"/set receipts groups " *> (SetUserGroupReceipts <$> receiptSettings),
"/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP),
"/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP),
"/_mute user " *> (APIMuteUser <$> A.decimal),

View File

@@ -203,6 +203,8 @@ data ChatCommand
| SetAllContactReceipts Bool
| APISetUserContactReceipts UserId UserMsgReceiptSettings
| SetUserContactReceipts UserMsgReceiptSettings
| APISetUserGroupReceipts UserId UserMsgReceiptSettings
| SetUserGroupReceipts UserMsgReceiptSettings
| APIHideUser UserId UserPwd
| APIUnhideUser UserId UserPwd
| APIMuteUser UserId

View File

@@ -21,7 +21,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Maybe (isJust, isNothing)
import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
@@ -624,13 +624,15 @@ data CIFileInfo = CIFileInfo
data CIStatus (d :: MsgDirection) where
CISSndNew :: CIStatus 'MDSnd
CISSndSent :: CIStatus 'MDSnd
CISSndRcvd :: MsgReceiptStatus -> CIStatus 'MDSnd
CISSndSent :: SndCIStatusProgress -> CIStatus 'MDSnd
CISSndRcvd :: MsgReceiptStatus -> SndCIStatusProgress -> CIStatus 'MDSnd
CISSndErrorAuth :: CIStatus 'MDSnd
CISSndError :: String -> CIStatus 'MDSnd
CISRcvNew :: CIStatus 'MDRcv
CISRcvRead :: CIStatus 'MDRcv
deriving instance Eq (CIStatus d)
deriving instance Show (CIStatus d)
instance ToJSON (CIStatus d) where
@@ -639,6 +641,8 @@ instance ToJSON (CIStatus d) where
instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode
instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d)
@@ -648,8 +652,8 @@ deriving instance Show ACIStatus
instance MsgDirectionI d => StrEncoding (CIStatus d) where
strEncode = \case
CISSndNew -> "snd_new"
CISSndSent -> "snd_sent"
CISSndRcvd status -> "snd_rcvd " <> strEncode status
CISSndSent sndProgress -> "snd_sent " <> strEncode sndProgress
CISSndRcvd msgRcptStatus sndProgress -> "snd_rcvd " <> strEncode msgRcptStatus <> " " <> strEncode sndProgress
CISSndErrorAuth -> "snd_error_auth"
CISSndError e -> "snd_error " <> encodeUtf8 (T.pack e)
CISRcvNew -> "rcv_new"
@@ -661,8 +665,8 @@ instance StrEncoding ACIStatus where
strP =
A.takeTill (== ' ') >>= \case
"snd_new" -> pure $ ACIStatus SMDSnd CISSndNew
"snd_sent" -> pure $ ACIStatus SMDSnd CISSndSent
"snd_rcvd" -> ACIStatus SMDSnd . CISSndRcvd <$> (A.space *> strP)
"snd_sent" -> ACIStatus SMDSnd . CISSndSent <$> ((A.space *> strP) <|> pure SSPComplete)
"snd_rcvd" -> ACIStatus SMDSnd <$> (CISSndRcvd <$> (A.space *> strP) <*> ((A.space *> strP) <|> pure SSPComplete))
"snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth
"snd_error" -> ACIStatus SMDSnd . CISSndError . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString)
"rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew
@@ -671,8 +675,8 @@ instance StrEncoding ACIStatus where
data JSONCIStatus
= JCISSndNew
| JCISSndSent
| JCISSndRcvd {msgRcptStatus :: MsgReceiptStatus}
| JCISSndSent {sndProgress :: SndCIStatusProgress}
| JCISSndRcvd {msgRcptStatus :: MsgReceiptStatus, sndProgress :: SndCIStatusProgress}
| JCISSndErrorAuth
| JCISSndError {agentError :: String}
| JCISRcvNew
@@ -686,8 +690,8 @@ instance ToJSON JSONCIStatus where
jsonCIStatus :: CIStatus d -> JSONCIStatus
jsonCIStatus = \case
CISSndNew -> JCISSndNew
CISSndSent -> JCISSndSent
CISSndRcvd ok -> JCISSndRcvd ok
CISSndSent sndProgress -> JCISSndSent sndProgress
CISSndRcvd msgRcptStatus sndProgress -> JCISSndRcvd msgRcptStatus sndProgress
CISSndErrorAuth -> JCISSndErrorAuth
CISSndError e -> JCISSndError e
CISRcvNew -> JCISRcvNew
@@ -703,6 +707,40 @@ ciCreateStatus content = case msgDirection @d of
SMDSnd -> ciStatusNew
SMDRcv -> if ciRequiresAttention content then ciStatusNew else CISRcvRead
membersGroupItemStatus :: [(CIStatus 'MDSnd, Int)] -> CIStatus 'MDSnd
membersGroupItemStatus memStatusCounts
| rcvdOk == total = CISSndRcvd MROk SSPComplete
| rcvdOk + rcvdBad == total = CISSndRcvd MRBadMsgHash SSPComplete
| rcvdBad > 0 = CISSndRcvd MRBadMsgHash SSPPartial
| rcvdOk > 0 = CISSndRcvd MROk SSPPartial
| sent == total = CISSndSent SSPComplete
| sent > 0 = CISSndSent SSPPartial
| otherwise = CISSndNew
where
total = sum $ map snd memStatusCounts
rcvdOk = fromMaybe 0 $ lookup (CISSndRcvd MROk SSPComplete) memStatusCounts
rcvdBad = fromMaybe 0 $ lookup (CISSndRcvd MRBadMsgHash SSPComplete) memStatusCounts
sent = fromMaybe 0 $ lookup (CISSndSent SSPComplete) memStatusCounts
data SndCIStatusProgress
= SSPPartial
| SSPComplete
deriving (Eq, Show, Generic)
instance ToJSON SndCIStatusProgress where
toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP"
toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP"
instance StrEncoding SndCIStatusProgress where
strEncode = \case
SSPPartial -> "partial"
SSPComplete -> "complete"
strP =
A.takeWhile1 (/= ' ') >>= \case
"partial" -> pure SSPPartial
"complete" -> pure SSPComplete
_ -> fail "bad SndCIStatusProgress"
type ChatItemId = Int64
type ChatItemTs = UTCTime
@@ -887,7 +925,8 @@ itemDeletedTs = \case
CIModerated ts _ -> ts
data ChatItemInfo = ChatItemInfo
{ itemVersions :: [ChatItemVersion]
{ itemVersions :: [ChatItemVersion],
memberDeliveryStatuses :: Maybe [MemberDeliveryStatus]
}
deriving (Eq, Show, Generic)
@@ -917,6 +956,14 @@ mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content
createdAt = createdAt
}
data MemberDeliveryStatus = MemberDeliveryStatus
{ groupMemberId :: GroupMemberId,
memberDeliveryStatus :: CIStatus 'MDSnd
}
deriving (Eq, Show, Generic)
instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions
data CIModeration = CIModeration
{ moderationId :: Int64,
moderatorMember :: GroupMember,

View File

@@ -0,0 +1,33 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230721_group_snd_item_statuses where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230721_group_snd_item_statuses :: Query
m20230721_group_snd_item_statuses =
[sql|
CREATE TABLE group_snd_item_statuses (
group_snd_item_status_id INTEGER PRIMARY KEY,
chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE,
group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
group_snd_item_status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses(chat_item_id);
CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses(group_member_id);
UPDATE users SET send_rcpts_small_groups = 1 WHERE send_rcpts_contacts = 1;
|]
down_m20230721_group_snd_item_statuses :: Query
down_m20230721_group_snd_item_statuses =
[sql|
DROP INDEX idx_group_snd_item_statuses_group_member_id;
DROP INDEX idx_group_snd_item_statuses_chat_item_id;
DROP TABLE group_snd_item_statuses;
|]

View File

@@ -496,6 +496,14 @@ CREATE TABLE chat_item_moderations(
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE group_snd_item_statuses(
group_snd_item_status_id INTEGER PRIMARY KEY,
chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE,
group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
group_snd_item_status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -687,3 +695,9 @@ CREATE INDEX idx_chat_item_moderations_group ON chat_item_moderations(
item_member_id,
shared_msg_id
);
CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses(
chat_item_id
);
CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses(
group_member_id
);

View File

@@ -39,6 +39,7 @@ module Simplex.Chat.Store.Groups
getGroupMemberById,
getGroupMembers,
getGroupMembersForExpiration,
getGroupCurrentMembersCount,
deleteGroupConnectionsAndFiles,
deleteGroupItemsAndMembers,
deleteGroup,
@@ -548,6 +549,20 @@ toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember
toContactMember User {userContactId} (memberRow :. connRow) =
(toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow}
getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int
getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do
statuses :: [GroupMemberStatus] <-
map fromOnly
<$> DB.query
db
[sql|
SELECT member_status
FROM group_members
WHERE group_id = ? AND user_id = ?
|]
(groupId, userId)
pure $ length $ filter memberCurrent' statuses
getGroupInvitation :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation
getGroupInvitation db user groupId =
getConnRec_ user >>= \case

View File

@@ -44,6 +44,7 @@ module Simplex.Chat.Store.Messages
createChatItemVersion,
deleteDirectChatItem,
markDirectChatItemDeleted,
updateGroupChatItemStatus,
updateGroupChatItem,
deleteGroupChatItem,
updateGroupChatItemModerated,
@@ -69,6 +70,7 @@ module Simplex.Chat.Store.Messages
getGroupChatItem,
getGroupChatItemBySharedMsgId,
getGroupMemberCIBySharedMsgId,
getGroupChatItemByAgentMsgId,
getGroupMemberChatItemLast,
getDirectChatItemIdByText,
getDirectChatItemIdByText',
@@ -87,6 +89,11 @@ module Simplex.Chat.Store.Messages
createCIModeration,
getCIModeration,
deleteCIModeration,
createGroupSndStatus,
getGroupSndStatus,
updateGroupSndStatus,
getGroupSndStatuses,
getGroupSndStatusCounts,
)
where
@@ -1325,6 +1332,16 @@ getDirectChatItemIdByText' db User {userId} contactId msg =
|]
(userId, contactId, msg <> "%")
updateGroupChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupId -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d)
updateGroupChatItemStatus db user@User {userId} groupId itemId itemStatus = do
ci <- liftEither . correctDir =<< getGroupChatItem db user groupId itemId
currentTs <- liftIO getCurrentTime
liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, groupId, itemId)
pure ci {meta = (meta ci) {itemStatus}}
where
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
updateGroupChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d)
updateGroupChatItem db user groupId ci newContent live msgId_ = do
currentTs <- liftIO getCurrentTime
@@ -1434,6 +1451,11 @@ getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId
(GCUserMember, userId, groupId, memberId, sharedMsgId)
getGroupChatItem db user groupId itemId
getGroupChatItemByAgentMsgId :: DB.Connection -> User -> GroupId -> Int64 -> AgentMsgId -> IO (Maybe (CChatItem 'CTGroup))
getGroupChatItemByAgentMsgId db user groupId connId msgId = do
itemId_ <- getChatItemIdByAgentMsgId db connId msgId
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupChatItem db user groupId) itemId_
getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup)
getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
currentTs <- getCurrentTime
@@ -1847,3 +1869,58 @@ deleteCIModeration db GroupInfo {groupId} itemMemberId (Just sharedMsgId) =
db
"DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ?"
(groupId, itemMemberId, sharedMsgId)
createGroupSndStatus :: DB.Connection -> ChatItemId -> GroupMemberId -> CIStatus 'MDSnd -> IO ()
createGroupSndStatus db itemId memberId status =
DB.execute
db
"INSERT INTO group_snd_item_statuses (chat_item_id, group_member_id, group_snd_item_status) VALUES (?,?,?)"
(itemId, memberId, status)
getGroupSndStatus :: DB.Connection -> ChatItemId -> GroupMemberId -> ExceptT StoreError IO (CIStatus 'MDSnd)
getGroupSndStatus db itemId memberId =
ExceptT . firstRow fromOnly (SENoGroupSndStatus itemId memberId) $
DB.query
db
[sql|
SELECT group_snd_item_status
FROM group_snd_item_statuses
WHERE chat_item_id = ? AND group_member_id = ?
LIMIT 1
|]
(itemId, memberId)
updateGroupSndStatus :: DB.Connection -> ChatItemId -> GroupMemberId -> CIStatus 'MDSnd -> IO ()
updateGroupSndStatus db itemId memberId status = do
currentTs <- liftIO getCurrentTime
DB.execute
db
[sql|
UPDATE group_snd_item_statuses
SET group_snd_item_status = ?, updated_at = ?
WHERE chat_item_id = ? AND group_member_id = ?
|]
(status, currentTs, itemId, memberId)
getGroupSndStatuses :: DB.Connection -> ChatItemId -> IO [(GroupMemberId, CIStatus 'MDSnd)]
getGroupSndStatuses db itemId =
DB.query
db
[sql|
SELECT group_member_id, group_snd_item_status
FROM group_snd_item_statuses
WHERE chat_item_id = ?
|]
(Only itemId)
getGroupSndStatusCounts :: DB.Connection -> ChatItemId -> IO [(CIStatus 'MDSnd, Int)]
getGroupSndStatusCounts db itemId =
DB.query
db
[sql|
SELECT group_snd_item_status, COUNT(1)
FROM group_snd_item_statuses
WHERE chat_item_id = ?
GROUP BY group_snd_item_status
|]
(Only itemId)

View File

@@ -74,6 +74,7 @@ import Simplex.Chat.Migrations.M20230608_deleted_contacts
import Simplex.Chat.Migrations.M20230618_favorite_chats
import Simplex.Chat.Migrations.M20230621_chat_item_moderations
import Simplex.Chat.Migrations.M20230705_delivery_receipts
import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -147,7 +148,8 @@ schemaMigrations =
("20230608_deleted_contacts", m20230608_deleted_contacts, Just down_m20230608_deleted_contacts),
("20230618_favorite_chats", m20230618_favorite_chats, Just down_m20230618_favorite_chats),
("20230621_chat_item_moderations", m20230621_chat_item_moderations, Just down_m20230621_chat_item_moderations),
("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts)
("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts),
("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses)
]
-- | The list of migrations in ascending order by date

View File

@@ -30,6 +30,7 @@ module Simplex.Chat.Store.Profiles
updateUserPrivacy,
updateAllContactReceipts,
updateUserContactReceipts,
updateUserGroupReceipts,
updateUserProfile,
setUserProfileContactLink,
getUserContactProfiles,
@@ -92,7 +93,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image,
when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0"
let showNtfs = True
sendRcptsContacts = True
sendRcptsSmallGroups = False
sendRcptsSmallGroups = True
DB.execute
db
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,0,?,?,?,?,?)"
@@ -222,13 +223,21 @@ updateUserPrivacy db User {userId, showNtfs, viewPwdHash} =
updateAllContactReceipts :: DB.Connection -> Bool -> IO ()
updateAllContactReceipts db onOff =
DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE view_pwd_hash IS NULL" (Only onOff)
DB.execute
db
"UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL"
(onOff, onOff)
updateUserContactReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO ()
updateUserContactReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do
DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (enable, userId)
when clearOverrides $ DB.execute_ db "UPDATE contacts SET send_rcpts = NULL"
updateUserGroupReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO ()
updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do
DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (enable, userId)
when clearOverrides $ DB.execute_ db "UPDATE groups SET send_rcpts = NULL"
updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User
updateUserProfile db user p'
| displayName == newName = do

View File

@@ -92,6 +92,7 @@ data StoreError
| SEGroupLinkNotFound {groupInfo :: GroupInfo}
| SEHostMemberIdNotFound {groupId :: Int64}
| SEContactNotFoundByFileId {fileId :: FileTransferId}
| SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId}
deriving (Show, Exception, Generic)
instance ToJSON StoreError where

View File

@@ -782,7 +782,10 @@ memberActive m = case memberStatus m of
GSMemCreator -> True
memberCurrent :: GroupMember -> Bool
memberCurrent m = case memberStatus m of
memberCurrent = memberCurrent' . memberStatus
memberCurrent' :: GroupMemberStatus -> Bool
memberCurrent' = \case
GSMemRemoved -> False
GSMemLeft -> False
GSMemGroupDeleted -> False

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