Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b11d1c5d | |||
| a1562bf0e7 | |||
| 73447ce22b | |||
| 956cf6b203 | |||
| 378118b82e | |||
| 92abdde69e | |||
| 69758971af | |||
| 5fa2d7f7ce | |||
| 0169589e7c | |||
| 2d4348c50d | |||
| 14f6e5c16f | |||
| 51a2fa8c28 | |||
| 7343e4a51a | |||
| b4d7afb4c1 | |||
| 2fc6873c42 | |||
| 7683254de2 | |||
| 3a077d927d | |||
| a5e74ea2f0 | |||
| 53a71cf28c | |||
| e6551abc68 | |||
| bd7aa81625 | |||
| 04592f52de | |||
| a06499d710 | |||
| 6d1414af71 | |||
| 9cd7a7fdb0 | |||
| 3c2f5d14f5 | |||
| 985f3dffd3 | |||
| 0a048eb286 | |||
| 2cffe91e0b | |||
| 9f94c6f98a | |||
| 164426db49 | |||
| 307db450d8 | |||
| e6233722db | |||
| d26083d8b7 | |||
| f561698fb9 | |||
| 74966b1425 | |||
| 51b8ce10ae | |||
| 8c716962fb | |||
| fee2b247e9 | |||
| 992ba75306 | |||
| 70168967a3 | |||
| 3221c0abb5 | |||
| d8049d4bfc | |||
| b15d39eb4a | |||
| 85e36ac12c | |||
| 5e67654249 | |||
| 404b7093b7 | |||
| cad1ad87a8 | |||
| fd27839442 | |||
| ae6fae5ced | |||
| e9cddd6ca3 | |||
| 76bde53206 | |||
| 0a2f7681d8 | |||
| 3776e1c29c | |||
| 2e4ffb7fe9 | |||
| 954338658f | |||
| 7f78b08100 | |||
| 5d8d636adc | |||
| aac80dacf7 | |||
| e43be1ad8b | |||
| 57000fa3f0 | |||
| db367b376b | |||
| cc498572cd | |||
| 622ab549a3 | |||
| f896c4453d | |||
| 38f65c82c3 | |||
| 22733f505d | |||
| 26a019d9d2 | |||
| 7531791f1b | |||
| cd28ba62a1 | |||
| 32dff4e1a3 | |||
| fd85026a0d | |||
| e3f63db5ab | |||
| 7f959103c1 | |||
| bd3d4467c7 | |||
| 5345199829 | |||
| 481c4c0763 | |||
| bf2b3855b7 | |||
| a254d5f050 | |||
| e8749debec | |||
| afbc7dd2c1 | |||
| 7a00a3e324 | |||
| 03d9d86aba | |||
| 13e7925348 | |||
| 46319044f8 | |||
| 8a7e320d12 | |||
| 152ed96ac0 | |||
| 8dc7bea724 | |||
| 497cf86eb0 | |||
| 9508ea5c97 | |||
| 257133db3b | |||
| c4bc88b49b | |||
| 80389ffe93 | |||
| e53540f43f | |||
| 55adbb4692 | |||
| 91baf9f362 | |||
| 04b9243d7e | |||
| b3d74933c2 | |||
| 90ab6f34bf | |||
| 57e7034b4d | |||
| 8455cca9c3 | |||
| 9fdc2a4631 | |||
| a5cdbc90f8 | |||
| a5972c7de1 | |||
| c74a4fcbca | |||
| 4c6ee95eb7 | |||
| 9e210256d2 | |||
| d67f86ada5 | |||
| 7a03f87822 | |||
| d6a4a245dc | |||
| 0fe7e64989 | |||
| e39f9bc251 | |||
| cbd7882ff4 | |||
| 4ad1abcbfa | |||
| a36c367b81 | |||
| a14859d8c0 | |||
| 9e23150938 | |||
| 35eeac194e | |||
| 0b4a6cf9eb | |||
| 2422f36d61 | |||
| 60117d0853 | |||
| 95757ed562 | |||
| cc0a74fae4 | |||
| ce91dcde7f | |||
| 999923bcf9 | |||
| 30c345933b | |||
| 1b8c55a0a3 | |||
| 4f4935256c | |||
| 1dd7520bbd | |||
| de0f231c60 | |||
| 0c58adff08 | |||
| e87c78e997 | |||
| ee6f6462cf | |||
| 7b9164f95a | |||
| 4a931bc145 | |||
| bf4072b365 | |||
| 658cc1af56 | |||
| 68bc572800 | |||
| 2286752fe0 | |||
| 9864533dae | |||
| aa7e377bce | |||
| a4aaf36774 | |||
| 608030dcaf | |||
| 6069108bb9 | |||
| e7f3dc3f41 | |||
| f150932e44 | |||
| 7dcde32680 | |||
| 552397d938 | |||
| cfa4b44d1f | |||
| 9fcd127c48 | |||
| 7c01ad7d4f | |||
| 13b236f754 | |||
| 589f560dd6 | |||
| 4fd13c637c | |||
| 88d1d3448d | |||
| 852f4f25c4 | |||
| 0da4651c3b | |||
| 10659c7c82 | |||
| ce2e1b9eb9 | |||
| b232226590 | |||
| 348ff612e9 | |||
| 8469f921b7 | |||
| e538a9e057 | |||
| 88c1f439c1 | |||
| 3845904443 | |||
| 6542de619d | |||
| 6f87a3bdb1 | |||
| 26e51a07c5 | |||
| 4e3d83fe0c | |||
| de9c112725 | |||
| a509e85195 | |||
| 3c03c96a53 | |||
| 5e71deaa3d | |||
| 1cb348c102 | |||
| 252897d0ff | |||
| add82d73fa | |||
| 048387ce88 | |||
| 931a5d928c | |||
| 0e84e131cd | |||
| cf1f921aed | |||
| efa79bc1f9 | |||
| c7f9262d0e | |||
| 53f3ee1f50 | |||
| 54f8dd8a2e | |||
| e28bd907a1 | |||
| 13fbb66a21 | |||
| e8da13c7ca | |||
| 20e3acc7c2 | |||
| eb89eec5b5 | |||
| 8e15460bdc | |||
| db87984dda | |||
| 414b174e32 | |||
| 01eff43585 | |||
| 9dd5a00a45 | |||
| a7445afbf7 | |||
| e0be5fda90 | |||
| 5c394f15a9 | |||
| ad5edeba6c | |||
| 494de9bc43 | |||
| 185be526ca |
@@ -37,6 +37,7 @@
|
||||
- [Make a private connection](#make-a-private-connection)
|
||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||
- [SimpleX Platform design](#simplex-platform-design)
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
|
||||
@@ -80,6 +81,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Selected updates:
|
||||
|
||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced netwrok settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
|
||||
|
||||
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
|
||||
|
||||
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
|
||||
@@ -120,7 +123,32 @@ Unlike federated networks, the server nodes **do not have records of the users**
|
||||
|
||||
Only the client devices have information about users, their contacts and groups.
|
||||
|
||||
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
|
||||
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
|
||||
|
||||
## Privacy: technical details and limitations
|
||||
|
||||
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
||||
|
||||
What is already implemented:
|
||||
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of content padding to frustrate message size attacks.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
|
||||
We plan to add soon:
|
||||
|
||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local database encryption. Currently the local chat database stored on your device is not encrypted.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
4. Independent implementation audit.
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -147,9 +175,10 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ End-to-end encrypted WebRTC audio and video calls via the mobile apps.
|
||||
- ✅ Privacy preserving instant notifications for iOS using Apple Push Notification service.
|
||||
- ✅ Chat database export and import
|
||||
- ✅ Chat groups in mobile apps.
|
||||
- ✅ Connecting to messaging servers via Tor.
|
||||
- 🏗 Dual server addresses to access messaging servers as v3 hidden services (in progress).
|
||||
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
|
||||
- 🏗 Connecting to messaging servers via Tor (in progress).
|
||||
- 🏗 Chat groups in mobile apps (in progress).
|
||||
- Chat database encryption.
|
||||
- Disappearing messages, with mutual agreement.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
@@ -160,8 +189,8 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Media server to optimize sending large files to groups.
|
||||
- Channels server for large groups and broadcast channels.
|
||||
- Media server to optimize sending large files to groups.
|
||||
|
||||
## Help us pay for 3rd party security audit
|
||||
|
||||
@@ -175,7 +204,11 @@ Our pledge to our users is that SimpleX protocols are and will remain open, and
|
||||
|
||||
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
|
||||
|
||||
It is possible to [donate via GitHub](https://github.com/sponsors/simplex-chat), which is commission-free for us, or [via OpenCollective](https://opencollective.com/simplex-chat), that also accepts donations in crypto-currencies, but charges a commission.
|
||||
It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
|
||||
Thank you,
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/uiDesigner.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 29
|
||||
targetSdk 32
|
||||
versionCode 44
|
||||
versionName "3.0.1"
|
||||
versionCode 51
|
||||
versionName "3.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
@@ -26,9 +26,19 @@ android {
|
||||
cppFlags ''
|
||||
}
|
||||
}
|
||||
manifestPlaceholders.app_name = "@string/app_name"
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
|
||||
manifestPlaceholders.extract_native_libs = compression_level != "0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix "$application_id_suffix"
|
||||
debuggable new Boolean("$enable_debuggable")
|
||||
manifestPlaceholders.app_name = "$app_name"
|
||||
// Provider can't be the same for different apps on the same device
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
@@ -52,7 +62,6 @@ android {
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.10.2'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
@@ -65,6 +74,7 @@ android {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +90,11 @@ dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
|
||||
|
||||
def work_version = "2.7.1"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
@@ -113,3 +125,70 @@ dependencies {
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
|
||||
def buildType = "unknown"
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.doLast {
|
||||
buildType = "debug"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.doLast {
|
||||
buildType = "release"
|
||||
}
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||
def keyAlias = ""
|
||||
def keyPassword = ""
|
||||
def storeFile = ""
|
||||
def storePassword = ""
|
||||
if (project.properties['android.injected.signing.key.alias'] != null) {
|
||||
keyAlias = project.properties['android.injected.signing.key.alias']
|
||||
keyPassword = project.properties['android.injected.signing.key.password']
|
||||
storeFile = project.properties['android.injected.signing.store.file']
|
||||
storePassword = project.properties['android.injected.signing.store.password']
|
||||
} else if (android.signingConfigs.hasProperty(buildType)) {
|
||||
def gradleConfig = android.signingConfigs[buildType]
|
||||
keyAlias = gradleConfig.keyAlias
|
||||
keyPassword = gradleConfig.keyPassword
|
||||
storeFile = gradleConfig.storeFile
|
||||
storePassword = gradleConfig.storePassword
|
||||
} else {
|
||||
// There is no signing config for current build type, can't sign the apk
|
||||
println("No signing configs for this build type: $buildType")
|
||||
return
|
||||
}
|
||||
|
||||
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
|
||||
|
||||
exec {
|
||||
workingDir '../../../scripts/android'
|
||||
setEnvironment(['JAVA_HOME': "$javaHome"])
|
||||
commandLine './compress-and-sign-apk.sh', \
|
||||
"$compression_level", \
|
||||
"$outputDir", \
|
||||
"$sdkDir", \
|
||||
"$storeFile", \
|
||||
"$storePassword", \
|
||||
"$keyAlias", \
|
||||
"$keyPassword"
|
||||
}
|
||||
|
||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
}
|
||||
|
||||
// View all gradle properties set
|
||||
// project.properties.each { k, v -> println "$k -> $v" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/icon"
|
||||
android:label="@string/app_name"
|
||||
android:label="${app_name}"
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
|
||||
@@ -34,11 +35,10 @@
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:label="${app_name}"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -65,12 +65,37 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivity_default"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/icon"
|
||||
android:enabled="true"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivity_dark_blue"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/icon_dark_blue"
|
||||
android:enabled="false"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
|
||||
<activity android:name=".views.call.IncomingCallActivity"
|
||||
android:showOnLockScreen="true"/>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="chat.simplex.app.provider"
|
||||
android:authorities="${provider_authorities}"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -70,7 +70,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
schedulePeriodicServiceRestartWorker()
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
@@ -90,6 +90,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,24 +131,6 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private fun schedulePeriodicServiceRestartWorker() {
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
|
||||
@@ -4,14 +4,17 @@ import android.app.Application
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.getFilesDirectory
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
@@ -57,7 +60,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
chatController.startChat(user)
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,9 +69,9 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
withApi {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP ->
|
||||
if (appPreferences.runServiceInBackground.get() && chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
|
||||
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
|
||||
Lifecycle.Event.ON_START ->
|
||||
SimplexService.stop(applicationContext)
|
||||
if (chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
|
||||
Lifecycle.Event.ON_RESUME ->
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||
@@ -79,11 +81,39 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
}
|
||||
|
||||
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
|
||||
appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext)
|
||||
}
|
||||
|
||||
/*
|
||||
* It takes 1-10 milliseconds to process this function. Better to do it in a background thread
|
||||
* */
|
||||
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
|
||||
if (!allowToStartServiceAfterAppExit()) {
|
||||
return@launch
|
||||
}
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SimplexService.ServiceStartWorker>(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SimplexService.TAG)
|
||||
.addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
|
||||
WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var context: SimplexApp private set
|
||||
|
||||
init {
|
||||
val socketName = "local.socket.address.listen.native.cmd2"
|
||||
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
@@ -97,12 +127,13 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
val inStream = receiver.inputStream
|
||||
val inStreamReader = InputStreamReader(inStream)
|
||||
val input = BufferedReader(inStreamReader)
|
||||
|
||||
Log.d(TAG, "starting receiver loop")
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
Log.w(TAG, "exited receiver loop")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ package chat.simplex.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -21,7 +22,6 @@ class SimplexService: Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
private var isStartingService = false
|
||||
private var isStoppingService = false
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
private val chatController by lazy { (application as SimplexApp).chatController }
|
||||
@@ -55,7 +55,10 @@ class SimplexService: Service() {
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Simplex service destroyed")
|
||||
stopService()
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary!
|
||||
|
||||
// If private notifications are enabled and battery optimization is disabled, restart the service
|
||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -88,8 +91,6 @@ class SimplexService: Service() {
|
||||
|
||||
private fun stopService() {
|
||||
Log.d(TAG, "Stopping foreground service")
|
||||
if (isStoppingService) return
|
||||
isStoppingService = true
|
||||
try {
|
||||
wakeLock?.let {
|
||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||
@@ -100,7 +101,6 @@ class SimplexService: Service() {
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
||||
}
|
||||
isStoppingService = false
|
||||
isServiceStarted = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
}
|
||||
@@ -122,15 +122,27 @@ class SimplexService: Service() {
|
||||
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ntf_service_icon)
|
||||
.setColor(0x88FFFF)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSound(null)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false) // no date/time
|
||||
.build()
|
||||
|
||||
// Shows a button which opens notification channel settings
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val setupIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
setupIntent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
setupIntent.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_ID)
|
||||
val setup = PendingIntent.getActivity(this, 0, setupIntent, flags)
|
||||
builder.addAction(0, getString(R.string.hide_notification), setup)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
@@ -139,6 +151,11 @@ class SimplexService: Service() {
|
||||
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
// If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
|
||||
return
|
||||
}
|
||||
|
||||
val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also {
|
||||
it.setPackage(packageName)
|
||||
};
|
||||
@@ -154,6 +171,17 @@ class SimplexService: Service() {
|
||||
Log.d(TAG, "StartReceiver: onReceive called")
|
||||
scheduleStart(context)
|
||||
}
|
||||
companion object {
|
||||
fun toggleReceiver(enable: Boolean) {
|
||||
Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable")
|
||||
val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name)
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
component,
|
||||
if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restart on destruction
|
||||
@@ -215,7 +243,6 @@ class SimplexService: Service() {
|
||||
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
|
||||
|
||||
private suspend fun serviceAction(context: Context, action: Action) {
|
||||
if (!AppPreferences(context).runServiceInBackground.get()) { return }
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(context, SimplexService::class.java).also {
|
||||
|
||||
@@ -26,8 +26,11 @@ class ChatModel(val controller: ChatController) {
|
||||
val chatRunning = mutableStateOf<Boolean?>(null)
|
||||
val chatDbChanged = mutableStateOf<Boolean>(false)
|
||||
val chats = mutableStateListOf<Chat>()
|
||||
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
val chatItems = mutableStateListOf<ChatItem>()
|
||||
val groupMembers = mutableStateListOf<GroupMember>()
|
||||
|
||||
var connReqInvitation: String? = null
|
||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||
@@ -44,6 +47,7 @@ class ChatModel(val controller: ChatController) {
|
||||
val runServiceInBackground = mutableStateOf(true)
|
||||
val performLA = mutableStateOf(false)
|
||||
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
|
||||
var incognito = mutableStateOf(false)
|
||||
|
||||
// current WebRTC call
|
||||
val callManager = CallManager(this)
|
||||
@@ -54,7 +58,7 @@ class ChatModel(val controller: ChatController) {
|
||||
val showCallView = mutableStateOf(false)
|
||||
val switchingCall = mutableStateOf(false)
|
||||
|
||||
fun updateUserProfile(profile: Profile) {
|
||||
fun updateUserProfile(profile: LocalProfile) {
|
||||
val user = currentUser.value
|
||||
if (user != null) {
|
||||
currentUser.value = user.copy(profile = profile)
|
||||
@@ -63,6 +67,7 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
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 }
|
||||
fun addChat(chat: Chat) = chats.add(index = 0, chat)
|
||||
|
||||
@@ -73,12 +78,14 @@ class ChatModel(val controller: ChatController) {
|
||||
|
||||
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
|
||||
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact))
|
||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact)
|
||||
|
||||
private fun updateChat(cInfo: ChatInfo) {
|
||||
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
|
||||
|
||||
private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) {
|
||||
if (hasChat(cInfo.id)) {
|
||||
updateChatInfo(cInfo)
|
||||
} else {
|
||||
} else if (addMissing) {
|
||||
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
|
||||
}
|
||||
}
|
||||
@@ -206,27 +213,39 @@ class ChatModel(val controller: ChatController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun markChatItemsRead(cInfo: ChatInfo) {
|
||||
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
|
||||
val markedRead = markItemsReadInCurrentChat(cInfo, range)
|
||||
// update preview
|
||||
val chatIdx = getChatIndex(cInfo.id)
|
||||
if (chatIdx >= 0) {
|
||||
val chat = chats[chatIdx]
|
||||
val lastId = chat.chatItems.lastOrNull()?.id
|
||||
if (lastId != null) {
|
||||
chats[chatIdx] = chat.copy(chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = lastId + 1))
|
||||
chats[chatIdx] = chat.copy(
|
||||
chatStats = chat.chatStats.copy(
|
||||
unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
|
||||
// Can't use minUnreadItemId currently since chat items can have unread items between read items
|
||||
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// update current chat
|
||||
}
|
||||
|
||||
private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int {
|
||||
var markedRead = 0
|
||||
if (chatId.value == cInfo.id) {
|
||||
var i = 0
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew) {
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
chatItems[i] = item.withStatus(CIStatus.RcvRead())
|
||||
markedRead++
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return markedRead
|
||||
}
|
||||
|
||||
// func popChat(_ id: String) {
|
||||
@@ -243,6 +262,22 @@ class ChatModel(val controller: ChatController) {
|
||||
fun removeChat(id: String) {
|
||||
chats.removeAll { it.id == id }
|
||||
}
|
||||
|
||||
fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean {
|
||||
// update current chat
|
||||
return if (chatId.value == groupInfo.id) {
|
||||
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
|
||||
if (memberIndex >= 0) {
|
||||
groupMembers[memberIndex] = member
|
||||
false
|
||||
} else {
|
||||
groupMembers.add(member)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChatType(val type: String) {
|
||||
@@ -257,19 +292,20 @@ data class User(
|
||||
val userId: Long,
|
||||
val userContactId: Long,
|
||||
val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
val profile: LocalProfile,
|
||||
val activeUser: Boolean
|
||||
): NamedChat {
|
||||
override val displayName: String get() = profile.displayName
|
||||
override val fullName: String get() = profile.fullName
|
||||
override val image: String? get() = profile.image
|
||||
override val localAlias: String = ""
|
||||
|
||||
companion object {
|
||||
val sampleData = User(
|
||||
userId = 1,
|
||||
userContactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
profile = LocalProfile.sampleData,
|
||||
activeUser = true
|
||||
)
|
||||
}
|
||||
@@ -281,8 +317,9 @@ interface NamedChat {
|
||||
val displayName: String
|
||||
val fullName: String
|
||||
val image: String?
|
||||
val localAlias: String
|
||||
val chatViewName: String
|
||||
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
|
||||
get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
|
||||
}
|
||||
|
||||
interface SomeChat {
|
||||
@@ -291,6 +328,8 @@ interface SomeChat {
|
||||
val id: ChatId
|
||||
val apiId: Long
|
||||
val ready: Boolean
|
||||
val sendMsgEnabled: Boolean
|
||||
val ntfsEnabled: Boolean
|
||||
val createdAt: Instant
|
||||
val updatedAt: Instant
|
||||
}
|
||||
@@ -312,7 +351,12 @@ data class Chat (
|
||||
|
||||
@Serializable
|
||||
sealed class NetworkStatus {
|
||||
val statusString: String get() = if (this is Connected) generalGetString(R.string.server_connected) else generalGetString(R.string.server_connecting)
|
||||
val statusString: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(R.string.server_connected)
|
||||
is Error -> generalGetString(R.string.server_error)
|
||||
else -> generalGetString(R.string.server_connecting)
|
||||
}
|
||||
val statusExplanation: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact)
|
||||
@@ -336,6 +380,8 @@ data class Chat (
|
||||
|
||||
@Serializable
|
||||
sealed class ChatInfo: SomeChat, NamedChat {
|
||||
abstract val incognito: Boolean
|
||||
|
||||
@Serializable @SerialName("direct")
|
||||
class Direct(val contact: Contact): ChatInfo() {
|
||||
override val chatType get() = ChatType.Direct
|
||||
@@ -343,11 +389,15 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val id get() = contact.id
|
||||
override val apiId get() = contact.apiId
|
||||
override val ready get() = contact.ready
|
||||
override val sendMsgEnabled get() = contact.sendMsgEnabled
|
||||
override val ntfsEnabled get() = contact.chatSettings.enableNtfs
|
||||
override val incognito get() = contact.contactConnIncognito
|
||||
override val createdAt get() = contact.createdAt
|
||||
override val updatedAt get() = contact.updatedAt
|
||||
override val displayName get() = contact.displayName
|
||||
override val fullName get() = contact.fullName
|
||||
override val image get() = contact.image
|
||||
override val localAlias: String get() = contact.localAlias
|
||||
|
||||
companion object {
|
||||
val sampleData = Direct(Contact.sampleData)
|
||||
@@ -361,11 +411,15 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val id get() = groupInfo.id
|
||||
override val apiId get() = groupInfo.apiId
|
||||
override val ready get() = groupInfo.ready
|
||||
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
|
||||
override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
|
||||
override val incognito get() = groupInfo.membership.memberIncognito
|
||||
override val createdAt get() = groupInfo.createdAt
|
||||
override val updatedAt get() = groupInfo.updatedAt
|
||||
override val displayName get() = groupInfo.displayName
|
||||
override val fullName get() = groupInfo.fullName
|
||||
override val image get() = groupInfo.image
|
||||
override val localAlias get() = groupInfo.localAlias
|
||||
|
||||
companion object {
|
||||
val sampleData = Group(GroupInfo.sampleData)
|
||||
@@ -379,11 +433,15 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val id get() = contactRequest.id
|
||||
override val apiId get() = contactRequest.apiId
|
||||
override val ready get() = contactRequest.ready
|
||||
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = false
|
||||
override val createdAt get() = contactRequest.createdAt
|
||||
override val updatedAt get() = contactRequest.updatedAt
|
||||
override val displayName get() = contactRequest.displayName
|
||||
override val fullName get() = contactRequest.fullName
|
||||
override val image get() = contactRequest.image
|
||||
override val localAlias get() = contactRequest.localAlias
|
||||
|
||||
companion object {
|
||||
val sampleData = ContactRequest(UserContactRequest.sampleData)
|
||||
@@ -397,11 +455,15 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val id get() = contactConnection.id
|
||||
override val apiId get() = contactConnection.apiId
|
||||
override val ready get() = contactConnection.ready
|
||||
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val incognito get() = contactConnection.incognito
|
||||
override val createdAt get() = contactConnection.createdAt
|
||||
override val updatedAt get() = contactConnection.updatedAt
|
||||
override val displayName get() = contactConnection.displayName
|
||||
override val fullName get() = contactConnection.fullName
|
||||
override val image get() = contactConnection.image
|
||||
override val localAlias get() = contactConnection.localAlias
|
||||
|
||||
companion object {
|
||||
fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection =
|
||||
@@ -411,12 +473,13 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Contact(
|
||||
data class Contact(
|
||||
val contactId: Long,
|
||||
override val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
val profile: LocalProfile,
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val chatSettings: ChatSettings,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -424,16 +487,26 @@ class Contact(
|
||||
override val id get() = "@$contactId"
|
||||
override val apiId get() = contactId
|
||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||
override val displayName get() = profile.displayName
|
||||
override val sendMsgEnabled get() = true
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = profile.localAlias
|
||||
|
||||
val isIndirectContact: Boolean get() =
|
||||
activeConn.connLevel > 0 || viaGroup != null
|
||||
|
||||
val contactConnIncognito =
|
||||
activeConn.customUserProfileId != null
|
||||
|
||||
companion object {
|
||||
val sampleData = Contact(
|
||||
contactId = 1,
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
chatSettings = ChatSettings(true),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -455,10 +528,10 @@ class ContactSubStatus(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Connection(val connId: Long, val connStatus: ConnStatus) {
|
||||
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val customUserProfileId: Long? = null) {
|
||||
val id: ChatId get() = ":$connId"
|
||||
companion object {
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready)
|
||||
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, customUserProfileId = null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,8 +539,16 @@ class Connection(val connId: Long, val connStatus: ConnStatus) {
|
||||
class Profile(
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null
|
||||
override val image: String? = null,
|
||||
override val localAlias : String = ""
|
||||
): NamedChat {
|
||||
val profileViewName: String
|
||||
get() {
|
||||
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
||||
}
|
||||
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias)
|
||||
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
displayName = "alice",
|
||||
@@ -477,10 +558,41 @@ class Profile(
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class GroupInfo (
|
||||
class LocalProfile(
|
||||
val profileId: Long,
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null,
|
||||
override val localAlias: String,
|
||||
): NamedChat {
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
|
||||
|
||||
companion object {
|
||||
val sampleData = LocalProfile(
|
||||
profileId = 1L,
|
||||
displayName = "alice",
|
||||
fullName = "Alice",
|
||||
localAlias = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Group (
|
||||
val groupInfo: GroupInfo,
|
||||
var members: List<GroupMember>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GroupInfo (
|
||||
val groupId: Long,
|
||||
override val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
val membership: GroupMember,
|
||||
val hostConnCustomUserProfileId: Long? = null,
|
||||
val chatSettings: ChatSettings,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -488,15 +600,30 @@ class GroupInfo (
|
||||
override val id get() = "#$groupId"
|
||||
override val apiId get() = groupId
|
||||
override val ready get() = true
|
||||
override val sendMsgEnabled get() = membership.memberActive
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val displayName get() = groupProfile.displayName
|
||||
override val fullName get() = groupProfile.fullName
|
||||
override val image get() = groupProfile.image
|
||||
override val localAlias get() = ""
|
||||
|
||||
val canEdit: Boolean
|
||||
get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
|
||||
|
||||
val canDelete: Boolean
|
||||
get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent
|
||||
|
||||
val canAddMembers: Boolean
|
||||
get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive
|
||||
|
||||
companion object {
|
||||
val sampleData = GroupInfo(
|
||||
groupId = 1,
|
||||
localDisplayName = "team",
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
membership = GroupMember.sampleData,
|
||||
hostConnCustomUserProfileId = null,
|
||||
chatSettings = ChatSettings(true),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -507,7 +634,8 @@ class GroupInfo (
|
||||
class GroupProfile (
|
||||
override val displayName: String,
|
||||
override val fullName: String,
|
||||
override val image: String? = null
|
||||
override val image: String? = null,
|
||||
override val localAlias: String = "",
|
||||
): NamedChat {
|
||||
companion object {
|
||||
val sampleData = GroupProfile(
|
||||
@@ -520,27 +648,152 @@ class GroupProfile (
|
||||
@Serializable
|
||||
class GroupMember (
|
||||
val groupMemberId: Long,
|
||||
val groupId: Long,
|
||||
val memberId: String,
|
||||
// var memberRole: GroupMemberRole
|
||||
// var memberCategory: GroupMemberCategory
|
||||
// var memberStatus: GroupMemberStatus
|
||||
// var invitedBy: InvitedBy
|
||||
var memberRole: GroupMemberRole,
|
||||
var memberCategory: GroupMemberCategory,
|
||||
var memberStatus: GroupMemberStatus,
|
||||
var invitedBy: InvitedBy,
|
||||
val localDisplayName: String,
|
||||
val memberProfile: Profile,
|
||||
val memberContactId: Long?
|
||||
// var activeConn: Connection?
|
||||
val memberProfile: LocalProfile,
|
||||
val memberContactId: Long? = null,
|
||||
val memberContactProfileId: Long,
|
||||
var activeConn: Connection? = null
|
||||
) {
|
||||
val id: String get() = "#$groupId @$groupMemberId"
|
||||
val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName }
|
||||
val fullName: String get() = memberProfile.fullName
|
||||
val image: String? get() = memberProfile.image
|
||||
|
||||
val chatViewName: String
|
||||
get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
|
||||
|
||||
val memberActive: Boolean get() = when (this.memberStatus) {
|
||||
GroupMemberStatus.MemRemoved -> false
|
||||
GroupMemberStatus.MemLeft -> false
|
||||
GroupMemberStatus.MemGroupDeleted -> false
|
||||
GroupMemberStatus.MemInvited -> false
|
||||
GroupMemberStatus.MemIntroduced -> false
|
||||
GroupMemberStatus.MemIntroInvited -> false
|
||||
GroupMemberStatus.MemAccepted -> false
|
||||
GroupMemberStatus.MemAnnounced -> false
|
||||
GroupMemberStatus.MemConnected -> true
|
||||
GroupMemberStatus.MemComplete -> true
|
||||
GroupMemberStatus.MemCreator -> true
|
||||
}
|
||||
|
||||
val memberCurrent: Boolean get() = when (this.memberStatus) {
|
||||
GroupMemberStatus.MemRemoved -> false
|
||||
GroupMemberStatus.MemLeft -> false
|
||||
GroupMemberStatus.MemGroupDeleted -> false
|
||||
GroupMemberStatus.MemInvited -> false
|
||||
GroupMemberStatus.MemIntroduced -> true
|
||||
GroupMemberStatus.MemIntroInvited -> true
|
||||
GroupMemberStatus.MemAccepted -> true
|
||||
GroupMemberStatus.MemAnnounced -> true
|
||||
GroupMemberStatus.MemConnected -> true
|
||||
GroupMemberStatus.MemComplete -> true
|
||||
GroupMemberStatus.MemCreator -> true
|
||||
}
|
||||
|
||||
fun canBeRemoved(membership: GroupMember): Boolean {
|
||||
val userRole = membership.memberRole
|
||||
return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft
|
||||
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent
|
||||
}
|
||||
|
||||
val memberIncognito = memberProfile.profileId != memberContactProfileId
|
||||
|
||||
companion object {
|
||||
val sampleData = GroupMember(
|
||||
groupMemberId = 1,
|
||||
groupId = 1,
|
||||
memberId = "abcd",
|
||||
memberRole = GroupMemberRole.Member,
|
||||
memberCategory = GroupMemberCategory.InviteeMember,
|
||||
memberStatus = GroupMemberStatus.MemComplete,
|
||||
invitedBy = InvitedBy.IBUser(),
|
||||
localDisplayName = "alice",
|
||||
memberProfile = Profile.sampleData,
|
||||
memberContactId = 1
|
||||
memberProfile = LocalProfile.sampleData,
|
||||
memberContactId = 1,
|
||||
memberContactProfileId = 1L,
|
||||
activeConn = Connection.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class GroupMemberRole(val memberRole: String) {
|
||||
@SerialName("member") Member("member"), // order matters in comparisons
|
||||
@SerialName("admin") Admin("admin"),
|
||||
@SerialName("owner") Owner("owner");
|
||||
|
||||
val text: String get() = when (this) {
|
||||
Member -> generalGetString(R.string.group_member_role_member)
|
||||
Admin -> generalGetString(R.string.group_member_role_admin)
|
||||
Owner -> generalGetString(R.string.group_member_role_owner)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class GroupMemberCategory {
|
||||
@SerialName("user") UserMember,
|
||||
@SerialName("invitee") InviteeMember,
|
||||
@SerialName("host") HostMember,
|
||||
@SerialName("pre") PreMember,
|
||||
@SerialName("post") PostMember;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class GroupMemberStatus {
|
||||
@SerialName("removed") MemRemoved,
|
||||
@SerialName("left") MemLeft,
|
||||
@SerialName("deleted") MemGroupDeleted,
|
||||
@SerialName("invited") MemInvited,
|
||||
@SerialName("introduced") MemIntroduced,
|
||||
@SerialName("intro-inv") MemIntroInvited,
|
||||
@SerialName("accepted") MemAccepted,
|
||||
@SerialName("announced") MemAnnounced,
|
||||
@SerialName("connected") MemConnected,
|
||||
@SerialName("complete") MemComplete,
|
||||
@SerialName("creator") MemCreator;
|
||||
|
||||
val text: String get() = when (this) {
|
||||
MemRemoved -> generalGetString(R.string.group_member_status_removed)
|
||||
MemLeft -> generalGetString(R.string.group_member_status_left)
|
||||
MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted)
|
||||
MemInvited -> generalGetString(R.string.group_member_status_invited)
|
||||
MemIntroduced -> generalGetString(R.string.group_member_status_introduced)
|
||||
MemIntroInvited -> generalGetString(R.string.group_member_status_intro_invitation)
|
||||
MemAccepted -> generalGetString(R.string.group_member_status_accepted)
|
||||
MemAnnounced -> generalGetString(R.string.group_member_status_announced)
|
||||
MemConnected -> generalGetString(R.string.group_member_status_connected)
|
||||
MemComplete -> generalGetString(R.string.group_member_status_complete)
|
||||
MemCreator -> generalGetString(R.string.group_member_status_creator)
|
||||
}
|
||||
|
||||
val shortText: String get() = when (this) {
|
||||
MemRemoved -> generalGetString(R.string.group_member_status_removed)
|
||||
MemLeft -> generalGetString(R.string.group_member_status_left)
|
||||
MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted)
|
||||
MemInvited -> generalGetString(R.string.group_member_status_invited)
|
||||
MemIntroduced -> generalGetString(R.string.group_member_status_connecting)
|
||||
MemIntroInvited -> generalGetString(R.string.group_member_status_connecting)
|
||||
MemAccepted -> generalGetString(R.string.group_member_status_connecting)
|
||||
MemAnnounced -> generalGetString(R.string.group_member_status_connecting)
|
||||
MemConnected -> generalGetString(R.string.group_member_status_connected)
|
||||
MemComplete -> generalGetString(R.string.group_member_status_complete)
|
||||
MemCreator -> generalGetString(R.string.group_member_status_creator)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class InvitedBy {
|
||||
@Serializable @SerialName("contact") class IBContact(val byContactId: Long): InvitedBy()
|
||||
@Serializable @SerialName("user") class IBUser: InvitedBy()
|
||||
@Serializable @SerialName("unknown") class IBUnknown: InvitedBy()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class LinkPreview (
|
||||
val uri: String,
|
||||
@@ -576,9 +829,12 @@ class UserContactRequest (
|
||||
override val id get() = "<@$contactRequestId"
|
||||
override val apiId get() = contactRequestId
|
||||
override val ready get() = true
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val displayName get() = profile.displayName
|
||||
override val fullName get() = profile.fullName
|
||||
override val image get() = profile.image
|
||||
override val localAlias get() = ""
|
||||
|
||||
companion object {
|
||||
val sampleData = UserContactRequest(
|
||||
@@ -597,6 +853,7 @@ class PendingContactConnection(
|
||||
val pccAgentConnId: String,
|
||||
val pccConnStatus: ConnStatus,
|
||||
val viaContactUri: Boolean,
|
||||
val customUserProfileId: Long? = null,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -604,6 +861,8 @@ class PendingContactConnection(
|
||||
override val id get () = ":$pccConnId"
|
||||
override val apiId get() = pccConnId
|
||||
override val ready get() = false
|
||||
override val sendMsgEnabled get() = false
|
||||
override val ntfsEnabled get() = false
|
||||
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
|
||||
override val displayName: String get() {
|
||||
val initiated = pccConnStatus.initiated
|
||||
@@ -619,14 +878,21 @@ class PendingContactConnection(
|
||||
}
|
||||
override val fullName get() = ""
|
||||
override val image get() = null
|
||||
override val localAlias get() = ""
|
||||
|
||||
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
|
||||
|
||||
val incognito = customUserProfileId != null
|
||||
|
||||
val description: String get() {
|
||||
val initiated = pccConnStatus.initiated
|
||||
return if (initiated == null) "" else generalGetString(
|
||||
if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link
|
||||
else if (viaContactUri ) R.string.description_via_contact_address_link
|
||||
else R.string.description_via_one_time_link
|
||||
if (initiated && !viaContactUri)
|
||||
if (incognito) R.string.description_you_shared_one_time_link_incognito else R.string.description_you_shared_one_time_link
|
||||
else if (viaContactUri )
|
||||
if (incognito) R.string.description_via_contact_address_link_incognito else R.string.description_via_contact_address_link
|
||||
else
|
||||
if (incognito) R.string.description_via_one_time_link_incognito else R.string.description_via_one_time_link
|
||||
)
|
||||
}
|
||||
|
||||
@@ -637,6 +903,7 @@ class PendingContactConnection(
|
||||
pccAgentConnId = "abcd",
|
||||
pccConnStatus = status,
|
||||
viaContactUri = viaContactUri,
|
||||
customUserProfileId = null,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
@@ -691,7 +958,7 @@ data class ChatItem (
|
||||
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
||||
|
||||
val memberDisplayName: String? get() =
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
|
||||
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
|
||||
else null
|
||||
|
||||
val isDeletedContent: Boolean get() =
|
||||
@@ -760,6 +1027,24 @@ data class ChatItem (
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
|
||||
fun getGroupInvitationSample(status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending) =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
|
||||
fun getGroupEventSample() =
|
||||
ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||
content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)),
|
||||
quotedItem = null,
|
||||
file = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,6 +1135,10 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
|
||||
override val text: String get() = when(this) {
|
||||
is SndMsgContent -> msgContent.text
|
||||
@@ -859,6 +1148,10 @@ sealed class CIContent: ItemContent {
|
||||
is SndCall -> status.text(duration)
|
||||
is RcvCall -> status.text(duration)
|
||||
is RcvIntegrityError -> msgError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,6 +1245,38 @@ sealed class MsgContent {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class CIGroupInvitation (
|
||||
val groupId: Long,
|
||||
val groupMemberId: Long,
|
||||
val localDisplayName: String,
|
||||
val groupProfile: GroupProfile,
|
||||
val status: CIGroupInvitationStatus,
|
||||
) {
|
||||
val text: String get() = String.format(
|
||||
generalGetString(R.string.group_invitation_item_description),
|
||||
groupProfile.displayName)
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
groupId: Long = 1,
|
||||
groupMemberId: Long = 1,
|
||||
localDisplayName: String = "team",
|
||||
groupProfile: GroupProfile = GroupProfile.sampleData,
|
||||
status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending
|
||||
): CIGroupInvitation =
|
||||
CIGroupInvitation(groupId = groupId, groupMemberId = groupMemberId, localDisplayName = localDisplayName, groupProfile = groupProfile, status = status)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CIGroupInvitationStatus {
|
||||
@SerialName("pending") Pending,
|
||||
@SerialName("accepted") Accepted,
|
||||
@SerialName("rejected") Rejected,
|
||||
@SerialName("expired") Expired;
|
||||
}
|
||||
|
||||
object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
|
||||
element("MCText", buildClassSerialDescriptor("MCText") {
|
||||
@@ -1137,3 +1462,37 @@ sealed class MsgErrorType() {
|
||||
is MsgDuplicate -> generalGetString(R.string.integrity_msg_duplicate) // not used now
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RcvGroupEvent() {
|
||||
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent()
|
||||
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
@Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent()
|
||||
@Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent()
|
||||
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): RcvGroupEvent()
|
||||
|
||||
val text: String get() = when (this) {
|
||||
is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.profileViewName)
|
||||
is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected)
|
||||
is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left)
|
||||
is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.profileViewName)
|
||||
is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted)
|
||||
is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted)
|
||||
is GroupUpdated -> generalGetString(R.string.rcv_group_event_updated_group_profile)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class SndGroupEvent() {
|
||||
@Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent()
|
||||
@Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent()
|
||||
@Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent()
|
||||
|
||||
val text: String get() = when (this) {
|
||||
is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.profileViewName)
|
||||
is UserLeft -> generalGetString(R.string.snd_group_event_user_left)
|
||||
is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (!cInfo.ntfsEnabled) return
|
||||
|
||||
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,14 @@ import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
@@ -84,6 +86,21 @@ class AppPreferences(val context: Context) {
|
||||
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
|
||||
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
|
||||
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
|
||||
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
|
||||
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
|
||||
val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name)
|
||||
val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false)
|
||||
val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout)
|
||||
val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout)
|
||||
val networkSMPPingInterval = mkLongPreference(SHARED_PREFS_NETWORK_SMP_PING_INTERVAL, NetCfg.defaults.smpPingInterval)
|
||||
val networkEnableKeepAlive = mkBoolPreference(SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE, NetCfg.defaults.enableKeepAlive)
|
||||
val networkTCPKeepIdle = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_IDLE, KeepAliveOpts.defaults.keepIdle)
|
||||
val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl)
|
||||
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
|
||||
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
|
||||
|
||||
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
|
||||
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
Preference(
|
||||
@@ -91,6 +108,20 @@ class AppPreferences(val context: Context) {
|
||||
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkLongPreference(prefName: String, default: Long) =
|
||||
Preference(
|
||||
get = fun() = sharedPreferences.getLong(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
|
||||
)
|
||||
|
||||
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): Preference<Long> {
|
||||
val d = if (networkUseSocksProxy.get()) proxyDefault else default
|
||||
return Preference(
|
||||
get = fun() = sharedPreferences.getLong(prefName, d),
|
||||
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
|
||||
)
|
||||
}
|
||||
|
||||
private fun mkBoolPreference(prefName: String, default: Boolean) =
|
||||
Preference(
|
||||
get = fun() = sharedPreferences.getBoolean(prefName, default),
|
||||
@@ -128,6 +159,20 @@ class AppPreferences(val context: Context) {
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
|
||||
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
|
||||
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
|
||||
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
|
||||
private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode"
|
||||
private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode"
|
||||
private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout"
|
||||
private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout"
|
||||
private const val SHARED_PREFS_NETWORK_SMP_PING_INTERVAL = "NetworkSMPPingInterval"
|
||||
private const val SHARED_PREFS_NETWORK_ENABLE_KEEP_ALIVE = "NetworkEnableKeepAlive"
|
||||
private const val SHARED_PREFS_NETWORK_TCP_KEEP_IDLE = "NetworkTCPKeepIdle"
|
||||
private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl"
|
||||
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
|
||||
private const val SHARED_PREFS_INCOGNITO = "Incognito"
|
||||
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
|
||||
private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,29 +185,34 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
init {
|
||||
chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get()
|
||||
chatModel.performLA.value = appPrefs.performLA.get()
|
||||
chatModel.incognito.value = appPrefs.incognito.get()
|
||||
}
|
||||
|
||||
suspend fun startChat(user: User) {
|
||||
Log.d(TAG, "user: $user")
|
||||
try {
|
||||
if (chatModel.chatRunning.value == true) return
|
||||
apiSetNetworkConfig(getNetCfg())
|
||||
val justStarted = apiStartChat()
|
||||
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
||||
chatModel.userAddress.value = apiGetUserAddress()
|
||||
chatModel.userSMPServers.value = getUserSMPServers()
|
||||
val chats = apiGetChats()
|
||||
if (justStarted) {
|
||||
chatModel.chats.clear()
|
||||
chatModel.chats.addAll(chats)
|
||||
} else {
|
||||
apiSetFilesFolder(getAppFilesDirectory(appContext))
|
||||
apiSetIncognito(chatModel.incognito.value)
|
||||
chatModel.userAddress.value = apiGetUserAddress()
|
||||
chatModel.userSMPServers.value = getUserSMPServers()
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.userCreated.value = true
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
startReceiver()
|
||||
Log.d(TAG, "startChat: started")
|
||||
} else {
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
Log.d(TAG, "startChat: running")
|
||||
}
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.userCreated.value = true
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
startReceiver()
|
||||
Log.d(TAG, "chat started")
|
||||
} catch (e: Error) {
|
||||
Log.e(TAG, "failed starting chat $e")
|
||||
throw e
|
||||
@@ -255,6 +305,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetIncognito(incognito: Boolean) {
|
||||
val r = sendCmd(CC.SetIncognito(incognito))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Exception("failed to set incognito: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiExportArchive(config: ArchiveConfig) {
|
||||
val r = sendCmd(CC.ApiExportArchive(config))
|
||||
if (r is CR.CmdOk) return
|
||||
@@ -279,8 +335,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiGetChat(type: ChatType, id: Long): Chat? {
|
||||
val r = sendCmd(CC.ApiGetChat(type, id))
|
||||
suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
|
||||
val r = sendCmd(CC.ApiGetChat(type, id, pagination, search))
|
||||
if (r is CR.ApiChat ) return r.chat
|
||||
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
@@ -330,6 +386,53 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiGetNetworkConfig(): NetCfg? {
|
||||
val r = sendCmd(CC.APIGetNetworkConfig())
|
||||
if (r is CR.NetworkConfig) return r.networkConfig
|
||||
Log.e(TAG, "apiGetNetworkConfig bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean {
|
||||
val r = sendCmd(CC.APISetNetworkConfig(cfg))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "apiSetNetworkConfig bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.error_setting_network_config),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiSetSettings(type: ChatType,id: Long, settings: ChatSettings): Boolean {
|
||||
val r = sendCmd(CC.APISetChatSettings(type, id, settings))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiContactInfo(contactId: Long): Pair<ConnectionStats, Profile?>? {
|
||||
val r = sendCmd(CC.APIContactInfo(contactId))
|
||||
if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile
|
||||
Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? {
|
||||
val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId))
|
||||
if (r is CR.GroupMemberInfo) return r.connectionStats_
|
||||
Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiAddContact(): String? {
|
||||
val r = sendCmd(CC.AddContact())
|
||||
if (r is CR.Invitation) return r.connReqInvitation
|
||||
@@ -377,6 +480,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
when {
|
||||
r is CR.ContactDeleted && type == ChatType.Direct -> return true
|
||||
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true
|
||||
r is CR.GroupDeletedUser && type == ChatType.Group -> return true
|
||||
r is CR.ChatCmdError -> {
|
||||
val e = r.chatError
|
||||
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
|
||||
@@ -406,6 +510,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiListContacts(): List<Contact>? {
|
||||
val r = sendCmd(CC.ListContacts())
|
||||
if (r is CR.ContactsList) return r.contacts
|
||||
Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiUpdateProfile(profile: Profile): Profile? {
|
||||
val r = sendCmd(CC.ApiUpdateProfile(profile))
|
||||
if (r is CR.UserProfileNoChange) return profile
|
||||
@@ -421,6 +532,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? {
|
||||
val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias))
|
||||
if (r is CR.ContactAliasUpdated) return r.toContact
|
||||
Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateUserAddress(): String? {
|
||||
val r = sendCmd(CC.CreateMyAddress())
|
||||
if (r is CR.UserContactLinkCreated) return r.connReqContact
|
||||
@@ -509,10 +627,85 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
suspend fun apiReceiveFile(fileId: Long): AChatItem? {
|
||||
val r = sendCmd(CC.ReceiveFile(fileId))
|
||||
if (r is CR.RcvFileAccepted) return r.chatItem
|
||||
Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}")
|
||||
Log.e(TAG, "apiReceiveFile bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
|
||||
val r = sendCmd(CC.NewGroup(p))
|
||||
if (r is CR.GroupCreated) return r.groupInfo
|
||||
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
|
||||
val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole))
|
||||
if (r is CR.SentGroupInvitation) return r.member
|
||||
Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiJoinGroup(groupId: Long) {
|
||||
val r = sendCmd(CC.ApiJoinGroup(groupId))
|
||||
when (r) {
|
||||
is CR.UserAcceptedGroupSent ->
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.ChatCmdError -> {
|
||||
val e = r.chatError
|
||||
suspend fun deleteGroup() { if (apiDeleteChat(ChatType.Group, groupId)) { chatModel.removeChat("#$groupId") } }
|
||||
if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) {
|
||||
deleteGroup()
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_group_invitation_expired), generalGetString(R.string.alert_message_group_invitation_expired))
|
||||
} else if (e is ChatError.ChatErrorStore && e.storeError is StoreError.GroupNotFound) {
|
||||
deleteGroup()
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_no_group), generalGetString(R.string.alert_message_no_group))
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.alert_title_join_group_error), "$e")
|
||||
}
|
||||
}
|
||||
else -> Log.e(TAG, "apiJoinGroup bad response: ${r.responseType} ${r.details}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? {
|
||||
val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))
|
||||
if (r is CR.UserDeletedMember) return r.member
|
||||
Log.e(TAG, "apiRemoveMember bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiLeaveGroup(groupId: Long): GroupInfo? {
|
||||
val r = sendCmd(CC.ApiLeaveGroup(groupId))
|
||||
if (r is CR.LeftMemberUser) return r.groupInfo
|
||||
Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiListMembers(groupId: Long): List<GroupMember> {
|
||||
val r = sendCmd(CC.ApiListMembers(groupId))
|
||||
if (r is CR.GroupMembers) return r.group.members
|
||||
Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? {
|
||||
return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) {
|
||||
is CR.GroupUpdated -> r.toGroup
|
||||
is CR.ChatCmdError -> {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_saving_group_profile), "$r.chatError")
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.error_saving_group_profile),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun apiErrorAlert(method: String, title: String, r: CR) {
|
||||
val errMsg = "${r.responseType}: ${r.details}"
|
||||
Log.e(TAG, "$method bad response: $errMsg")
|
||||
@@ -572,7 +765,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) {
|
||||
withApi { receiveFile(file.fileId) }
|
||||
}
|
||||
if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||
if (!cItem.chatDir.sent && !cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
@@ -599,6 +792,28 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
chatModel.upsertChatItem(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
is CR.ReceivedGroupInvitation -> {
|
||||
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
|
||||
// TODO NtfManager.shared.notifyGroupInvitation
|
||||
}
|
||||
is CR.JoinedGroupMemberConnecting ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.DeletedMemberUser -> // TODO update user member
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.DeletedMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.deletedMember)
|
||||
is CR.LeftMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.GroupDeleted -> // TODO update user member
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.UserJoinedGroup ->
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
is CR.JoinedGroupMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.ConnectedToGroupMember ->
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
is CR.GroupUpdated ->
|
||||
chatModel.updateGroup(r.toGroup)
|
||||
is CR.RcvFileStart ->
|
||||
chatItemSimpleUpdate(r.chatItem)
|
||||
is CR.RcvFileComplete ->
|
||||
@@ -674,6 +889,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun leaveGroup(groupId: Long) {
|
||||
val groupInfo = apiLeaveGroup(groupId)
|
||||
if (groupInfo != null) {
|
||||
chatModel.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
|
||||
val cInfo = aChatItem.chatInfo
|
||||
val cItem = aChatItem.chatItem
|
||||
@@ -707,7 +929,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
fun showBackgroundServiceNoticeIfNeeded() {
|
||||
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||
if (!appPrefs.backgroundServiceNoticeShown.get()) {
|
||||
// the branch for the new users who has never seen service notice
|
||||
// the branch for the new users who have never seen service notice
|
||||
if (isIgnoringBatteryOptimizations(appContext)) {
|
||||
showBGServiceNotice()
|
||||
} else {
|
||||
@@ -720,15 +942,19 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
// the branch for users who have app installed, and have seen the service notice,
|
||||
// but the battery optimization for the app is on (Android 12) AND the service is running
|
||||
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
|
||||
showDisablingServiceNotice()
|
||||
appPrefs.runServiceInBackground.set(false)
|
||||
chatModel.runServiceInBackground.value = false
|
||||
SimplexService.StartReceiver.toggleReceiver(false)
|
||||
} else {
|
||||
// show battery optimization notice
|
||||
showBGServiceNoticeIgnoreOptimization()
|
||||
appPrefs.backgroundServiceBatteryNoticeShown.set(true)
|
||||
}
|
||||
} else {
|
||||
// service is allowed and battery optimization is disabled
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +980,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -785,7 +1011,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
|
||||
TextButton(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -811,7 +1037,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -877,6 +1103,51 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
|
||||
context.startActivity(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun getNetCfg(): NetCfg {
|
||||
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
|
||||
val socksProxy = if (useSocksProxy) ":9050" else null
|
||||
val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!)
|
||||
val requiredHostMode = appPrefs.networkRequiredHostMode.get()
|
||||
val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get()
|
||||
val tcpTimeout = appPrefs.networkTCPTimeout.get()
|
||||
val smpPingInterval = appPrefs.networkSMPPingInterval.get()
|
||||
val enableKeepAlive = appPrefs.networkEnableKeepAlive.get()
|
||||
val tcpKeepAlive = if (enableKeepAlive) {
|
||||
val keepIdle = appPrefs.networkTCPKeepIdle.get()
|
||||
val keepIntvl = appPrefs.networkTCPKeepIntvl.get()
|
||||
val keepCnt = appPrefs.networkTCPKeepCnt.get()
|
||||
KeepAliveOpts(keepIdle = keepIdle, keepIntvl = keepIntvl, keepCnt = keepCnt)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return NetCfg(
|
||||
socksProxy = socksProxy,
|
||||
hostMode = hostMode,
|
||||
requiredHostMode = requiredHostMode,
|
||||
tcpConnectTimeout = tcpConnectTimeout,
|
||||
tcpTimeout = tcpTimeout,
|
||||
tcpKeepAlive = tcpKeepAlive,
|
||||
smpPingInterval = smpPingInterval
|
||||
)
|
||||
}
|
||||
|
||||
fun setNetCfg(cfg: NetCfg) {
|
||||
appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy)
|
||||
appPrefs.networkHostMode.set(cfg.hostMode.name)
|
||||
appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode)
|
||||
appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout)
|
||||
appPrefs.networkTCPTimeout.set(cfg.tcpTimeout)
|
||||
appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval)
|
||||
if (cfg.tcpKeepAlive != null) {
|
||||
appPrefs.networkEnableKeepAlive.set(true)
|
||||
appPrefs.networkTCPKeepIdle.set(cfg.tcpKeepAlive.keepIdle)
|
||||
appPrefs.networkTCPKeepIntvl.set(cfg.tcpKeepAlive.keepIntvl)
|
||||
appPrefs.networkTCPKeepCnt.set(cfg.tcpKeepAlive.keepCnt)
|
||||
} else {
|
||||
appPrefs.networkEnableKeepAlive.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Preference<T>(val get: () -> T, val set: (T) -> Unit)
|
||||
@@ -889,22 +1160,38 @@ sealed class CC {
|
||||
class StartChat: CC()
|
||||
class ApiStopChat: CC()
|
||||
class SetFilesFolder(val filesFolder: String): CC()
|
||||
class SetIncognito(val incognito: Boolean): CC()
|
||||
class ApiExportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiDeleteStorage: CC()
|
||||
class ApiGetChats: CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
|
||||
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
|
||||
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC()
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
class GetUserSMPServers(): CC()
|
||||
class NewGroup(val groupProfile: GroupProfile): CC()
|
||||
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
|
||||
class ApiJoinGroup(val groupId: Long): CC()
|
||||
// class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
|
||||
class ApiRemoveMember(val groupId: Long, val memberId: Long): CC()
|
||||
class ApiLeaveGroup(val groupId: Long): CC()
|
||||
class ApiListMembers(val groupId: Long): CC()
|
||||
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
|
||||
class GetUserSMPServers: CC()
|
||||
class SetUserSMPServers(val smpServers: List<String>): CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
class APIGetNetworkConfig: CC()
|
||||
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
|
||||
class APIContactInfo(val contactId: Long): CC()
|
||||
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class AddContact: CC()
|
||||
class Connect(val connReq: String): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiClearChat(val type: ChatType, val id: Long): CC()
|
||||
class ListContacts: CC()
|
||||
class ApiUpdateProfile(val profile: Profile): CC()
|
||||
class ApiParseMarkdown(val text: String): CC()
|
||||
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
|
||||
class CreateMyAddress: CC()
|
||||
class DeleteMyAddress: CC()
|
||||
class ShowMyAddress: CC()
|
||||
@@ -927,22 +1214,37 @@ sealed class CC {
|
||||
is StartChat -> "/_start"
|
||||
is ApiStopChat -> "/_stop"
|
||||
is SetFilesFolder -> "/_files_folder $filesFolder"
|
||||
is SetIncognito -> "/incognito ${if (incognito) "on" else "off"}"
|
||||
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
|
||||
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||
is ApiDeleteStorage -> "/_db delete"
|
||||
is ApiGetChats -> "/_get chats pcc=on"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is NewGroup -> "/_group ${json.encodeToString(groupProfile)}"
|
||||
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
|
||||
is ApiJoinGroup -> "/_join #$groupId"
|
||||
is ApiRemoveMember -> "/_remove #$groupId $memberId"
|
||||
is ApiLeaveGroup -> "/_leave #$groupId"
|
||||
is ApiListMembers -> "/_members #$groupId"
|
||||
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
|
||||
is GetUserSMPServers -> "/smp_servers"
|
||||
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
is APIGetNetworkConfig -> "/network"
|
||||
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
|
||||
is APIContactInfo -> "/_info @$contactId"
|
||||
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
|
||||
is AddContact -> "/connect"
|
||||
is Connect -> "/connect $connReq"
|
||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
|
||||
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
|
||||
is ListContacts -> "/contacts"
|
||||
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
|
||||
is ApiParseMarkdown -> "/_parse $text"
|
||||
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
|
||||
is CreateMyAddress -> "/address"
|
||||
is DeleteMyAddress -> "/delete_address"
|
||||
is ShowMyAddress -> "/show_address"
|
||||
@@ -966,6 +1268,7 @@ sealed class CC {
|
||||
is StartChat -> "startChat"
|
||||
is ApiStopChat -> "apiStopChat"
|
||||
is SetFilesFolder -> "setFilesFolder"
|
||||
is SetIncognito -> "setIncognito"
|
||||
is ApiExportArchive -> "apiExportArchive"
|
||||
is ApiImportArchive -> "apiImportArchive"
|
||||
is ApiDeleteStorage -> "apiDeleteStorage"
|
||||
@@ -974,14 +1277,28 @@ sealed class CC {
|
||||
is ApiSendMessage -> "apiSendMessage"
|
||||
is ApiUpdateChatItem -> "apiUpdateChatItem"
|
||||
is ApiDeleteChatItem -> "apiDeleteChatItem"
|
||||
is NewGroup -> "newGroup"
|
||||
is ApiAddMember -> "apiAddMember"
|
||||
is ApiJoinGroup -> "apiJoinGroup"
|
||||
is ApiRemoveMember -> "apiRemoveMember"
|
||||
is ApiLeaveGroup -> "apiLeaveGroup"
|
||||
is ApiListMembers -> "apiListMembers"
|
||||
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
|
||||
is GetUserSMPServers -> "getUserSMPServers"
|
||||
is SetUserSMPServers -> "setUserSMPServers"
|
||||
is APISetNetworkConfig -> "/apiSetNetworkConfig"
|
||||
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
|
||||
is APISetChatSettings -> "/apiSetChatSettings"
|
||||
is APIContactInfo -> "apiContactInfo"
|
||||
is APIGroupMemberInfo -> "apiGroupMemberInfo"
|
||||
is AddContact -> "addContact"
|
||||
is Connect -> "connect"
|
||||
is ApiDeleteChat -> "apiDeleteChat"
|
||||
is ApiClearChat -> "apiClearChat"
|
||||
is ListContacts -> "listContacts"
|
||||
is ApiUpdateProfile -> "updateProfile"
|
||||
is ApiParseMarkdown -> "apiParseMarkdown"
|
||||
is ApiSetContactAlias -> "apiSetContactAlias"
|
||||
is CreateMyAddress -> "createMyAddress"
|
||||
is DeleteMyAddress -> "deleteMyAddress"
|
||||
is ShowMyAddress -> "showMyAddress"
|
||||
@@ -1007,15 +1324,112 @@ sealed class CC {
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ChatPagination {
|
||||
class Last(val count: Int): ChatPagination()
|
||||
class After(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
class Before(val chatItemId: Long, val count: Int): ChatPagination()
|
||||
|
||||
val cmdString: String get() = when (this) {
|
||||
is Last -> "count=${this.count}"
|
||||
is After -> "after=${this.chatItemId} count=${this.count}"
|
||||
is Before -> "before=${this.chatItemId} count=${this.count}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INITIAL_COUNT = 100
|
||||
const val PRELOAD_COUNT = 100
|
||||
const val UNTIL_PRELOAD_COUNT = 50
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
|
||||
|
||||
@Serializable
|
||||
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class NetCfg(
|
||||
val socksProxy: String? = null,
|
||||
val hostMode: HostMode = HostMode.OnionViaSocks,
|
||||
val requiredHostMode: Boolean = false,
|
||||
val tcpConnectTimeout: Long, // microseconds
|
||||
val tcpTimeout: Long, // microseconds
|
||||
val tcpKeepAlive: KeepAliveOpts?,
|
||||
val smpPingInterval: Long // microseconds
|
||||
) {
|
||||
val useSocksProxy: Boolean get() = socksProxy != null
|
||||
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
|
||||
|
||||
companion object {
|
||||
val defaults: NetCfg =
|
||||
NetCfg(
|
||||
socksProxy = null,
|
||||
tcpConnectTimeout = 7_500_000,
|
||||
tcpTimeout = 5_000_000,
|
||||
tcpKeepAlive = KeepAliveOpts.defaults,
|
||||
smpPingInterval = 600_000_000
|
||||
)
|
||||
|
||||
val proxyDefaults: NetCfg =
|
||||
NetCfg(
|
||||
socksProxy = ":9050",
|
||||
tcpConnectTimeout = 15_000_000,
|
||||
tcpTimeout = 10_000_000,
|
||||
tcpKeepAlive = KeepAliveOpts.defaults,
|
||||
smpPingInterval = 600_000_000
|
||||
)
|
||||
}
|
||||
|
||||
val onionHosts: OnionHosts get() = when {
|
||||
hostMode == HostMode.Public && requiredHostMode -> OnionHosts.NEVER
|
||||
hostMode == HostMode.OnionViaSocks && !requiredHostMode -> OnionHosts.PREFER
|
||||
hostMode == HostMode.OnionViaSocks && requiredHostMode -> OnionHosts.REQUIRED
|
||||
else -> OnionHosts.PREFER
|
||||
}
|
||||
|
||||
fun withOnionHosts(mode: OnionHosts): NetCfg = when (mode) {
|
||||
OnionHosts.NEVER ->
|
||||
this.copy(hostMode = HostMode.Public, requiredHostMode = true)
|
||||
OnionHosts.PREFER ->
|
||||
this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = false)
|
||||
OnionHosts.REQUIRED ->
|
||||
this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = true)
|
||||
}
|
||||
}
|
||||
|
||||
enum class OnionHosts {
|
||||
NEVER, PREFER, REQUIRED
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class HostMode {
|
||||
@SerialName("onionViaSocks") OnionViaSocks,
|
||||
@SerialName("onion") Onion,
|
||||
@SerialName("public") Public;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KeepAliveOpts(
|
||||
val keepIdle: Int, // seconds
|
||||
val keepIntvl: Int, // seconds
|
||||
val keepCnt: Int // times
|
||||
) {
|
||||
companion object {
|
||||
val defaults: KeepAliveOpts =
|
||||
KeepAliveOpts(keepIdle = 30, keepIntvl = 15, keepCnt = 4)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatSettings(
|
||||
val enableNtfs: Boolean
|
||||
)
|
||||
|
||||
val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1050,6 +1464,9 @@ sealed class CR {
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
|
||||
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
|
||||
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
|
||||
@Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR()
|
||||
@Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR()
|
||||
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
|
||||
@@ -1058,6 +1475,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
|
||||
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR()
|
||||
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
|
||||
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
|
||||
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
|
||||
@@ -1080,9 +1498,33 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("contactsList") class ContactsList(val contacts: List<Contact>): CR()
|
||||
// group events
|
||||
@Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("groupMembers") class GroupMembers(val group: Group): CR()
|
||||
@Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR()
|
||||
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("deletedMember") class DeletedMember(val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR()
|
||||
@Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR()
|
||||
@Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused
|
||||
@Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused
|
||||
@Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR()
|
||||
// receiving file events
|
||||
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR()
|
||||
// sending file events
|
||||
@Serializable @SerialName("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileComplete") class SndFileComplete(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@@ -1109,6 +1551,9 @@ sealed class CR {
|
||||
is ApiChats -> "apiChats"
|
||||
is ApiChat -> "apiChat"
|
||||
is UserSMPServers -> "userSMPServers"
|
||||
is NetworkConfig -> "networkConfig"
|
||||
is ContactInfo -> "contactInfo"
|
||||
is GroupMemberInfo -> "groupMemberInfo"
|
||||
is Invitation -> "invitation"
|
||||
is SentConfirmation -> "sentConfirmation"
|
||||
is SentInvitation -> "sentInvitation"
|
||||
@@ -1117,6 +1562,7 @@ sealed class CR {
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
is ContactAliasUpdated -> "contactAliasUpdated"
|
||||
is ParsedMarkdown -> "apiParsedMarkdown"
|
||||
is UserContactLink -> "userContactLink"
|
||||
is UserContactLinkCreated -> "userContactLinkCreated"
|
||||
@@ -1139,6 +1585,27 @@ sealed class CR {
|
||||
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
|
||||
is ChatItemUpdated -> "chatItemUpdated"
|
||||
is ChatItemDeleted -> "chatItemDeleted"
|
||||
is ContactsList -> "contactsList"
|
||||
is GroupCreated -> "groupCreated"
|
||||
is SentGroupInvitation -> "sentGroupInvitation"
|
||||
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
|
||||
is UserDeletedMember -> "userDeletedMember"
|
||||
is LeftMemberUser -> "leftMemberUser"
|
||||
is GroupMembers -> "groupMembers"
|
||||
is ReceivedGroupInvitation -> "receivedGroupInvitation"
|
||||
is GroupDeletedUser -> "groupDeletedUser"
|
||||
is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
|
||||
is DeletedMemberUser -> "deletedMemberUser"
|
||||
is DeletedMember -> "deletedMember"
|
||||
is LeftMember -> "leftMember"
|
||||
is GroupDeleted -> "groupDeleted"
|
||||
is ContactsMerged -> "contactsMerged"
|
||||
is GroupInvitation -> "groupInvitation"
|
||||
is UserJoinedGroup -> "userJoinedGroup"
|
||||
is JoinedGroupMember -> "joinedGroupMember"
|
||||
is ConnectedToGroupMember -> "connectedToGroupMember"
|
||||
is GroupRemoved -> "groupRemoved"
|
||||
is GroupUpdated -> "groupUpdated"
|
||||
is RcvFileAccepted -> "rcvFileAccepted"
|
||||
is RcvFileStart -> "rcvFileStart"
|
||||
is RcvFileComplete -> "rcvFileComplete"
|
||||
@@ -1169,6 +1636,9 @@ sealed class CR {
|
||||
is ApiChats -> json.encodeToString(chats)
|
||||
is ApiChat -> json.encodeToString(chat)
|
||||
is UserSMPServers -> json.encodeToString(smpServers)
|
||||
is NetworkConfig -> json.encodeToString(networkConfig)
|
||||
is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}"
|
||||
is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}"
|
||||
is Invitation -> connReqInvitation
|
||||
is SentConfirmation -> noDetails()
|
||||
is SentInvitation -> noDetails()
|
||||
@@ -1177,6 +1647,7 @@ sealed class CR {
|
||||
is ChatCleared -> json.encodeToString(chatInfo)
|
||||
is UserProfileNoChange -> noDetails()
|
||||
is UserProfileUpdated -> json.encodeToString(toProfile)
|
||||
is ContactAliasUpdated -> json.encodeToString(toContact)
|
||||
is ParsedMarkdown -> json.encodeToString(formattedText)
|
||||
is UserContactLink -> connReqContact
|
||||
is UserContactLinkCreated -> connReqContact
|
||||
@@ -1199,6 +1670,27 @@ sealed class CR {
|
||||
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
|
||||
is ChatItemUpdated -> json.encodeToString(chatItem)
|
||||
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
|
||||
is ContactsList -> json.encodeToString(contacts)
|
||||
is GroupCreated -> json.encodeToString(groupInfo)
|
||||
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member"
|
||||
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
|
||||
is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is LeftMemberUser -> json.encodeToString(groupInfo)
|
||||
is GroupMembers -> json.encodeToString(group)
|
||||
is ReceivedGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole"
|
||||
is GroupDeletedUser -> json.encodeToString(groupInfo)
|
||||
is JoinedGroupMemberConnecting -> "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member"
|
||||
is DeletedMemberUser -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is DeletedMember -> "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember"
|
||||
is LeftMember -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is GroupDeleted -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is ContactsMerged -> "intoContact: $intoContact\nmergedContact: $mergedContact"
|
||||
is GroupInvitation -> json.encodeToString(groupInfo)
|
||||
is UserJoinedGroup -> json.encodeToString(groupInfo)
|
||||
is JoinedGroupMember -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member"
|
||||
is GroupRemoved -> json.encodeToString(groupInfo)
|
||||
is GroupUpdated -> json.encodeToString(toGroup)
|
||||
is RcvFileAccepted -> json.encodeToString(chatItem)
|
||||
is RcvFileStart -> json.encodeToString(chatItem)
|
||||
is RcvFileComplete -> json.encodeToString(chatItem)
|
||||
@@ -1251,6 +1743,9 @@ abstract class TerminalItem {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ConnectionStats(val rcvServers: List<String>?, val sndServers: List<String>?)
|
||||
|
||||
@Serializable
|
||||
sealed class ChatError {
|
||||
val string: String get() = when (this) {
|
||||
@@ -1279,8 +1774,10 @@ sealed class ChatErrorType {
|
||||
sealed class StoreError {
|
||||
val string: String get() = when (this) {
|
||||
is UserContactLinkNotFound -> "userContactLinkNotFound"
|
||||
is GroupNotFound -> "groupNotFound"
|
||||
}
|
||||
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
|
||||
@Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -7,6 +7,7 @@ val Purple500 = Color(0xFF6200EE)
|
||||
val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
val Gray = Color(0x22222222)
|
||||
val Indigo = Color(0xff330099)
|
||||
val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files
|
||||
val SimplexGreen = Color(77, 218, 103, 255)
|
||||
val SecretColor = Color(0x40808080)
|
||||
@@ -15,9 +16,10 @@ val DarkGray = Color(43, 44, 46, 255)
|
||||
val HighOrLowlight = Color(139, 135, 134, 255)
|
||||
val MessagePreviewDark = Color(179, 175, 174, 255)
|
||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 20)
|
||||
val ToolbarDark = Color(80, 80, 80, 20)
|
||||
val ToolbarLight = Color(220, 220, 220, 12)
|
||||
val ToolbarDark = Color(80, 80, 80, 12)
|
||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
||||
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
||||
val GroupDark = Color(80, 80, 80, 60)
|
||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||
val IncomingCallDark = Color(34, 30, 29, 255)
|
||||
|
||||
@@ -2,10 +2,15 @@ package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
enum class DefaultTheme {
|
||||
SYSTEM, DARK, LIGHT
|
||||
}
|
||||
|
||||
val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = DarkGray,
|
||||
@@ -18,7 +23,7 @@ private val DarkColorPalette = darkColors(
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
@@ -30,16 +35,28 @@ private val LightColorPalette = lightColors(
|
||||
// onSurface = Color.Black,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
}
|
||||
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(true))
|
||||
|
||||
@Composable
|
||||
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||
LaunchedEffect(darkTheme) {
|
||||
// For preview
|
||||
if (darkTheme != null)
|
||||
CurrentColors.value = ThemeManager.currentColors(darkTheme)
|
||||
}
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
LaunchedEffect(systemDark) {
|
||||
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
|
||||
// Change active colors from light to dark and back based on system theme
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||
}
|
||||
}
|
||||
val theme by CurrentColors.collectAsState()
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
colors = theme.first,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
object ThemeManager {
|
||||
private val appPrefs: AppPreferences by lazy {
|
||||
AppPreferences(SimplexApp.context)
|
||||
}
|
||||
|
||||
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
|
||||
val theme = appPrefs.currentTheme.get()!!
|
||||
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
|
||||
val res = when (theme) {
|
||||
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
||||
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
|
||||
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
|
||||
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
||||
}
|
||||
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
|
||||
}
|
||||
|
||||
// colors, default theme enum, localized name of theme
|
||||
fun allThemes(darkForSystemTheme: Boolean): List<Triple<Colors, DefaultTheme, String>> {
|
||||
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
||||
allThemes.add(
|
||||
Triple(
|
||||
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
|
||||
DefaultTheme.SYSTEM,
|
||||
generalGetString(R.string.theme_system)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
LightColorPalette,
|
||||
DefaultTheme.LIGHT,
|
||||
generalGetString(R.string.theme_light)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
DarkColorPalette,
|
||||
DefaultTheme.DARK,
|
||||
generalGetString(R.string.theme_dark)
|
||||
)
|
||||
)
|
||||
return allThemes
|
||||
}
|
||||
|
||||
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
|
||||
appPrefs.currentTheme.set(name)
|
||||
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||
}
|
||||
|
||||
fun saveAndApplyPrimaryColor(color: Color) {
|
||||
appPrefs.primaryColor.set(color.toArgb())
|
||||
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ val Inter = FontFamily(
|
||||
Font(R.font.inter_bold, weight = FontWeight.Bold),
|
||||
Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold),
|
||||
Font(R.font.inter_medium, weight = FontWeight.Medium),
|
||||
Font(R.font.inter_light, weight = FontWeight.Light),
|
||||
)
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
|
||||
@@ -115,6 +115,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
||||
)
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
SimplexService.start(chatModel.controller.appContext)
|
||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ fun IncomingCallAlertLayout(
|
||||
ignoreCall: () -> Unit,
|
||||
acceptCall: () -> Unit
|
||||
) {
|
||||
val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||
Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
||||
IncomingCallInfo(invitation)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -1,43 +1,105 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import InfoRowEllipsis
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
fun ChatInfoView(
|
||||
chatModel: ChatModel,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
close: () -> Unit,
|
||||
onChatUpdated: (Chat) -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
close = close,
|
||||
contact,
|
||||
connStats,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
developerTools,
|
||||
onLocalAliasChanged = {
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated)
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
changeNtfsState = { enabled ->
|
||||
changeNtfsState(enabled, chat, chatModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeNtfsState(enabled: Boolean, chat: Chat, chatModel: ChatModel) {
|
||||
val newChatInfo = when(chat.chatInfo) {
|
||||
is ChatInfo.Direct -> with (chat.chatInfo) {
|
||||
ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled)))
|
||||
}
|
||||
is ChatInfo.Group -> with(chat.chatInfo) {
|
||||
ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled)))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
withApi {
|
||||
val res = when (newChatInfo) {
|
||||
is ChatInfo.Direct -> with(newChatInfo) {
|
||||
chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings)
|
||||
}
|
||||
is ChatInfo.Group -> with(newChatInfo) {
|
||||
chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
if (res && newChatInfo != null) {
|
||||
chatModel.updateChatInfo(newChatInfo)
|
||||
if (!enabled) {
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_contact__question),
|
||||
title = generalGetString(R.string.delete_contact_question),
|
||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
@@ -75,107 +137,278 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
|
||||
@Composable
|
||||
fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
close: () -> Unit,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit
|
||||
clearChat: () -> Unit,
|
||||
changeNtfsState: (Boolean) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
CloseSheetBar(close)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
val cInfo = chat.chatInfo
|
||||
ChatInfoImage(cInfo, size = 192.dp)
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoHeader(chat.chatInfo, contact)
|
||||
}
|
||||
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(Modifier.padding(horizontal = 32.dp)) {
|
||||
ServerImage(chat)
|
||||
Text(
|
||||
chat.serverInfo.networkStatus.statusString,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||
|
||||
if (customUserProfile != null) {
|
||||
SectionSpacer()
|
||||
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
if (connStats != null) {
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SectionItemView {
|
||||
NetworkStatusRow(chat.serverInfo.networkStatus)
|
||||
}
|
||||
val rcvServers = connStats.rcvServers
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||
}
|
||||
val sndServers = connStats.sndServers
|
||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
Text(
|
||||
chat.serverInfo.networkStatus.statusExplanation,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1F))
|
||||
var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
|
||||
SectionView(title = stringResource(R.string.settings_section_title_settings)) {
|
||||
SectionItemView {
|
||||
NtfsSwitch(ntfsEnabled) {
|
||||
ntfsEnabled = !ntfsEnabled
|
||||
changeNtfsState(ntfsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
Box(Modifier.padding(4.dp)) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.clear_chat_button),
|
||||
icon = Icons.Outlined.Restore,
|
||||
color = WarningOrange,
|
||||
click = clearChat
|
||||
)
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ClearChatButton(clearChat)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.button_delete_contact),
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteContact
|
||||
)
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
DeleteContactButton(deleteContact)
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
Spacer(Modifier.weight(1F))
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.clear_chat_button),
|
||||
icon = Icons.Outlined.Restore,
|
||||
color = WarningOrange,
|
||||
click = clearChat
|
||||
)
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(chat: Chat) {
|
||||
when (chat.serverInfo.networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||
is Chat.NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||
is Chat.NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||
fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) {
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
DefaultBasicTextField(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 10.dp),
|
||||
initialValue,
|
||||
{
|
||||
Text(
|
||||
generalGetString(R.string.text_field_set_contact_placeholder),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
},
|
||||
color = HighOrLowlight,
|
||||
textStyle = TextStyle.Default.copy(textAlign = TextAlign.Center),
|
||||
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
||||
) {
|
||||
value = it
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { updateValue(value) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
networkStatus.statusExplanation
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.network_status))
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
stringResource(R.string.network_status),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
networkStatus.statusString,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
ServerImage(networkStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
||||
Box(Modifier.size(18.dp)) {
|
||||
when (networkStatus) {
|
||||
is Chat.NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||
is Chat.NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||
is Chat.NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimplexServers(text: String, servers: List<String>) {
|
||||
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
InfoRowEllipsis(text, info) {
|
||||
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
|
||||
Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NtfsSwitch(
|
||||
ntfsEnabled: Boolean,
|
||||
toggleNtfs: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Notifications,
|
||||
stringResource(R.string.notifications),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Text(stringResource(R.string.notifications))
|
||||
}
|
||||
Switch(
|
||||
checked = ntfsEnabled,
|
||||
onCheckedChange = toggleNtfs,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(clearChat: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { clearChat() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Restore,
|
||||
stringResource(R.string.clear_chat_button),
|
||||
tint = WarningOrange
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.clear_chat_button), color = WarningOrange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteContactButton(deleteContact: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { deleteContact() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_contact),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_delete_contact), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi {
|
||||
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
|
||||
chatModel.updateContact(it)
|
||||
onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +422,14 @@ fun PreviewChatInfoLayout() {
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
close = {}, deleteContact = {}, clearChat = {}
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
changeNtfsState = {},
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
deleteContact = {}, clearChat = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
@@ -19,30 +20,34 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chat.group.*
|
||||
import chat.simplex.app.views.chat.item.ChatItemView
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.AppBarHeight
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatView(chatModel: ChatModel) {
|
||||
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||
var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
||||
val searchText = remember { mutableStateOf("") }
|
||||
val user = chatModel.currentUser.value
|
||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
|
||||
@@ -50,48 +55,106 @@ fun ChatView(chatModel: ChatModel) {
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (chat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
// TODO a more advanced version would mark as read only if in view
|
||||
LaunchedEffect(chat.chatItems) {
|
||||
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
|
||||
delay(750L)
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
||||
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
activeChat = if (chatModel.chatId.value == null) {
|
||||
null
|
||||
} else {
|
||||
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
|
||||
// Also for situation when chatId changes after clicking in notification, etc
|
||||
chatModel.getChat(chatModel.chatId.value!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeChat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
} else {
|
||||
val chat = activeChat!!
|
||||
BackHandler { chatModel.chatId.value = null }
|
||||
|
||||
// We need to have real unreadCount value for displaying it inside top right button
|
||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||
val unreadCount = remember {
|
||||
derivedStateOf {
|
||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
ChatLayout(
|
||||
user,
|
||||
chat,
|
||||
unreadCount,
|
||||
composeState,
|
||||
composeView = {
|
||||
ComposeView(
|
||||
chatModel, chat, composeState, attachmentOption,
|
||||
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
|
||||
)
|
||||
if (chat.chatInfo.sendMsgEnabled) {
|
||||
ComposeView(
|
||||
chatModel, chat, composeState, attachmentOption,
|
||||
showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }
|
||||
)
|
||||
}
|
||||
},
|
||||
attachmentOption,
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
searchText,
|
||||
useLinkPreviews = useLinkPreviews,
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
back = { chatModel.chatId.value = null },
|
||||
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
|
||||
openDirectChat = { contactId ->
|
||||
val c = chatModel.chats.firstOrNull {
|
||||
it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId
|
||||
info = {
|
||||
withApi {
|
||||
val cInfo = chat.chatInfo
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) {
|
||||
activeChat = it
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (cInfo is ChatInfo.Group) {
|
||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupChatInfoView(chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
loadPrevMessages = { cInfo ->
|
||||
val c = chatModel.getChat(cInfo.id)
|
||||
val firstId = chatModel.chatItems.firstOrNull()?.id
|
||||
if (c != null && firstId != null) {
|
||||
withApi {
|
||||
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
|
||||
}
|
||||
}
|
||||
if (c != null) withApi { openChat(c.chatInfo, chatModel) }
|
||||
},
|
||||
deleteMessage = { itemId, mode ->
|
||||
withApi {
|
||||
@@ -108,6 +171,9 @@ fun ChatView(chatModel: ChatModel) {
|
||||
receiveFile = { fileId ->
|
||||
withApi { chatModel.controller.receiveFile(fileId) }
|
||||
},
|
||||
joinGroup = { groupId ->
|
||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||
},
|
||||
startCall = { media ->
|
||||
val cInfo = chat.chatInfo
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
@@ -123,6 +189,38 @@ fun ChatView(chatModel: ChatModel) {
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
},
|
||||
addMembers = { groupInfo ->
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
markRead = { range, unreadCountAfter ->
|
||||
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
range
|
||||
)
|
||||
}
|
||||
},
|
||||
onSearchValueChanged = { value ->
|
||||
if (searchText.value == value) return@ChatLayout
|
||||
val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout
|
||||
withApi {
|
||||
apiFindMessages(c.chatInfo, chatModel, value)
|
||||
searchText.value = value
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -132,20 +230,28 @@ fun ChatView(chatModel: ChatModel) {
|
||||
fun ChatLayout(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
composeView: (@Composable () -> Unit),
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
scope: CoroutineScope,
|
||||
attachmentBottomSheetState: ModalBottomSheetState,
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
chatModelIncognito: Boolean,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
acceptCall: (Contact) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
Modifier
|
||||
@@ -165,13 +271,23 @@ fun ChatLayout(
|
||||
sheetState = attachmentBottomSheetState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
|
||||
val setFloatingButton = { button: @Composable () -> Unit ->
|
||||
floatingButton.value = button
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) },
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding()
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = { floatingButton.value() },
|
||||
) { contentPadding ->
|
||||
Box(Modifier.padding(contentPadding)) {
|
||||
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall)
|
||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||
ChatItemsList(
|
||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
||||
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,62 +296,112 @@ fun ChatLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
|
||||
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
|
||||
IconButton(onClick, modifier = modifier) {
|
||||
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
|
||||
fun ChatInfoToolbar(
|
||||
chat: Chat,
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
val onBackClicked = {
|
||||
if (!showSearch) {
|
||||
back()
|
||||
} else {
|
||||
onSearchValueChanged("")
|
||||
showSearch = false
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
|
||||
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {
|
||||
startCall(CallMediaType.Audio)
|
||||
}
|
||||
}
|
||||
toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) {
|
||||
startCall(CallMediaType.Video)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 80.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = info),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 40.dp)
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = onBackClicked)
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
|
||||
showMenu = false
|
||||
showSearch = true
|
||||
})
|
||||
}
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Direct) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
startCall(CallMediaType.Audio)
|
||||
}) {
|
||||
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
|
||||
showMenu = false
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
}
|
||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu = false
|
||||
addMembers(chat.chatInfo.groupInfo)
|
||||
}) {
|
||||
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
barButtons.add {
|
||||
IconButton({ showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { NavigationButtonBack(onBackClicked) },
|
||||
title = { ChatInfoToolbarTitle(chat.chatInfo) },
|
||||
onTitleClick = info,
|
||||
showSearch = showSearch,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = barButtons
|
||||
)
|
||||
|
||||
Divider(Modifier.padding(top = AppBarHeight))
|
||||
|
||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
Modifier.widthIn(min = 220.dp)
|
||||
) {
|
||||
menuItems.forEach { it() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (cInfo.incognito) {
|
||||
IncognitoImage(size = 36.dp, Indigo)
|
||||
}
|
||||
ChatInfoImage(cInfo, size = imageSize, iconColor)
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
|
||||
Text(
|
||||
cInfo.fullName,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,80 +418,274 @@ val CIListStateSaver = run {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemsList(
|
||||
fun BoxWithConstraintsScope.ChatItemsList(
|
||||
user: User,
|
||||
chat: Chat,
|
||||
unreadCount: State<Int>,
|
||||
composeState: MutableState<ComposeState>,
|
||||
chatItems: List<ChatItem>,
|
||||
searchValue: State<String>,
|
||||
useLinkPreviews: Boolean,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
chatModelIncognito: Boolean,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadPrevMessages: (ChatInfo) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
|
||||
val keyboardState by getKeyboardState()
|
||||
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
|
||||
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val cxt = LocalContext.current
|
||||
LazyColumn(state = listState) {
|
||||
itemsIndexed(chatItems) { i, cItem ->
|
||||
if (i == 0) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i > 0) chatItems[i - 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
|
||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||
// and prevents scrolling to bottom on orientation change
|
||||
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
|
||||
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) {
|
||||
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
// Don't autoscroll next time until it will be needed
|
||||
shouldAutoScroll = false
|
||||
}
|
||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||
// Scroll to bottom when search value changes from something to nothing and back
|
||||
LaunchedEffect(searchValue.value.isEmpty()) {
|
||||
// They are equal when orientation was changed, don't need to scroll.
|
||||
// LaunchedEffect unaware of this event since it uses remember, not rememberSaveable
|
||||
if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect
|
||||
prevSearchEmptiness = searchValue.value.isEmpty()
|
||||
|
||||
if (listState.firstVisibleItemIndex != 0) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
}
|
||||
|
||||
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c ->
|
||||
loadPrevMessages(c.chatInfo)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
||||
CompositionLocalProvider(
|
||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false }
|
||||
val directions = setOf(DismissDirection.EndToStart)
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = directions,
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd))
|
||||
val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart))
|
||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { openDirectChat(contactId) }
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Group) {
|
||||
if (cItem.chatDir is CIDirection.GroupRcv) {
|
||||
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
|
||||
val member = cItem.chatDir.groupMember
|
||||
val showMember = showMemberImage(member, prevItem)
|
||||
Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) {
|
||||
if (showMember) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId == null) {
|
||||
MemberImage(member)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
showMemberInfo(chat.chatInfo.groupInfo, member)
|
||||
}
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
} else {
|
||||
Spacer(Modifier.size(42.dp))
|
||||
}
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent) 76.dp else 12.dp,
|
||||
end = if (sent) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
|
||||
}
|
||||
}
|
||||
|
||||
if (cItem.isRcvNew) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
scope.launch {
|
||||
delay(750)
|
||||
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val len = chatItems.count()
|
||||
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
|
||||
scope.launch {
|
||||
ciListState.value = CIListState(true, len, keyboardState)
|
||||
listState.animateScrollToItem(len - 1)
|
||||
}
|
||||
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxWithConstraintsScope.FloatingButtons(
|
||||
chatItems: List<ChatItem>,
|
||||
unreadCount: State<Int>,
|
||||
minUnreadItemId: Long,
|
||||
searchValue: State<String>,
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
listState: LazyListState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
|
||||
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
|
||||
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
firstVisibleIndex = it
|
||||
firstItemIsVisible = firstVisibleIndex == 0
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
|
||||
// so separate them into two LaunchedEffects
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
lastIndexOfVisibleItems = it
|
||||
}
|
||||
}
|
||||
|
||||
val bottomUnreadCount by remember {
|
||||
derivedStateOf {
|
||||
if (unreadCount.value == 0) return@derivedStateOf 0
|
||||
|
||||
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
|
||||
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
|
||||
|
||||
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
|
||||
}
|
||||
}
|
||||
|
||||
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
|
||||
|
||||
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
|
||||
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
|
||||
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
|
||||
setFloatingButton(
|
||||
bottomEndFloatingButton(
|
||||
bottomUnreadCount,
|
||||
showButtonWithCounter,
|
||||
showButtonWithArrow,
|
||||
onClickArrowDown = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
onClickCounter = {
|
||||
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
|
||||
}
|
||||
))
|
||||
}
|
||||
// Don't show top FAB if is in search
|
||||
if (searchValue.value.isNotEmpty()) return
|
||||
val fabSize = 56.dp
|
||||
val topUnreadCount by remember {
|
||||
derivedStateOf { unreadCount.value - bottomUnreadCount }
|
||||
}
|
||||
val showButtonWithCounter = topUnreadCount > 0
|
||||
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
|
||||
TopEndFloatingButton(
|
||||
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
|
||||
topUnreadCount,
|
||||
showButtonWithCounter,
|
||||
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||
onLongClick = { showDropDown = true }
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
Modifier.width(220.dp),
|
||||
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
markRead(
|
||||
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||
bottomUnreadCount
|
||||
)
|
||||
showDropDown = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.mark_read),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreloadItems(
|
||||
listState: LazyListState,
|
||||
remaining: Int = 10,
|
||||
chat: Chat,
|
||||
items: List<*>,
|
||||
onLoadMore: (chat: Chat) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(listState, chat, items) {
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.map {
|
||||
val totalItemsNumber = it.totalItemsCount
|
||||
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
|
||||
totalItemsNumber
|
||||
else
|
||||
0
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.filter { it > 0 }
|
||||
.collect {
|
||||
onLoadMore(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
|
||||
@@ -338,6 +698,88 @@ fun MemberImage(member: GroupMember) {
|
||||
ProfileImage(38.dp, member.memberProfile.image)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopEndFloatingButton(
|
||||
modifier: Modifier = Modifier,
|
||||
unreadCount: Int,
|
||||
showButtonWithCounter: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) = when {
|
||||
showButtonWithCounter -> {
|
||||
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
|
||||
FloatingActionButton(
|
||||
{}, // no action here
|
||||
modifier.size(48.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bottomEndFloatingButton(
|
||||
unreadCount: Int,
|
||||
showButtonWithCounter: Boolean,
|
||||
showButtonWithArrow: Boolean,
|
||||
onClickArrowDown: () -> Unit,
|
||||
onClickCounter: () -> Unit
|
||||
): @Composable () -> Unit = when {
|
||||
showButtonWithCounter -> {
|
||||
{
|
||||
FloatingActionButton(
|
||||
onClick = onClickCounter,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Text(
|
||||
unreadCountStr(unreadCount),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
showButtonWithArrow -> {
|
||||
{
|
||||
FloatingActionButton(
|
||||
onClick = onClickArrowDown,
|
||||
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
{}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
|
||||
override val longPressTimeoutMillis
|
||||
get() =
|
||||
this@bigTouchSlop.longPressTimeoutMillis
|
||||
override val doubleTapTimeoutMillis
|
||||
get() =
|
||||
this@bigTouchSlop.doubleTapTimeoutMillis
|
||||
override val doubleTapMinTimeMillis
|
||||
get() =
|
||||
this@bigTouchSlop.doubleTapMinTimeMillis
|
||||
override val touchSlop: Float get() = slop
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -365,6 +807,8 @@ fun PreviewChatLayout() {
|
||||
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||
val searchValue = remember { mutableStateOf("") }
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
@@ -372,20 +816,28 @@ fun PreviewChatLayout() {
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
unreadCount = unreadCount,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
showMemberInfo = {_, _ -> },
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> }
|
||||
acceptCall = { _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -412,6 +864,8 @@ fun PreviewGroupChatLayout() {
|
||||
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||
)
|
||||
)
|
||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||
val searchValue = remember { mutableStateOf("") }
|
||||
ChatLayout(
|
||||
user = User.sampleData,
|
||||
chat = Chat(
|
||||
@@ -419,20 +873,28 @@ fun PreviewGroupChatLayout() {
|
||||
chatItems = chatItems,
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
unreadCount = unreadCount,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
composeView = {},
|
||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
searchValue,
|
||||
useLinkPreviews = true,
|
||||
chatModelIncognito = false,
|
||||
back = {},
|
||||
info = {},
|
||||
openDirectChat = {},
|
||||
showMemberInfo = {_, _ -> },
|
||||
loadPrevMessages = { _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
acceptCall = { _ -> }
|
||||
acceptCall = { _ -> },
|
||||
addMembers = { _ -> },
|
||||
markRead = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,7 +30,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
Text(fileName)
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -26,7 +28,9 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
composeState: MutableState<ComposeState>,
|
||||
@@ -35,6 +39,16 @@ fun SendMsgView(
|
||||
textStyle: MutableState<TextStyle>
|
||||
) {
|
||||
val cs = composeState.value
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect
|
||||
// In replying state
|
||||
focusRequester.requestFocus()
|
||||
delay(50)
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = cs.message,
|
||||
onValueChange = onMessageChange,
|
||||
@@ -44,7 +58,7 @@ fun SendMsgView(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
autoCorrect = true
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester),
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||
val selectedContacts = remember { mutableStateListOf<Long>() }
|
||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }
|
||||
|
||||
BackHandler(onBack = close)
|
||||
AddGroupMembersLayout(
|
||||
groupInfo = groupInfo,
|
||||
contactsToAdd = getContactsToAdd(chatModel),
|
||||
selectedContacts = selectedContacts,
|
||||
selectedRole = selectedRole,
|
||||
inviteMembers = {
|
||||
withApi {
|
||||
selectedContacts.forEach {
|
||||
val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value)
|
||||
if (member != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
},
|
||||
clearSelection = { selectedContacts.clear() },
|
||||
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
|
||||
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
||||
)
|
||||
}
|
||||
|
||||
fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
||||
val memberContactIds = chatModel.groupMembers
|
||||
.filter { it.memberCurrent }
|
||||
.mapNotNull { it.memberContactId }
|
||||
return chatModel.chats
|
||||
.asSequence()
|
||||
.map { it.chatInfo }
|
||||
.filterIsInstance<ChatInfo.Direct>()
|
||||
.map { it.contact }
|
||||
.filter { it.contactId !in memberContactIds }
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddGroupMembersLayout(
|
||||
groupInfo: GroupInfo,
|
||||
contactsToAdd: List<Contact>,
|
||||
selectedContacts: SnapshotStateList<Long>,
|
||||
selectedRole: MutableState<GroupMemberRole>,
|
||||
inviteMembers: () -> Unit,
|
||||
clearSelection: () -> Unit,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoToolbarTitle(
|
||||
ChatInfo.Group(groupInfo),
|
||||
imageSize = 60.dp,
|
||||
iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight
|
||||
)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (contactsToAdd.isEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.no_contacts_to_add),
|
||||
Modifier.padding(),
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
RoleSelectionRow(groupInfo, selectedRole)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty())
|
||||
}
|
||||
}
|
||||
SectionCustomFooter {
|
||||
InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(R.string.new_member_role))
|
||||
RoleDropdownMenu(groupInfo, selectedRole)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole>) {
|
||||
val options = GroupMemberRole.values()
|
||||
.filter { it <= groupInfo.membership.memberRole }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(0.7f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
selectedRole.value.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.invite_to_group_button),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
options.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selectedRole.value = selectionOption
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
selectionOption.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) {
|
||||
val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() }
|
||||
Row(
|
||||
modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Text(stringResource(R.string.invite_to_group_button), color = color)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Icon(
|
||||
Icons.Outlined.Check,
|
||||
stringResource(R.string.invite_to_group_button),
|
||||
tint = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (selectedContactsCount >= 1) {
|
||||
Box(
|
||||
Modifier.clickable { clearSelection() }
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.clear_contacts_selection_button),
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.no_contacts_selected),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactList(
|
||||
contacts: List<Contact>,
|
||||
selectedContacts: SnapshotStateList<Long>,
|
||||
groupInfo: GroupInfo,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit
|
||||
) {
|
||||
Column {
|
||||
contacts.forEachIndexed { index, contact ->
|
||||
SectionItemView {
|
||||
ContactCheckRow(
|
||||
contact, groupInfo, addContact, removeContact,
|
||||
checked = selectedContacts.contains(contact.apiId)
|
||||
)
|
||||
}
|
||||
if (index < contacts.lastIndex) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactCheckRow(
|
||||
contact: Contact,
|
||||
groupInfo: GroupInfo,
|
||||
addContact: (Long) -> Unit,
|
||||
removeContact: (Long) -> Unit,
|
||||
checked: Boolean
|
||||
) {
|
||||
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
||||
val icon: ImageVector
|
||||
val iconColor: Color
|
||||
if (prohibitedToInviteIncognito) {
|
||||
icon = Icons.Filled.TheaterComedy
|
||||
iconColor = HighOrLowlight
|
||||
} else if (checked) {
|
||||
icon = Icons.Filled.CheckCircle
|
||||
iconColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
icon = Icons.Outlined.Circle
|
||||
iconColor = HighOrLowlight
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
if (prohibitedToInviteIncognito) {
|
||||
showProhibitedToInviteIncognitoAlertDialog()
|
||||
} else if (!checked)
|
||||
addContact(contact.apiId)
|
||||
else
|
||||
removeContact(contact.apiId)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
ProfileImage(size = 36.dp, contact.image)
|
||||
Text(
|
||||
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = stringResource(R.string.icon_descr_contact_checked),
|
||||
tint = iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showProhibitedToInviteIncognitoAlertDialog() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.invite_prohibited),
|
||||
text = generalGetString(R.string.invite_prohibited_description),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAddGroupMembersLayout() {
|
||||
SimpleXTheme {
|
||||
AddGroupMembersLayout(
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||
selectedContacts = remember { mutableStateListOf() },
|
||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||
inviteMembers = {},
|
||||
clearSelection = {},
|
||||
addContact = {},
|
||||
removeContact = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Group) {
|
||||
val groupInfo = chat.chatInfo.groupInfo
|
||||
GroupChatInfoLayout(
|
||||
chat,
|
||||
groupInfo,
|
||||
members = chatModel.groupMembers
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedBy { it.displayName.lowercase() },
|
||||
developerTools,
|
||||
addMembers = {
|
||||
withApi {
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
showMemberInfo = { member ->
|
||||
withApi {
|
||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||
ModalManager.shared.showCustomModal { closeCurrent ->
|
||||
ModalView(
|
||||
close = closeCurrent, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editGroupProfile = {
|
||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||
},
|
||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||
changeNtfsState = { enabled ->
|
||||
changeNtfsState(enabled, chat, chatModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteGroupDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_group_question),
|
||||
text = generalGetString(R.string.delete_group_for_all_members_cannot_undo_warning),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.leave_group_question),
|
||||
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||
confirmText = generalGetString(R.string.leave_group_button),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.leaveGroup(groupInfo.groupId)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoLayout(
|
||||
chat: Chat,
|
||||
groupInfo: GroupInfo,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
deleteGroup: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
leaveGroup: () -> Unit,
|
||||
changeNtfsState: (Boolean) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupChatInfoHeader(chat.chatInfo)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
SectionItemView {
|
||||
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
|
||||
val onClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||
AddMembersButton(tint, onClick)
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
SectionItemView(height = 50.dp) {
|
||||
MemberRow(groupInfo.membership, user = true)
|
||||
}
|
||||
if (members.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
}
|
||||
MembersList(members, showMemberInfo)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
|
||||
SectionView(title = stringResource(R.string.settings_section_title_settings)) {
|
||||
SectionItemView {
|
||||
NtfsSwitch(ntfsEnabled) {
|
||||
ntfsEnabled = !ntfsEnabled
|
||||
changeNtfsState(ntfsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
if (groupInfo.canEdit) {
|
||||
SectionItemView {
|
||||
EditGroupProfileButton(editGroupProfile)
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
SectionItemView {
|
||||
ClearChatButton(clearChat)
|
||||
}
|
||||
if (groupInfo.canDelete) {
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
DeleteGroupButton(deleteGroup)
|
||||
}
|
||||
}
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
LeaveGroupButton(leaveGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { addMembers() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Add,
|
||||
stringResource(R.string.button_add_members),
|
||||
tint = tint
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_add_members), color = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||
Column {
|
||||
members.forEachIndexed { index, member ->
|
||||
SectionItemView(height = 50.dp) {
|
||||
MemberRow(member, showMemberInfo)
|
||||
}
|
||||
if (index < members.lastIndex) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) {
|
||||
val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier
|
||||
Row(
|
||||
modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
ProfileImage(size = 46.dp, member.image)
|
||||
Column {
|
||||
Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified)
|
||||
val s = member.memberStatus.shortText
|
||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||
Text(
|
||||
statusDescr,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
val role = member.memberRole
|
||||
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
||||
Text(role.text, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditGroupProfileButton(editGroupProfile: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { editGroupProfile() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
stringResource(R.string.button_edit_group_profile),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveGroupButton(leaveGroup: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { leaveGroup() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Logout,
|
||||
stringResource(R.string.button_leave_group),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_leave_group), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteGroupButton(deleteGroup: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { deleteGroup() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_group),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_delete_group), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
GroupChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf(),
|
||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
||||
),
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {},
|
||||
changeNtfsState = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.SimplexServers
|
||||
import chat.simplex.app.views.chatlist.openChat
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoView(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
chatModel: ChatModel,
|
||||
close: () -> Unit,
|
||||
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
GroupMemberInfoLayout(
|
||||
groupInfo,
|
||||
member,
|
||||
connStats,
|
||||
developerTools,
|
||||
openDirectChat = {
|
||||
withApi {
|
||||
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
|
||||
if (oldChat != null) {
|
||||
openChat(oldChat.chatInfo, chatModel)
|
||||
} else {
|
||||
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
|
||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||
chatModel.addChat(newChat)
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = newChat.id
|
||||
}
|
||||
closeAll()
|
||||
}
|
||||
},
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.button_remove_member),
|
||||
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
|
||||
if (removedMember != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, removedMember)
|
||||
}
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
developerTools: Boolean,
|
||||
openDirectChat: () -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupMemberInfoHeader(member)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
OpenChatButton(openDirectChat)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||
val conn = member.activeConn
|
||||
if (conn != null) {
|
||||
SectionDivider()
|
||||
val connLevelDesc =
|
||||
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
||||
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
||||
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (connStats != null) {
|
||||
val rcvServers = connStats.rcvServers
|
||||
val sndServers = connStats.sndServers
|
||||
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
if (member.canBeRemoved(groupInfo.membership)) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||
Text(
|
||||
member.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveMemberButton(removeMember: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { removeMember() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_remove_member),
|
||||
tint = Color.Red
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_remove_member), color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenChatButton(onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { onClick() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Message,
|
||||
stringResource(R.string.button_send_direct_message),
|
||||
Modifier.padding(top = 5.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.button_send_direct_message), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupMemberInfoLayout() {
|
||||
SimpleXTheme {
|
||||
GroupMemberInfoLayout(
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
member = GroupMember.sampleData,
|
||||
connStats = null,
|
||||
developerTools = false,
|
||||
openDirectChat = {},
|
||||
removeMember = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
||||
GroupProfileLayout(
|
||||
close = close,
|
||||
groupProfile = groupInfo.groupProfile,
|
||||
saveProfile = { p ->
|
||||
withApi {
|
||||
val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p)
|
||||
if (gInfo != null) {
|
||||
chatModel.updateGroup(gInfo)
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupProfileLayout(
|
||||
close: () -> Unit,
|
||||
groupProfile: GroupProfile,
|
||||
saveProfile: (GroupProfile) -> Unit,
|
||||
) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(groupProfile.displayName) }
|
||||
val fullName = remember { mutableStateOf(groupProfile.fullName) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val profileImage = remember { mutableStateOf(groupProfile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
|
||||
hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.group_profile_is_stored_on_members_devices),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(192.dp, profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.group_full_name_field),
|
||||
Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row {
|
||||
TextButton(stringResource(R.string.cancel_verb)) {
|
||||
close.invoke()
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.save_group_profile),
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewGroupProfileLayout() {
|
||||
SimpleXTheme {
|
||||
GroupProfileLayout(
|
||||
close = {},
|
||||
groupProfile = GroupProfile.sampleData,
|
||||
saveProfile = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(status.duration(duration), color = HighOrLowlight)
|
||||
}
|
||||
CICallStatus.Error -> {}
|
||||
}
|
||||
|
||||
Text(
|
||||
|
||||
@@ -2,7 +2,6 @@ package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -39,7 +38,7 @@ fun CIFileView(
|
||||
@Composable
|
||||
fun fileIcon(
|
||||
innerIcon: ImageVector? = null,
|
||||
color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -105,7 +104,7 @@ fun CIFileView(
|
||||
fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isSystemInDarkTheme()) FileDark else FileLight,
|
||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun CIGroupEventView(ci: ChatItem) {
|
||||
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
|
||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
||||
}
|
||||
|
||||
Surface {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
val memberDisplayName = ci.memberDisplayName
|
||||
if (memberDisplayName != null) {
|
||||
withGroupEventStyle(this, memberDisplayName)
|
||||
append(" ")
|
||||
}
|
||||
withGroupEventStyle(this, ci.content.text)
|
||||
append(" ")
|
||||
withGroupEventStyle(this, ci.timestampText)
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun CIGroupEventViewPreview() {
|
||||
SimpleXTheme {
|
||||
CIGroupEventView(
|
||||
ChatItem.getGroupEventSample()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun CIGroupInvitationView(
|
||||
ci: ChatItem,
|
||||
groupInvitation: CIGroupInvitation,
|
||||
memberRole: GroupMemberRole,
|
||||
chatIncognito: Boolean = false,
|
||||
joinGroup: (Long) -> Unit
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
|
||||
|
||||
@Composable
|
||||
fun groupInfoView() {
|
||||
val p = groupInvitation.groupProfile
|
||||
val iconColor =
|
||||
if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
|
||||
else if (isInDarkTheme()) FileDark else FileLight
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.padding(end = 2.dp)
|
||||
) {
|
||||
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
|
||||
Spacer(Modifier.padding(horizontal = 3.dp))
|
||||
Column(
|
||||
Modifier.defaultMinSize(minHeight = 60.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (p.fullName != "" && p.displayName != p.fullName) {
|
||||
Text(p.fullName, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun groupInvitationText() {
|
||||
when {
|
||||
sent -> Text(stringResource(R.string.you_sent_group_invitation))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
|
||||
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = if (action) Modifier.clickable(onClick = {
|
||||
joinGroup(groupInvitation.groupId)
|
||||
}) else Modifier,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.padding(vertical = 3.dp)
|
||||
.padding(start = 8.dp, end = 12.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
groupInfoView()
|
||||
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
|
||||
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
|
||||
if (action) {
|
||||
groupInvitationText()
|
||||
Text(stringResource(
|
||||
if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join),
|
||||
color = if (chatIncognito) Indigo else MaterialTheme.colors.primary)
|
||||
} else {
|
||||
Box(Modifier.padding(end = 48.dp)) {
|
||||
groupInvitationText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
ci.timestampText,
|
||||
color = HighOrLowlight,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PendingCIGroupInvitationViewPreview() {
|
||||
SimpleXTheme {
|
||||
CIGroupInvitationView(
|
||||
ci = ChatItem.getGroupInvitationSample(),
|
||||
groupInvitation = CIGroupInvitation.getSample(),
|
||||
memberRole = GroupMemberRole.Admin,
|
||||
joinGroup = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun CIGroupInvitationViewAcceptedPreview() {
|
||||
SimpleXTheme {
|
||||
CIGroupInvitationView(
|
||||
ci = ChatItem.getGroupInvitationSample(),
|
||||
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
|
||||
memberRole = GroupMemberRole.Admin,
|
||||
joinGroup = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CIGroupInvitationViewLongNamePreview() {
|
||||
SimpleXTheme {
|
||||
CIGroupInvitationView(
|
||||
ci = ChatItem.getGroupInvitationSample(),
|
||||
groupInvitation = CIGroupInvitation.getSample(
|
||||
groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name"),
|
||||
status = CIGroupInvitationStatus.Accepted
|
||||
),
|
||||
memberRole = GroupMemberRole.Admin,
|
||||
joinGroup = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -16,7 +15,6 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimplexBlue
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
@@ -56,7 +54,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
|
||||
}
|
||||
is CIStatus.RcvNew -> {
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue)
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.Context
|
||||
import android.content.*
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -14,8 +14,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -36,9 +35,11 @@ fun ChatItemView(
|
||||
cxt: Context,
|
||||
uriHandler: UriHandler? = null,
|
||||
showMember: Boolean = false,
|
||||
chatModelIncognito: Boolean,
|
||||
useLinkPreviews: Boolean,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -61,7 +62,8 @@ fun ChatItemView(
|
||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
} else {
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
@@ -146,6 +148,10 @@ fun ChatItemView(
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvGroupEventContent -> CIGroupEventView(cItem)
|
||||
is CIContent.SndGroupEventContent -> CIGroupEventView(cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +165,8 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
@@ -178,13 +185,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_me_only)) }
|
||||
if (chatItem.meta.editable) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_everybody)) }
|
||||
@@ -207,8 +214,10 @@ fun PreviewChatItemView() {
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
chatModelIncognito = false,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> }
|
||||
)
|
||||
}
|
||||
@@ -225,8 +234,10 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
useLinkPreviews = true,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
cxt = LocalContext.current,
|
||||
chatModelIncognito = false,
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,12 +31,19 @@ fun EmojiText(text: String) {
|
||||
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||
}
|
||||
|
||||
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
|
||||
// https://stackoverflow.com/a/46279500
|
||||
private const val emojiStr = "^(" +
|
||||
"(?:[\\u2700-\\u27bf]|" +
|
||||
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
|
||||
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?" +
|
||||
"(?:\\u200d(?:[^\\ud800-\\udfff]|" +
|
||||
"(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" +
|
||||
"[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?)*|" +
|
||||
"[\\u0023-\\u0039]\\ufe0f?\\u20e3|\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|[\\ud83c\\udd70-\\ud83c\\udd71]|[\\ud83c\\udd7e-\\ud83c\\udd7f]|\\ud83c\\udd8e|[\\ud83c\\udd91-\\ud83c\\udd9a]|[\\ud83c\\udde6-\\ud83c\\uddff]|[\\ud83c\\ude01-\\ud83c\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|[\\ud83c\\ude32-\\ud83c\\ude3a]|[\\ud83c\\ude50-\\ud83c\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff]" +
|
||||
")+$" // Multiple matches with emojis where one follows another without interruptions from other characters
|
||||
private val emojiRegex = Regex(emojiStr)
|
||||
|
||||
fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c)
|
||||
|
||||
// TODO count perceived emojis, possibly using icu4j
|
||||
fun isShortEmoji(str: String): Boolean {
|
||||
val s = str.trim()
|
||||
return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji)
|
||||
return s.codePoints().count() in 1..5 && emojiRegex.matches(str)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ fun FramedItemView(
|
||||
uriHandler: UriHandler? = null,
|
||||
showMember: Boolean = false,
|
||||
showMenu: MutableState<Boolean>,
|
||||
receiveFile: (Long) -> Unit
|
||||
receiveFile: (Long) -> Unit,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val sent = ci.chatDir.sent
|
||||
|
||||
@@ -86,7 +87,7 @@ fun FramedItemView(
|
||||
Modifier
|
||||
.padding(top = 6.dp, end = 4.dp)
|
||||
.size(22.dp),
|
||||
tint = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
else -> ciQuotedMsgView(qi)
|
||||
@@ -136,9 +137,9 @@ fun FramedItemView(
|
||||
}
|
||||
is MsgContent.MCLink -> {
|
||||
ChatItemLinkView(mc.preview)
|
||||
CIMarkdownText(ci, showMember, uriHandler)
|
||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
||||
}
|
||||
else -> CIMarkdownText(ci, showMember, uriHandler)
|
||||
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,11 +152,17 @@ fun FramedItemView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CIMarkdownText(ci: ChatItem, showMember: Boolean, uriHandler: UriHandler?) {
|
||||
fun CIMarkdownText(
|
||||
ci: ChatItem,
|
||||
showMember: Boolean,
|
||||
uriHandler: UriHandler?,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
MarkdownText(
|
||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true
|
||||
metaText = ci.timestampText, edited = ci.meta.itemEdited,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun IntegrityErrorItemViewView() {
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.detectGesture
|
||||
|
||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||
@@ -45,7 +47,8 @@ fun MarkdownText (
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
uriHandler: UriHandler? = null,
|
||||
senderBold: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onLinkLongClick: (link: String) -> Unit = {}
|
||||
) {
|
||||
val reserve = if (edited) " " else " "
|
||||
if (formattedText == null) {
|
||||
@@ -65,8 +68,9 @@ fun MarkdownText (
|
||||
val link = ft.link
|
||||
if (link != null) {
|
||||
hasLinks = true
|
||||
val ftStyle = ft.format.style
|
||||
withAnnotation(tag = "URL", annotation = link) {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
withStyle(ftStyle) { append(ft.text) }
|
||||
}
|
||||
} else {
|
||||
withStyle(ft.format.style) { append(ft.text) }
|
||||
@@ -77,9 +81,16 @@ fun MarkdownText (
|
||||
}
|
||||
if (hasLinks && uriHandler != null) {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onLongClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
|
||||
},
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -87,3 +98,53 @@ fun MarkdownText (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
onClick: (Int) -> Unit,
|
||||
onLongClick: (Int) -> Unit = {},
|
||||
shouldConsumeEvent: (Int) -> Boolean
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val pressIndicator = Modifier.pointerInput(onClick, onLongClick) {
|
||||
detectGesture(onLongPress = { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onLongClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}, onPress = { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
val res = tryAwaitRelease()
|
||||
if (res) {
|
||||
onClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}
|
||||
}, shouldConsumeEvent = { pos ->
|
||||
var consume = false
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
consume
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.then(pressIndicator),
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
onTextLayout(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -16,10 +16,10 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.WarningOrange
|
||||
import chat.simplex.app.views.chat.clearChatDialog
|
||||
import chat.simplex.app.views.chat.deleteContactDialog
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.chat.group.deleteGroupDialog
|
||||
import chat.simplex.app.views.chat.group.leaveGroupDialog
|
||||
import chat.simplex.app.views.chat.item.ItemAction
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -38,23 +38,23 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, stopped) },
|
||||
click = { openOrPendingChat(chat.chatInfo, chatModel) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
stopped
|
||||
)
|
||||
is ChatInfo.Group ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ChatPreviewView(chat, stopped) },
|
||||
click = { openOrPendingChat(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
stopped
|
||||
)
|
||||
is ChatInfo.ContactRequest ->
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
|
||||
chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) },
|
||||
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu,
|
||||
@@ -71,7 +71,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
if (chatInfo.ready) {
|
||||
withApi { openChat(chatInfo, chatModel) }
|
||||
} else {
|
||||
@@ -79,6 +79,14 @@ fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
when (groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
|
||||
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
|
||||
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null) {
|
||||
@@ -88,19 +96,86 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
|
||||
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
|
||||
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||
}
|
||||
|
||||
suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) {
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(0, chat.chatItems)
|
||||
}
|
||||
|
||||
suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId)
|
||||
chatModel.groupMembers.clear()
|
||||
chatModel.groupMembers.addAll(groupMembers)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
if (showMarkRead) {
|
||||
ItemAction(
|
||||
stringResource(R.string.mark_read),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
MarkReadChatAction(chat, chatModel, showMenu)
|
||||
}
|
||||
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||
ClearChatAction(chat, chatModel, showMenu)
|
||||
DeleteContactAction(chat, chatModel, showMenu)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
when (groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> {
|
||||
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
|
||||
if (groupInfo.canDelete) {
|
||||
DeleteGroupAction(chat, chatModel, showMenu)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (showMarkRead) {
|
||||
MarkReadChatAction(chat, chatModel, showMenu)
|
||||
}
|
||||
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||
ClearChatAction(chat, chatModel, showMenu)
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
LeaveGroupAction(groupInfo, chatModel, showMenu)
|
||||
}
|
||||
if (groupInfo.canDelete) {
|
||||
DeleteGroupAction(chat, chatModel, showMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.mark_read),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
||||
onClick = {
|
||||
changeNtfsState(!ntfsEnabled, chat, chatModel)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.clear_verb),
|
||||
Icons.Outlined.Restore,
|
||||
@@ -110,11 +185,15 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
|
||||
},
|
||||
color = WarningOrange
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
deleteContactDialog(chat.chatInfo as ChatInfo.Direct, chatModel)
|
||||
deleteContactDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
@@ -122,34 +201,51 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
if (showMarkRead) {
|
||||
ItemAction(
|
||||
stringResource(R.string.mark_read),
|
||||
Icons.Outlined.Check,
|
||||
onClick = {
|
||||
markChatRead(chat, chatModel)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.clear_verb),
|
||||
Icons.Outlined.Restore,
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.Delete,
|
||||
onClick = {
|
||||
clearChatDialog(chat.chatInfo, chatModel)
|
||||
deleteGroupDialog(chat.chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = WarningOrange
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
||||
ItemAction(
|
||||
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
||||
if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login,
|
||||
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
joinGroup()
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.leave_group_button),
|
||||
Icons.Outlined.Logout,
|
||||
onClick = {
|
||||
leaveGroupDialog(groupInfo, chatModel)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(R.string.accept_contact_button),
|
||||
Icons.Outlined.Check,
|
||||
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
||||
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
|
||||
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
acceptContactRequest(chatInfo, chatModel)
|
||||
showMenu.value = false
|
||||
@@ -180,12 +276,16 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
||||
}
|
||||
|
||||
fun markChatRead(chat: Chat, chatModel: ChatModel) {
|
||||
// Just to be sure
|
||||
if (chat.chatStats.unreadCount == 0) return
|
||||
|
||||
val minUnreadItemId = chat.chatStats.minUnreadItemId
|
||||
chatModel.markChatItemsRead(chat.chatInfo)
|
||||
withApi {
|
||||
chatModel.controller.apiChatRead(
|
||||
chat.chatInfo.chatType,
|
||||
chat.chatInfo.apiId,
|
||||
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
|
||||
CC.ItemRange(minUnreadItemId, chat.chatItems.last().id)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -194,7 +294,7 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.accept_connection_request__question),
|
||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||
confirmText = generalGetString(R.string.accept_contact_button),
|
||||
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
||||
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
|
||||
dismissText = generalGetString(R.string.reject_contact_button),
|
||||
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||
@@ -235,14 +335,14 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContactConnectionAlert(connection, chatModel)
|
||||
}) {
|
||||
Text(stringResource(R.string.delete_verb))
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Button(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
}
|
||||
@@ -287,6 +387,43 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.join_group_question),
|
||||
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
|
||||
confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button),
|
||||
onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
|
||||
dismissText = generalGetString(R.string.delete_verb),
|
||||
onDismiss = { deleteGroup(groupInfo, chatModel) }
|
||||
)
|
||||
}
|
||||
|
||||
fun cantInviteIncognitoAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.alert_title_cant_invite_contacts),
|
||||
text = generalGetString(R.string.alert_title_cant_invite_contacts_descr),
|
||||
confirmText = generalGetString(R.string.ok),
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(groupInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun groupInvitationAcceptedAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.joining_group),
|
||||
generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
@@ -347,6 +484,8 @@ fun PreviewChatListNavLinkDirect() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
false,
|
||||
null,
|
||||
stopped = false
|
||||
)
|
||||
},
|
||||
@@ -382,6 +521,8 @@ fun PreviewChatListNavLinkGroup() {
|
||||
),
|
||||
chatStats = Chat.ChatStats()
|
||||
),
|
||||
false,
|
||||
null,
|
||||
stopped = false
|
||||
)
|
||||
},
|
||||
@@ -404,7 +545,7 @@ fun PreviewChatListNavLinkContactRequest() {
|
||||
SimpleXTheme {
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = {
|
||||
ContactRequestView(ChatInfo.ContactRequest.sampleData)
|
||||
ContactRequestView(false, ChatInfo.ContactRequest.sampleData)
|
||||
},
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
|
||||
@@ -8,8 +8,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -19,10 +19,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.ToolbarDark
|
||||
import chat.simplex.app.ui.theme.ToolbarLight
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.app.ui.theme.Indigo
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.NewChatSheet
|
||||
import chat.simplex.app.views.onboarding.MakeConnection
|
||||
import chat.simplex.app.views.usersettings.SettingsView
|
||||
@@ -73,6 +71,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
|
||||
}
|
||||
BottomSheetScaffold(
|
||||
topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) },
|
||||
scaffoldState = scaffoldCtrl.state,
|
||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||
sheetPeekHeight = 0.dp,
|
||||
@@ -85,8 +84,6 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ChatListToolbar(scaffoldCtrl, stopped)
|
||||
Divider()
|
||||
if (chatModel.chats.isNotEmpty()) {
|
||||
ChatList(chatModel)
|
||||
} else {
|
||||
@@ -106,50 +103,53 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Menu,
|
||||
stringResource(R.string.icon_descr_settings),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(5.dp)
|
||||
)
|
||||
if (!stopped) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.PersonAdd,
|
||||
stringResource(R.string.add_contact),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean) {
|
||||
DefaultTopAppBar(
|
||||
navigationButton = { NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(R.string.your_chats),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (chatModel.incognito.value) {
|
||||
Icon(
|
||||
Icons.Filled.TheaterComedy,
|
||||
stringResource(R.string.incognito),
|
||||
tint = Indigo,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
},
|
||||
onTitleClick = null,
|
||||
showSearch = false,
|
||||
onSearchValueChanged = {},
|
||||
buttons = listOf{
|
||||
if (!stopped) {
|
||||
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
|
||||
Icon(
|
||||
Icons.Outlined.AddCircle,
|
||||
stringResource(R.string.add_contact),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication),
|
||||
generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
generalGetString(R.string.chat_is_stopped_indication),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -2,12 +2,13 @@ package chat.simplex.app.views.chatlist
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,41 +23,103 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.MarkdownText
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
import chat.simplex.app.views.helpers.badgeLayout
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
fun groupInactiveIcon() {
|
||||
Icon(
|
||||
Icons.Filled.Cancel,
|
||||
stringResource(R.string.icon_descr_group_inactive),
|
||||
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewImageOverlayIcon() {
|
||||
if (cInfo is ChatInfo.Group) {
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemLeft -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitleText(color: Color = Color.Unspecified) {
|
||||
Text(
|
||||
cInfo.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewTitle() {
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
|
||||
else -> chatPreviewTitleText()
|
||||
}
|
||||
else -> chatPreviewTitleText()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun chatPreviewText(chatModelIncognito: Boolean) {
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
ci.text, ci.formattedText, ci.memberDisplayName,
|
||||
metaText = ci.timestampText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
)
|
||||
} else {
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
if (!cInfo.ready) {
|
||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
|
||||
else -> {}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
val cInfo = chat.chatInfo
|
||||
ChatInfoImage(cInfo, size = 72.dp)
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
ChatInfoImage(cInfo, size = 72.dp)
|
||||
Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
|
||||
chatPreviewImageOverlayIcon()
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
cInfo.chatViewName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (cInfo.ready) Color.Unspecified else HighOrLowlight
|
||||
)
|
||||
if (cInfo.ready) {
|
||||
val ci = chat.chatItems.lastOrNull()
|
||||
if (ci != null) {
|
||||
MarkdownText(
|
||||
ci.text, ci.formattedText, ci.memberDisplayName,
|
||||
metaText = ci.timestampText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
||||
}
|
||||
chatPreviewTitle()
|
||||
chatPreviewText(chatModelIncognito)
|
||||
}
|
||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
|
||||
|
||||
@@ -70,22 +133,38 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
val n = chat.chatStats.unreadCount
|
||||
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
|
||||
if (n > 0) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
|
||||
unreadCountStr(n),
|
||||
color = MaterialTheme.colors.onPrimary,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
} else if (showNtfsIcon) {
|
||||
Box(
|
||||
Modifier.padding(top = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.NotificationsOff,
|
||||
contentDescription = generalGetString(R.string.notifications),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.padding(vertical = 1.dp)
|
||||
.size(17.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
Box(
|
||||
@@ -99,6 +178,21 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
|
||||
return if (groupInfo.membership.memberIncognito)
|
||||
String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName)
|
||||
else if (chatModelIncognito)
|
||||
String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "")
|
||||
else
|
||||
stringResource(R.string.group_preview_you_are_invited)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun unreadCountStr(n: Int): String {
|
||||
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStatusImage(chat: Chat) {
|
||||
val s = chat.serverInfo.networkStatus
|
||||
@@ -131,6 +225,6 @@ fun ChatStatusImage(chat: Chat) {
|
||||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, stopped = false)
|
||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -37,7 +36,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
Text(contactConnection.description, maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactConnection.updatedAt)
|
||||
Column(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.app.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -11,13 +10,12 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.model.getTimestampText
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ChatInfoImage
|
||||
|
||||
@Composable
|
||||
fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
||||
fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) {
|
||||
Row {
|
||||
ChatInfoImage(contactRequest, size = 72.dp)
|
||||
Column(
|
||||
@@ -31,9 +29,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary
|
||||
color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary
|
||||
)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||
}
|
||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||
Column(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionDivider
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
@@ -25,8 +28,7 @@ import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.app.views.usersettings.SettingsSectionView
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.datetime.*
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
@@ -57,21 +59,20 @@ fun ChatArchiveLayout(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
title,
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
|
||||
SettingsSectionView(stringResource(R.string.chat_archive_section)) {
|
||||
SectionView(stringResource(R.string.chat_archive_section)) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.save_archive),
|
||||
saveArchive,
|
||||
textColor = MaterialTheme.colors.primary
|
||||
)
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.delete_archive),
|
||||
@@ -80,7 +81,7 @@ fun ChatArchiveLayout(
|
||||
)
|
||||
}
|
||||
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||
SettingsSectionFooter(
|
||||
SectionTextFooter(
|
||||
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package chat.simplex.app.views.database
|
||||
|
||||
import SectionDivider
|
||||
import SectionTextFooter
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
@@ -23,9 +28,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
@@ -69,8 +73,8 @@ fun DatabaseView(
|
||||
chatArchiveName,
|
||||
chatArchiveTime,
|
||||
chatLastStart,
|
||||
startChat = { startChat(m, runChat, chatLastStart) },
|
||||
stopChatAlert = { stopChatAlert(m, runChat) },
|
||||
startChat = { startChat(m, runChat, chatLastStart, context) },
|
||||
stopChatAlert = { stopChatAlert(m, runChat, context) },
|
||||
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
|
||||
showSettingsModal
|
||||
@@ -114,19 +118,18 @@ fun DatabaseLayout(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
stringResource(R.string.your_chat_database),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
|
||||
SettingsSectionView(stringResource(R.string.run_chat_section)) {
|
||||
SectionView(stringResource(R.string.run_chat_section)) {
|
||||
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
|
||||
}
|
||||
Spacer(Modifier.height(30.dp))
|
||||
SectionSpacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.chat_database_section)) {
|
||||
SectionView(stringResource(R.string.chat_database_section)) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.IosShare,
|
||||
stringResource(R.string.export_database),
|
||||
@@ -134,7 +137,7 @@ fun DatabaseLayout(
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.FileDownload,
|
||||
stringResource(R.string.import_database),
|
||||
@@ -142,7 +145,7 @@ fun DatabaseLayout(
|
||||
textColor = Color.Red,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
divider()
|
||||
SectionDivider()
|
||||
val chatArchiveNameVal = chatArchiveName.value
|
||||
val chatArchiveTimeVal = chatArchiveTime.value
|
||||
val chatLastStartVal = chatLastStart.value
|
||||
@@ -154,7 +157,7 @@ fun DatabaseLayout(
|
||||
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
divider()
|
||||
SectionDivider()
|
||||
}
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.DeleteForever,
|
||||
@@ -164,7 +167,7 @@ fun DatabaseLayout(
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
}
|
||||
SettingsSectionFooter(
|
||||
SectionTextFooter(
|
||||
if (chatDbChanged) {
|
||||
stringResource(R.string.restart_the_app_to_use_new_chat_database)
|
||||
} else {
|
||||
@@ -186,7 +189,7 @@ fun RunChatSetting(
|
||||
startChat: () -> Unit,
|
||||
stopChatAlert: () -> Unit
|
||||
) {
|
||||
SettingsItemView() {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
||||
Icon(
|
||||
@@ -225,12 +228,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
||||
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionFooter(text: String) {
|
||||
Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp)
|
||||
}
|
||||
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>) {
|
||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, context: Context) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiStartChat()
|
||||
@@ -239,6 +237,7 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStar
|
||||
val ts = Clock.System.now()
|
||||
m.controller.appPrefs.chatLastStart.set(ts)
|
||||
chatLastStart.value = ts
|
||||
SimplexService.start(context)
|
||||
} catch (e: Error) {
|
||||
runChat.value = false
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||
@@ -246,22 +245,23 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStar
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>) {
|
||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.stop_chat_question),
|
||||
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
||||
confirmText = generalGetString(R.string.stop_chat_confirmation),
|
||||
onConfirm = { stopChat(m, runChat) },
|
||||
onConfirm = { stopChat(m, runChat, context) },
|
||||
onDismiss = { runChat.value = true }
|
||||
)
|
||||
}
|
||||
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>) {
|
||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
||||
withApi {
|
||||
try {
|
||||
m.controller.apiStopChat()
|
||||
runChat.value = false
|
||||
m.chatRunning.value = false
|
||||
SimplexService.stop(context)
|
||||
} catch (e: Error) {
|
||||
runChat.value = true
|
||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
|
||||
|
||||
@@ -2,8 +2,10 @@ package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
|
||||
@@ -44,7 +46,8 @@ class AlertManager {
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
@@ -53,13 +56,13 @@ class AlertManager {
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
@@ -79,7 +82,7 @@ class AlertManager {
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
|
||||
@@ -6,8 +6,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -24,11 +23,22 @@ import chat.simplex.app.model.ChatInfo
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
|
||||
@Composable
|
||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) {
|
||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
||||
val icon =
|
||||
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
|
||||
else Icons.Filled.AccountCircle
|
||||
ProfileImage(size, chatInfo.image, icon)
|
||||
ProfileImage(size, chatInfo.image, icon, iconColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
||||
Box(Modifier.size(size)) {
|
||||
Icon(
|
||||
Icons.Filled.TheaterComedy, stringResource(R.string.incognito),
|
||||
modifier = Modifier.size(size).padding(size / 12),
|
||||
iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DefaultBasicTextField(
|
||||
modifier: Modifier,
|
||||
initialValue: String,
|
||||
placeholder: (@Composable () -> Unit)? = null,
|
||||
focus: Boolean = false,
|
||||
color: Color = MaterialTheme.colors.onBackground,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
selectTextOnFocus: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
onValueChange: (String) -> Unit,
|
||||
) {
|
||||
val state = remember {
|
||||
mutableStateOf(TextFieldValue(initialValue))
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focus) return@LaunchedEffect
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
}
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused && selectTextOnFocus) {
|
||||
val text = state.value.text
|
||||
state.value = state.value.copy(
|
||||
selection = TextRange(0, text.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
state.value = it
|
||||
onValueChange(it.text)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboard?.hide()
|
||||
keyboardActions.onDone?.invoke(this)
|
||||
}),
|
||||
singleLine = true,
|
||||
textStyle = textStyle.copy(
|
||||
color = color,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = placeholder,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import chat.simplex.app.R
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIos
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun DefaultTopAppBar(
|
||||
navigationButton: @Composable RowScope.() -> Unit,
|
||||
title: @Composable () -> Unit,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
showSearch: Boolean,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
|
||||
) {
|
||||
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
|
||||
val modifier = if (!showSearch) {
|
||||
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
|
||||
} else Modifier
|
||||
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (!showSearch) {
|
||||
title()
|
||||
} else {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
||||
}
|
||||
},
|
||||
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||
navigationIcon = navigationButton,
|
||||
buttons = if (!showSearch) buttons else emptyList(),
|
||||
centered = !showSearch
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
||||
IconButton(onButtonClicked) {
|
||||
Icon(
|
||||
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
||||
IconButton(onClick = onButtonClicked) {
|
||||
Icon(
|
||||
Icons.Outlined.Menu,
|
||||
stringResource(R.string.icon_descr_settings),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
|
||||
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
|
||||
backgroundColor: Color = MaterialTheme.colors.primarySurface,
|
||||
centered: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(AppBarHeight)
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (navigationIcon != null) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(TitleInsetWithIcon - AppBarHorizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = navigationIcon
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
buttons.forEach { it() }
|
||||
}
|
||||
val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
|
||||
val endPadding = (buttons.size * 50f).dp
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
|
||||
end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
title()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val AppBarHeight = 56.dp
|
||||
private val AppBarHorizontalPadding = 4.dp
|
||||
private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding
|
||||
private val TitleInsetWithIcon = 72.dp
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.interaction.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.consumeAllChanges
|
||||
import androidx.compose.ui.input.pointer.consumeDownChange
|
||||
import androidx.compose.ui.input.pointer.isOutOfBounds
|
||||
import androidx.compose.ui.input.pointer.positionChangeConsumed
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.util.fastAll
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import chat.simplex.app.TAG
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
/**
|
||||
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
|
||||
* */
|
||||
interface PressGestureScope: Density {
|
||||
suspend fun tryAwaitRelease(): Boolean
|
||||
}
|
||||
|
||||
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
|
||||
|
||||
suspend fun PointerInputScope.detectGesture(
|
||||
onLongPress: ((Offset) -> Unit)? = null,
|
||||
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
|
||||
shouldConsumeEvent: (Offset) -> Boolean
|
||||
) = coroutineScope {
|
||||
val pressScope = PressGestureScopeImpl(this@detectGesture)
|
||||
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown()
|
||||
// If shouldConsumeEvent == false, all touches will be propagated to parent
|
||||
val shouldConsume = shouldConsumeEvent(down.position)
|
||||
if (shouldConsume)
|
||||
down.consumeDownChange()
|
||||
pressScope.reset()
|
||||
if (onPress !== NoPressGesture) launch {
|
||||
pressScope.onPress(down.position)
|
||||
}
|
||||
val longPressTimeout = onLongPress?.let {
|
||||
viewConfiguration.longPressTimeoutMillis
|
||||
} ?: (Long.MAX_VALUE / 2)
|
||||
|
||||
try {
|
||||
val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) {
|
||||
waitForUpOrCancellation()
|
||||
}
|
||||
if (upOrCancel == null) {
|
||||
pressScope.cancel()
|
||||
} else {
|
||||
if (shouldConsume)
|
||||
upOrCancel.consumeDownChange()
|
||||
// If onLongPress event is needed, cancel short press event
|
||||
if (onLongPress != null)
|
||||
pressScope.cancel()
|
||||
else
|
||||
pressScope.release()
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
onLongPress?.invoke(down.position)
|
||||
if (shouldConsume)
|
||||
consumeUntilUp()
|
||||
pressScope.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
event.changes.fastForEach { it.consumeAllChanges() }
|
||||
} while (event.changes.fastAny { it.pressed })
|
||||
}
|
||||
|
||||
suspend fun AwaitPointerEventScope.awaitFirstDown(
|
||||
requireUnconsumed: Boolean = true
|
||||
): PointerInputChange =
|
||||
awaitFirstDownOnPass(pass = PointerEventPass.Main, requireUnconsumed = requireUnconsumed)
|
||||
|
||||
internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
|
||||
pass: PointerEventPass,
|
||||
requireUnconsumed: Boolean
|
||||
): PointerInputChange {
|
||||
var event: PointerEvent
|
||||
do {
|
||||
event = awaitPointerEvent(pass)
|
||||
} while (
|
||||
!event.changes.fastAll {
|
||||
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
|
||||
}
|
||||
)
|
||||
return event.changes[0]
|
||||
}
|
||||
|
||||
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
if (event.changes.fastAll { it.changedToUp() }) {
|
||||
return event.changes[0]
|
||||
}
|
||||
|
||||
if (event.changes.fastAny {
|
||||
it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding)
|
||||
}
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PressGestureScopeImpl(
|
||||
density: Density
|
||||
): PressGestureScope, Density by density {
|
||||
private var isReleased = false
|
||||
private var isCanceled = false
|
||||
private val mutex = Mutex(locked = false)
|
||||
|
||||
fun cancel() {
|
||||
isCanceled = true
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
isReleased = true
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
mutex.tryLock()
|
||||
isReleased = false
|
||||
isCanceled = false
|
||||
}
|
||||
|
||||
override suspend fun tryAwaitRelease(): Boolean {
|
||||
if (!isReleased && !isCanceled) {
|
||||
mutex.lock()
|
||||
}
|
||||
return isCanceled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing.
|
||||
* Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]).
|
||||
* Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable]
|
||||
* */
|
||||
@Composable
|
||||
fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
var topLevelInteraction: Interaction? by remember { mutableStateOf(null) }
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
topLevelInteraction = interaction
|
||||
}
|
||||
}
|
||||
LaunchedEffect(topLevelInteraction is PressInteraction.Press) {
|
||||
if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect
|
||||
try {
|
||||
withTimeout(longPressTimeoutMillis) {
|
||||
while (isActive) {
|
||||
delay(10)
|
||||
when (topLevelInteraction) {
|
||||
is PressInteraction.Press -> {}
|
||||
is PressInteraction.Release -> {
|
||||
onClick(); break
|
||||
}
|
||||
is PressInteraction.Cancel -> break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
// Long click happened
|
||||
onLongClick()
|
||||
} catch (ex: CancellationException) {
|
||||
// Canceled coroutine + PressInteraction.Release == short click
|
||||
if (topLevelInteraction is PressInteraction.Release)
|
||||
onClick()
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
} catch (ex: Exception) {
|
||||
// Should never be called
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
return interactionSource
|
||||
}
|
||||
@@ -88,12 +88,20 @@ private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
|
||||
return stream
|
||||
}
|
||||
|
||||
val errorBitmapBytes = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==", Base64.NO_WRAP)
|
||||
val errorBitmap: Bitmap = BitmapFactory.decodeByteArray(errorBitmapBytes, 0, errorBitmapBytes.size)
|
||||
|
||||
fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
try {
|
||||
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "base64ToBitmap error: $e")
|
||||
return errorBitmap
|
||||
}
|
||||
}
|
||||
|
||||
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun Modifier.badgeLayout() =
|
||||
layout { measurable, constraints ->
|
||||
@@ -15,3 +23,22 @@ fun Modifier.badgeLayout() =
|
||||
placeable.place((width - placeable.width) / 2, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwipeToDismissModifier(
|
||||
state: DismissState,
|
||||
directions: Set<DismissDirection> = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd),
|
||||
swipeDistance: Float,
|
||||
): Modifier {
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val anchors = mutableMapOf(0f to DismissValue.Default)
|
||||
if (DismissDirection.StartToEnd in directions) anchors += swipeDistance to DismissValue.DismissedToEnd
|
||||
if (DismissDirection.EndToStart in directions) anchors += -swipeDistance to DismissValue.DismissedToStart
|
||||
return Modifier.swipeable(
|
||||
state = state,
|
||||
anchors = anchors,
|
||||
thresholds = { _, _ -> FractionalThreshold(0.5f) },
|
||||
orientation = Orientation.Horizontal,
|
||||
reverseDirection = isRtl,
|
||||
).offset { IntOffset(state.offset.value.roundToInt(), 0) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
delay(200)
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = searchText,
|
||||
modifier = modifier
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.focusRequester(focusRequester)
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
searchText = it
|
||||
onValueChange(it)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = searchText,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = {
|
||||
Text(placeholder)
|
||||
},
|
||||
trailingIcon = if (searchText.isNotEmpty()) {{
|
||||
IconButton({ searchText = ""; onValueChange("") }) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||
}
|
||||
}} else null,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun SectionView(title: String? = null, content: (@Composable () -> Unit)) {
|
||||
Column {
|
||||
if (title != null) {
|
||||
Text(
|
||||
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
|
||||
val modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
Row(
|
||||
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionItemViewSpaceBetween(
|
||||
click: (() -> Unit)? = null,
|
||||
height: Dp = 46.dp,
|
||||
padding: PaddingValues = PaddingValues(horizontal = 8.dp),
|
||||
disabled: Boolean = false,
|
||||
content: (@Composable () -> Unit)
|
||||
) {
|
||||
val modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
Row(
|
||||
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTextFooter(text: String) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F),
|
||||
color = HighOrLowlight,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = 16.dp, end = 16.dp, top = 5.dp), content: (@Composable () -> Unit)) {
|
||||
Row(
|
||||
Modifier.padding(padding)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionDivider() {
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionSpacer() {
|
||||
Spacer(Modifier.height(30.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRow(title: String, value: String) {
|
||||
SectionItemView {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(title)
|
||||
Text(value, color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
|
||||
SectionItemView {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
Text(title)
|
||||
Text(value,
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.widthIn(max = (configuration.screenWidthDp / 2).dp)
|
||||
.clickable(onClick = onClick),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,12 @@ fun SimpleButton(text: String, icon: ImageVector,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) {
|
||||
fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) {
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
val modifier = if (disabled) Modifier else Modifier.clickable { click() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable { click() }
|
||||
.padding(8.dp)
|
||||
modifier = modifier.padding(8.dp)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -18,8 +19,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.SimpleButton
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.app.views.helpers.shareText
|
||||
|
||||
@Composable
|
||||
@@ -28,6 +29,7 @@ fun AddContactView(chatModel: ChatModel) {
|
||||
if (connReq != null) {
|
||||
val cxt = LocalContext.current
|
||||
AddContactLayout(
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
connReq = connReq,
|
||||
share = { shareText(cxt, connReq) }
|
||||
)
|
||||
@@ -35,22 +37,32 @@ fun AddContactView(chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddContactLayout(connReq: String, share: () -> Unit) {
|
||||
fun AddContactLayout(chatModelIncognito: Boolean, connReq: String, share: () -> Unit) {
|
||||
BoxWithConstraints {
|
||||
val screenHeight = maxHeight
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Modifier.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.add_contact),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 5.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
Row(Modifier.padding(horizontal = 8.dp)) {
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_description),
|
||||
generalGetString(R.string.your_profile_will_be_sent)
|
||||
)
|
||||
}
|
||||
QRCode(
|
||||
connReq, Modifier
|
||||
.weight(1f, fill = false)
|
||||
@@ -59,14 +71,52 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 22.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp)
|
||||
)
|
||||
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) {
|
||||
if (chatModelIncognito) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info,
|
||||
stringResource(R.string.incognito),
|
||||
tint = if (supportedIncognito) Indigo else WarningOrange,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
stringResource(R.string.incognito),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +131,7 @@ fun AddContactLayout(connReq: String, share: () -> Unit) {
|
||||
fun PreviewAddContactView() {
|
||||
SimpleXTheme {
|
||||
AddContactLayout(
|
||||
chatModelIncognito = false,
|
||||
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
share = {}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.ProfileNameField
|
||||
import chat.simplex.app.views.chat.group.AddGroupMembersView
|
||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import chat.simplex.app.views.usersettings.DeleteImageButton
|
||||
import chat.simplex.app.views.usersettings.EditImageButton
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
||||
AddGroupLayout(
|
||||
chatModel.incognito.value,
|
||||
createGroup = { groupProfile ->
|
||||
withApi {
|
||||
val groupInfo = chatModel.controller.apiNewGroup(groupProfile)
|
||||
if (groupInfo != null) {
|
||||
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf()))
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatId.value = groupInfo.id
|
||||
setGroupMembers(groupInfo, chatModel)
|
||||
close.invoke()
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
AddGroupMembersView(groupInfo, chatModel, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
close
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val profileImage = remember { mutableStateOf<String?>(null) }
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
|
||||
hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close) {
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground).fillMaxSize()) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.create_secret_group_title),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
Text(stringResource(R.string.group_is_decentralized))
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
false,
|
||||
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||
generalGetString(R.string.group_main_profile_sent)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(size = 192.dp, image = profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.group_display_name_field),
|
||||
Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.group_full_name_field),
|
||||
Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
if (enabled) {
|
||||
CreateGroupButton(MaterialTheme.colors.primary, Modifier
|
||||
.clickable { createGroup(GroupProfile(displayName.value, fullName.value, profileImage.value)) }
|
||||
.padding(8.dp))
|
||||
} else {
|
||||
CreateGroupButton(HighOrLowlight, Modifier.padding(8.dp))
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateGroupButton(color: Color, modifier: Modifier) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = color)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAddGroupLayout() {
|
||||
SimpleXTheme {
|
||||
AddGroupLayout(
|
||||
chatModelIncognito = false,
|
||||
createGroup = {},
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -48,66 +48,108 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
|
||||
pasteLink = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> PasteToConnectView(chatModel, close) }
|
||||
},
|
||||
createGroup = {
|
||||
newChatCtrl.collapse()
|
||||
ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, pasteLink: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
fun NewChatSheetLayout(
|
||||
addContact: () -> Unit,
|
||||
scanCode: () -> Unit,
|
||||
pasteLink: () -> Unit,
|
||||
createGroup: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.add_contact_to_start_new_chat),
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(top = 32.dp)
|
||||
stringResource(R.string.add_contact_or_create_group),
|
||||
modifier = Modifier.padding(horizontal = 4.dp).padding(top = 20.dp, bottom = 20.dp),
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
val boxModifier = Modifier.fillMaxWidth().height(80.dp).padding(horizontal = 0.dp)
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.create_one_time_link),
|
||||
stringResource(R.string.to_share_with_your_contact),
|
||||
Icons.Outlined.AddLink,
|
||||
click = addContact
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.paste_received_link),
|
||||
stringResource(R.string.paste_received_link_from_clipboard),
|
||||
Icons.Outlined.Article,
|
||||
click = pasteLink
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.scan_QR_code),
|
||||
stringResource(R.string.in_person_or_in_video_call__bracketed),
|
||||
Icons.Outlined.QrCode,
|
||||
click = scanCode
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Box(boxModifier) {
|
||||
ActionRowButton(
|
||||
stringResource(R.string.create_group),
|
||||
stringResource(R.string.only_stored_on_members_devices),
|
||||
icon = Icons.Outlined.Group,
|
||||
click = createGroup
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionRowButton(
|
||||
text: String, comment: String? = null, icon: ImageVector, disabled: Boolean = false,
|
||||
click: () -> Unit = {}
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(top = 24.dp, bottom = 40.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
Modifier.clickable(onClick = click).size(48.dp).padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
stringResource(R.string.create_one_time_link),
|
||||
stringResource(R.string.to_share_with_your_contact),
|
||||
Icons.Outlined.AddLink,
|
||||
click = addContact
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
stringResource(R.string.paste_received_link),
|
||||
stringResource(R.string.paste_received_link_from_clipboard),
|
||||
Icons.Outlined.Article,
|
||||
click = pasteLink
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1F)
|
||||
.fillMaxWidth()) {
|
||||
ActionButton(
|
||||
stringResource(R.string.scan_QR_code),
|
||||
stringResource(R.string.in_person_or_in_video_call__bracketed),
|
||||
Icons.Outlined.QrCode,
|
||||
click = scanCode
|
||||
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Icon(icon, text, tint = tint, modifier = Modifier.size(48.dp).padding(start = 4.dp, end = 16.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint
|
||||
)
|
||||
|
||||
if (comment != null) {
|
||||
Text(
|
||||
comment,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
|
||||
click: () -> Unit = {}) {
|
||||
fun ActionButton(
|
||||
text: String?,
|
||||
comment: String?,
|
||||
icon: ImageVector,
|
||||
disabled: Boolean = false,
|
||||
click: () -> Unit = {}
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(18.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
@@ -148,7 +190,8 @@ fun PreviewNewChatSheet() {
|
||||
NewChatSheetLayout(
|
||||
addContact = {},
|
||||
scanCode = {},
|
||||
pasteLink = {}
|
||||
pasteLink = {},
|
||||
createGroup = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val clipboard = getSystemService(context, ClipboardManager::class.java)
|
||||
BackHandler(onBack = close)
|
||||
PasteToConnectLayout(
|
||||
chatModel.incognito.value,
|
||||
connectionLink = connectionLink,
|
||||
pasteFromClipboard = {
|
||||
connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String
|
||||
@@ -55,6 +56,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun PasteToConnectLayout(
|
||||
chatModelIncognito: Boolean,
|
||||
connectionLink: MutableState<String>,
|
||||
pasteFromClipboard: () -> Unit,
|
||||
connectViaLink: (String) -> Unit,
|
||||
@@ -62,16 +64,22 @@ fun PasteToConnectLayout(
|
||||
) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Modifier.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.connect_via_link),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
Text(stringResource(R.string.paste_connection_link_below_to_connect))
|
||||
Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link))
|
||||
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_from_contact_description),
|
||||
generalGetString(R.string.profile_will_be_sent_to_contact_sending_link)
|
||||
)
|
||||
|
||||
Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) {
|
||||
TextEditor(Modifier.height(180.dp), text = connectionLink)
|
||||
@@ -111,6 +119,7 @@ fun PasteToConnectLayout(
|
||||
fun PreviewPasteToConnectTextbox() {
|
||||
SimpleXTheme {
|
||||
PasteToConnectLayout(
|
||||
chatModelIncognito = false,
|
||||
connectionLink = remember { mutableStateOf("") },
|
||||
pasteFromClipboard = {},
|
||||
connectViaLink = { link ->
|
||||
|
||||
@@ -30,6 +30,16 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
var lastAnalyzedTimeStamp = 0L
|
||||
var contactLink = ""
|
||||
|
||||
val cameraProviderFuture by produceState<ListenableFuture<ProcessCameraProvider>?>(initialValue = null) {
|
||||
value = ProcessCameraProvider.getInstance(context)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
onDispose {
|
||||
cameraProviderFuture?.get()?.unbindAll()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { AndroidViewContext ->
|
||||
PreviewView(AndroidViewContext).apply {
|
||||
@@ -46,14 +56,10 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
|
||||
ProcessCameraProvider.getInstance(context)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
cameraProviderFuture?.addListener({
|
||||
preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
|
||||
val detector: QrCodeDetector<GrayU8> = FactoryFiducial.qrcode(null, GrayU8::class.java)
|
||||
fun getQR(imageProxy: ImageProxy) {
|
||||
val currentTimeStamp = System.currentTimeMillis()
|
||||
@@ -78,8 +84,8 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
.build()
|
||||
.also { it.setAnalyzer(cameraExecutor, imageAnalyzer) }
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
|
||||
cameraProviderFuture?.get()?.unbindAll()
|
||||
cameraProviderFuture?.get()?.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -22,6 +21,7 @@ import chat.simplex.app.views.helpers.*
|
||||
fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
BackHandler(onBack = close)
|
||||
ConnectContactLayout(
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
qrCodeScanner = {
|
||||
QRCodeScanner { connReqUri ->
|
||||
try {
|
||||
@@ -67,21 +67,22 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
|
||||
fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
|
||||
ModalView(close) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Modifier.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.scan_QR_code),
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
)
|
||||
Text(
|
||||
generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact),
|
||||
style = MaterialTheme.typography.h3,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
InfoAboutIncognito(
|
||||
chatModelIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_description),
|
||||
generalGetString(R.string.your_profile_will_be_sent)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
@@ -106,6 +107,7 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni
|
||||
fun PreviewConnectContactLayout() {
|
||||
SimpleXTheme {
|
||||
ConnectContactLayout(
|
||||
chatModelIncognito = false,
|
||||
qrCodeScanner = { Surface {} },
|
||||
close = {},
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ fun SimpleXInfoLayout(
|
||||
@Composable
|
||||
fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo),
|
||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 20.dp)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@Composable
|
||||
fun AdvancedNetworkSettingsView(chatModel: ChatModel) {
|
||||
val currentCfg = remember { mutableStateOf(chatModel.controller.getNetCfg()) }
|
||||
val currentCfgVal = currentCfg.value // used only on initialization
|
||||
val networkTCPConnectTimeout = remember { mutableStateOf(currentCfgVal.tcpConnectTimeout) }
|
||||
val networkTCPTimeout = remember { mutableStateOf(currentCfgVal.tcpTimeout) }
|
||||
val networkSMPPingInterval = remember { mutableStateOf(currentCfgVal.smpPingInterval) }
|
||||
val networkEnableKeepAlive = remember { mutableStateOf(currentCfgVal.enableKeepAlive) }
|
||||
val networkTCPKeepIdle: MutableState<Int>
|
||||
val networkTCPKeepIntvl: MutableState<Int>
|
||||
val networkTCPKeepCnt: MutableState<Int>
|
||||
if (currentCfgVal.tcpKeepAlive != null) {
|
||||
networkTCPKeepIdle = remember { mutableStateOf(currentCfgVal.tcpKeepAlive.keepIdle) }
|
||||
networkTCPKeepIntvl = remember { mutableStateOf(currentCfgVal.tcpKeepAlive.keepIntvl) }
|
||||
networkTCPKeepCnt = remember { mutableStateOf(currentCfgVal.tcpKeepAlive.keepCnt) }
|
||||
} else {
|
||||
networkTCPKeepIdle = remember { mutableStateOf(KeepAliveOpts.defaults.keepIdle) }
|
||||
networkTCPKeepIntvl = remember { mutableStateOf(KeepAliveOpts.defaults.keepIntvl) }
|
||||
networkTCPKeepCnt = remember { mutableStateOf(KeepAliveOpts.defaults.keepCnt) }
|
||||
}
|
||||
|
||||
fun buildCfg(): NetCfg {
|
||||
val socksProxy = currentCfg.value.socksProxy
|
||||
val tcpConnectTimeout = networkTCPConnectTimeout.value
|
||||
val tcpTimeout = networkTCPTimeout.value
|
||||
val smpPingInterval = networkSMPPingInterval.value
|
||||
val enableKeepAlive = networkEnableKeepAlive.value
|
||||
val tcpKeepAlive = if (enableKeepAlive) {
|
||||
val keepIdle = networkTCPKeepIdle.value
|
||||
val keepIntvl = networkTCPKeepIntvl.value
|
||||
val keepCnt = networkTCPKeepCnt.value
|
||||
KeepAliveOpts(keepIdle = keepIdle, keepIntvl = keepIntvl, keepCnt = keepCnt)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return NetCfg(
|
||||
socksProxy = socksProxy,
|
||||
tcpConnectTimeout = tcpConnectTimeout,
|
||||
tcpTimeout = tcpTimeout,
|
||||
tcpKeepAlive = tcpKeepAlive,
|
||||
smpPingInterval = smpPingInterval
|
||||
)
|
||||
}
|
||||
|
||||
fun updateView(cfg: NetCfg) {
|
||||
networkTCPConnectTimeout.value = cfg.tcpConnectTimeout
|
||||
networkTCPTimeout.value = cfg.tcpTimeout
|
||||
networkSMPPingInterval.value = cfg.smpPingInterval
|
||||
networkEnableKeepAlive.value = cfg.enableKeepAlive
|
||||
if (cfg.tcpKeepAlive != null) {
|
||||
networkTCPKeepIdle.value = cfg.tcpKeepAlive.keepIdle
|
||||
networkTCPKeepIntvl.value = cfg.tcpKeepAlive.keepIntvl
|
||||
networkTCPKeepCnt.value = cfg.tcpKeepAlive.keepCnt
|
||||
} else {
|
||||
networkTCPKeepIdle.value = KeepAliveOpts.defaults.keepIdle
|
||||
networkTCPKeepIntvl.value = KeepAliveOpts.defaults.keepIntvl
|
||||
networkTCPKeepCnt.value = KeepAliveOpts.defaults.keepCnt
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCfg(cfg: NetCfg) {
|
||||
withApi {
|
||||
chatModel.controller.apiSetNetworkConfig(cfg)
|
||||
currentCfg.value = cfg
|
||||
chatModel.controller.setNetCfg(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
val newCfg = if (currentCfg.value.useSocksProxy) NetCfg.proxyDefaults else NetCfg.defaults
|
||||
updateView(newCfg)
|
||||
saveCfg(newCfg)
|
||||
}
|
||||
|
||||
fun updateSettingsDialog(action: () -> Unit) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.update_network_settings_question),
|
||||
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onConfirm = action
|
||||
)
|
||||
}
|
||||
|
||||
AdvancedNetworkSettingsLayout(
|
||||
networkTCPConnectTimeout,
|
||||
networkTCPTimeout,
|
||||
networkSMPPingInterval,
|
||||
networkEnableKeepAlive,
|
||||
networkTCPKeepIdle,
|
||||
networkTCPKeepIntvl,
|
||||
networkTCPKeepCnt,
|
||||
resetDisabled = if (currentCfg.value.useSocksProxy) currentCfg.value == NetCfg.proxyDefaults else currentCfg.value == NetCfg.defaults,
|
||||
reset = { updateSettingsDialog(::reset) },
|
||||
footerDisabled = buildCfg() == currentCfg.value,
|
||||
revert = { updateView(currentCfg.value) },
|
||||
save = { updateSettingsDialog { saveCfg(buildCfg()) } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun AdvancedNetworkSettingsLayout(
|
||||
networkTCPConnectTimeout: MutableState<Long>,
|
||||
networkTCPTimeout: MutableState<Long>,
|
||||
networkSMPPingInterval: MutableState<Long>,
|
||||
networkEnableKeepAlive: MutableState<Boolean>,
|
||||
networkTCPKeepIdle: MutableState<Int>,
|
||||
networkTCPKeepIntvl: MutableState<Int>,
|
||||
networkTCPKeepCnt: MutableState<Int>,
|
||||
resetDisabled: Boolean,
|
||||
reset: () -> Unit,
|
||||
footerDisabled: Boolean,
|
||||
revert: () -> Unit,
|
||||
save: () -> Unit
|
||||
) {
|
||||
val secondsLabel = stringResource(R.string.network_option_seconds_label)
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.network_settings_title),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
ResetToDefaultsButton(reset, disabled = resetDisabled)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
TimeoutSettingRow(
|
||||
stringResource(R.string.network_option_tcp_connection_timeout), networkTCPConnectTimeout,
|
||||
listOf(2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000), secondsLabel
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
TimeoutSettingRow(
|
||||
stringResource(R.string.network_option_protocol_timeout), networkTCPTimeout,
|
||||
listOf(1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000), secondsLabel
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
TimeoutSettingRow(
|
||||
stringResource(R.string.network_option_ping_interval), networkSMPPingInterval,
|
||||
listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000), secondsLabel
|
||||
)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
EnableKeepAliveSwitch(networkEnableKeepAlive)
|
||||
}
|
||||
SectionDivider()
|
||||
if (networkEnableKeepAlive.value) {
|
||||
SectionItemView {
|
||||
IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "")
|
||||
}
|
||||
} else {
|
||||
SectionItemView {
|
||||
Text("TCP_KEEPIDLE", color = HighOrLowlight)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
Text("TCP_KEEPINTVL", color = HighOrLowlight)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
Text("TCP_KEEPCNT", color = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionCustomFooter {
|
||||
SettingsSectionFooter(revert, save, footerDisabled)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetToDefaultsButton(reset: () -> Unit, disabled: Boolean) {
|
||||
val modifier = if (disabled) Modifier else Modifier.clickable { reset() }
|
||||
Row(
|
||||
modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
Text(stringResource(R.string.network_options_reset_to_defaults), color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EnableKeepAliveSwitch(
|
||||
networkEnableKeepAlive: MutableState<Boolean>
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(R.string.network_option_enable_tcp_keep_alive))
|
||||
Switch(
|
||||
checked = networkEnableKeepAlive.value,
|
||||
onCheckedChange = { networkEnableKeepAlive.value = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IntSettingRow(title: String, selection: MutableState<Int>, values: List<Int>, label: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Text(title)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.width(140.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
"${selection.value} $label",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.invite_to_group_button),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selection.value = selectionOption
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"$selectionOption $label",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimeoutSettingRow(title: String, selection: MutableState<Long>, values: List<Long>, label: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Text(title)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
val df = DecimalFormat("#.#")
|
||||
|
||||
Row(
|
||||
Modifier.width(140.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
"${df.format(selection.value / 1_000_000.toDouble())} $label",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.invite_to_group_button),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
selection.value = selectionOption
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"${df.format(selectionOption / 1_000_000.toDouble())} $label",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionFooter(revert: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FooterButton(Icons.Outlined.Replay, stringResource(R.string.network_options_revert), revert, disabled)
|
||||
FooterButton(Icons.Outlined.Check, stringResource(R.string.network_options_save), save, disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FooterButton(icon: ImageVector, title: String, action: () -> Unit, disabled: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color.Black.copy(alpha = 0f)
|
||||
) {
|
||||
val modifier = if (disabled) Modifier else Modifier.clickable { action() }
|
||||
Row(
|
||||
modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
title,
|
||||
tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewAdvancedNetworkSettingsLayout() {
|
||||
SimpleXTheme {
|
||||
AdvancedNetworkSettingsLayout(
|
||||
networkTCPConnectTimeout = remember { mutableStateOf(10_000000) },
|
||||
networkTCPTimeout = remember { mutableStateOf(10_000000) },
|
||||
networkSMPPingInterval = remember { mutableStateOf(10_000000) },
|
||||
networkEnableKeepAlive = remember { mutableStateOf(true) },
|
||||
networkTCPKeepIdle = remember { mutableStateOf(10) },
|
||||
networkTCPKeepIntvl = remember { mutableStateOf(10) },
|
||||
networkTCPKeepCnt = remember { mutableStateOf(10) },
|
||||
resetDisabled = false,
|
||||
reset = {},
|
||||
footerDisabled = false,
|
||||
revert = {},
|
||||
save = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.godaddy.android.colorpicker.*
|
||||
|
||||
enum class AppIcon(val resId: Int) {
|
||||
DEFAULT(R.mipmap.icon),
|
||||
DARK_BLUE(R.mipmap.icon_dark_blue),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppearanceView(
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
|
||||
|
||||
fun setAppIcon(newIcon: AppIcon) {
|
||||
if (appIcon.value == newIcon) return
|
||||
val newComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}")
|
||||
val oldComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}")
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
newComponent,
|
||||
COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
oldComponent,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
appIcon.value = newIcon
|
||||
}
|
||||
|
||||
AppearanceLayout(
|
||||
appIcon,
|
||||
changeIcon = ::setAppIcon,
|
||||
showThemeSelector = showCustomModal { _, close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight
|
||||
) { ThemeSelectorView() }
|
||||
},
|
||||
editPrimaryColor = { primary ->
|
||||
showCustomModal { _, close ->
|
||||
ModalView(
|
||||
close = close, modifier = Modifier,
|
||||
background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight
|
||||
) { ColorEditor(primary, close) }
|
||||
}()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
showThemeSelector: () -> Unit,
|
||||
editPrimaryColor: (Color) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.appearance_settings),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView(stringResource(R.string.settings_section_title_icon)) {
|
||||
LazyRow {
|
||||
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
|
||||
val item = AppIcon.values()[index]
|
||||
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
|
||||
Image(
|
||||
bitmap = mipmap.toBitmap().asImageBitmap(),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondary)
|
||||
.size(70.dp)
|
||||
.clickable { changeIcon(item) }
|
||||
.padding(10.dp)
|
||||
)
|
||||
|
||||
if (index + 1 != AppIcon.values().size) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
SectionView(stringResource(R.string.settings_section_title_themes)) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
SectionItemViewSpaceBetween(showThemeSelector, padding = PaddingValues()) {
|
||||
Text(generalGetString(R.string.theme))
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
|
||||
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }, padding = PaddingValues()) {
|
||||
val title = generalGetString(R.string.color_primary)
|
||||
Text(title)
|
||||
Icon(Icons.Filled.Circle, title, tint = colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentTheme.first.primary != LightColorPalette.primary) {
|
||||
SectionCustomFooter(PaddingValues(start = 7.dp, end = 7.dp, top = 5.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyPrimaryColor(LightColorPalette.primary)
|
||||
},
|
||||
) {
|
||||
Text(generalGetString(R.string.reset_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorEditor(
|
||||
initialColor: Color,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
var currentColor by remember { mutableStateOf(initialColor) }
|
||||
ColorPicker(initialColor) {
|
||||
currentColor = it
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyPrimaryColor(currentColor)
|
||||
close()
|
||||
},
|
||||
Modifier.align(Alignment.CenterHorizontally),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = currentColor)
|
||||
) {
|
||||
Text(generalGetString(R.string.save_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
|
||||
ClassicColorPicker(
|
||||
color = initialColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
showAlphaBar = false,
|
||||
onColorChanged = { color: HsvColor ->
|
||||
onColorChanged(color.toColor())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
SimplexApp.context.packageManager.getComponentEnabledSetting(
|
||||
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
|
||||
).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
changeIcon = {},
|
||||
showThemeSelector = {},
|
||||
editPrimaryColor = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
@@ -29,18 +36,17 @@ fun CallSettingsLayout(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
|
||||
Text(
|
||||
stringResource(R.string.your_calls),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
Box(Modifier.padding(start = 10.dp)) {
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SectionItemView() {
|
||||
SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay)
|
||||
}
|
||||
divider()
|
||||
SectionDivider()
|
||||
|
||||
Column(Modifier.padding(start = 10.dp, top = 12.dp)) {
|
||||
Text(stringResource(R.string.call_on_lock_screen))
|
||||
@@ -66,6 +72,39 @@ fun SharedPreferenceToggle(
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 24.dp))
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = prefState.value,
|
||||
onCheckedChange = {
|
||||
preference.set(it)
|
||||
prefState.value = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPreferenceToggleWithIcon(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
stopped: Boolean = false,
|
||||
onClickInfo: () -> Unit,
|
||||
preference: Preference<Boolean>,
|
||||
preferenceState: MutableState<Boolean>? = null
|
||||
) {
|
||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, Modifier.padding(end = 4.dp))
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
Modifier.clickable(onClick = onClickInfo),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = prefState.value,
|
||||
onCheckedChange = {
|
||||
@@ -76,7 +115,7 @@ fun SharedPreferenceToggle(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
enabled = !stopped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -25,7 +26,7 @@ fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState<Boo
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SettingsSectionView("") {
|
||||
SectionView("") {
|
||||
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
@Composable
|
||||
fun IncognitoView() {
|
||||
IncognitoLayout()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoLayout() {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_section_title_incognito),
|
||||
Modifier.padding(start = 8.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Text(generalGetString(R.string.incognito_info_protects))
|
||||
Text(generalGetString(R.string.incognito_info_allows))
|
||||
Text(generalGetString(R.string.incognito_info_share))
|
||||
Text(generalGetString(R.string.incognito_info_find))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun NetworkAndServersView(
|
||||
chatModel: ChatModel,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
) {
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
||||
|
||||
NetworkAndServersLayout(
|
||||
developerTools = developerTools,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = onionHosts,
|
||||
showModal = showModal,
|
||||
showSettingsModal = showSettingsModal,
|
||||
toggleSocksProxy = { enable ->
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.network_enable_socks),
|
||||
text = generalGetString(R.string.network_enable_socks_info),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults)
|
||||
chatModel.controller.setNetCfg(NetCfg.proxyDefaults)
|
||||
networkUseSocksProxy.value = true
|
||||
onionHosts.value = NetCfg.proxyDefaults.onionHosts
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.network_disable_socks),
|
||||
text = generalGetString(R.string.network_disable_socks_info),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiSetNetworkConfig(NetCfg.defaults)
|
||||
chatModel.controller.setNetCfg(NetCfg.defaults)
|
||||
networkUseSocksProxy.value = false
|
||||
onionHosts.value = NetCfg.defaults.onionHosts
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
useOnion = {
|
||||
val prevValue = onionHosts.value
|
||||
onionHosts.value = it
|
||||
updateNetworkSettingsDialog(onDismiss = {
|
||||
onionHosts.value = prevValue
|
||||
}) {
|
||||
withApi {
|
||||
val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it)
|
||||
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
|
||||
if (res) {
|
||||
chatModel.controller.setNetCfg(newCfg)
|
||||
onionHosts.value = it
|
||||
} else {
|
||||
onionHosts.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun NetworkAndServersLayout(
|
||||
developerTools: Boolean,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.network_and_servers),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
SectionView {
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, useOnion)
|
||||
}
|
||||
if (developerTools) {
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UseSocksProxySwitch(
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
toggleSocksProxy: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.SettingsEthernet,
|
||||
stringResource(R.string.network_socks_toggle),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Text(stringResource(R.string.network_socks_toggle))
|
||||
}
|
||||
Switch(
|
||||
checked = networkUseSocksProxy.value,
|
||||
onCheckedChange = toggleSocksProxy,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseOnionHosts(onionHosts: MutableState<OnionHosts>, enabled: State<Boolean>, useOnion: (OnionHosts) -> Unit) {
|
||||
val values = remember {
|
||||
OnionHosts.values().map {
|
||||
when (it) {
|
||||
OnionHosts.NEVER -> OnionHosts.NEVER to generalGetString(R.string.network_use_onion_hosts_no)
|
||||
OnionHosts.PREFER -> OnionHosts.PREFER to generalGetString(R.string.network_use_onion_hosts_prefer)
|
||||
OnionHosts.REQUIRED -> OnionHosts.REQUIRED to generalGetString(R.string.network_use_onion_hosts_required)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.network_use_onion_hosts),
|
||||
values,
|
||||
onionHosts,
|
||||
icon = Icons.Outlined.Security,
|
||||
enabled = enabled,
|
||||
onSelected = useOnion
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ExposedDropDownSettingRow(
|
||||
title: String,
|
||||
values: List<Pair<T, String>>,
|
||||
selection: State<T>,
|
||||
label: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = HighOrLowlight,
|
||||
enabled: State<Boolean> = mutableStateOf(true),
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
"",
|
||||
Modifier.padding(end = 8.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
}
|
||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded && enabled.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(
|
||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Icon(
|
||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
||||
generalGetString(R.string.icon_descr_more_button),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = 200.dp),
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
values.forEach { selectionOption ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onSelected(selectionOption.first)
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
selectionOption.second + (if (label != null) " $label" else ""),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNetworkSettingsDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.update_network_settings_question),
|
||||
text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewNetworkAndServersLayout() {
|
||||
SimpleXTheme {
|
||||
NetworkAndServersLayout(
|
||||
developerTools = true,
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
toggleSocksProxy = {},
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
useOnion = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -15,7 +18,6 @@ import chat.simplex.app.model.ChatModel
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
@@ -25,14 +27,14 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
SectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
}
|
||||
Spacer(Modifier.height(30.dp))
|
||||
SectionSpacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -19,7 +23,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
@@ -33,6 +37,8 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
MaintainIncognitoState(chatModel)
|
||||
|
||||
fun setRunServiceInBackground(on: Boolean) {
|
||||
chatModel.controller.appPrefs.runServiceInBackground.set(on)
|
||||
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
|
||||
@@ -40,24 +46,28 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
}
|
||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
||||
chatModel.runServiceInBackground.value = on
|
||||
SimplexService.StartReceiver.toggleReceiver(on)
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
stopped,
|
||||
chatModel.incognito,
|
||||
chatModel.controller.appPrefs.incognito,
|
||||
runServiceInBackground = chatModel.runServiceInBackground,
|
||||
developerTools = chatModel.controller.appPrefs.developerTools,
|
||||
setRunServiceInBackground = ::setRunServiceInBackground,
|
||||
setPerformLA = setPerformLA,
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
|
||||
ModalView(close = close, modifier = Modifier,
|
||||
background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||
modalView(chatModel)
|
||||
}
|
||||
} } },
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
|
||||
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
|
||||
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
|
||||
)
|
||||
}
|
||||
@@ -66,11 +76,24 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val simplexTeamUri =
|
||||
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
|
||||
// TODO pass close
|
||||
//fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) {
|
||||
// ModalManager.shared.showCustomModal { close ->
|
||||
// ModalView(close = close, modifier = Modifier,
|
||||
// background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) {
|
||||
// modalView(chatModel)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
profile: Profile,
|
||||
profile: LocalProfile,
|
||||
stopped: Boolean,
|
||||
incognito: MutableState<Boolean>,
|
||||
incognitoPref: Preference<Boolean>,
|
||||
runServiceInBackground: MutableState<Boolean>,
|
||||
developerTools: Preference<Boolean>,
|
||||
setRunServiceInBackground: (Boolean) -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
@@ -84,80 +107,113 @@ fun SettingsLayout(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
|
||||
.background(if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
|
||||
@Composable fun spacer() = Spacer(Modifier.height(30.dp))
|
||||
Text(
|
||||
stringResource(R.string.your_settings),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
SectionSpacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
|
||||
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
|
||||
SectionView(stringResource(R.string.settings_section_title_you)) {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { onClickIncognitoInfo(showModal) }
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
|
||||
divider()
|
||||
SectionDivider()
|
||||
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||
}
|
||||
spacer()
|
||||
SectionSpacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
||||
divider()
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
|
||||
divider()
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView(showCustomModal) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
|
||||
}
|
||||
spacer()
|
||||
SectionSpacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||
}
|
||||
spacer()
|
||||
SectionSpacer()
|
||||
|
||||
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
SectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
ChatConsoleItem(showTerminal, stopped)
|
||||
divider()
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools)
|
||||
SectionDivider()
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
divider()
|
||||
SectionDivider()
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
|
||||
// divider()
|
||||
// SectionDivider()
|
||||
AppVersionItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
|
||||
Column {
|
||||
Text(
|
||||
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
|
||||
)
|
||||
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||
Column(Modifier.padding(horizontal = 6.dp)) { content() }
|
||||
@Composable
|
||||
fun SettingsIncognitoActionItem(
|
||||
incognitoPref: Preference<Boolean>,
|
||||
incognito: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
) {
|
||||
SettingsPreferenceItemWithInfo(
|
||||
if (incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.TheaterComedy,
|
||||
if (incognito.value) Indigo else HighOrLowlight,
|
||||
stringResource(R.string.incognito),
|
||||
stopped,
|
||||
onClickInfo,
|
||||
incognitoPref,
|
||||
incognito
|
||||
)
|
||||
}
|
||||
|
||||
private val onClickIncognitoInfo: ((@Composable (ChatModel) -> Unit) -> (() -> Unit)) -> Unit = { showModal ->
|
||||
showModal { IncognitoView() }()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
// Cache previous value and once it changes in background, update it via API
|
||||
var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) }
|
||||
LaunchedEffect(chatModel.incognito.value) {
|
||||
// Don't do anything if nothing changed
|
||||
if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect
|
||||
try {
|
||||
chatModel.controller.apiSetIncognito(chatModel.incognito.value)
|
||||
} catch (e: Exception) {
|
||||
// Rollback the state
|
||||
chatModel.controller.appPrefs.incognito.set(cachedIncognito)
|
||||
// Crash the app
|
||||
throw e
|
||||
}
|
||||
cachedIncognito = chatModel.incognito.value
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
|
||||
SettingsItemView(openDatabaseView) {
|
||||
SectionItemView(openDatabaseView) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
@@ -188,7 +244,7 @@ fun SettingsLayout(
|
||||
setRunServiceInBackground: (Boolean) -> Unit,
|
||||
stopped: Boolean
|
||||
) {
|
||||
SettingsItemView(disabled = stopped) {
|
||||
SectionItemView(disabled = stopped) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Bolt,
|
||||
@@ -219,7 +275,7 @@ fun SettingsLayout(
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SettingsItemView() {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Lock,
|
||||
@@ -239,15 +295,14 @@ fun SettingsLayout(
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
|
||||
SettingsItemView(showTerminal, disabled = stopped) {
|
||||
SectionItemView(showTerminal, disabled = stopped) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = stringResource(R.string.chat_console),
|
||||
@@ -262,19 +317,19 @@ fun SettingsLayout(
|
||||
}
|
||||
|
||||
@Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
|
||||
Text(generalGetString(R.string.install_simplex_chat_for_terminal), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem() {
|
||||
SettingsItemView() {
|
||||
SectionItemView() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
}
|
||||
@@ -296,23 +351,9 @@ fun SettingsLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
|
||||
val modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
Row(
|
||||
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
|
||||
SettingsItemView(click, disabled = disabled) {
|
||||
SectionItemView(click, disabled = disabled) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(text, color = if (disabled) HighOrLowlight else textColor)
|
||||
@@ -321,7 +362,7 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
||||
SettingsItemView() {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
@@ -330,6 +371,25 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boo
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItemWithInfo(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
text: String,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
pref: Preference<Boolean>,
|
||||
prefState: MutableState<Boolean>? = null
|
||||
) {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onClickInfo() }) {
|
||||
Icon(icon, text, tint = if (stopped) HighOrLowlight else iconTint)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
SharedPreferenceToggleWithIcon(text, Icons.Outlined.Info, stopped, onClickInfo, pref, prefState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -340,9 +400,12 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boo
|
||||
fun PreviewSettingsLayout() {
|
||||
SimpleXTheme {
|
||||
SettingsLayout(
|
||||
profile = Profile.sampleData,
|
||||
profile = LocalProfile.sampleData,
|
||||
stopped = false,
|
||||
incognito = remember { mutableStateOf(false) },
|
||||
incognitoPref = Preference({ false}, {}),
|
||||
runServiceInBackground = remember { mutableStateOf(true) },
|
||||
developerTools = Preference({ false }, {}),
|
||||
setRunServiceInBackground = {},
|
||||
setPerformLA = {},
|
||||
showModal = { {} },
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun ThemeSelectorView() {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val allThemes by remember { mutableStateOf(ThemeManager.allThemes(darkTheme)) }
|
||||
|
||||
ThemeSelectorLayout(
|
||||
allThemes,
|
||||
onSelectTheme = {
|
||||
ThemeManager.applyTheme(it, darkTheme)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun ThemeSelectorLayout(
|
||||
allThemes: List<Triple<Colors, DefaultTheme, String>>,
|
||||
onSelectTheme: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current),
|
||||
Modifier.padding(start = 16.dp, bottom = 24.dp),
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
SectionView(null) {
|
||||
LazyColumn(
|
||||
Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
items(allThemes.size) { index ->
|
||||
val item = allThemes[index]
|
||||
val onClick = {
|
||||
onSelectTheme(item.second.name)
|
||||
}
|
||||
SectionItemViewSpaceBetween(onClick, padding = PaddingValues()) {
|
||||
Text(item.third)
|
||||
if (currentTheme.second == item.second) {
|
||||
Icon(Icons.Outlined.Check, item.third, tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
@@ -37,7 +36,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
val editProfile = remember { mutableStateOf(false) }
|
||||
var profile by remember { mutableStateOf(user.profile) }
|
||||
var profile by remember { mutableStateOf(user.profile.toProfile()) }
|
||||
UserProfileLayout(
|
||||
close = close,
|
||||
editProfile = editProfile,
|
||||
@@ -47,7 +46,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val p = Profile(displayName, fullName, image)
|
||||
val newProfile = chatModel.controller.apiUpdateProfile(p)
|
||||
if (newProfile != null) {
|
||||
chatModel.updateUserProfile(newProfile)
|
||||
chatModel.currentUser.value?.profile?.profileId?.let {
|
||||
chatModel.updateUserProfile(newProfile.toLocalProfile(it))
|
||||
}
|
||||
profile = newProfile
|
||||
}
|
||||
editProfile.value = false
|
||||
@@ -200,7 +201,7 @@ fun UserProfileLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameTextField(name: MutableState<String>) {
|
||||
fun ProfileNameTextField(name: MutableState<String>) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
@@ -218,7 +219,7 @@ private fun ProfileNameTextField(name: MutableState<String>) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameRow(label: String, text: String) {
|
||||
fun ProfileNameRow(label: String, text: String) {
|
||||
Row(Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
label,
|
||||
@@ -234,7 +235,7 @@ private fun ProfileNameRow(label: String, text: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextButton(text: String, click: () -> Unit) {
|
||||
fun TextButton(text: String, click: () -> Unit) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.primary,
|
||||
|
||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<background android:drawable="@color/icon_background"/>
|
||||
<foreground android:drawable="@mipmap/icon_foreground"/>
|
||||
</adaptive-icon>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/icon_dark_blue_background"/>
|
||||
<foreground android:drawable="@mipmap/icon_dark_blue_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/icon_dark_blue_background"/>
|
||||
<foreground android:drawable="@mipmap/icon_dark_blue_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<foreground android:drawable="@color/white"/>
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@color/icon_background"/>
|
||||
<foreground android:drawable="@mipmap/icon_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/icon_background"/>
|
||||
<foreground android:drawable="@mipmap/icon_dark_blue_round"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |