mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 138dc7fe8f | |||
| 0535d84719 | |||
| f4447ffe89 | |||
| 146d5f99bc | |||
| 73e5fff8f5 | |||
| 33e7538172 | |||
| 49c9c501aa | |||
| a177dc5a13 | |||
| a4f207875f | |||
| bcca0998d5 | |||
| 95cc9e1e55 | |||
| ab5ae2d2cb | |||
| 40a91a7273 | |||
| 1240b31df8 | |||
| ff14730738 | |||
| 0cba3a4bb3 | |||
| 208f8a3346 | |||
| caa3efb9ed | |||
| 4beb916754 | |||
| c1ee04eed1 | |||
| 0ad3bc9993 | |||
| 9893aa665a | |||
| fda8836ab8 | |||
| 05fdd07409 | |||
| fb8f5facd0 | |||
| 8bdb784a14 | |||
| 5d785aad2e | |||
| ce11d58a76 | |||
| 887b374bfc | |||
| 94dc967197 | |||
| 4319a581ca | |||
| fb05218558 | |||
| edf2d02a0d | |||
| 87ba429dfd | |||
| 7af1a7cf76 | |||
| df619acdd4 | |||
| 503d0cd451 | |||
| 1294a00ee7 | |||
| 0a8069ada2 | |||
| c167f594b9 | |||
| ce5124594d | |||
| 5de96aa7c4 | |||
| cdbf8e2715 | |||
| 69b2f8f535 | |||
| ff17f89551 | |||
| 358712fa31 | |||
| 75cad8a6bf | |||
| e5969e197a | |||
| a9ffe4e039 | |||
| bf2129c4ae | |||
| 04f10aede7 | |||
| ffbff93374 | |||
| f3630d934c | |||
| 6f59df4e33 | |||
| e44e9a0940 | |||
| c43ba7bf23 | |||
| 9e48e1f74a | |||
| 0001885971 | |||
| e0c932c04e | |||
| 01a86336c0 | |||
| 48d24d3582 | |||
| 07ef6e4090 | |||
| 19163776e3 | |||
| 62b1f786f1 | |||
| d479e9b2bf | |||
| 0beb260b00 | |||
| bc28568c63 | |||
| a4dd520248 | |||
| 9ad29aa17e | |||
| 6f24281671 | |||
| eb81b62892 | |||
| ef1133ee98 | |||
| 1872744543 | |||
| 303aeaaba5 | |||
| c5359d698c | |||
| acd72fb269 | |||
| 8d096f469d | |||
| b204d21d9e | |||
| c9620a594e | |||
| 538024de61 | |||
| 5c9a14fdb6 | |||
| 9295bdca3e | |||
| 00466f4654 | |||
| 5d976d3c67 | |||
| 7360bd098a | |||
| 3a755286c1 | |||
| e5f07993a7 | |||
| 56a3f98dc0 | |||
| 9949ac073f | |||
| c102a884d1 | |||
| 1e6e9ad5e2 | |||
| e6c5ad5833 | |||
| 8af0229f52 | |||
| 7f0355ec67 | |||
| ade8c97b16 | |||
| ee18bce964 | |||
| 7e204127b8 | |||
| 5619152810 | |||
| 6f463c16a5 | |||
| 33b3557950 | |||
| e5912e58f5 | |||
| 7a0d2add17 | |||
| ac30602a50 | |||
| 6cc4e2e801 | |||
| 5650898c2c | |||
| 336a170b3a | |||
| 9c06acd4bc | |||
| 1d819a4af3 | |||
| 5263698e64 | |||
| 5d0d9a1c18 | |||
| 7407884223 | |||
| 07acbfe743 | |||
| 5e2c868612 | |||
| f6ed099f17 | |||
| 098cbf33b6 | |||
| f8cf35879f | |||
| 60fedbf5d2 | |||
| 87d306383c | |||
| 18b772a80b | |||
| 789c54bd5f | |||
| f8214b0604 | |||
| 2eec81c35e | |||
| eb099c526a | |||
| e18bb74bfd | |||
| 9225f437e9 | |||
| de7548a9a8 | |||
| a58a0fae29 | |||
| e32b24ef70 | |||
| 5d73b364d8 | |||
| fa2f303547 | |||
| a6e4e68bc5 | |||
| 4485d46307 | |||
| a7345ee4d9 | |||
| 388aaec80b | |||
| 21722b3417 | |||
| e6e5faeb9c | |||
| 67d78e14be | |||
| 2b09fb425d | |||
| c7f70f0ed0 | |||
| 5806a2ceb4 | |||
| fb165622aa | |||
| 7e3d53b621 | |||
| 7544d2f9e7 | |||
| 4296b6c622 | |||
| b5652bce81 | |||
| b8298aa458 | |||
| c3244f1b76 | |||
| 0ad74d9538 | |||
| a4be68f4bd | |||
| 0cb8f8ad82 | |||
| 9d7bb06396 | |||
| a9c2a7dcaa | |||
| 38b28f866c | |||
| bfa7ff16ff | |||
| 5c2b70a214 | |||
| 7e3f91f87c | |||
| f54faebff3 | |||
| 4e5aa3dcbc | |||
| 56f3874a93 | |||
| 828b502431 | |||
| 491fe4a9bf | |||
| f8302e2030 | |||
| fd34c39552 | |||
| b1fa1a84fe | |||
| cf23399262 | |||
| b5a812769b | |||
| 40e1b01baf | |||
| 9c925ab040 | |||
| faceeb6fce | |||
| 07e8c1d76e | |||
| b1d8600215 | |||
| e14ab0fed0 | |||
| cb0c499f57 |
+28
-6
@@ -1,10 +1,32 @@
|
|||||||
FROM haskell:8.10.4 AS build-stage
|
FROM ubuntu:focal AS build
|
||||||
# if you encounter "version `GLIBC_2.28' not found" error when running
|
|
||||||
# chat client executable, build with the following base image instead:
|
# Install curl and simplex-chat-related dependencies
|
||||||
# FROM haskell:8.10.4-stretch AS build-stage
|
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
||||||
|
|
||||||
|
# Install ghcup
|
||||||
|
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||||
|
chmod +x /usr/bin/ghcup
|
||||||
|
|
||||||
|
# Install ghc
|
||||||
|
RUN ghcup install ghc 8.10.7
|
||||||
|
# Install cabal
|
||||||
|
RUN ghcup install cabal
|
||||||
|
# Set both as default
|
||||||
|
RUN ghcup set ghc 8.10.7 && \
|
||||||
|
ghcup set cabal
|
||||||
|
|
||||||
COPY . /project
|
COPY . /project
|
||||||
WORKDIR /project
|
WORKDIR /project
|
||||||
RUN stack install
|
|
||||||
|
# Adjust PATH
|
||||||
|
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||||
|
|
||||||
|
# Adjust build
|
||||||
|
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||||
|
|
||||||
|
# Compile simplex-chat
|
||||||
|
RUN cabal update
|
||||||
|
RUN cabal install
|
||||||
|
|
||||||
FROM scratch AS export-stage
|
FROM scratch AS export-stage
|
||||||
COPY --from=build-stage /root/.local/bin/simplex-chat /
|
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
[](https://twitter.com/SimpleXChat)
|
|
||||||
[](https://www.reddit.com/r/SimpleXChat)
|
[](https://www.reddit.com/r/SimpleXChat)
|
||||||
|
[](https://mastodon.social/@simplex)
|
||||||
|
[](https://twitter.com/SimpleXChat)
|
||||||
|
|
||||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||||
|
|
||||||
@@ -85,16 +86,14 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent updates:
|
Recent updates:
|
||||||
|
|
||||||
|
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration.](./blog/20221206-simplex-chat-v4.3-voice-messages.md)
|
||||||
|
|
||||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
||||||
|
|
||||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
|
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
|
||||||
|
|
||||||
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
|
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
|
||||||
|
|
||||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network 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)
|
|
||||||
|
|
||||||
[All updates](./blog)
|
[All updates](./blog)
|
||||||
|
|
||||||
## Make a private connection
|
## Make a private connection
|
||||||
@@ -103,7 +102,7 @@ You need to share a link or scan a QR code (in person or during a video call) to
|
|||||||
|
|
||||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||||
|
|
||||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
|
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||||
|
|
||||||
## :zap: Quick installation of a terminal app
|
## :zap: Quick installation of a terminal app
|
||||||
|
|
||||||
@@ -187,19 +186,20 @@ If you are considering developing with SimpleX platform please get in touch for
|
|||||||
- ✅ Chat database encryption.
|
- ✅ Chat database encryption.
|
||||||
- ✅ Automatic chat history deletion.
|
- ✅ Automatic chat history deletion.
|
||||||
- ✅ Links to join groups and improve groups stability.
|
- ✅ Links to join groups and improve groups stability.
|
||||||
|
- ✅ Voice messages (with recipient opt-out per contact).
|
||||||
|
- ✅ Basic authentication for SMP servers (to authorize creating new queues).
|
||||||
|
- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
||||||
|
- ✅ Block screenshots and view in recent apps.
|
||||||
|
- ✅ Advanced server configuration.
|
||||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||||
- 🏗 Voice messages (with recipient opt-out per contact).
|
- 🏗 Contact verification via a separate out-of-band channel.
|
||||||
- 🏗 Basic authentication for SMP servers (to authorize creating new queues).
|
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||||
- View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
|
||||||
- Block screenshots and view in recent apps.
|
|
||||||
- Optionally avoid re-using the same TCP session for multiple connections.
|
- Optionally avoid re-using the same TCP session for multiple connections.
|
||||||
- Access password/pin (with optional alternative access password).
|
- Access password/pin (with optional alternative access password).
|
||||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
|
||||||
- Media server to optimize sending large files to groups.
|
- Media server to optimize sending large files to groups.
|
||||||
- Video messages.
|
- Video messages.
|
||||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||||
- Multiple user profiles in the same chat database.
|
- Multiple user profiles in the same chat database.
|
||||||
- Advanced server configuration.
|
|
||||||
- Feeds/broadcasts.
|
- Feeds/broadcasts.
|
||||||
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
|
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
|
||||||
- Web widgets for custom interactivity in the chats.
|
- Web widgets for custom interactivity in the chats.
|
||||||
@@ -246,8 +246,10 @@ It is possible to donate via:
|
|||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
- [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.
|
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
|
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
|
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||||
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "chat.simplex.app"
|
applicationId "chat.simplex.app"
|
||||||
minSdk 29
|
minSdk 29
|
||||||
targetSdk 32
|
targetSdk 32
|
||||||
versionCode 70
|
versionCode 78
|
||||||
versionName "4.2.2"
|
versionName "4.3.2"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<application
|
<application
|
||||||
android:name="SimplexApp"
|
android:name="SimplexApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupOnly="true"
|
||||||
|
android:backupAgent="BackupAgent"
|
||||||
android:icon="@mipmap/icon"
|
android:icon="@mipmap/icon"
|
||||||
android:label="${app_name}"
|
android:label="${app_name}"
|
||||||
android:extractNativeLibs="${extract_native_libs}"
|
android:extractNativeLibs="${extract_native_libs}"
|
||||||
@@ -102,7 +104,9 @@
|
|||||||
|
|
||||||
|
|
||||||
<activity android:name=".views.call.IncomingCallActivity"
|
<activity android:name=".views.call.IncomingCallActivity"
|
||||||
android:showOnLockScreen="true"/>
|
android:showOnLockScreen="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTask"/>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
|||||||
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||||
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||||
extern char *chat_parse_markdown(const char *str);
|
extern char *chat_parse_markdown(const char *str);
|
||||||
|
extern char *chat_parse_server(const char *str);
|
||||||
|
|
||||||
JNIEXPORT jobjectArray JNICALL
|
JNIEXPORT jobjectArray JNICALL
|
||||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
||||||
@@ -76,3 +77,11 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
|
|||||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||||
|
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package chat.simplex.app
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgentHelper
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import chat.simplex.app.model.AppPreferences
|
||||||
|
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||||
|
|
||||||
|
class BackupAgent: BackupAgentHelper() {
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||||
|
if (applicationContext
|
||||||
|
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
|
||||||
|
) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ package chat.simplex.app
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.os.SystemClock.elapsedRealtime
|
import android.os.SystemClock.elapsedRealtime
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
@@ -19,7 +22,9 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
@@ -36,6 +41,9 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.newchat.*
|
import chat.simplex.app.views.newchat.*
|
||||||
import chat.simplex.app.views.onboarding.*
|
import chat.simplex.app.views.onboarding.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity: FragmentActivity() {
|
class MainActivity: FragmentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -67,6 +75,13 @@ class MainActivity: FragmentActivity() {
|
|||||||
processIntent(intent, m)
|
processIntent(intent, m)
|
||||||
processExternalIntent(intent, m)
|
processExternalIntent(intent, m)
|
||||||
}
|
}
|
||||||
|
if (m.controller.appPrefs.privacyProtectScreen.get()) {
|
||||||
|
Log.d(TAG, "onCreate: set FLAG_SECURE")
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE
|
||||||
|
)
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -119,7 +134,15 @@ class MainActivity: FragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
super.onBackPressed()
|
if (
|
||||||
|
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|
||||||
|
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|
||||||
|
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
|
||||||
|
) {
|
||||||
|
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
|
||||||
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
||||||
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||||
clearAuthState()
|
clearAuthState()
|
||||||
@@ -317,14 +340,56 @@ fun MainPage(
|
|||||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||||
else {
|
else {
|
||||||
showAdvertiseLAAlert = true
|
showAdvertiseLAAlert = true
|
||||||
val stopped = chatModel.chatRunning.value == false
|
BoxWithConstraints {
|
||||||
if (chatModel.chatId.value == null) {
|
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||||
if (chatModel.sharedContent.value == null)
|
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||||
ChatListView(chatModel, setPerformLA, stopped)
|
Box(
|
||||||
else
|
Modifier
|
||||||
ShareListView(chatModel, stopped)
|
.graphicsLayer {
|
||||||
|
translationX = -offset.value.dp.toPx()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
if (chatModel.sharedContent.value == null)
|
||||||
|
ChatListView(chatModel, setPerformLA, stopped)
|
||||||
|
else
|
||||||
|
ShareListView(chatModel, stopped)
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onComposed: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
offset.animateTo(
|
||||||
|
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||||
|
chatListAnimationSpec()
|
||||||
|
)
|
||||||
|
if (offset.value == 0f) {
|
||||||
|
currentChatId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
snapshotFlow { chatModel.chatId.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
if (it != null) currentChatId = it
|
||||||
|
else onComposed()
|
||||||
|
|
||||||
|
// Deletes files that were not sent but already stored in files directory.
|
||||||
|
// Currently, it's voice records only
|
||||||
|
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
|
||||||
|
chatModel.filesToDelete.forEach { it.delete() }
|
||||||
|
chatModel.filesToDelete.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||||
|
currentChatId?.let {
|
||||||
|
ChatView(it, chatModel, onComposed)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else ChatView(chatModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import chat.simplex.app.views.onboarding.OnboardingStage
|
|||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import java.io.BufferedReader
|
import java.io.*
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.Semaphore
|
import java.util.concurrent.Semaphore
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -32,10 +31,13 @@ external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
|||||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||||
external fun chatParseMarkdown(str: String): String
|
external fun chatParseMarkdown(str: String): String
|
||||||
|
external fun chatParseServer(str: String): String
|
||||||
|
|
||||||
class SimplexApp: Application(), LifecycleEventObserver {
|
class SimplexApp: Application(), LifecycleEventObserver {
|
||||||
lateinit var chatController: ChatController
|
lateinit var chatController: ChatController
|
||||||
|
|
||||||
|
var isAppOnForeground: Boolean = false
|
||||||
|
|
||||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
||||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
|
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
|
||||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||||
@@ -95,6 +97,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
withApi {
|
withApi {
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_START -> {
|
Lifecycle.Event.ON_START -> {
|
||||||
|
isAppOnForeground = true
|
||||||
if (chatModel.chatRunning.value == true) {
|
if (chatModel.chatRunning.value == true) {
|
||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
val chats = chatController.apiGetChats()
|
val chats = chatController.apiGetChats()
|
||||||
@@ -103,6 +106,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Lifecycle.Event.ON_RESUME -> {
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
isAppOnForeground = true
|
||||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -114,7 +118,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||||
SimplexService.start(applicationContext)
|
SimplexService.start(applicationContext)
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> isAppOnForeground = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +172,18 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
val s = Semaphore(0)
|
val s = Semaphore(0)
|
||||||
thread(name="stdout/stderr pipe") {
|
thread(name="stdout/stderr pipe") {
|
||||||
Log.d(TAG, "starting server")
|
Log.d(TAG, "starting server")
|
||||||
val server = LocalServerSocket(socketName)
|
var server: LocalServerSocket? = null
|
||||||
|
for (i in 0..100) {
|
||||||
|
try {
|
||||||
|
server = LocalServerSocket(socketName + i)
|
||||||
|
break
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server == null) {
|
||||||
|
throw Error("Unable to setup local server socket. Contact developers")
|
||||||
|
}
|
||||||
Log.d(TAG, "started server")
|
Log.d(TAG, "started server")
|
||||||
s.release()
|
s.release()
|
||||||
val receiver = server.accept()
|
val receiver = server.accept()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class SimplexService: Service() {
|
class SimplexService: Service() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isServiceStarted = false
|
|
||||||
private var isStartingService = false
|
private var isStartingService = false
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
private var serviceNotification: Notification? = null
|
private var serviceNotification: Notification? = null
|
||||||
@@ -48,11 +46,32 @@ class SimplexService: Service() {
|
|||||||
notificationManager = createNotificationChannel()
|
notificationManager = createNotificationChannel()
|
||||||
serviceNotification = createNotification(title, text)
|
serviceNotification = createNotification(title, text)
|
||||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||||
|
/**
|
||||||
|
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
|
||||||
|
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
|
||||||
|
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
|
||||||
|
* */
|
||||||
|
if (stopAfterStart) {
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
isServiceStarted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.d(TAG, "Simplex service destroyed")
|
Log.d(TAG, "Simplex service destroyed")
|
||||||
stopService()
|
try {
|
||||||
|
wakeLock?.let {
|
||||||
|
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||||
|
}
|
||||||
|
isServiceStarted = false
|
||||||
|
stopAfterStart = false
|
||||||
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
|
|
||||||
// If notification service is enabled and battery optimization is disabled, restart the service
|
// If notification service is enabled and battery optimization is disabled, restart the service
|
||||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||||
@@ -62,7 +81,7 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
private fun startService() {
|
private fun startService() {
|
||||||
Log.d(TAG, "SimplexService startService")
|
Log.d(TAG, "SimplexService startService")
|
||||||
if (isServiceStarted || isStartingService) return
|
if (wakeLock != null || isStartingService) return
|
||||||
val self = this
|
val self = this
|
||||||
isStartingService = true
|
isStartingService = true
|
||||||
withApi {
|
withApi {
|
||||||
@@ -73,10 +92,9 @@ class SimplexService: Service() {
|
|||||||
if (chatDbStatus != DBMigrationResult.OK) {
|
if (chatDbStatus != DBMigrationResult.OK) {
|
||||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||||
showPassphraseNotification(chatDbStatus)
|
showPassphraseNotification(chatDbStatus)
|
||||||
stopService()
|
safeStopService(self)
|
||||||
return@withApi
|
return@withApi
|
||||||
}
|
}
|
||||||
isServiceStarted = true
|
|
||||||
saveServiceState(self, ServiceState.STARTED)
|
saveServiceState(self, ServiceState.STARTED)
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||||
@@ -89,22 +107,6 @@ class SimplexService: Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopService() {
|
|
||||||
Log.d(TAG, "Stopping foreground service")
|
|
||||||
try {
|
|
||||||
wakeLock?.let {
|
|
||||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
|
||||||
}
|
|
||||||
wakeLock = null
|
|
||||||
stopForeground(true)
|
|
||||||
stopSelf()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
|
||||||
}
|
|
||||||
isServiceStarted = false
|
|
||||||
saveServiceState(this, ServiceState.STOPPED)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel(): NotificationManager? {
|
private fun createNotificationChannel(): NotificationManager? {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -235,6 +237,9 @@ class SimplexService: Service() {
|
|||||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||||
|
|
||||||
|
private var isServiceStarted = false
|
||||||
|
private var stopAfterStart = false
|
||||||
|
|
||||||
fun scheduleStart(context: Context) {
|
fun scheduleStart(context: Context) {
|
||||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||||
val workManager = WorkManager.getInstance(context)
|
val workManager = WorkManager.getInstance(context)
|
||||||
@@ -244,7 +249,17 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||||
|
|
||||||
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
|
/**
|
||||||
|
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||||
|
* exception related to foreground services lifecycle
|
||||||
|
* */
|
||||||
|
fun safeStopService(context: Context) {
|
||||||
|
if (isServiceStarted) {
|
||||||
|
context.stopService(Intent(context, SimplexService::class.java))
|
||||||
|
} else {
|
||||||
|
stopAfterStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun serviceAction(context: Context, action: Action) {
|
private suspend fun serviceAction(context: Context, action: Action) {
|
||||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import kotlinx.serialization.descriptors.*
|
|||||||
import kotlinx.serialization.encoding.Decoder
|
import kotlinx.serialization.encoding.Decoder
|
||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
|
||||||
|
* */
|
||||||
|
@Stable
|
||||||
class ChatModel(val controller: ChatController) {
|
class ChatModel(val controller: ChatController) {
|
||||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||||
val currentUser = mutableStateOf<User?>(null)
|
val currentUser = mutableStateOf<User?>(null)
|
||||||
@@ -39,7 +44,10 @@ class ChatModel(val controller: ChatController) {
|
|||||||
|
|
||||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||||
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
||||||
val userSMPServers = mutableStateOf<(List<String>)?>(null)
|
val userSMPServers = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||||
|
// Allows to temporary save servers that are being edited on multiple screens
|
||||||
|
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||||
|
val presetSMPServers = mutableStateOf<(List<String>)?>(null)
|
||||||
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
||||||
|
|
||||||
// set when app opened from external intent
|
// set when app opened from external intent
|
||||||
@@ -70,6 +78,9 @@ class ChatModel(val controller: ChatController) {
|
|||||||
// working with external intents
|
// working with external intents
|
||||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||||
|
|
||||||
|
val filesToDelete = mutableSetOf<File>()
|
||||||
|
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
||||||
|
|
||||||
fun updateUserProfile(profile: LocalProfile) {
|
fun updateUserProfile(profile: LocalProfile) {
|
||||||
val user = currentUser.value
|
val user = currentUser.value
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
@@ -90,7 +101,7 @@ class ChatModel(val controller: ChatController) {
|
|||||||
|
|
||||||
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
|
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
|
||||||
|
|
||||||
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact && !contact.viaGroupLink)
|
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directContact)
|
||||||
|
|
||||||
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
|
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
|
||||||
|
|
||||||
@@ -204,6 +215,9 @@ class ChatModel(val controller: ChatController) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||||
|
if (cItem.isRcvNew) {
|
||||||
|
decreaseCounterInChat(cInfo.id)
|
||||||
|
}
|
||||||
// update previews
|
// update previews
|
||||||
val i = getChatIndex(cInfo.id)
|
val i = getChatIndex(cInfo.id)
|
||||||
val chat: Chat
|
val chat: Chat
|
||||||
@@ -211,13 +225,14 @@ class ChatModel(val controller: ChatController) {
|
|||||||
chat = chats[i]
|
chat = chats[i]
|
||||||
val pItem = chat.chatItems.lastOrNull()
|
val pItem = chat.chatItems.lastOrNull()
|
||||||
if (pItem?.id == cItem.id) {
|
if (pItem?.id == cItem.id) {
|
||||||
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
|
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// remove from current chat
|
// remove from current chat
|
||||||
if (chatId.value == cInfo.id) {
|
if (chatId.value == cInfo.id) {
|
||||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||||
if (itemIndex >= 0) {
|
if (itemIndex >= 0) {
|
||||||
|
AudioPlayer.stop(chatItems[itemIndex])
|
||||||
chatItems.removeAt(itemIndex)
|
chatItems.removeAt(itemIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,6 +356,7 @@ data class User(
|
|||||||
val userContactId: Long,
|
val userContactId: Long,
|
||||||
val localDisplayName: String,
|
val localDisplayName: String,
|
||||||
val profile: LocalProfile,
|
val profile: LocalProfile,
|
||||||
|
val fullPreferences: FullChatPreferences,
|
||||||
val activeUser: Boolean
|
val activeUser: Boolean
|
||||||
): NamedChat {
|
): NamedChat {
|
||||||
override val displayName: String get() = profile.displayName
|
override val displayName: String get() = profile.displayName
|
||||||
@@ -354,6 +370,7 @@ data class User(
|
|||||||
userContactId = 1,
|
userContactId = 1,
|
||||||
localDisplayName = "alice",
|
localDisplayName = "alice",
|
||||||
profile = LocalProfile.sampleData,
|
profile = LocalProfile.sampleData,
|
||||||
|
fullPreferences = FullChatPreferences.sampleData,
|
||||||
activeUser = true
|
activeUser = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -378,11 +395,14 @@ interface SomeChat {
|
|||||||
val ready: Boolean
|
val ready: Boolean
|
||||||
val sendMsgEnabled: Boolean
|
val sendMsgEnabled: Boolean
|
||||||
val ntfsEnabled: Boolean
|
val ntfsEnabled: Boolean
|
||||||
|
val incognito: Boolean
|
||||||
|
val voiceMessageAllowed: Boolean
|
||||||
|
val fullDeletionAllowed: Boolean
|
||||||
val createdAt: Instant
|
val createdAt: Instant
|
||||||
val updatedAt: Instant
|
val updatedAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable @Stable
|
||||||
data class Chat (
|
data class Chat (
|
||||||
val chatInfo: ChatInfo,
|
val chatInfo: ChatInfo,
|
||||||
val chatItems: List<ChatItem>,
|
val chatItems: List<ChatItem>,
|
||||||
@@ -428,7 +448,6 @@ data class Chat (
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class ChatInfo: SomeChat, NamedChat {
|
sealed class ChatInfo: SomeChat, NamedChat {
|
||||||
abstract val incognito: Boolean
|
|
||||||
|
|
||||||
@Serializable @SerialName("direct")
|
@Serializable @SerialName("direct")
|
||||||
data class Direct(val contact: Contact): ChatInfo() {
|
data class Direct(val contact: Contact): ChatInfo() {
|
||||||
@@ -438,8 +457,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = contact.apiId
|
override val apiId get() = contact.apiId
|
||||||
override val ready get() = contact.ready
|
override val ready get() = contact.ready
|
||||||
override val sendMsgEnabled get() = contact.sendMsgEnabled
|
override val sendMsgEnabled get() = contact.sendMsgEnabled
|
||||||
override val ntfsEnabled get() = contact.chatSettings.enableNtfs
|
override val ntfsEnabled get() = contact.ntfsEnabled
|
||||||
override val incognito get() = contact.contactConnIncognito
|
override val incognito get() = contact.incognito
|
||||||
|
override val voiceMessageAllowed get() = contact.voiceMessageAllowed
|
||||||
|
override val fullDeletionAllowed get() = contact.fullDeletionAllowed
|
||||||
override val createdAt get() = contact.createdAt
|
override val createdAt get() = contact.createdAt
|
||||||
override val updatedAt get() = contact.updatedAt
|
override val updatedAt get() = contact.updatedAt
|
||||||
override val displayName get() = contact.displayName
|
override val displayName get() = contact.displayName
|
||||||
@@ -460,8 +481,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = groupInfo.apiId
|
override val apiId get() = groupInfo.apiId
|
||||||
override val ready get() = groupInfo.ready
|
override val ready get() = groupInfo.ready
|
||||||
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
|
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
|
||||||
override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
|
override val ntfsEnabled get() = groupInfo.ntfsEnabled
|
||||||
override val incognito get() = groupInfo.membership.memberIncognito
|
override val incognito get() = groupInfo.incognito
|
||||||
|
override val voiceMessageAllowed get() = groupInfo.voiceMessageAllowed
|
||||||
|
override val fullDeletionAllowed get() = groupInfo.fullDeletionAllowed
|
||||||
override val createdAt get() = groupInfo.createdAt
|
override val createdAt get() = groupInfo.createdAt
|
||||||
override val updatedAt get() = groupInfo.updatedAt
|
override val updatedAt get() = groupInfo.updatedAt
|
||||||
override val displayName get() = groupInfo.displayName
|
override val displayName get() = groupInfo.displayName
|
||||||
@@ -482,8 +505,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = contactRequest.apiId
|
override val apiId get() = contactRequest.apiId
|
||||||
override val ready get() = contactRequest.ready
|
override val ready get() = contactRequest.ready
|
||||||
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
|
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
|
||||||
override val ntfsEnabled get() = false
|
override val ntfsEnabled get() = contactRequest.ntfsEnabled
|
||||||
override val incognito get() = false
|
override val incognito get() = contactRequest.incognito
|
||||||
|
override val voiceMessageAllowed get() = contactRequest.voiceMessageAllowed
|
||||||
|
override val fullDeletionAllowed get() = contactRequest.fullDeletionAllowed
|
||||||
override val createdAt get() = contactRequest.createdAt
|
override val createdAt get() = contactRequest.createdAt
|
||||||
override val updatedAt get() = contactRequest.updatedAt
|
override val updatedAt get() = contactRequest.updatedAt
|
||||||
override val displayName get() = contactRequest.displayName
|
override val displayName get() = contactRequest.displayName
|
||||||
@@ -504,8 +529,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||||||
override val apiId get() = contactConnection.apiId
|
override val apiId get() = contactConnection.apiId
|
||||||
override val ready get() = contactConnection.ready
|
override val ready get() = contactConnection.ready
|
||||||
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
||||||
override val ntfsEnabled get() = false
|
override val ntfsEnabled get() = contactConnection.incognito
|
||||||
override val incognito get() = contactConnection.incognito
|
override val incognito get() = contactConnection.incognito
|
||||||
|
override val voiceMessageAllowed get() = contactConnection.voiceMessageAllowed
|
||||||
|
override val fullDeletionAllowed get() = contactConnection.fullDeletionAllowed
|
||||||
override val createdAt get() = contactConnection.createdAt
|
override val createdAt get() = contactConnection.createdAt
|
||||||
override val updatedAt get() = contactConnection.updatedAt
|
override val updatedAt get() = contactConnection.updatedAt
|
||||||
override val displayName get() = contactConnection.displayName
|
override val displayName get() = contactConnection.displayName
|
||||||
@@ -527,9 +554,10 @@ data class Contact(
|
|||||||
val profile: LocalProfile,
|
val profile: LocalProfile,
|
||||||
val activeConn: Connection,
|
val activeConn: Connection,
|
||||||
val viaGroup: Long? = null,
|
val viaGroup: Long? = null,
|
||||||
|
val contactUsed: Boolean,
|
||||||
val chatSettings: ChatSettings,
|
val chatSettings: ChatSettings,
|
||||||
// User applies his preferences for the contact here. Named user_preferences on the contact in DB
|
|
||||||
val userPreferences: ChatPreferences,
|
val userPreferences: ChatPreferences,
|
||||||
|
val mergedPreferences: ContactUserPreferences,
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
override val updatedAt: Instant
|
override val updatedAt: Instant
|
||||||
): SomeChat, NamedChat {
|
): SomeChat, NamedChat {
|
||||||
@@ -539,16 +567,16 @@ data class Contact(
|
|||||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||||
override val sendMsgEnabled get() = true
|
override val sendMsgEnabled get() = true
|
||||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||||
|
override val incognito get() = contactConnIncognito
|
||||||
|
override val voiceMessageAllowed get() = mergedPreferences.voice.enabled.forUser
|
||||||
|
override val fullDeletionAllowed get() = mergedPreferences.fullDelete.enabled.forUser
|
||||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||||
override val fullName get() = profile.fullName
|
override val fullName get() = profile.fullName
|
||||||
override val image get() = profile.image
|
override val image get() = profile.image
|
||||||
override val localAlias get() = profile.localAlias
|
override val localAlias get() = profile.localAlias
|
||||||
|
|
||||||
val isIndirectContact: Boolean get() =
|
val directContact: Boolean get() =
|
||||||
activeConn.connLevel > 0 || viaGroup != null
|
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
|
||||||
|
|
||||||
val viaGroupLink: Boolean get() =
|
|
||||||
activeConn.viaGroupLink
|
|
||||||
|
|
||||||
val contactConnIncognito =
|
val contactConnIncognito =
|
||||||
activeConn.customUserProfileId != null
|
activeConn.customUserProfileId != null
|
||||||
@@ -559,8 +587,10 @@ data class Contact(
|
|||||||
localDisplayName = "alice",
|
localDisplayName = "alice",
|
||||||
profile = LocalProfile.sampleData,
|
profile = LocalProfile.sampleData,
|
||||||
activeConn = Connection.sampleData,
|
activeConn = Connection.sampleData,
|
||||||
|
contactUsed = true,
|
||||||
chatSettings = ChatSettings(true),
|
chatSettings = ChatSettings(true),
|
||||||
userPreferences = ChatPreferences(),
|
userPreferences = ChatPreferences.sampleData,
|
||||||
|
mergedPreferences = ContactUserPreferences.sampleData,
|
||||||
createdAt = Clock.System.now(),
|
createdAt = Clock.System.now(),
|
||||||
updatedAt = Clock.System.now()
|
updatedAt = Clock.System.now()
|
||||||
)
|
)
|
||||||
@@ -590,12 +620,11 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Profile(
|
data class Profile(
|
||||||
override val displayName: String,
|
override val displayName: String,
|
||||||
override val fullName: String,
|
override val fullName: String,
|
||||||
override val image: String? = null,
|
override val image: String? = null,
|
||||||
override val localAlias : String = "",
|
override val localAlias : String = "",
|
||||||
// Contact applies his preferences here
|
|
||||||
val preferences: ChatPreferences? = null
|
val preferences: ChatPreferences? = null
|
||||||
): NamedChat {
|
): NamedChat {
|
||||||
val profileViewName: String
|
val profileViewName: String
|
||||||
@@ -603,7 +632,7 @@ class Profile(
|
|||||||
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias)
|
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias, preferences)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val sampleData = Profile(
|
val sampleData = Profile(
|
||||||
@@ -620,18 +649,18 @@ class LocalProfile(
|
|||||||
override val fullName: String,
|
override val fullName: String,
|
||||||
override val image: String? = null,
|
override val image: String? = null,
|
||||||
override val localAlias: String,
|
override val localAlias: String,
|
||||||
// Contact applies his preferences here
|
|
||||||
val preferences: ChatPreferences? = null
|
val preferences: ChatPreferences? = null
|
||||||
): NamedChat {
|
): NamedChat {
|
||||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||||
|
|
||||||
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias)
|
fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias, preferences)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val sampleData = LocalProfile(
|
val sampleData = LocalProfile(
|
||||||
profileId = 1L,
|
profileId = 1L,
|
||||||
displayName = "alice",
|
displayName = "alice",
|
||||||
fullName = "Alice",
|
fullName = "Alice",
|
||||||
|
preferences = ChatPreferences.sampleData,
|
||||||
localAlias = ""
|
localAlias = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -648,10 +677,10 @@ data class GroupInfo (
|
|||||||
val groupId: Long,
|
val groupId: Long,
|
||||||
override val localDisplayName: String,
|
override val localDisplayName: String,
|
||||||
val groupProfile: GroupProfile,
|
val groupProfile: GroupProfile,
|
||||||
|
val fullGroupPreferences: FullGroupPreferences,
|
||||||
val membership: GroupMember,
|
val membership: GroupMember,
|
||||||
val hostConnCustomUserProfileId: Long? = null,
|
val hostConnCustomUserProfileId: Long? = null,
|
||||||
val chatSettings: ChatSettings,
|
val chatSettings: ChatSettings,
|
||||||
// val groupPreferences: GroupPreferences? = null,
|
|
||||||
override val createdAt: Instant,
|
override val createdAt: Instant,
|
||||||
override val updatedAt: Instant
|
override val updatedAt: Instant
|
||||||
): SomeChat, NamedChat {
|
): SomeChat, NamedChat {
|
||||||
@@ -661,6 +690,9 @@ data class GroupInfo (
|
|||||||
override val ready get() = membership.memberActive
|
override val ready get() = membership.memberActive
|
||||||
override val sendMsgEnabled get() = membership.memberActive
|
override val sendMsgEnabled get() = membership.memberActive
|
||||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||||
|
override val incognito get() = membership.memberIncognito
|
||||||
|
override val voiceMessageAllowed get() = fullGroupPreferences.voice.on
|
||||||
|
override val fullDeletionAllowed get() = fullGroupPreferences.fullDelete.on
|
||||||
override val displayName get() = groupProfile.displayName
|
override val displayName get() = groupProfile.displayName
|
||||||
override val fullName get() = groupProfile.fullName
|
override val fullName get() = groupProfile.fullName
|
||||||
override val image get() = groupProfile.image
|
override val image get() = groupProfile.image
|
||||||
@@ -680,6 +712,7 @@ data class GroupInfo (
|
|||||||
groupId = 1,
|
groupId = 1,
|
||||||
localDisplayName = "team",
|
localDisplayName = "team",
|
||||||
groupProfile = GroupProfile.sampleData,
|
groupProfile = GroupProfile.sampleData,
|
||||||
|
fullGroupPreferences = FullGroupPreferences.sampleData,
|
||||||
membership = GroupMember.sampleData,
|
membership = GroupMember.sampleData,
|
||||||
hostConnCustomUserProfileId = null,
|
hostConnCustomUserProfileId = null,
|
||||||
chatSettings = ChatSettings(true),
|
chatSettings = ChatSettings(true),
|
||||||
@@ -690,11 +723,12 @@ data class GroupInfo (
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class GroupProfile (
|
data class GroupProfile (
|
||||||
override val displayName: String,
|
override val displayName: String,
|
||||||
override val fullName: String,
|
override val fullName: String,
|
||||||
override val image: String? = null,
|
override val image: String? = null,
|
||||||
override val localAlias: String = "",
|
override val localAlias: String = "",
|
||||||
|
val groupPreferences: GroupPreferences? = null
|
||||||
): NamedChat {
|
): NamedChat {
|
||||||
companion object {
|
companion object {
|
||||||
val sampleData = GroupProfile(
|
val sampleData = GroupProfile(
|
||||||
@@ -902,6 +936,9 @@ class UserContactRequest (
|
|||||||
override val ready get() = true
|
override val ready get() = true
|
||||||
override val sendMsgEnabled get() = false
|
override val sendMsgEnabled get() = false
|
||||||
override val ntfsEnabled get() = false
|
override val ntfsEnabled get() = false
|
||||||
|
override val incognito get() = false
|
||||||
|
override val voiceMessageAllowed get() = false
|
||||||
|
override val fullDeletionAllowed get() = false
|
||||||
override val displayName get() = profile.displayName
|
override val displayName get() = profile.displayName
|
||||||
override val fullName get() = profile.fullName
|
override val fullName get() = profile.fullName
|
||||||
override val image get() = profile.image
|
override val image get() = profile.image
|
||||||
@@ -937,6 +974,9 @@ class PendingContactConnection(
|
|||||||
override val ready get() = false
|
override val ready get() = false
|
||||||
override val sendMsgEnabled get() = false
|
override val sendMsgEnabled get() = false
|
||||||
override val ntfsEnabled get() = false
|
override val ntfsEnabled get() = false
|
||||||
|
override val incognito get() = customUserProfileId != null
|
||||||
|
override val voiceMessageAllowed get() = false
|
||||||
|
override val fullDeletionAllowed get() = false
|
||||||
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
|
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
|
||||||
override val displayName: String get() {
|
override val displayName: String get() {
|
||||||
if (localAlias.isNotEmpty()) return localAlias
|
if (localAlias.isNotEmpty()) return localAlias
|
||||||
@@ -956,8 +996,6 @@ class PendingContactConnection(
|
|||||||
|
|
||||||
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
|
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
|
||||||
|
|
||||||
val incognito = customUserProfileId != null
|
|
||||||
|
|
||||||
val description: String get() {
|
val description: String get() {
|
||||||
val initiated = pccConnStatus.initiated
|
val initiated = pccConnStatus.initiated
|
||||||
return if (initiated == null) "" else generalGetString(
|
return if (initiated == null) "" else generalGetString(
|
||||||
@@ -1015,7 +1053,7 @@ class AChatItem (
|
|||||||
val chatItem: ChatItem
|
val chatItem: ChatItem
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable @Stable
|
||||||
data class ChatItem (
|
data class ChatItem (
|
||||||
val chatDir: CIDirection,
|
val chatDir: CIDirection,
|
||||||
val meta: CIMeta,
|
val meta: CIMeta,
|
||||||
@@ -1027,11 +1065,14 @@ data class ChatItem (
|
|||||||
val id: Long get() = meta.itemId
|
val id: Long get() = meta.itemId
|
||||||
val timestampText: String get() = meta.timestampText
|
val timestampText: String get() = meta.timestampText
|
||||||
|
|
||||||
val text: String get() =
|
val text: String get() {
|
||||||
when {
|
val mc = content.msgContent
|
||||||
|
return when {
|
||||||
|
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(R.string.voice_message_with_duration), durationText(mc.duration))
|
||||||
content.text == "" && file != null -> file.fileName
|
content.text == "" && file != null -> file.fileName
|
||||||
else -> content.text
|
else -> content.text
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
|
||||||
|
|
||||||
@@ -1046,30 +1087,40 @@ data class ChatItem (
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
val isCall: Boolean get() =
|
private val showNtfDir: Boolean get() = !chatDir.sent
|
||||||
when (content) {
|
|
||||||
is CIContent.SndCall -> true
|
|
||||||
is CIContent.RcvCall -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
val isMutedMemberEvent: Boolean get() =
|
val showNotification: Boolean get() =
|
||||||
when (content) {
|
when (content) {
|
||||||
is CIContent.RcvGroupEventContent ->
|
is CIContent.SndMsgContent -> showNtfDir
|
||||||
when (content.rcvGroupEvent) {
|
is CIContent.RcvMsgContent -> showNtfDir
|
||||||
is RcvGroupEvent.GroupUpdated -> true
|
is CIContent.SndDeleted -> showNtfDir
|
||||||
is RcvGroupEvent.MemberConnected -> true
|
is CIContent.RcvDeleted -> showNtfDir
|
||||||
is RcvGroupEvent.UserDeleted -> false
|
is CIContent.SndCall -> showNtfDir
|
||||||
is RcvGroupEvent.GroupDeleted -> false
|
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
|
||||||
is RcvGroupEvent.MemberAdded -> false
|
is CIContent.RcvIntegrityError -> showNtfDir
|
||||||
is RcvGroupEvent.MemberLeft -> false
|
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||||
is RcvGroupEvent.MemberRole -> true
|
is CIContent.SndGroupInvitation -> showNtfDir
|
||||||
is RcvGroupEvent.UserRole -> false
|
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||||
is RcvGroupEvent.MemberDeleted -> false
|
is RcvGroupEvent.MemberAdded -> false
|
||||||
is RcvGroupEvent.InvitedViaGroupLink -> false
|
is RcvGroupEvent.MemberConnected -> false
|
||||||
}
|
is RcvGroupEvent.MemberLeft -> false
|
||||||
is CIContent.SndGroupEventContent -> true
|
is RcvGroupEvent.MemberRole -> false
|
||||||
else -> false
|
is RcvGroupEvent.UserRole -> showNtfDir
|
||||||
|
is RcvGroupEvent.MemberDeleted -> false
|
||||||
|
is RcvGroupEvent.UserDeleted -> showNtfDir
|
||||||
|
is RcvGroupEvent.GroupDeleted -> showNtfDir
|
||||||
|
is RcvGroupEvent.GroupUpdated -> false
|
||||||
|
is RcvGroupEvent.InvitedViaGroupLink -> false
|
||||||
|
}
|
||||||
|
is CIContent.SndGroupEventContent -> showNtfDir
|
||||||
|
is CIContent.RcvConnEventContent -> false
|
||||||
|
is CIContent.SndConnEventContent -> showNtfDir
|
||||||
|
is CIContent.RcvChatFeature -> false
|
||||||
|
is CIContent.SndChatFeature -> showNtfDir
|
||||||
|
is CIContent.RcvGroupFeature -> false
|
||||||
|
is CIContent.SndGroupFeature -> showNtfDir
|
||||||
|
is CIContent.RcvChatFeatureRejected -> showNtfDir
|
||||||
|
is CIContent.RcvGroupFeatureRejected -> showNtfDir
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
|
fun withStatus(status: CIStatus): ChatItem = this.copy(meta = meta.copy(itemStatus = status))
|
||||||
@@ -1142,6 +1193,37 @@ data class ChatItem (
|
|||||||
quotedItem = null,
|
quotedItem = null,
|
||||||
file = null
|
file = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
|
||||||
|
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
|
||||||
|
return ChatItem(
|
||||||
|
chatDir = CIDirection.DirectRcv(),
|
||||||
|
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
||||||
|
content = content,
|
||||||
|
quotedItem = null,
|
||||||
|
file = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TEMP_DELETED_CHAT_ITEM_ID = -1L
|
||||||
|
|
||||||
|
val deletedItemDummy: ChatItem
|
||||||
|
get() = ChatItem(
|
||||||
|
chatDir = CIDirection.DirectRcv(),
|
||||||
|
meta = CIMeta(
|
||||||
|
itemId = TEMP_DELETED_CHAT_ITEM_ID,
|
||||||
|
itemTs = Clock.System.now(),
|
||||||
|
itemText = generalGetString(R.string.deleted_description),
|
||||||
|
itemStatus = CIStatus.RcvRead(),
|
||||||
|
createdAt = Clock.System.now(),
|
||||||
|
itemDeleted = false,
|
||||||
|
itemEdited = false,
|
||||||
|
editable = false
|
||||||
|
),
|
||||||
|
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
|
||||||
|
quotedItem = null,
|
||||||
|
file = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,7 +1288,7 @@ sealed class CIStatus {
|
|||||||
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
|
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
|
||||||
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
|
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
|
||||||
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
|
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
|
||||||
@Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus()
|
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
|
||||||
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
|
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
|
||||||
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
|
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
|
||||||
}
|
}
|
||||||
@@ -1238,22 +1320,34 @@ sealed class CIContent: ItemContent {
|
|||||||
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
|
||||||
override val text: String get() = when(this) {
|
override val text: String get() = when (this) {
|
||||||
is SndMsgContent -> msgContent.text
|
is SndMsgContent -> msgContent.text
|
||||||
is RcvMsgContent -> msgContent.text
|
is RcvMsgContent -> msgContent.text
|
||||||
is SndDeleted -> generalGetString(R.string.deleted_description)
|
is SndDeleted -> generalGetString(R.string.deleted_description)
|
||||||
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
is RcvDeleted -> generalGetString(R.string.deleted_description)
|
||||||
is SndCall -> status.text(duration)
|
is SndCall -> status.text(duration)
|
||||||
is RcvCall -> status.text(duration)
|
is RcvCall -> status.text(duration)
|
||||||
is RcvIntegrityError -> msgError.text
|
is RcvIntegrityError -> msgError.text
|
||||||
is RcvGroupInvitation -> groupInvitation.text
|
is RcvGroupInvitation -> groupInvitation.text
|
||||||
is SndGroupInvitation -> groupInvitation.text
|
is SndGroupInvitation -> groupInvitation.text
|
||||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||||
is SndGroupEventContent -> sndGroupEvent.text
|
is SndGroupEventContent -> sndGroupEvent.text
|
||||||
is RcvConnEventContent -> rcvConnEvent.text
|
is RcvConnEventContent -> rcvConnEvent.text
|
||||||
is SndConnEventContent -> sndConnEvent.text
|
is SndConnEventContent -> sndConnEvent.text
|
||||||
}
|
is RcvChatFeature -> "${feature.text}: ${enabled.text}"
|
||||||
|
is SndChatFeature -> "${feature.text}: ${enabled.text}"
|
||||||
|
is RcvGroupFeature -> "${groupFeature.text}: ${preference.enable.text}"
|
||||||
|
is SndGroupFeature -> "${groupFeature.text}: ${preference.enable.text}"
|
||||||
|
is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||||
|
is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -1265,7 +1359,13 @@ class CIQuote (
|
|||||||
val content: MsgContent,
|
val content: MsgContent,
|
||||||
val formattedText: List<FormattedText>? = null
|
val formattedText: List<FormattedText>? = null
|
||||||
): ItemContent {
|
): ItemContent {
|
||||||
override val text: String get() = content.text
|
override val text: String by lazy {
|
||||||
|
if (content.text == "" && content is MsgContent.MCVoice)
|
||||||
|
durationText(content.duration)
|
||||||
|
else
|
||||||
|
content.text
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun sender(membership: GroupMember?): String? = when (chatDir) {
|
fun sender(membership: GroupMember?): String? = when (chatDir) {
|
||||||
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
|
is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun)
|
||||||
@@ -1334,6 +1434,7 @@ sealed class MsgContent {
|
|||||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||||
|
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||||
|
|
||||||
@@ -1341,6 +1442,7 @@ sealed class MsgContent {
|
|||||||
is MCText -> "text $text"
|
is MCText -> "text $text"
|
||||||
is MCLink -> "json ${json.encodeToString(this)}"
|
is MCLink -> "json ${json.encodeToString(this)}"
|
||||||
is MCImage -> "json ${json.encodeToString(this)}"
|
is MCImage -> "json ${json.encodeToString(this)}"
|
||||||
|
is MCVoice-> "json ${json.encodeToString(this)}"
|
||||||
is MCFile -> "json ${json.encodeToString(this)}"
|
is MCFile -> "json ${json.encodeToString(this)}"
|
||||||
is MCUnknown -> "json $json"
|
is MCUnknown -> "json $json"
|
||||||
}
|
}
|
||||||
@@ -1414,6 +1516,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||||
MsgContent.MCImage(text, image)
|
MsgContent.MCImage(text, image)
|
||||||
}
|
}
|
||||||
|
"voice" -> {
|
||||||
|
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||||
|
MsgContent.MCVoice(text, duration)
|
||||||
|
}
|
||||||
"file" -> MsgContent.MCFile(text)
|
"file" -> MsgContent.MCFile(text)
|
||||||
else -> MsgContent.MCUnknown(t, text, json)
|
else -> MsgContent.MCUnknown(t, text, json)
|
||||||
}
|
}
|
||||||
@@ -1445,6 +1551,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
put("text", value.text)
|
put("text", value.text)
|
||||||
put("image", value.image)
|
put("image", value.image)
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVoice ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "voice")
|
||||||
|
put("text", value.text)
|
||||||
|
put("duration", value.duration)
|
||||||
|
}
|
||||||
is MsgContent.MCFile ->
|
is MsgContent.MCFile ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("type", "file")
|
put("type", "file")
|
||||||
@@ -1458,12 +1570,21 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class FormattedText(val text: String, val format: Format? = null) {
|
class FormattedText(val text: String, val format: Format? = null) {
|
||||||
val link: String? = when (format) {
|
// TODO make it dependent on simplexLinkMode preference
|
||||||
|
fun link(mode: SimplexLinkMode): String? = when (format) {
|
||||||
is Format.Uri -> text
|
is Format.Uri -> text
|
||||||
|
is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri
|
||||||
is Format.Email -> "mailto:$text"
|
is Format.Email -> "mailto:$text"
|
||||||
is Format.Phone -> "tel:$text"
|
is Format.Phone -> "tel:$text"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO make it dependent on simplexLinkMode preference
|
||||||
|
fun viewText(mode: SimplexLinkMode): String =
|
||||||
|
if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text
|
||||||
|
|
||||||
|
fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List<String>): String =
|
||||||
|
"${linkType.description} (${String.format(generalGetString(R.string.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -1475,6 +1596,7 @@ sealed class Format {
|
|||||||
@Serializable @SerialName("secret") class Secret: Format()
|
@Serializable @SerialName("secret") class Secret: Format()
|
||||||
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
||||||
@Serializable @SerialName("uri") class Uri: Format()
|
@Serializable @SerialName("uri") class Uri: Format()
|
||||||
|
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List<String>): Format()
|
||||||
@Serializable @SerialName("email") class Email: Format()
|
@Serializable @SerialName("email") class Email: Format()
|
||||||
@Serializable @SerialName("phone") class Phone: Format()
|
@Serializable @SerialName("phone") class Phone: Format()
|
||||||
|
|
||||||
@@ -1486,6 +1608,7 @@ sealed class Format {
|
|||||||
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
|
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
|
||||||
is Colored -> SpanStyle(color = this.color.uiColor)
|
is Colored -> SpanStyle(color = this.color.uiColor)
|
||||||
is Uri -> linkStyle
|
is Uri -> linkStyle
|
||||||
|
is SimplexLink -> linkStyle
|
||||||
is Email -> linkStyle
|
is Email -> linkStyle
|
||||||
is Phone -> linkStyle
|
is Phone -> linkStyle
|
||||||
}
|
}
|
||||||
@@ -1495,6 +1618,19 @@ sealed class Format {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SimplexLinkType(val linkType: String) {
|
||||||
|
contact("contact"),
|
||||||
|
invitation("invitation"),
|
||||||
|
group("group");
|
||||||
|
|
||||||
|
val description: String get() = generalGetString(when (this) {
|
||||||
|
contact -> R.string.simplex_link_contact
|
||||||
|
invitation -> R.string.simplex_link_invitation
|
||||||
|
group -> R.string.simplex_link_group
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class FormatColor(val color: String) {
|
enum class FormatColor(val color: String) {
|
||||||
red("red"),
|
red("red"),
|
||||||
@@ -1510,7 +1646,7 @@ enum class FormatColor(val color: String) {
|
|||||||
red -> Color.Red
|
red -> Color.Red
|
||||||
green -> SimplexGreen
|
green -> SimplexGreen
|
||||||
blue -> SimplexBlue
|
blue -> SimplexBlue
|
||||||
yellow -> Color.Yellow
|
yellow -> WarningYellow
|
||||||
cyan -> Color.Cyan
|
cyan -> Color.Cyan
|
||||||
magenta -> Color.Magenta
|
magenta -> Color.Magenta
|
||||||
black -> MaterialTheme.colors.onBackground
|
black -> MaterialTheme.colors.onBackground
|
||||||
@@ -1545,13 +1681,13 @@ enum class CICallStatus {
|
|||||||
Accepted -> generalGetString(R.string.callstatus_accepted)
|
Accepted -> generalGetString(R.string.callstatus_accepted)
|
||||||
Negotiated -> generalGetString(R.string.callstatus_connecting)
|
Negotiated -> generalGetString(R.string.callstatus_connecting)
|
||||||
Progress -> generalGetString(R.string.callstatus_in_progress)
|
Progress -> generalGetString(R.string.callstatus_in_progress)
|
||||||
Ended -> String.format(generalGetString(R.string.callstatus_ended), duration(sec))
|
Ended -> String.format(generalGetString(R.string.callstatus_ended), durationText(sec))
|
||||||
Error -> generalGetString(R.string.callstatus_error)
|
Error -> generalGetString(R.string.callstatus_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun duration(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class MsgErrorType() {
|
sealed class MsgErrorType() {
|
||||||
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
|
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package chat.simplex.app.model
|
|||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Display
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
@@ -23,9 +25,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||||
|
|
||||||
// DO NOT change notification channel settings / names
|
// DO NOT change notification channel settings / names
|
||||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
|
||||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
|
||||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||||
|
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||||
const val CallNotificationId: Int = -1
|
const val CallNotificationId: Int = -1
|
||||||
|
|
||||||
private const val ChatIdKey: String = "chatId"
|
private const val ChatIdKey: String = "chatId"
|
||||||
@@ -37,24 +39,29 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
|
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||||
manager.createNotificationChannel(callNotificationChannel())
|
// Remove old channels since they can't be edited
|
||||||
|
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||||
|
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class NotificationAction {
|
enum class NotificationAction {
|
||||||
ACCEPT_CONTACT_REQUEST
|
ACCEPT_CONTACT_REQUEST
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun callNotificationChannel(): NotificationChannel {
|
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||||
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
|
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||||
val attrs = AudioAttributes.Builder()
|
val attrs = AudioAttributes.Builder()
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
.build()
|
.build()
|
||||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||||
callChannel.setSound(soundUri, attrs)
|
callChannel.setSound(soundUri, attrs)
|
||||||
callChannel.enableVibration(true)
|
callChannel.enableVibration(true)
|
||||||
|
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
||||||
|
// (wait, vibration duration, wait till off, wait till on again = ringtone mp3 duration - vibration duration - ~50ms lost somewhere)
|
||||||
|
callChannel.vibrationPattern = longArrayOf(250, 250, 0, 2600)
|
||||||
return callChannel
|
return callChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,24 +158,34 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||||
if (isAppOnForeground(context)) return
|
val keyguardManager = getKeyguardManager(context)
|
||||||
|
Log.d(TAG,
|
||||||
|
"notifyCallInvitation pre-requests: " +
|
||||||
|
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||||
|
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||||
|
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||||
|
)
|
||||||
|
if (SimplexApp.context.isAppOnForeground) return
|
||||||
val contactId = invitation.contact.id
|
val contactId = invitation.contact.id
|
||||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||||
val keyguardManager = getKeyguardManager(context)
|
|
||||||
val image = invitation.contact.image
|
val image = invitation.contact.image
|
||||||
|
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||||
|
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
|
||||||
var ntfBuilder =
|
var ntfBuilder =
|
||||||
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
NotificationCompat.Builder(context, LockScreenCallChannel)
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setSilent(true)
|
|
||||||
} else {
|
} else {
|
||||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
NotificationCompat.Builder(context, CallChannel)
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
||||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
||||||
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, contactId, true))
|
||||||
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
.setSound(soundUri)
|
.setSound(soundUri)
|
||||||
}
|
}
|
||||||
val text = generalGetString(
|
val text = generalGetString(
|
||||||
@@ -197,8 +214,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
.setLargeIcon(largeIcon)
|
.setLargeIcon(largeIcon)
|
||||||
.setColor(0x88FFFF)
|
.setColor(0x88FFFF)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
val notification = ntfBuilder.build()
|
||||||
|
// This makes notification sound and vibration repeat endlessly
|
||||||
|
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
notify(CallNotificationId, ntfBuilder.build())
|
notify(CallNotificationId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,33 +226,35 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
manager.cancel(CallNotificationId)
|
manager.cancel(CallNotificationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||||
|
|
||||||
private fun hideSecrets(cItem: ChatItem) : String {
|
private fun hideSecrets(cItem: ChatItem) : String {
|
||||||
val md = cItem.formattedText
|
val md = cItem.formattedText
|
||||||
return if (md == null) {
|
return if (md != null) {
|
||||||
if (cItem.content.text != "") {
|
|
||||||
cItem.content.text
|
|
||||||
} else {
|
|
||||||
cItem.file?.fileName ?: ""
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var res = ""
|
var res = ""
|
||||||
for (ft in md) {
|
for (ft in md) {
|
||||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||||
}
|
}
|
||||||
res
|
res
|
||||||
|
} else {
|
||||||
|
cItem.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
private fun chatPendingIntent(intentAction: String, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||||
var intent = Intent(context, MainActivity::class.java)
|
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
.setAction(intentAction)
|
.setAction(intentAction)
|
||||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||||
return TaskStackBuilder.create(context).run {
|
return if (!broadcast) {
|
||||||
addNextIntentWithParentStack(intent)
|
TaskStackBuilder.create(context).run {
|
||||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
addNextIntentWithParentStack(intent)
|
||||||
|
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PendingIntent.getBroadcast(SimplexApp.context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +272,12 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
||||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||||
}
|
}
|
||||||
|
RejectCallAction -> {
|
||||||
|
val invitation = SimplexApp.context.chatModel.callInvitations[chatId]
|
||||||
|
if (invitation != null) {
|
||||||
|
SimplexApp.context.chatModel.callManager.endCall(invitation = invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -135,7 +135,7 @@ fun TerminalLayout(
|
|||||||
topBar = { CloseSheetBar(close) },
|
topBar = { CloseSheetBar(close) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.navigationBarsWithImePadding()
|
modifier = Modifier.navigationBarsWithImePadding()
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage
|
|||||||
import chat.simplex.app.views.helpers.withApi
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
// Stop it when call ended
|
// Stop it when call ended
|
||||||
if (!ntfModeService) SimplexService.stop(SimplexApp.context)
|
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||||
// Clear selected communication device to default value after we changed it in call
|
// Clear selected communication device to default value after we changed it in call
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
permissions = listOf(
|
permissions = listOf(
|
||||||
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
|||||||
Log.d(TAG, "WebRTCView: webview ready")
|
Log.d(TAG, "WebRTCView: webview ready")
|
||||||
// for debugging
|
// for debugging
|
||||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||||
withApi {
|
scope.launch {
|
||||||
delay(2000L)
|
delay(2000L)
|
||||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||||
webView.value = wv
|
webView.value = wv
|
||||||
|
|||||||
+22
-16
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.KeyguardManager
|
import android.app.KeyguardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -43,8 +44,7 @@ class IncomingCallActivity: ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val activity = this
|
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||||
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
|
||||||
unlockForIncomingCall()
|
unlockForIncomingCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +83,12 @@ fun getKeyguardManager(context: Context): KeyguardManager =
|
|||||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
fun IncomingCallActivityView(m: ChatModel) {
|
||||||
val switchingCall = m.switchingCall.value
|
val switchingCall = m.switchingCall.value
|
||||||
val invitation = m.activeCallInvitation.value
|
val invitation = m.activeCallInvitation.value
|
||||||
val call = m.activeCall.value
|
val call = m.activeCall.value
|
||||||
val showCallView = m.showCallView.value
|
val showCallView = m.showCallView.value
|
||||||
|
val activity = LocalContext.current as Activity
|
||||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||||
@@ -105,36 +106,41 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
|||||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||||
}
|
}
|
||||||
} else if (invitation != null) {
|
} else if (invitation != null) {
|
||||||
IncomingCallLockScreenAlert(invitation, m, activity)
|
IncomingCallLockScreenAlert(invitation, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
val cm = chatModel.callManager
|
val cm = chatModel.callManager
|
||||||
val cxt = LocalContext.current
|
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||||
val scope = rememberCoroutineScope()
|
val context = LocalContext.current
|
||||||
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
DisposableEffect(Unit) {
|
||||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
onDispose {
|
||||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
IncomingCallLockScreenAlertLayout(
|
IncomingCallLockScreenAlertLayout(
|
||||||
invitation,
|
invitation,
|
||||||
callOnLockScreen,
|
callOnLockScreen,
|
||||||
rejectCall = { cm.endCall(invitation = invitation) },
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
ignoreCall = {
|
||||||
|
chatModel.activeCallInvitation.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
},
|
||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||||
openApp = {
|
openApp = {
|
||||||
SoundPlayer.shared.stop()
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
var intent = Intent(activity, MainActivity::class.java)
|
|
||||||
.setAction(OpenChatAction)
|
.setAction(OpenChatAction)
|
||||||
.putExtra("chatId", invitation.contact.id)
|
.putExtra("chatId", invitation.contact.id)
|
||||||
activity.startActivity(intent)
|
context.startActivity(intent)
|
||||||
activity.finish()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
|
||||||
}
|
}
|
||||||
|
(context as Activity).finish()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
|||||||
IncomingCallAlertLayout(
|
IncomingCallAlertLayout(
|
||||||
invitation,
|
invitation,
|
||||||
rejectCall = { cm.endCall(invitation = invitation) },
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
ignoreCall = {
|
||||||
|
chatModel.activeCallInvitation.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
},
|
||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import chat.simplex.app.SimplexApp
|
|||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.usersettings.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
|
||||||
@@ -62,6 +62,14 @@ fun ChatInfoView(
|
|||||||
onLocalAliasChanged = {
|
onLocalAliasChanged = {
|
||||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||||
},
|
},
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
val user = chatModel.currentUser.value
|
||||||
|
if (user != null) {
|
||||||
|
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
switchContactAddress = {
|
switchContactAddress = {
|
||||||
@@ -117,6 +125,7 @@ fun ChatInfoLayout(
|
|||||||
localAlias: String,
|
localAlias: String,
|
||||||
developerTools: Boolean,
|
developerTools: Boolean,
|
||||||
onLocalAliasChanged: (String) -> Unit,
|
onLocalAliasChanged: (String) -> Unit,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
deleteContact: () -> Unit,
|
deleteContact: () -> Unit,
|
||||||
clearChat: () -> Unit,
|
clearChat: () -> Unit,
|
||||||
switchContactAddress: () -> Unit,
|
switchContactAddress: () -> Unit,
|
||||||
@@ -143,13 +152,16 @@ fun ChatInfoLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionSpacer()
|
||||||
|
SectionView {
|
||||||
|
ContactPreferencesButton(openPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
if (developerTools) {
|
SwitchAddressButton(switchContactAddress)
|
||||||
SwitchAddressButton(switchContactAddress)
|
SectionDivider()
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
if (connStats != null) {
|
if (connStats != null) {
|
||||||
SectionItemView({
|
SectionItemView({
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -327,6 +339,15 @@ fun SwitchAddressButton(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.ToggleOn,
|
||||||
|
stringResource(R.string.contact_preferences),
|
||||||
|
click = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ClearChatButton(onClick: () -> Unit) {
|
fun ClearChatButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
@@ -386,6 +407,7 @@ fun PreviewChatInfoLayout() {
|
|||||||
connStats = null,
|
connStats = null,
|
||||||
onLocalAliasChanged = {},
|
onLocalAliasChanged = {},
|
||||||
customUserProfile = null,
|
customUserProfile = null,
|
||||||
|
openPreferences = {},
|
||||||
deleteContact = {},
|
deleteContact = {},
|
||||||
clearChat = {},
|
clearChat = {},
|
||||||
switchContactAddress = {},
|
switchContactAddress = {},
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.gestures.*
|
import androidx.compose.foundation.gestures.*
|
||||||
@@ -52,8 +50,8 @@ import java.io.File
|
|||||||
import kotlin.math.sign
|
import kotlin.math.sign
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatView(chatModel: ChatModel) {
|
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||||
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
|
||||||
val searchText = rememberSaveable { mutableStateOf("") }
|
val searchText = rememberSaveable { mutableStateOf("") }
|
||||||
val user = chatModel.currentUser.value
|
val user = chatModel.currentUser.value
|
||||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
@@ -63,7 +61,6 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
||||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
// 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
|
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
||||||
@@ -97,17 +94,15 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
} else {
|
} else {
|
||||||
val chat = activeChat.value!!
|
val chat = activeChat.value!!
|
||||||
BackHandler { chatModel.chatId.value = null }
|
|
||||||
// We need to have real unreadCount value for displaying it inside top right button
|
// 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)
|
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||||
val unreadCount = remember {
|
val unreadCount = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user,
|
|
||||||
chat,
|
chat,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
composeState,
|
composeState,
|
||||||
@@ -120,27 +115,30 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
attachmentOption,
|
attachmentOption,
|
||||||
scope,
|
|
||||||
attachmentBottomSheetState,
|
attachmentBottomSheetState,
|
||||||
chatModel.chatItems,
|
chatModel.chatItems,
|
||||||
searchText,
|
searchText,
|
||||||
useLinkPreviews = useLinkPreviews,
|
useLinkPreviews = useLinkPreviews,
|
||||||
|
linkMode = chatModel.simplexLinkMode.value,
|
||||||
chatModelIncognito = chatModel.incognito.value,
|
chatModelIncognito = chatModel.incognito.value,
|
||||||
back = {
|
back = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
|
AudioPlayer.stop()
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
},
|
},
|
||||||
info = {
|
info = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
withApi {
|
withApi {
|
||||||
val cInfo = chat.chatInfo
|
if (chat.chatInfo is ChatInfo.Direct) {
|
||||||
if (cInfo is ChatInfo.Direct) {
|
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
|
||||||
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
|
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
|
val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||||
|
contact.value?.let { ct ->
|
||||||
|
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (cInfo is ChatInfo.Group) {
|
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
GroupChatInfoView(chatModel, close)
|
GroupChatInfoView(chatModel, close)
|
||||||
}
|
}
|
||||||
@@ -168,13 +166,20 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
deleteMessage = { itemId, mode ->
|
deleteMessage = { itemId, mode ->
|
||||||
withApi {
|
withApi {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
val toItem = chatModel.controller.apiDeleteChatItem(
|
val r = chatModel.controller.apiDeleteChatItem(
|
||||||
type = cInfo.chatType,
|
type = cInfo.chatType,
|
||||||
id = cInfo.apiId,
|
id = cInfo.apiId,
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
mode = mode
|
mode = mode
|
||||||
)
|
)
|
||||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
if (r != null) {
|
||||||
|
val toChatItem = r.toChatItem
|
||||||
|
if (toChatItem == null) {
|
||||||
|
chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem)
|
||||||
|
} else {
|
||||||
|
chatModel.upsertChatItem(cInfo, toChatItem.chatItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveFile = { fileId ->
|
receiveFile = { fileId ->
|
||||||
@@ -205,14 +210,14 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
withApi {
|
withApi {
|
||||||
setGroupMembers(groupInfo, chatModel)
|
setGroupMembers(groupInfo, chatModel)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
AddGroupMembersView(groupInfo, chatModel, close)
|
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
markRead = { range, unreadCountAfter ->
|
markRead = { range, unreadCountAfter ->
|
||||||
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
withApi {
|
withBGApi {
|
||||||
chatModel.controller.apiChatRead(
|
chatModel.controller.apiChatRead(
|
||||||
chat.chatInfo.chatType,
|
chat.chatInfo.chatType,
|
||||||
chat.chatInfo.apiId,
|
chat.chatInfo.apiId,
|
||||||
@@ -228,24 +233,24 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
apiFindMessages(c.chatInfo, chatModel, value)
|
apiFindMessages(c.chatInfo, chatModel, value)
|
||||||
searchText.value = value
|
searchText.value = value
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatLayout(
|
fun ChatLayout(
|
||||||
user: User,
|
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
unreadCount: State<Int>,
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
composeView: (@Composable () -> Unit),
|
composeView: (@Composable () -> Unit),
|
||||||
attachmentOption: MutableState<AttachmentOption?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
scope: CoroutineScope,
|
|
||||||
attachmentBottomSheetState: ModalBottomSheetState,
|
attachmentBottomSheetState: ModalBottomSheetState,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
searchValue: State<String>,
|
searchValue: State<String>,
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
chatModelIncognito: Boolean,
|
chatModelIncognito: Boolean,
|
||||||
back: () -> Unit,
|
back: () -> Unit,
|
||||||
info: () -> Unit,
|
info: () -> Unit,
|
||||||
@@ -260,8 +265,10 @@ fun ChatLayout(
|
|||||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||||
onSearchValueChanged: (String) -> Unit,
|
onSearchValueChanged: (String) -> Unit,
|
||||||
|
onComposed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
val scope = rememberCoroutineScope()
|
||||||
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.background)
|
.background(MaterialTheme.colors.background)
|
||||||
@@ -292,9 +299,9 @@ fun ChatLayout(
|
|||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||||
ChatItemsList(
|
ChatItemsList(
|
||||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
chat, unreadCount, composeState, chatItems, searchValue,
|
||||||
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
|
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,13 +452,13 @@ val CIListStateSaver = run {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxWithConstraintsScope.ChatItemsList(
|
fun BoxWithConstraintsScope.ChatItemsList(
|
||||||
user: User,
|
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
unreadCount: State<Int>,
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
searchValue: State<String>,
|
searchValue: State<String>,
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
chatModelIncognito: Boolean,
|
chatModelIncognito: Boolean,
|
||||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||||
loadPrevMessages: (ChatInfo) -> Unit,
|
loadPrevMessages: (ChatInfo) -> Unit,
|
||||||
@@ -461,12 +468,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||||
|
onComposed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val uriHandler = LocalUriHandler.current
|
ScrollToBottom(chat.id, listState, chatItems)
|
||||||
val cxt = LocalContext.current
|
|
||||||
ScrollToBottom(chat.id, listState)
|
|
||||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||||
// Scroll to bottom when search value changes from something to nothing and back
|
// Scroll to bottom when search value changes from something to nothing and back
|
||||||
LaunchedEffect(searchValue.value.isEmpty()) {
|
LaunchedEffect(searchValue.value.isEmpty()) {
|
||||||
@@ -493,8 +499,18 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
var stopListening = false
|
||||||
|
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { !stopListening }
|
||||||
|
.collect {
|
||||||
|
onComposed()
|
||||||
|
stopListening = true
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||||
@@ -555,11 +571,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(42.dp))
|
Spacer(Modifier.size(42.dp))
|
||||||
}
|
}
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // direct message
|
} else { // direct message
|
||||||
@@ -570,7 +586,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
end = if (sent) 12.dp else 76.dp,
|
end = if (sent) 12.dp else 76.dp,
|
||||||
).then(swipeableModifier)
|
).then(swipeableModifier)
|
||||||
) {
|
) {
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +605,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
|
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||||
// and prevents scrolling to bottom on orientation change
|
// and prevents scrolling to bottom on orientation change
|
||||||
@@ -601,6 +617,19 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
|
|||||||
// Don't autoscroll next time until it will be needed
|
// Don't autoscroll next time until it will be needed
|
||||||
shouldAutoScroll = false to chatId
|
shouldAutoScroll = false to chatId
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
|
||||||
|
* When the first visible item (from bottom) is fully visible we can autoscroll to 0 item
|
||||||
|
* */
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { chatItems.lastOrNull()?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 }
|
||||||
|
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||||
|
.collect {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -717,7 +746,7 @@ fun PreloadItems(
|
|||||||
.map {
|
.map {
|
||||||
val totalItemsNumber = it.totalItemsCount
|
val totalItemsNumber = it.totalItemsCount
|
||||||
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
|
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
|
||||||
totalItemsNumber
|
totalItemsNumber
|
||||||
else
|
else
|
||||||
0
|
0
|
||||||
@@ -931,7 +960,6 @@ fun PreviewChatLayout() {
|
|||||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
val searchValue = remember { mutableStateOf("") }
|
val searchValue = remember { mutableStateOf("") }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Direct.sampleData,
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
@@ -941,11 +969,11 @@ fun PreviewChatLayout() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
searchValue,
|
searchValue,
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
chatModelIncognito = false,
|
chatModelIncognito = false,
|
||||||
back = {},
|
back = {},
|
||||||
info = {},
|
info = {},
|
||||||
@@ -960,6 +988,7 @@ fun PreviewChatLayout() {
|
|||||||
markRead = { _, _ -> },
|
markRead = { _, _ -> },
|
||||||
changeNtfsState = { _, _ -> },
|
changeNtfsState = { _, _ -> },
|
||||||
onSearchValueChanged = {},
|
onSearchValueChanged = {},
|
||||||
|
onComposed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -989,7 +1018,6 @@ fun PreviewGroupChatLayout() {
|
|||||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
val searchValue = remember { mutableStateOf("") }
|
val searchValue = remember { mutableStateOf("") }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Group.sampleData,
|
chatInfo = ChatInfo.Group.sampleData,
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
@@ -999,11 +1027,11 @@ fun PreviewGroupChatLayout() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
searchValue,
|
searchValue,
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
chatModelIncognito = false,
|
chatModelIncognito = false,
|
||||||
back = {},
|
back = {},
|
||||||
info = {},
|
info = {},
|
||||||
@@ -1018,6 +1046,7 @@ fun PreviewGroupChatLayout() {
|
|||||||
markRead = { _, _ -> },
|
markRead = { _, _ -> },
|
||||||
changeNtfsState = { _, _ -> },
|
changeNtfsState = { _, _ -> },
|
||||||
onSearchValueChanged = {},
|
onSearchValueChanged = {},
|
||||||
|
onComposed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import ComposeVoiceView
|
||||||
import ComposeFileView
|
import ComposeFileView
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.*
|
import android.content.*
|
||||||
@@ -14,11 +15,9 @@ import android.util.Log
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
@@ -29,26 +28,30 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class ComposePreview {
|
sealed class ComposePreview {
|
||||||
@Serializable object NoPreview: ComposePreview()
|
@Serializable object NoPreview: ComposePreview()
|
||||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||||
|
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ data class ComposeState(
|
|||||||
get() = {
|
get() = {
|
||||||
val hasContent = when (preview) {
|
val hasContent = when (preview) {
|
||||||
is ComposePreview.ImagePreview -> true
|
is ComposePreview.ImagePreview -> true
|
||||||
|
is ComposePreview.VoicePreview -> true
|
||||||
is ComposePreview.FilePreview -> true
|
is ComposePreview.FilePreview -> true
|
||||||
else -> message.isNotEmpty()
|
else -> message.isNotEmpty()
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,7 @@ data class ComposeState(
|
|||||||
get() =
|
get() =
|
||||||
when (preview) {
|
when (preview) {
|
||||||
is ComposePreview.ImagePreview -> false
|
is ComposePreview.ImagePreview -> false
|
||||||
|
is ComposePreview.VoicePreview -> false
|
||||||
is ComposePreview.FilePreview -> false
|
is ComposePreview.FilePreview -> false
|
||||||
else -> useLinkPreviews
|
else -> useLinkPreviews
|
||||||
}
|
}
|
||||||
@@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
|||||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
||||||
|
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCFile -> {
|
||||||
val fileName = chatItem.file?.fileName ?: ""
|
val fileName = chatItem.file?.fileName ?: ""
|
||||||
ComposePreview.FilePreview(fileName)
|
ComposePreview.FilePreview(fileName)
|
||||||
}
|
}
|
||||||
else -> ComposePreview.NoPreview
|
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +150,11 @@ fun ComposeView(
|
|||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
// attachments
|
// attachments
|
||||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||||
|
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>>(
|
||||||
|
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
|
||||||
|
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
|
||||||
|
)
|
||||||
|
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
|
||||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -156,7 +167,7 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
@@ -228,7 +239,7 @@ fun ComposeView(
|
|||||||
AttachmentOption.TakePhoto -> {
|
AttachmentOption.TakePhoto -> {
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
@@ -321,6 +332,7 @@ fun ComposeView(
|
|||||||
is MsgContent.MCText -> checkLinkPreview()
|
is MsgContent.MCText -> checkLinkPreview()
|
||||||
is MsgContent.MCLink -> checkLinkPreview()
|
is MsgContent.MCLink -> checkLinkPreview()
|
||||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
||||||
|
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
|
||||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
||||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
||||||
}
|
}
|
||||||
@@ -330,6 +342,7 @@ fun ComposeView(
|
|||||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
textStyle.value = smallFont
|
textStyle.value = smallFont
|
||||||
chosenContent.value = emptyList()
|
chosenContent.value = emptyList()
|
||||||
|
chosenAudio.value = null
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
linkUrl.value = null
|
linkUrl.value = null
|
||||||
prevLinkUrl.value = null
|
prevLinkUrl.value = null
|
||||||
@@ -376,6 +389,15 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is ComposePreview.VoicePreview -> {
|
||||||
|
val chosenAudioVal = chosenAudio.value
|
||||||
|
if (chosenAudioVal != null) {
|
||||||
|
val file = chosenAudioVal.first.toFile().name
|
||||||
|
files.add((file))
|
||||||
|
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
|
||||||
|
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
is ComposePreview.FilePreview -> {
|
is ComposePreview.FilePreview -> {
|
||||||
val chosenFileVal = chosenFile.value
|
val chosenFileVal = chosenFile.value
|
||||||
if (chosenFileVal != null) {
|
if (chosenFileVal != null) {
|
||||||
@@ -426,6 +448,36 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||||
|
val file = File(filePath)
|
||||||
|
chosenAudio.value = file.toUri() to durationMs
|
||||||
|
chatModel.filesToDelete.add(file)
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun allowVoiceToContact() {
|
||||||
|
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
||||||
|
val prefs = contact.mergedPreferences.toPreferences().copy(voice = ChatPreference(allow = FeatureAllowed.YES))
|
||||||
|
withApi {
|
||||||
|
val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs)
|
||||||
|
if (toContact != null) {
|
||||||
|
chatModel.updateContact(toContact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDisabledVoiceAlert() {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.voice_messages_prohibited),
|
||||||
|
text = generalGetString(
|
||||||
|
if (chat.chatInfo is ChatInfo.Direct)
|
||||||
|
R.string.ask_your_contact_to_enable_voice
|
||||||
|
else
|
||||||
|
R.string.only_group_owners_can_enable_voice
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelLinkPreview() {
|
fun cancelLinkPreview() {
|
||||||
val uri = composeState.value.linkPreview?.uri
|
val uri = composeState.value.linkPreview?.uri
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -440,6 +492,11 @@ fun ComposeView(
|
|||||||
chosenContent.value = emptyList()
|
chosenContent.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelVoice() {
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
|
chosenAudio.value = null
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelFile() {
|
fun cancelFile() {
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
@@ -455,6 +512,13 @@ fun ComposeView(
|
|||||||
::cancelImages,
|
::cancelImages,
|
||||||
cancelEnabled = !composeState.value.editing
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
|
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||||
|
preview.voice,
|
||||||
|
preview.durationMs,
|
||||||
|
preview.finished,
|
||||||
|
cancelEnabled = !composeState.value.editing,
|
||||||
|
::cancelVoice
|
||||||
|
)
|
||||||
is ComposePreview.FilePreview -> ComposeFileView(
|
is ComposePreview.FilePreview -> ComposeFileView(
|
||||||
preview.fileName,
|
preview.fileName,
|
||||||
::cancelFile,
|
::cancelFile,
|
||||||
@@ -477,6 +541,9 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(chatModel.sharedContent.value) {
|
LaunchedEffect(chatModel.sharedContent.value) {
|
||||||
|
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
|
||||||
|
if (chatModel.chatId.value == null) return@LaunchedEffect
|
||||||
|
|
||||||
when (val shared = chatModel.sharedContent.value) {
|
when (val shared = chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> onMessageChange(shared.text)
|
is SharedContent.Text -> onMessageChange(shared.text)
|
||||||
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
||||||
@@ -489,37 +556,54 @@ fun ComposeView(
|
|||||||
Column {
|
Column {
|
||||||
contextItemView()
|
contextItemView()
|
||||||
when {
|
when {
|
||||||
|
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||||
else -> previewView()
|
else -> previewView()
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
) {
|
||||||
val attachEnabled = !composeState.value.editing
|
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
Box(Modifier.padding(bottom = 12.dp)) {
|
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.AttachFile,
|
Icons.Filled.AttachFile,
|
||||||
contentDescription = stringResource(R.string.attach),
|
contentDescription = stringResource(R.string.attach),
|
||||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable {
|
|
||||||
if (attachEnabled) {
|
|
||||||
showChooseAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.voiceMessageAllowed }
|
||||||
|
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||||
|
when (chat.chatInfo) {
|
||||||
|
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||||
|
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||||
|
contactPreference.allow == FeatureAllowed.YES
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(allowedVoiceByPrefs) {
|
||||||
|
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
|
||||||
|
// Voice was disabled right when this user records it, just cancel it
|
||||||
|
cancelVoice()
|
||||||
|
}
|
||||||
|
}
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState,
|
composeState,
|
||||||
|
showVoiceRecordIcon = true,
|
||||||
|
allowedVoiceByPrefs = allowedVoiceByPrefs,
|
||||||
|
needToAllowVoiceToContact = needToAllowVoiceToContact,
|
||||||
sendMessage = {
|
sendMessage = {
|
||||||
sendMessage()
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
::onMessageChange,
|
::onMessageChange,
|
||||||
|
::onAudioAdded,
|
||||||
|
::allowVoiceToContact,
|
||||||
|
::showDisabledVoiceAlert,
|
||||||
textStyle
|
textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.durationText
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.item.SentColorLight
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComposeVoiceView(
|
||||||
|
filePath: String,
|
||||||
|
recordedDurationMs: Int,
|
||||||
|
finishedRecording: Boolean,
|
||||||
|
cancelEnabled: Boolean,
|
||||||
|
cancelVoice: () -> Unit
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val progress = rememberSaveable { mutableStateOf(0) }
|
||||||
|
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||||
|
val progressBarWidth = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(recordedDurationMs, finishedRecording) {
|
||||||
|
snapshotFlow { progress.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
val startTime = when {
|
||||||
|
finishedRecording -> progress.value
|
||||||
|
else -> recordedDurationMs
|
||||||
|
}
|
||||||
|
val endTime = when {
|
||||||
|
finishedRecording -> duration.value
|
||||||
|
audioPlaying.value -> recordedDurationMs
|
||||||
|
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
|
||||||
|
}
|
||||||
|
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||||
|
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.requiredWidth(progressBarWidth.value.dp)
|
||||||
|
.padding(top = 58.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(MaterialTheme.colors.primary)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.background(SentColorLight),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!audioPlaying.value) {
|
||||||
|
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
|
||||||
|
} else {
|
||||||
|
AudioPlayer.pause(audioPlaying, progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = finishedRecording) {
|
||||||
|
Icon(
|
||||||
|
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
stringResource(R.string.icon_descr_file),
|
||||||
|
Modifier
|
||||||
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
|
.size(36.dp),
|
||||||
|
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||||
|
derivedStateOf {
|
||||||
|
when {
|
||||||
|
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||||
|
finishedRecording -> progress.value / 1000
|
||||||
|
else -> recordedDurationMs / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
durationText(numberInText.value),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = HighOrLowlight,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (cancelEnabled) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
AudioPlayer.stop(filePath)
|
||||||
|
cancelVoice()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(0.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Close,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier.padding(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewComposeAudioView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ComposeFileView(
|
||||||
|
"test.txt",
|
||||||
|
cancelFile = {},
|
||||||
|
cancelEnabled = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import SectionDivider
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactPreferencesView(
|
||||||
|
m: ChatModel,
|
||||||
|
user: User,
|
||||||
|
contactId: Long,
|
||||||
|
close: () -> Unit,
|
||||||
|
) {
|
||||||
|
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||||
|
val ct = contact.value ?: return
|
||||||
|
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||||
|
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
|
||||||
|
|
||||||
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||||
|
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||||
|
if (toContact != null) {
|
||||||
|
m.updateContact(toContact)
|
||||||
|
currentFeaturesAllowed = featuresAllowed
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (featuresAllowed == currentFeaturesAllowed) close()
|
||||||
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
|
},
|
||||||
|
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||||
|
) {
|
||||||
|
ContactPreferencesLayout(
|
||||||
|
featuresAllowed,
|
||||||
|
currentFeaturesAllowed,
|
||||||
|
user,
|
||||||
|
ct,
|
||||||
|
applyPrefs = { prefs ->
|
||||||
|
featuresAllowed = prefs
|
||||||
|
},
|
||||||
|
reset = {
|
||||||
|
featuresAllowed = currentFeaturesAllowed
|
||||||
|
},
|
||||||
|
savePrefs = ::savePrefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactPreferencesLayout(
|
||||||
|
featuresAllowed: ContactFeaturesAllowed,
|
||||||
|
currentFeaturesAllowed: ContactFeaturesAllowed,
|
||||||
|
user: User,
|
||||||
|
contact: Contact,
|
||||||
|
applyPrefs: (ContactFeaturesAllowed) -> Unit,
|
||||||
|
reset: () -> Unit,
|
||||||
|
savePrefs: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(bottom = DEFAULT_PADDING),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||||
|
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||||
|
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
||||||
|
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
||||||
|
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
||||||
|
applyPrefs(featuresAllowed.copy(voice = it))
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
ResetSaveButtons(
|
||||||
|
reset = reset,
|
||||||
|
save = savePrefs,
|
||||||
|
disabled = featuresAllowed == currentFeaturesAllowed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeatureSection(
|
||||||
|
feature: ChatFeature,
|
||||||
|
userDefault: FeatureAllowed,
|
||||||
|
pref: ContactUserPreference,
|
||||||
|
allowFeature: State<ContactFeatureAllowed>,
|
||||||
|
onSelected: (ContactFeatureAllowed) -> Unit
|
||||||
|
) {
|
||||||
|
val enabled = FeatureEnabled.enabled(
|
||||||
|
user = ChatPreference(allow = allowFeature.value.allowed),
|
||||||
|
contact = pref.contactPreference
|
||||||
|
)
|
||||||
|
|
||||||
|
SectionView(
|
||||||
|
feature.text.uppercase(),
|
||||||
|
icon = feature.iconFilled,
|
||||||
|
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||||
|
leadingIcon = true,
|
||||||
|
) {
|
||||||
|
SectionItemView {
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
|
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||||
|
allowFeature,
|
||||||
|
icon = null,
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
InfoRow(
|
||||||
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
|
pref.contactPreference.allow.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionTextFooter(feature.enabledDescription(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(reset, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,8 +14,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.CIDirection
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.ChatItem
|
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
@@ -53,6 +52,7 @@ fun ContextItemView(
|
|||||||
MarkdownText(
|
MarkdownText(
|
||||||
contextItem.text, contextItem.formattedText,
|
contextItem.text, contextItem.formattedText,
|
||||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -12,145 +15,300 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.SharedContent
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendMsgView(
|
fun SendMsgView(
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
|
showVoiceRecordIcon: Boolean,
|
||||||
|
allowedVoiceByPrefs: Boolean,
|
||||||
|
needToAllowVoiceToContact: Boolean,
|
||||||
sendMessage: () -> Unit,
|
sendMessage: () -> Unit,
|
||||||
onMessageChange: (String) -> Unit,
|
onMessageChange: (String) -> Unit,
|
||||||
|
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||||
|
allowVoiceToContact: () -> Unit,
|
||||||
|
showDisabledVoiceAlert: () -> Unit,
|
||||||
textStyle: MutableState<TextStyle>
|
textStyle: MutableState<TextStyle>
|
||||||
) {
|
) {
|
||||||
val cs = composeState.value
|
Column(Modifier.padding(vertical = 8.dp)) {
|
||||||
var showKeyboard by remember { mutableStateOf(false) }
|
Box {
|
||||||
LaunchedEffect(cs.contextItem) {
|
val cs = composeState.value
|
||||||
when (cs.contextItem) {
|
val attachEnabled = !composeState.value.editing
|
||||||
is ComposeContextItem.QuotedItem -> {
|
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||||
delay(100)
|
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||||
showKeyboard = true
|
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
|
||||||
|
Box(if (recordingTimeRange.first == 0L)
|
||||||
|
Modifier
|
||||||
|
else
|
||||||
|
Modifier.clickable(false, onClick = {})
|
||||||
|
) {
|
||||||
|
NativeKeyboard(composeState, textStyle, onMessageChange)
|
||||||
}
|
}
|
||||||
is ComposeContextItem.EditingItem -> {
|
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||||
// Keyboard will not show up if we try to show it too fast
|
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||||
delay(300)
|
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||||
showKeyboard = true
|
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
|
||||||
|
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
||||||
|
} else if (!showVoiceButton) {
|
||||||
|
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(R.string.icon_descr_send_message),
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
|
permissions = listOf(
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||||
|
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
|
||||||
|
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (isActive) {
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stopRecordingAndAddAudio: () -> Unit = {
|
||||||
|
rec.stop()
|
||||||
|
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||||
|
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||||
|
}
|
||||||
|
val startStopRecording: () -> Unit = {
|
||||||
|
when {
|
||||||
|
needToAllowVoiceToContact -> {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.allow_voice_messages_question),
|
||||||
|
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||||
|
confirmText = generalGetString(R.string.allow_verb),
|
||||||
|
dismissText = generalGetString(R.string.cancel_verb),
|
||||||
|
onConfirm = allowVoiceToContact,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
|
||||||
|
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
|
||||||
|
recordingInProgress.value -> stopRecordingAndAddAudio()
|
||||||
|
filePath.value == null -> {
|
||||||
|
recordingTimeRange = System.currentTimeMillis()..0L
|
||||||
|
filePath.value = rec.start(stopRecordingAndAddAudio)
|
||||||
|
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stopRecOnNextClick by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(stopRecOnNextClick) {
|
||||||
|
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||||
|
if (stopRecOnNextClick) {
|
||||||
|
// Lock orientation to current orientation because screen rotation will break the recording
|
||||||
|
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
else
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
}
|
||||||
|
// Unlock orientation
|
||||||
|
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||||
|
}
|
||||||
|
val cleanUp = { remove: Boolean ->
|
||||||
|
rec.stop()
|
||||||
|
AudioPlayer.stop(filePath.value)
|
||||||
|
if (remove) filePath.value?.let { File(it).delete() }
|
||||||
|
filePath.value = null
|
||||||
|
stopRecOnNextClick = false
|
||||||
|
recordingTimeRange = 0L..0L
|
||||||
|
}
|
||||||
|
LaunchedEffect(cs.preview) {
|
||||||
|
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
|
||||||
|
// Pressed on X icon in preview
|
||||||
|
cleanUp(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val interactionSource = interactionSourceWithTapDetection(
|
||||||
|
// It's just a key for triggering dropping a state in the compose function. Without it
|
||||||
|
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
|
||||||
|
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
|
||||||
|
onPress = {
|
||||||
|
if (filePath.value == null) startStopRecording()
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
// Voice not allowed or not granted voice record permission for the app
|
||||||
|
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
|
||||||
|
if (!recordingInProgress.value && filePath.value != null) {
|
||||||
|
sendMessage()
|
||||||
|
cleanUp(false)
|
||||||
|
} else if (stopRecOnNextClick) {
|
||||||
|
stopRecordingAndAddAudio()
|
||||||
|
stopRecOnNextClick = false
|
||||||
|
} else {
|
||||||
|
// tapped and didn't hold a finger
|
||||||
|
stopRecOnNextClick = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = startStopRecording,
|
||||||
|
onRelease = startStopRecording
|
||||||
|
)
|
||||||
|
val sendButtonModifier = if (recordingTimeRange.last != 0L)
|
||||||
|
Modifier.clip(CircleShape).background(color)
|
||||||
|
else
|
||||||
|
Modifier
|
||||||
|
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
|
||||||
|
Icon(
|
||||||
|
when {
|
||||||
|
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
|
||||||
|
stopRecOnNextClick -> Icons.Filled.Stop
|
||||||
|
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
|
||||||
|
else -> Icons.Outlined.KeyboardVoice
|
||||||
|
},
|
||||||
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
|
tint = when {
|
||||||
|
recordingTimeRange.last != 0L -> Color.White
|
||||||
|
stopRecOnNextClick -> MaterialTheme.colors.primary
|
||||||
|
allowedVoiceByPrefs -> MaterialTheme.colors.primary
|
||||||
|
else -> HighOrLowlight
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
.then(sendButtonModifier)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
rec.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NativeKeyboard(
|
||||||
|
composeState: MutableState<ComposeState>,
|
||||||
|
textStyle: MutableState<TextStyle>,
|
||||||
|
onMessageChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val cs = composeState.value
|
||||||
val textColor = MaterialTheme.colors.onBackground
|
val textColor = MaterialTheme.colors.onBackground
|
||||||
val tintColor = MaterialTheme.colors.secondary
|
val tintColor = MaterialTheme.colors.secondary
|
||||||
|
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
|
|
||||||
Column(Modifier.padding(vertical = 8.dp)) {
|
var showKeyboard by remember { mutableStateOf(false) }
|
||||||
Box {
|
LaunchedEffect(cs.contextItem) {
|
||||||
AndroidView(modifier = Modifier, factory = {
|
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
delay(100)
|
||||||
override fun setOnReceiveContentListener(
|
showKeyboard = true
|
||||||
mimeTypes: Array<out String>?,
|
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||||
listener: android.view.OnReceiveContentListener?
|
// Keyboard will not show up if we try to show it too fast
|
||||||
) {
|
delay(300)
|
||||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
showKeyboard = true
|
||||||
}
|
}
|
||||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
}
|
||||||
val connection = super.onCreateInputConnection(editorInfo)
|
|
||||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
AndroidView(modifier = Modifier, factory = {
|
||||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||||
try {
|
override fun setOnReceiveContentListener(
|
||||||
inputContentInfo.requestPermission()
|
mimeTypes: Array<out String>?,
|
||||||
} catch (e: Exception) {
|
listener: android.view.OnReceiveContentListener?
|
||||||
return@OnCommitContentListener false
|
) {
|
||||||
}
|
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
||||||
editText.maxLines = 16
|
|
||||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
|
||||||
editText.setTextColor(textColor.toArgb())
|
|
||||||
editText.textSize = textStyle.value.fontSize.value
|
|
||||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
|
||||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
|
||||||
editText.background = drawable
|
|
||||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
|
||||||
editText.setText(cs.message)
|
|
||||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
|
||||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
|
||||||
editText
|
|
||||||
}) {
|
|
||||||
it.setTextColor(textColor.toArgb())
|
|
||||||
it.textSize = textStyle.value.fontSize.value
|
|
||||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
|
||||||
if (cs.message != it.text.toString()) {
|
|
||||||
it.setText(cs.message)
|
|
||||||
// Set cursor to the end of the text
|
|
||||||
it.setSelection(it.text.length)
|
|
||||||
}
|
|
||||||
if (showKeyboard) {
|
|
||||||
it.requestFocus()
|
|
||||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
||||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
showKeyboard = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
val connection = super.onCreateInputConnection(editorInfo)
|
||||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||||
if (cs.inProgress
|
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
try {
|
||||||
) {
|
inputContentInfo.requestPermission()
|
||||||
CircularProgressIndicator(
|
} catch (e: Exception) {
|
||||||
Modifier
|
return@OnCommitContentListener false
|
||||||
.size(36.dp)
|
}
|
||||||
.padding(4.dp),
|
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||||
color = HighOrLowlight,
|
true
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
stringResource(R.string.icon_descr_send_message),
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.padding(4.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(color)
|
|
||||||
.clickable {
|
|
||||||
if (cs.sendEnabled()) {
|
|
||||||
sendMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
editText.maxLines = 16
|
||||||
|
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||||
|
editText.setTextColor(textColor.toArgb())
|
||||||
|
editText.textSize = textStyle.value.fontSize.value
|
||||||
|
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||||
|
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||||
|
editText.background = drawable
|
||||||
|
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||||
|
editText.setText(cs.message)
|
||||||
|
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
||||||
|
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||||
|
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||||
|
editText
|
||||||
|
}) {
|
||||||
|
it.setTextColor(textColor.toArgb())
|
||||||
|
it.textSize = textStyle.value.fontSize.value
|
||||||
|
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||||
|
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
|
it.isFocusableInTouchMode = it.isFocusable
|
||||||
|
if (cs.message != it.text.toString()) {
|
||||||
|
it.setText(cs.message)
|
||||||
|
// Set cursor to the end of the text
|
||||||
|
it.setSelection(it.text.length)
|
||||||
|
}
|
||||||
|
if (showKeyboard) {
|
||||||
|
it.requestFocus()
|
||||||
|
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
showKeyboard = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
Text(
|
||||||
|
generalGetString(R.string.voice_message_send_text),
|
||||||
|
Modifier.padding(padding),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +325,14 @@ fun PreviewSendMsgView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
allowedVoiceByPrefs = false,
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
|
allowVoiceToContact = {},
|
||||||
|
showDisabledVoiceAlert = {},
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -188,8 +352,14 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
allowedVoiceByPrefs = false,
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
|
allowVoiceToContact = {},
|
||||||
|
showDisabledVoiceAlert = {},
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -209,8 +379,14 @@ fun PreviewSendMsgViewInProgress() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
allowedVoiceByPrefs = false,
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
|
allowVoiceToContact = {},
|
||||||
|
showDisabledVoiceAlert = {},
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-4
@@ -31,17 +31,23 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
|
||||||
val selectedContacts = remember { mutableStateListOf<Long>() }
|
val selectedContacts = remember { mutableStateListOf<Long>() }
|
||||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
||||||
var allowModifyMembers by remember { mutableStateOf(true) }
|
var allowModifyMembers by remember { mutableStateOf(true) }
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
groupInfo = groupInfo,
|
groupInfo = groupInfo,
|
||||||
|
creatingGroup = creatingGroup,
|
||||||
contactsToAdd = getContactsToAdd(chatModel),
|
contactsToAdd = getContactsToAdd(chatModel),
|
||||||
selectedContacts = selectedContacts,
|
selectedContacts = selectedContacts,
|
||||||
selectedRole = selectedRole,
|
selectedRole = selectedRole,
|
||||||
allowModifyMembers = allowModifyMembers,
|
allowModifyMembers = allowModifyMembers,
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
GroupPreferencesView(chatModel, groupInfo.id, close)
|
||||||
|
}
|
||||||
|
},
|
||||||
inviteMembers = {
|
inviteMembers = {
|
||||||
allowModifyMembers = false
|
allowModifyMembers = false
|
||||||
withApi {
|
withApi {
|
||||||
@@ -59,6 +65,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
|
|||||||
clearSelection = { selectedContacts.clear() },
|
clearSelection = { selectedContacts.clear() },
|
||||||
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
|
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
|
||||||
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
||||||
|
close = close,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +86,17 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersLayout(
|
fun AddGroupMembersLayout(
|
||||||
groupInfo: GroupInfo,
|
groupInfo: GroupInfo,
|
||||||
|
creatingGroup: Boolean,
|
||||||
contactsToAdd: List<Contact>,
|
contactsToAdd: List<Contact>,
|
||||||
selectedContacts: List<Long>,
|
selectedContacts: List<Long>,
|
||||||
selectedRole: MutableState<GroupMemberRole>,
|
selectedRole: MutableState<GroupMemberRole>,
|
||||||
allowModifyMembers: Boolean,
|
allowModifyMembers: Boolean,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
inviteMembers: () -> Unit,
|
inviteMembers: () -> Unit,
|
||||||
clearSelection: () -> Unit,
|
clearSelection: () -> Unit,
|
||||||
addContact: (Long) -> Unit,
|
addContact: (Long) -> Unit,
|
||||||
removeContact: (Long) -> Unit,
|
removeContact: (Long) -> Unit,
|
||||||
|
close: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -120,18 +130,28 @@ fun AddGroupMembersLayout(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SectionView {
|
SectionView {
|
||||||
|
if (creatingGroup) {
|
||||||
|
SectionItemView(openPreferences) {
|
||||||
|
Text(stringResource(R.string.set_group_preferences))
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
}
|
||||||
SectionItemView {
|
SectionItemView {
|
||||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||||
}
|
}
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
if (creatingGroup && selectedContacts.isEmpty()) {
|
||||||
|
SkipInvitingButton(close)
|
||||||
|
} else {
|
||||||
|
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SectionCustomFooter {
|
SectionCustomFooter {
|
||||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView {
|
SectionView(stringResource(R.string.select_contacts)) {
|
||||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
@@ -170,6 +190,17 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.Check,
|
||||||
|
stringResource(R.string.skip_inviting_button),
|
||||||
|
click = onClick,
|
||||||
|
textColor = MaterialTheme.colors.primary,
|
||||||
|
iconColor = MaterialTheme.colors.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
|
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -288,14 +319,17 @@ fun PreviewAddGroupMembersLayout() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
groupInfo = GroupInfo.sampleData,
|
groupInfo = GroupInfo.sampleData,
|
||||||
|
creatingGroup = false,
|
||||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||||
selectedContacts = remember { mutableStateListOf() },
|
selectedContacts = remember { mutableStateListOf() },
|
||||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||||
allowModifyMembers = true,
|
allowModifyMembers = true,
|
||||||
|
openPreferences = {},
|
||||||
inviteMembers = {},
|
inviteMembers = {},
|
||||||
clearSelection = {},
|
clearSelection = {},
|
||||||
addContact = {},
|
addContact = {},
|
||||||
removeContact = {}
|
removeContact = {},
|
||||||
|
close = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-10
@@ -4,6 +4,7 @@ import InfoRow
|
|||||||
import SectionDivider
|
import SectionDivider
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
@@ -28,6 +29,7 @@ import chat.simplex.app.views.chat.*
|
|||||||
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
||||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
||||||
@@ -47,7 +49,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
withApi {
|
withApi {
|
||||||
setGroupMembers(groupInfo, chatModel)
|
setGroupMembers(groupInfo, chatModel)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
AddGroupMembersView(groupInfo, chatModel, close)
|
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,6 +64,15 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
editGroupProfile = {
|
editGroupProfile = {
|
||||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||||
},
|
},
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
GroupPreferencesView(
|
||||||
|
chatModel,
|
||||||
|
chat.id,
|
||||||
|
close
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||||
@@ -120,6 +131,7 @@ fun GroupChatInfoLayout(
|
|||||||
addMembers: () -> Unit,
|
addMembers: () -> Unit,
|
||||||
showMemberInfo: (GroupMember) -> Unit,
|
showMemberInfo: (GroupMember) -> Unit,
|
||||||
editGroupProfile: () -> Unit,
|
editGroupProfile: () -> Unit,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
deleteGroup: () -> Unit,
|
deleteGroup: () -> Unit,
|
||||||
clearChat: () -> Unit,
|
clearChat: () -> Unit,
|
||||||
leaveGroup: () -> Unit,
|
leaveGroup: () -> Unit,
|
||||||
@@ -139,6 +151,16 @@ fun GroupChatInfoLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
|
SectionView {
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
||||||
|
SectionDivider()
|
||||||
|
}
|
||||||
|
GroupPreferencesButton(openPreferences)
|
||||||
|
}
|
||||||
|
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||||
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||||
if (groupInfo.canAddMembers) {
|
if (groupInfo.canAddMembers) {
|
||||||
SectionItemView(manageGroupLink) { GroupLinkButton() }
|
SectionItemView(manageGroupLink) { GroupLinkButton() }
|
||||||
@@ -160,10 +182,6 @@ fun GroupChatInfoLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
SectionView {
|
SectionView {
|
||||||
if (groupInfo.canEdit) {
|
|
||||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
ClearChatButton(clearChat)
|
ClearChatButton(clearChat)
|
||||||
if (groupInfo.canDelete) {
|
if (groupInfo.canDelete) {
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
@@ -211,6 +229,15 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.ToggleOn,
|
||||||
|
stringResource(R.string.group_preferences),
|
||||||
|
click = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
||||||
Row(
|
Row(
|
||||||
@@ -286,10 +313,10 @@ fun GroupLinkButton() {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Link,
|
Icons.Outlined.Link,
|
||||||
stringResource(R.string.group_link),
|
stringResource(R.string.group_link),
|
||||||
tint = MaterialTheme.colors.primary
|
tint = HighOrLowlight
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
|
Text(stringResource(R.string.group_link))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,10 +330,10 @@ fun EditGroupProfileButton() {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Edit,
|
Icons.Outlined.Edit,
|
||||||
stringResource(R.string.button_edit_group_profile),
|
stringResource(R.string.button_edit_group_profile),
|
||||||
tint = MaterialTheme.colors.primary
|
tint = HighOrLowlight
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
|
Text(stringResource(R.string.button_edit_group_profile))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +382,7 @@ fun PreviewGroupChatInfoLayout() {
|
|||||||
groupInfo = GroupInfo.sampleData,
|
groupInfo = GroupInfo.sampleData,
|
||||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||||
developerTools = false,
|
developerTools = false,
|
||||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-17
@@ -49,20 +49,26 @@ fun GroupMemberInfoView(
|
|||||||
connStats,
|
connStats,
|
||||||
newRole,
|
newRole,
|
||||||
developerTools,
|
developerTools,
|
||||||
openDirectChat = {
|
getContactChat = { chatModel.getContactChat(it) },
|
||||||
|
knownDirectChat = {
|
||||||
withApi {
|
withApi {
|
||||||
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
|
chatModel.chatItems.clear()
|
||||||
if (oldChat != null) {
|
chatModel.chatItems.addAll(it.chatItems)
|
||||||
openChat(oldChat.chatInfo, chatModel)
|
chatModel.chatId.value = it.chatInfo.id
|
||||||
} else {
|
closeAll()
|
||||||
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
|
}
|
||||||
|
},
|
||||||
|
newDirectChat = {
|
||||||
|
withApi {
|
||||||
|
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||||
|
if (c != null) {
|
||||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
// 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()))
|
val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
||||||
chatModel.addChat(newChat)
|
chatModel.addChat(newChat)
|
||||||
chatModel.chatItems.clear()
|
chatModel.chatItems.clear()
|
||||||
chatModel.chatId.value = newChat.id
|
chatModel.chatId.value = newChat.id
|
||||||
|
closeAll()
|
||||||
}
|
}
|
||||||
closeAll()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||||
@@ -114,7 +120,9 @@ fun GroupMemberInfoLayout(
|
|||||||
connStats: ConnectionStats?,
|
connStats: ConnectionStats?,
|
||||||
newRole: MutableState<GroupMemberRole>,
|
newRole: MutableState<GroupMemberRole>,
|
||||||
developerTools: Boolean,
|
developerTools: Boolean,
|
||||||
openDirectChat: () -> Unit,
|
getContactChat: (Long) -> Chat?,
|
||||||
|
knownDirectChat: (Chat) -> Unit,
|
||||||
|
newDirectChat: (Long) -> Unit,
|
||||||
removeMember: () -> Unit,
|
removeMember: () -> Unit,
|
||||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||||
switchMemberAddress: () -> Unit,
|
switchMemberAddress: () -> Unit,
|
||||||
@@ -133,10 +141,21 @@ fun GroupMemberInfoLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView {
|
val contactId = member.memberContactId
|
||||||
OpenChatButton(openDirectChat)
|
if (contactId != null) {
|
||||||
|
val chat = getContactChat(contactId)
|
||||||
|
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directContact) {
|
||||||
|
SectionView {
|
||||||
|
OpenChatButton(onClick = { knownDirectChat(chat) })
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
||||||
|
SectionView {
|
||||||
|
OpenChatButton(onClick = { newDirectChat(contactId) })
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||||
@@ -160,10 +179,8 @@ fun GroupMemberInfoLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
if (developerTools) {
|
SwitchAddressButton(switchMemberAddress)
|
||||||
SwitchAddressButton(switchMemberAddress)
|
SectionDivider()
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
if (connStats != null) {
|
if (connStats != null) {
|
||||||
val rcvServers = connStats.rcvServers
|
val rcvServers = connStats.rcvServers
|
||||||
val sndServers = connStats.sndServers
|
val sndServers = connStats.sndServers
|
||||||
@@ -302,7 +319,9 @@ fun PreviewGroupMemberInfoLayout() {
|
|||||||
connStats = null,
|
connStats = null,
|
||||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||||
developerTools = false,
|
developerTools = false,
|
||||||
openDirectChat = {},
|
getContactChat = { Chat.sampleData },
|
||||||
|
knownDirectChat = {},
|
||||||
|
newDirectChat = {},
|
||||||
removeMember = {},
|
removeMember = {},
|
||||||
onRoleSelected = {},
|
onRoleSelected = {},
|
||||||
switchMemberAddress = {},
|
switchMemberAddress = {},
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import SectionDivider
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||||
|
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
|
||||||
|
val gInfo = groupInfo.value ?: return
|
||||||
|
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||||
|
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||||
|
|
||||||
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||||
|
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||||
|
if (gInfo != null) {
|
||||||
|
m.updateGroup(gInfo)
|
||||||
|
currentPreferences = preferences
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (preferences == currentPreferences) close()
|
||||||
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
|
},
|
||||||
|
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||||
|
) {
|
||||||
|
GroupPreferencesLayout(
|
||||||
|
preferences,
|
||||||
|
currentPreferences,
|
||||||
|
gInfo,
|
||||||
|
applyPrefs = { prefs ->
|
||||||
|
preferences = prefs
|
||||||
|
},
|
||||||
|
reset = {
|
||||||
|
preferences = currentPreferences
|
||||||
|
},
|
||||||
|
savePrefs = ::savePrefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupPreferencesLayout(
|
||||||
|
preferences: FullGroupPreferences,
|
||||||
|
currentPreferences: FullGroupPreferences,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
applyPrefs: (FullGroupPreferences) -> Unit,
|
||||||
|
reset: () -> Unit,
|
||||||
|
savePrefs: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.group_preferences))
|
||||||
|
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
||||||
|
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo) {
|
||||||
|
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||||
|
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo) {
|
||||||
|
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||||
|
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) {
|
||||||
|
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||||
|
}
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
SectionSpacer()
|
||||||
|
ResetSaveButtons(
|
||||||
|
reset = reset,
|
||||||
|
save = savePrefs,
|
||||||
|
disabled = preferences == currentPreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeatureSection(feature: GroupFeature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
|
||||||
|
SectionView {
|
||||||
|
val on = enableFeature.value == GroupFeatureEnabled.ON
|
||||||
|
val icon = if (on) feature.iconFilled else feature.icon
|
||||||
|
val iconTint = if (on) SimplexGreen else HighOrLowlight
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
SectionItemView {
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
feature.text,
|
||||||
|
GroupFeatureEnabled.values().map { it to it.text },
|
||||||
|
enableFeature,
|
||||||
|
icon = icon,
|
||||||
|
iconTint = iconTint,
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
InfoRow(
|
||||||
|
feature.text,
|
||||||
|
enableFeature.value.text,
|
||||||
|
icon = icon,
|
||||||
|
iconTint = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(reset, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
|
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
||||||
when (status) {
|
when (status) {
|
||||||
CICallStatus.Pending -> if (sent) {
|
CICallStatus.Pending -> if (sent) {
|
||||||
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
||||||
@@ -38,7 +38,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||||
CICallStatus.Ended -> Row {
|
CICallStatus.Ended -> Row {
|
||||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
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)
|
Text(durationText(duration), color = HighOrLowlight)
|
||||||
}
|
}
|
||||||
CICallStatus.Error -> {}
|
CICallStatus.Error -> {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIChatFeatureView(
|
||||||
|
chatItem: ChatItem,
|
||||||
|
feature: Feature,
|
||||||
|
iconColor: Color
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(feature.iconFilled, feature.text, Modifier.size(15.dp), tint = iconColor)
|
||||||
|
Text(
|
||||||
|
chatEventText(chatItem),
|
||||||
|
Modifier,
|
||||||
|
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,32 +18,42 @@ import chat.simplex.app.ui.theme.SimpleXTheme
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIEventView(ci: ChatItem) {
|
fun CIEventView(ci: ChatItem) {
|
||||||
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
|
@Composable
|
||||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
fun chatEventTextView(text: AnnotatedString) {
|
||||||
|
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface {
|
Surface {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
val memberDisplayName = ci.memberDisplayName
|
||||||
buildAnnotatedString {
|
if (memberDisplayName != null) {
|
||||||
val memberDisplayName = ci.memberDisplayName
|
chatEventTextView(
|
||||||
if (memberDisplayName != null) {
|
buildAnnotatedString {
|
||||||
withGroupEventStyle(this, memberDisplayName)
|
withChatEventStyle(this, memberDisplayName)
|
||||||
append(" ")
|
append(" ")
|
||||||
}
|
}.plus(chatEventText(ci))
|
||||||
withGroupEventStyle(this, ci.content.text)
|
)
|
||||||
append(" ")
|
} else {
|
||||||
withGroupEventStyle(this, ci.timestampText)
|
chatEventTextView(chatEventText(ci))
|
||||||
},
|
}
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun withChatEventStyle(builder: AnnotatedString.Builder, text: String) {
|
||||||
|
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||||
|
buildAnnotatedString {
|
||||||
|
withChatEventStyle(this, ci.content.text)
|
||||||
|
append(" ")
|
||||||
|
withChatEventStyle(this, ci.timestampText)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
|||||||
@@ -205,6 +205,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
|||||||
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
|
import chat.simplex.app.ui.theme.WarningYellow
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -51,7 +52,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
|||||||
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
||||||
}
|
}
|
||||||
is CIStatus.SndError -> {
|
is CIStatus.SndError -> {
|
||||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
|
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow)
|
||||||
}
|
}
|
||||||
is CIStatus.RcvNew -> {
|
is CIStatus.RcvNew -> {
|
||||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
||||||
@@ -87,7 +88,7 @@ fun PreviewCIMetaViewSendFailed() {
|
|||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||||
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
|
status = CIStatus.SndError("CMD SYNTAX")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIVoiceView(
|
||||||
|
providedDurationSec: Int,
|
||||||
|
file: CIFile?,
|
||||||
|
edited: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
ci: ChatItem,
|
||||||
|
longClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (file != null) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
|
||||||
|
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
|
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
|
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
|
||||||
|
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
|
||||||
|
val play = {
|
||||||
|
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
|
||||||
|
brokenAudio = !audioPlaying.value
|
||||||
|
}
|
||||||
|
val pause = {
|
||||||
|
AudioPlayer.pause(audioPlaying, progress)
|
||||||
|
}
|
||||||
|
val text = remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val time = when {
|
||||||
|
audioPlaying.value || progress.value != 0 -> progress.value
|
||||||
|
else -> duration.value
|
||||||
|
}
|
||||||
|
durationText(time / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, play, pause, longClick)
|
||||||
|
} else {
|
||||||
|
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||||
|
val metaReserve = if (edited)
|
||||||
|
" "
|
||||||
|
else
|
||||||
|
" "
|
||||||
|
Text(metaReserve)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceLayout(
|
||||||
|
file: CIFile,
|
||||||
|
ci: ChatItem,
|
||||||
|
text: State<String>,
|
||||||
|
audioPlaying: State<Boolean>,
|
||||||
|
progress: State<Int>,
|
||||||
|
duration: State<Int>,
|
||||||
|
brokenAudio: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit,
|
||||||
|
longClick: () -> Unit
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
hasText -> {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
|
DurationText(text, PaddingValues(start = 12.dp))
|
||||||
|
}
|
||||||
|
sent -> {
|
||||||
|
Row {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
DurationText(text, PaddingValues(end = 12.dp))
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
|
CIMetaView(ci)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Row {
|
||||||
|
Column {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
|
CIMetaView(ci)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DurationText(text, PaddingValues(start = 12.dp))
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DurationText(text: State<String>, padding: PaddingValues) {
|
||||||
|
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
|
||||||
|
Text(
|
||||||
|
text.value,
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.widthIn(min = minWidth),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayPauseButton(
|
||||||
|
audioPlaying: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
angle: Float,
|
||||||
|
strokeWidth: Float,
|
||||||
|
strokeColor: Color,
|
||||||
|
enabled: Boolean,
|
||||||
|
error: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit,
|
||||||
|
longClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = if (sent) SentColorLight else ReceivedColorLight,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { if (!audioPlaying) play() else pause() },
|
||||||
|
onLongClick = longClick
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.size(36.dp),
|
||||||
|
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceMsgIndicator(
|
||||||
|
file: CIFile?,
|
||||||
|
audioPlaying: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
progress: State<Int>?,
|
||||||
|
duration: State<Int>?,
|
||||||
|
error: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit,
|
||||||
|
longClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||||
|
val strokeColor = MaterialTheme.colors.primary
|
||||||
|
if (file != null && file.loaded && progress != null && duration != null) {
|
||||||
|
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
|
||||||
|
if (hasText) {
|
||||||
|
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.size(36.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
||||||
|
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
||||||
|
|| file?.fileStatus == CIFileStatus.RcvAccepted
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||||
|
val brush = Brush.linearGradient(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
0f to color,
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(strokeWidth, strokeWidth),
|
||||||
|
tileMode = TileMode.Clamp
|
||||||
|
)
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawArc(
|
||||||
|
brush = brush,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = angle,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
|
||||||
|
size = Size(size.width - strokeWidth, size.height - strokeWidth),
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(32.dp),
|
||||||
|
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||||
|
strokeWidth = 4.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.*
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -27,18 +26,17 @@ import chat.simplex.app.views.chat.ComposeState
|
|||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatItemView(
|
fun ChatItemView(
|
||||||
user: User,
|
|
||||||
cInfo: ChatInfo,
|
cInfo: ChatInfo,
|
||||||
cItem: ChatItem,
|
cItem: ChatItem,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
cxt: Context,
|
|
||||||
uriHandler: UriHandler? = null,
|
|
||||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
showMember: Boolean = false,
|
showMember: Boolean = false,
|
||||||
chatModelIncognito: Boolean,
|
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
@@ -46,10 +44,15 @@ fun ChatItemView(
|
|||||||
scrollToItem: (Long) -> Unit,
|
scrollToItem: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
val sent = cItem.chatDir.sent
|
val sent = cItem.chatDir.sent
|
||||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
|
val revealed = remember { mutableStateOf(false) }
|
||||||
|
val fullDeleteAllowed = remember(cInfo) { cInfo.fullDeletionAllowed }
|
||||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||||
|
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 4.dp)
|
.padding(bottom = 4.dp)
|
||||||
@@ -62,7 +65,7 @@ fun ChatItemView(
|
|||||||
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||||
}
|
}
|
||||||
is CIStatus.SndError -> {
|
is CIStatus.SndError -> {
|
||||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
|
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
@@ -70,68 +73,123 @@ fun ChatItemView(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
|
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||||
) {
|
) {
|
||||||
@Composable fun ContentItem() {
|
@Composable
|
||||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
fun framedItemView() {
|
||||||
EmojiItemView(cItem)
|
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessageQuestionText(): String {
|
||||||
|
return if (fullDeleteAllowed) {
|
||||||
|
generalGetString(R.string.delete_message_cannot_be_undone_warning)
|
||||||
} else {
|
} else {
|
||||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
generalGetString(R.string.delete_message_mark_deleted_warning)
|
||||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MsgContentItemDropdownMenu() {
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu.value,
|
expanded = showMenu.value,
|
||||||
onDismissRequest = { showMenu.value = false },
|
onDismissRequest = { showMenu.value = false },
|
||||||
Modifier.width(220.dp)
|
Modifier.width(220.dp)
|
||||||
) {
|
) {
|
||||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
if (!cItem.meta.itemDeleted) {
|
||||||
if (composeState.value.editing) {
|
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
if (composeState.value.editing) {
|
||||||
} else {
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
} else {
|
||||||
}
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||||
showMenu.value = false
|
}
|
||||||
})
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||||
when {
|
when {
|
||||||
filePath != null -> shareFile(cxt, cItem.text, filePath)
|
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||||
else -> shareText(cxt, cItem.content.text)
|
else -> shareText(context, cItem.content.text)
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
||||||
copyText(cxt, cItem.content.text)
|
copyText(context, cItem.content.text)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||||
val filePath = getLoadedFilePath(context, cItem.file)
|
val filePath = getLoadedFilePath(context, cItem.file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||||
when (cItem.content.msgContent) {
|
when (cItem.content.msgContent) {
|
||||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
|
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cItem.meta.editable) {
|
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
|
||||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (cItem.meta.itemDeleted && revealed.value) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.hide_verb),
|
||||||
|
Icons.Outlined.VisibilityOff,
|
||||||
|
onClick = {
|
||||||
|
revealed.value = false
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkedDeletedItemDropdownMenu() {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu.value,
|
||||||
|
onDismissRequest = { showMenu.value = false },
|
||||||
|
Modifier.width(220.dp)
|
||||||
|
) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_verb),
|
stringResource(R.string.reveal_verb),
|
||||||
Icons.Outlined.Delete,
|
Icons.Outlined.Visibility,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
revealed.value = true
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
}
|
||||||
},
|
|
||||||
color = Color.Red
|
|
||||||
)
|
)
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContentItem() {
|
||||||
|
val mc = cItem.content.msgContent
|
||||||
|
if (cItem.meta.itemDeleted && !revealed.value) {
|
||||||
|
MarkedDeletedItemView(cItem, showMember = showMember)
|
||||||
|
MarkedDeletedItemDropdownMenu()
|
||||||
|
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) {
|
||||||
|
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||||
|
EmojiItemView(cItem)
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||||
|
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, longClick = { onLinkLongClick("") })
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,15 +200,7 @@ fun ChatItemView(
|
|||||||
onDismissRequest = { showMenu.value = false },
|
onDismissRequest = { showMenu.value = false },
|
||||||
Modifier.width(220.dp)
|
Modifier.width(220.dp)
|
||||||
) {
|
) {
|
||||||
ItemAction(
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
stringResource(R.string.delete_verb),
|
|
||||||
Icons.Outlined.Delete,
|
|
||||||
onClick = {
|
|
||||||
showMenu.value = false
|
|
||||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
|
||||||
},
|
|
||||||
color = Color.Red
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +222,35 @@ fun ChatItemView(
|
|||||||
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
||||||
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
||||||
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||||
|
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||||
|
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||||
|
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
|
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
|
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||||
|
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteItemAction(
|
||||||
|
cItem: ChatItem,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
questionText: String,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||||
DropdownMenuItem(onClick) {
|
DropdownMenuItem(onClick) {
|
||||||
@@ -194,10 +268,10 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
AlertManager.shared.showAlertDialogButtons(
|
AlertManager.shared.showAlertDialogButtons(
|
||||||
title = generalGetString(R.string.delete_message__question),
|
title = generalGetString(R.string.delete_message__question),
|
||||||
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
|
text = questionText,
|
||||||
buttons = {
|
buttons = {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -233,15 +307,13 @@ private fun showMsgDeliveryErrorAlert(description: String) {
|
|||||||
fun PreviewChatItemView() {
|
fun PreviewChatItemView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
|
||||||
ChatInfo.Direct.sampleData,
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||||
),
|
),
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
|
||||||
chatModelIncognito = false,
|
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
@@ -256,13 +328,11 @@ fun PreviewChatItemView() {
|
|||||||
fun PreviewChatItemViewDeletedContent() {
|
fun PreviewChatItemViewDeletedContent() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
|
||||||
ChatInfo.Direct.sampleData,
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getDeletedContentSampleData(),
|
ChatItem.getDeletedContentSampleData(),
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
|
||||||
chatModelIncognito = false,
|
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -16,15 +17,15 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||||||
import androidx.compose.ui.layout.*
|
import androidx.compose.ui.layout.*
|
||||||
import androidx.compose.ui.platform.UriHandler
|
import androidx.compose.ui.platform.UriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.tooling.preview.*
|
import androidx.compose.ui.tooling.preview.*
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.util.fastMap
|
import androidx.compose.ui.util.fastMap
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ChatItemLinkView
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
val SentColorLight = Color(0x1E45B8FF)
|
val SentColorLight = Color(0x1E45B8FF)
|
||||||
@@ -39,6 +40,7 @@ fun FramedItemView(
|
|||||||
uriHandler: UriHandler? = null,
|
uriHandler: UriHandler? = null,
|
||||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
showMember: Boolean = false,
|
showMember: Boolean = false,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
onLinkLongClick: (link: String) -> Unit = {},
|
onLinkLongClick: (link: String) -> Unit = {},
|
||||||
@@ -58,7 +60,35 @@ fun FramedItemView(
|
|||||||
) {
|
) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||||
|
linkMode = linkMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ciDeletedView() {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.padding(end = 12.dp)
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
stringResource(R.string.marked_deleted_description),
|
||||||
|
Modifier.size(18.dp),
|
||||||
|
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,13 +117,13 @@ fun FramedItemView(
|
|||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
|
||||||
stringResource(R.string.icon_descr_file),
|
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp, end = 4.dp)
|
.padding(top = 6.dp, end = 4.dp)
|
||||||
.size(22.dp),
|
.size(22.dp),
|
||||||
@@ -109,11 +139,12 @@ fun FramedItemView(
|
|||||||
fun ciFileView(ci: ChatItem, text: String) {
|
fun ciFileView(ci: ChatItem, text: String) {
|
||||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||||
if (text != "") {
|
if (text != "") {
|
||||||
CIMarkdownText(ci, showMember, uriHandler)
|
CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
|
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||||
|
|
||||||
Box(Modifier
|
Box(Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(
|
.background(
|
||||||
@@ -127,6 +158,7 @@ fun FramedItemView(
|
|||||||
Box(contentAlignment = Alignment.BottomEnd) {
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||||
|
if (ci.meta.itemDeleted) { ciDeletedView() }
|
||||||
ci.quotedItem?.let { ciQuoteView(it) }
|
ci.quotedItem?.let { ciQuoteView(it) }
|
||||||
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
||||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||||
@@ -147,21 +179,27 @@ fun FramedItemView(
|
|||||||
if (mc.text == "") {
|
if (mc.text == "") {
|
||||||
metaColor = Color.White
|
metaColor = Color.White
|
||||||
} else {
|
} else {
|
||||||
CIMarkdownText(ci, showMember, uriHandler)
|
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MsgContent.MCVoice -> {
|
||||||
|
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, longClick = { onLinkLongClick("") })
|
||||||
|
if (mc.text != "") {
|
||||||
|
CIMarkdownText(ci, showMember, linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||||
is MsgContent.MCUnknown ->
|
is MsgContent.MCUnknown ->
|
||||||
if (ci.file == null) {
|
if (ci.file == null) {
|
||||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
} else {
|
} else {
|
||||||
ciFileView(ci, mc.text)
|
ciFileView(ci, mc.text)
|
||||||
}
|
}
|
||||||
is MsgContent.MCLink -> {
|
is MsgContent.MCLink -> {
|
||||||
ChatItemLinkView(mc.preview)
|
ChatItemLinkView(mc.preview)
|
||||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
}
|
}
|
||||||
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,13 +215,14 @@ fun FramedItemView(
|
|||||||
fun CIMarkdownText(
|
fun CIMarkdownText(
|
||||||
ci: ChatItem,
|
ci: ChatItem,
|
||||||
showMember: Boolean,
|
showMember: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
uriHandler: UriHandler?,
|
uriHandler: UriHandler?,
|
||||||
onLinkLongClick: (link: String) -> Unit = {}
|
onLinkLongClick: (link: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||||
metaText = ci.timestampText, edited = ci.meta.itemEdited,
|
metaText = ci.timestampText, edited = ci.meta.itemEdited, linkMode = linkMode,
|
||||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -234,6 +273,7 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
|
|||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -250,6 +290,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
|
|||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -270,6 +311,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
|
|||||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -291,6 +333,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
|
|||||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
|
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -312,6 +355,7 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
|
|||||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
|
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -340,6 +384,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
|
|||||||
quotedItem = ciQuote,
|
quotedItem = ciQuote,
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -368,6 +413,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
|
|||||||
quotedItem = ciQuote,
|
quotedItem = ciQuote,
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -395,6 +441,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
|||||||
quotedItem = ciQuote,
|
quotedItem = ciQuote,
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-6
@@ -77,12 +77,14 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
val image = provider.getImage(index)
|
val image = provider.getImage(index)
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||||
scope.launch {
|
SideEffect {
|
||||||
when (settledCurrentPage) {
|
scope.launch {
|
||||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
when (settledCurrentPage) {
|
||||||
index + 1 -> {
|
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
||||||
provider.scrollToStart()
|
index + 1 -> {
|
||||||
pagerState.scrollToPage(0)
|
provider.scrollToStart()
|
||||||
|
pagerState.scrollToPage(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
||||||
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
CIMetaView(ci)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewMarkedDeletedItemView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
DeletedItemView(
|
||||||
|
ChatItem.getSampleData(itemDeleted = true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.foundation.text.*
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
@@ -10,9 +12,11 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.core.text.BidiFormatter
|
import androidx.core.text.BidiFormatter
|
||||||
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.views.helpers.detectGesture
|
import chat.simplex.app.views.helpers.detectGesture
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ fun MarkdownText (
|
|||||||
uriHandler: UriHandler? = null,
|
uriHandler: UriHandler? = null,
|
||||||
senderBold: Boolean = false,
|
senderBold: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
onLinkLongClick: (link: String) -> Unit = {}
|
onLinkLongClick: (link: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val textLayoutDirection = remember (text) {
|
val textLayoutDirection = remember (text) {
|
||||||
@@ -79,12 +84,16 @@ fun MarkdownText (
|
|||||||
for (ft in formattedText) {
|
for (ft in formattedText) {
|
||||||
if (ft.format == null) append(ft.text)
|
if (ft.format == null) append(ft.text)
|
||||||
else {
|
else {
|
||||||
val link = ft.link
|
val link = ft.link(linkMode)
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
hasLinks = true
|
hasLinks = true
|
||||||
val ftStyle = ft.format.style
|
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
|
||||||
|
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
|
||||||
|
} else {
|
||||||
|
ft.format.style
|
||||||
|
}
|
||||||
withAnnotation(tag = "URL", annotation = link) {
|
withAnnotation(tag = "URL", annotation = link) {
|
||||||
withStyle(ftStyle) { append(ft.text) }
|
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withStyle(ft.format.style) { append(ft.text) }
|
withStyle(ft.format.style) { append(ft.text) }
|
||||||
@@ -104,7 +113,15 @@ fun MarkdownText (
|
|||||||
},
|
},
|
||||||
onClick = { offset ->
|
onClick = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
.firstOrNull()?.let { annotation ->
|
||||||
|
try {
|
||||||
|
uriHandler.openUri(annotation.item)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
|
||||||
|
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
|
||||||
|
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
shouldConsumeEvent = { offset ->
|
shouldConsumeEvent = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||||
|
|||||||
+9
-8
@@ -33,6 +33,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||||
}
|
}
|
||||||
val stopped = chatModel.chatRunning.value == false
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
val linkMode by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
|
||||||
LaunchedEffect(chat.id) {
|
LaunchedEffect(chat.id) {
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
delay(500L)
|
delay(500L)
|
||||||
@@ -40,7 +41,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
when (chat.chatInfo) {
|
when (chat.chatInfo) {
|
||||||
is ChatInfo.Direct ->
|
is ChatInfo.Direct ->
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||||
showMenu,
|
showMenu,
|
||||||
@@ -48,7 +49,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
)
|
)
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) },
|
||||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||||
showMenu,
|
showMenu,
|
||||||
@@ -536,13 +537,11 @@ fun ChatListNavLinkLayout(
|
|||||||
) {
|
) {
|
||||||
var modifier = Modifier.fillMaxWidth()
|
var modifier = Modifier.fillMaxWidth()
|
||||||
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||||
Surface(modifier) {
|
Box(modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||||
.padding(start = 8.dp)
|
|
||||||
.padding(end = 12.dp),
|
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
chatLinkPreview()
|
chatLinkPreview()
|
||||||
@@ -588,7 +587,8 @@ fun PreviewChatListNavLinkDirect() {
|
|||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
stopped = false
|
stopped = false,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
click = {},
|
click = {},
|
||||||
@@ -625,7 +625,8 @@ fun PreviewChatListNavLinkGroup() {
|
|||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
stopped = false
|
stopped = false,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
click = {},
|
click = {},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import chat.simplex.app.views.chat.item.MarkdownText
|
|||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
|
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -83,9 +83,10 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
val ci = chat.chatItems.lastOrNull()
|
val ci = chat.chatItems.lastOrNull()
|
||||||
if (ci != null) {
|
if (ci != null) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
ci.text,
|
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
|
||||||
ci.formattedText,
|
if (!ci.meta.itemDeleted) ci.formattedText else null,
|
||||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
||||||
|
linkMode = linkMode,
|
||||||
senderBold = true,
|
senderBold = true,
|
||||||
metaText = null,
|
metaText = null,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -232,6 +233,6 @@ fun ChatStatusImage(chat: Chat) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun PreviewChatPreviewView() {
|
fun PreviewChatPreviewView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
|
ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ fun DatabaseView(
|
|||||||
chatArchiveTime,
|
chatArchiveTime,
|
||||||
chatLastStart,
|
chatLastStart,
|
||||||
chatDbDeleted.value,
|
chatDbDeleted.value,
|
||||||
|
m.controller.appPrefs.privacyFullBackup,
|
||||||
appFilesCountAndSize,
|
appFilesCountAndSize,
|
||||||
chatItemTTL,
|
chatItemTTL,
|
||||||
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
||||||
@@ -126,12 +127,13 @@ fun DatabaseLayout(
|
|||||||
chatDbChanged: Boolean,
|
chatDbChanged: Boolean,
|
||||||
useKeyChain: Boolean,
|
useKeyChain: Boolean,
|
||||||
chatDbEncrypted: Boolean?,
|
chatDbEncrypted: Boolean?,
|
||||||
initialRandomDBPassphrase: Preference<Boolean>,
|
initialRandomDBPassphrase: SharedPreference<Boolean>,
|
||||||
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||||
chatArchiveName: MutableState<String?>,
|
chatArchiveName: MutableState<String?>,
|
||||||
chatArchiveTime: MutableState<Instant?>,
|
chatArchiveTime: MutableState<Instant?>,
|
||||||
chatLastStart: MutableState<Instant?>,
|
chatLastStart: MutableState<Instant?>,
|
||||||
chatDbDeleted: Boolean,
|
chatDbDeleted: Boolean,
|
||||||
|
privacyFullBackup: SharedPreference<Boolean>,
|
||||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||||
chatItemTTL: MutableState<ChatItemTTL>,
|
chatItemTTL: MutableState<ChatItemTTL>,
|
||||||
startChat: () -> Unit,
|
startChat: () -> Unit,
|
||||||
@@ -165,6 +167,8 @@ fun DatabaseLayout(
|
|||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
|
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
|
||||||
|
SectionDivider()
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.IosShare,
|
Icons.Outlined.IosShare,
|
||||||
stringResource(R.string.export_database),
|
stringResource(R.string.export_database),
|
||||||
@@ -409,7 +413,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
|
|||||||
m.controller.apiStopChat()
|
m.controller.apiStopChat()
|
||||||
runChat.value = false
|
runChat.value = false
|
||||||
m.chatRunning.value = false
|
m.chatRunning.value = false
|
||||||
SimplexService.stop(context)
|
SimplexService.safeStopService(context)
|
||||||
MessagesFetcherWorker.cancelAll()
|
MessagesFetcherWorker.cancelAll()
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
runChat.value = true
|
runChat.value = true
|
||||||
@@ -683,12 +687,13 @@ fun PreviewDatabaseLayout() {
|
|||||||
chatDbChanged = false,
|
chatDbChanged = false,
|
||||||
useKeyChain = false,
|
useKeyChain = false,
|
||||||
chatDbEncrypted = false,
|
chatDbEncrypted = false,
|
||||||
initialRandomDBPassphrase = Preference({ true }, {}),
|
initialRandomDBPassphrase = SharedPreference({ true }, {}),
|
||||||
importArchiveLauncher = rememberGetContentLauncher {},
|
importArchiveLauncher = rememberGetContentLauncher {},
|
||||||
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||||
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||||
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
||||||
chatDbDeleted = false,
|
chatDbDeleted = false,
|
||||||
|
privacyFullBackup = SharedPreference({ true }, {}),
|
||||||
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
||||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
||||||
startChat = {},
|
startChat = {},
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
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.Color
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
|
|
||||||
class AlertManager {
|
class AlertManager {
|
||||||
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||||
@@ -35,6 +42,26 @@ class AlertManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showAlertDialogButtonsColumn(
|
||||||
|
title: String,
|
||||||
|
text: String? = null,
|
||||||
|
buttons: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
showAlert {
|
||||||
|
Dialog(onDismissRequest = this::hideAlert) {
|
||||||
|
Column(Modifier.background(MaterialTheme.colors.background)) {
|
||||||
|
Text(title, Modifier.padding(DEFAULT_PADDING), fontSize = 18.sp)
|
||||||
|
if (text != null) {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||||
|
buttons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun showAlertDialog(
|
fun showAlertDialog(
|
||||||
title: String,
|
title: String,
|
||||||
text: String? = null,
|
text: String? = null,
|
||||||
@@ -67,6 +94,41 @@ class AlertManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showAlertDialogStacked(
|
||||||
|
title: String,
|
||||||
|
text: String? = null,
|
||||||
|
confirmText: String = generalGetString(R.string.ok),
|
||||||
|
onConfirm: (() -> Unit)? = null,
|
||||||
|
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||||
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
onDismissRequest: (() -> Unit)? = null,
|
||||||
|
destructive: Boolean = false
|
||||||
|
) {
|
||||||
|
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||||
|
showAlert {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||||
|
title = { Text(title) },
|
||||||
|
text = alertText,
|
||||||
|
buttons = {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismiss?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(dismissText) }
|
||||||
|
TextButton(onClick = {
|
||||||
|
onConfirm?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun showAlertMsg(
|
fun showAlertMsg(
|
||||||
title: String, text: String? = null,
|
title: String, text: String? = null,
|
||||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||||
@@ -102,4 +164,4 @@ class AlertManager {
|
|||||||
companion object {
|
companion object {
|
||||||
val shared = AlertManager()
|
val shared = AlertManager()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
|
|||||||
|
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
|
||||||
|
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||||
|
|
||||||
|
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||||
|
|||||||
+7
-4
@@ -10,9 +10,12 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,7 +30,7 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
onSelected: (T) -> Unit
|
onSelected: (T) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
@@ -40,9 +43,7 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
tint = iconTint
|
tint = iconTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
||||||
|
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
@@ -55,8 +56,10 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
|
val maxWidth = with(LocalDensity.current){ 180.sp.toDp() }
|
||||||
Text(
|
Text(
|
||||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||||
|
Modifier.widthIn(max = maxWidth),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = HighOrLowlight
|
color = HighOrLowlight
|
||||||
|
|||||||
@@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
|||||||
return interactionSource
|
return interactionSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun interactionSourceWithTapDetection(key: Any, onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||||
|
val interactionSource = remember(key) { MutableInteractionSource() }
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
var firstTapTime = 0L
|
||||||
|
interactionSource.interactions.collect { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> {
|
||||||
|
firstTapTime = System.currentTimeMillis(); onPress()
|
||||||
|
}
|
||||||
|
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
|
||||||
|
is PressInteraction.Cancel -> onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return interactionSource
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun PointerInputScope.detectTransformGestures(
|
suspend fun PointerInputScope.detectTransformGestures(
|
||||||
allowIntercept: () -> Boolean,
|
allowIntercept: () -> Boolean,
|
||||||
panZoomLock: Boolean = false,
|
panZoomLock: Boolean = false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.*
|
import android.content.*
|
||||||
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -32,11 +33,9 @@ import androidx.core.content.FileProvider
|
|||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.json
|
import chat.simplex.app.model.json
|
||||||
import chat.simplex.app.views.chat.ComposeState
|
|
||||||
import chat.simplex.app.views.chat.PickFromGallery
|
import chat.simplex.app.views.chat.PickFromGallery
|
||||||
import chat.simplex.app.views.newchat.ActionButton
|
import chat.simplex.app.views.newchat.ActionButton
|
||||||
import kotlinx.serialization.builtins.*
|
import kotlinx.serialization.builtins.*
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -177,6 +176,25 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
|
|||||||
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
||||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
||||||
|
|
||||||
|
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||||
|
try {
|
||||||
|
launch(null)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
|
||||||
|
// Means, no system camera app (Android 11+ requirement)
|
||||||
|
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
|
||||||
|
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to open any camera just to capture an image, will not be returned like with previous intent
|
||||||
|
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// No camera apps available at all
|
||||||
|
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GetImageBottomSheet(
|
fun GetImageBottomSheet(
|
||||||
imageBitmap: MutableState<Uri?>,
|
imageBitmap: MutableState<Uri?>,
|
||||||
@@ -204,7 +222,7 @@ fun GetImageBottomSheet(
|
|||||||
}
|
}
|
||||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
hideBottomSheet()
|
hideBottomSheet()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
@@ -228,7 +246,7 @@ fun GetImageBottomSheet(
|
|||||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
hideBottomSheet()
|
hideBottomSheet()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.*
|
||||||
|
import android.media.AudioManager.AudioPlaybackCallback
|
||||||
|
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface Recorder {
|
||||||
|
val recordingInProgress: MutableState<Boolean>
|
||||||
|
fun start(onStop: () -> Unit): String
|
||||||
|
fun stop()
|
||||||
|
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
||||||
|
companion object {
|
||||||
|
// Allows to stop the recorder from outside without having the recorder in a variable
|
||||||
|
var stopRecording: (() -> Unit)? = null
|
||||||
|
}
|
||||||
|
override val recordingInProgress = mutableStateOf(false)
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private fun initRecorder() =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(SimplexApp.context)
|
||||||
|
} else {
|
||||||
|
MediaRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start(onStop: () -> Unit): String {
|
||||||
|
AudioPlayer.stop()
|
||||||
|
recordingInProgress.value = true
|
||||||
|
val rec: MediaRecorder
|
||||||
|
recorder = initRecorder().also { rec = it }
|
||||||
|
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
rec.setAudioChannels(1)
|
||||||
|
rec.setAudioSamplingRate(16000)
|
||||||
|
rec.setAudioEncodingBitRate(16000)
|
||||||
|
rec.setMaxDuration(-1) // TODO set limit
|
||||||
|
rec.setMaxFileSize(recordedBytesLimit)
|
||||||
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
|
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
|
||||||
|
rec.setOutputFile(filePath)
|
||||||
|
rec.prepare()
|
||||||
|
rec.start()
|
||||||
|
rec.setOnInfoListener { mr, what, extra ->
|
||||||
|
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
|
||||||
|
stop()
|
||||||
|
onStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopRecording = { stop(); onStop() }
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
if (!recordingInProgress.value) return
|
||||||
|
stopRecording = null
|
||||||
|
recordingInProgress.value = false
|
||||||
|
recorder?.metrics?.
|
||||||
|
runCatching {
|
||||||
|
recorder?.stop()
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
recorder?.reset()
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
// release all resources
|
||||||
|
recorder?.release()
|
||||||
|
}
|
||||||
|
recorder = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
|
||||||
|
stop()
|
||||||
|
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object AudioPlayer {
|
||||||
|
private val player = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||||
|
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
|
||||||
|
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
||||||
|
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
|
||||||
|
// In a process of making a call
|
||||||
|
RecorderNative.stopRecording?.invoke()
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
super.onPlaybackConfigChanged(configs)
|
||||||
|
}
|
||||||
|
}, null)
|
||||||
|
}
|
||||||
|
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Filepath: String, onProgressUpdate
|
||||||
|
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
|
||||||
|
private var progressJob: Job? = null
|
||||||
|
|
||||||
|
enum class TrackState {
|
||||||
|
PLAYING, PAUSED, REPLACED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns real duration of the track
|
||||||
|
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
|
||||||
|
if (!File(filePath).exists()) {
|
||||||
|
Log.e(TAG, "No such file: $filePath")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
RecorderNative.stopRecording?.invoke()
|
||||||
|
val current = currentlyPlaying.value
|
||||||
|
if (current == null || current.first != filePath) {
|
||||||
|
stopListener()
|
||||||
|
player.reset()
|
||||||
|
runCatching {
|
||||||
|
player.setDataSource(filePath)
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, it.stackTraceToString())
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
runCatching { player.prepare() }.onFailure {
|
||||||
|
// Can happen when audio file is broken
|
||||||
|
Log.e(TAG, it.stackTraceToString())
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seek != null) player.seekTo(seek)
|
||||||
|
player.start()
|
||||||
|
currentlyPlaying.value = filePath to onProgressUpdate
|
||||||
|
progressJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||||
|
while(isActive && player.isPlaying) {
|
||||||
|
// Even when current position is equal to duration, the player has isPlaying == true for some time,
|
||||||
|
// so help to make the playback stopped in UI immediately
|
||||||
|
if (player.currentPosition == player.duration) {
|
||||||
|
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
|
||||||
|
* the player can show position != duration even if they actually equal.
|
||||||
|
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
|
||||||
|
* */
|
||||||
|
if (isActive) {
|
||||||
|
onProgressUpdate(player.duration, TrackState.PAUSED)
|
||||||
|
}
|
||||||
|
onProgressUpdate(null, TrackState.PAUSED)
|
||||||
|
}
|
||||||
|
return player.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pause(): Int {
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob = null
|
||||||
|
player.pause()
|
||||||
|
return player.currentPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (currentlyPlaying.value == null) return
|
||||||
|
player.stop()
|
||||||
|
stopListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(item: ChatItem) = stop(item.file?.fileName)
|
||||||
|
|
||||||
|
// FileName or filePath are ok
|
||||||
|
fun stop(fileName: String?) {
|
||||||
|
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopListener() {
|
||||||
|
val afterCoroutineCancel: CompletionHandler = {
|
||||||
|
// Notify prev audio listener about stop
|
||||||
|
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
|
||||||
|
currentlyPlaying.value = null
|
||||||
|
}
|
||||||
|
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||||
|
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
|
||||||
|
* */
|
||||||
|
if (progressJob != null) {
|
||||||
|
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||||
|
} else {
|
||||||
|
afterCoroutineCancel(null)
|
||||||
|
}
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(
|
||||||
|
filePath: String?,
|
||||||
|
audioPlaying: MutableState<Boolean>,
|
||||||
|
progress: MutableState<Int>,
|
||||||
|
duration: MutableState<Int>,
|
||||||
|
resetOnEnd: Boolean,
|
||||||
|
) {
|
||||||
|
if (progress.value == duration.value) {
|
||||||
|
progress.value = 0
|
||||||
|
}
|
||||||
|
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
|
||||||
|
if (pro != null) {
|
||||||
|
progress.value = pro
|
||||||
|
}
|
||||||
|
if (pro == null || pro == duration.value) {
|
||||||
|
audioPlaying.value = false
|
||||||
|
if (pro == duration.value) {
|
||||||
|
progress.value = if (resetOnEnd) 0 else duration.value
|
||||||
|
} else if (state == TrackState.REPLACED) {
|
||||||
|
progress.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioPlaying.value = realDuration != null
|
||||||
|
// Update to real duration instead of what was received in ChatInfo
|
||||||
|
realDuration?.let { duration.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
|
||||||
|
pro.value = pause()
|
||||||
|
audioPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun duration(filePath: String): Int {
|
||||||
|
var res = 0
|
||||||
|
kotlin.runCatching {
|
||||||
|
helperPlayer.setDataSource(filePath)
|
||||||
|
helperPlayer.prepare()
|
||||||
|
helperPlayer.start()
|
||||||
|
helperPlayer.stop()
|
||||||
|
res = helperPlayer.duration
|
||||||
|
helperPlayer.reset()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
@@ -31,6 +32,28 @@ fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionView(
|
||||||
|
title: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
iconTint: Color = HighOrLowlight,
|
||||||
|
leadingIcon: Boolean = false,
|
||||||
|
padding: PaddingValues = PaddingValues(),
|
||||||
|
content: (@Composable ColumnScope.() -> Unit)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||||
|
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint)
|
||||||
|
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp)
|
||||||
|
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint)
|
||||||
|
}
|
||||||
|
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
||||||
|
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> SectionViewSelectable(
|
fun <T> SectionViewSelectable(
|
||||||
title: String?,
|
title: String?,
|
||||||
@@ -56,7 +79,12 @@ fun <T> SectionViewSelectable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionItemView(click: (() -> Unit)? = null, minHeight: Dp = 46.dp, disabled: Boolean = false, content: (@Composable RowScope.() -> Unit)) {
|
fun SectionItemView(
|
||||||
|
click: (() -> Unit)? = null,
|
||||||
|
minHeight: Dp = 46.dp,
|
||||||
|
disabled: Boolean = false,
|
||||||
|
content: (@Composable RowScope.() -> Unit)
|
||||||
|
) {
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.sizeIn(minHeight = minHeight)
|
.sizeIn(minHeight = minHeight)
|
||||||
@@ -158,9 +186,13 @@ fun SectionSpacer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoRow(title: String, value: String) {
|
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
|
||||||
SectionItemViewSpaceBetween {
|
SectionItemViewSpaceBetween {
|
||||||
Text(title)
|
Row {
|
||||||
|
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||||
|
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: HighOrLowlight)
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
Text(value, color = HighOrLowlight)
|
Text(value, color = HighOrLowlight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,28 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.unit.sp
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextEditor(modifier: Modifier, text: MutableState<String>) {
|
fun TextEditor(
|
||||||
|
modifier: Modifier,
|
||||||
|
text: MutableState<String>,
|
||||||
|
border: Boolean = true,
|
||||||
|
fontSize: TextUnit = 14.sp,
|
||||||
|
background: Color = MaterialTheme.colors.background,
|
||||||
|
onChange: ((String) -> Unit)? = null
|
||||||
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = text.value,
|
value = text.value,
|
||||||
onValueChange = { text.value = it },
|
onValueChange = { text.value = it; onChange?.invoke(it) },
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
|
fontFamily = FontFamily.Monospace, fontSize = fontSize,
|
||||||
color = MaterialTheme.colors.onBackground
|
color = MaterialTheme.colors.onBackground
|
||||||
),
|
),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
@@ -37,17 +44,17 @@ fun TextEditor(modifier: Modifier, text: MutableState<String>) {
|
|||||||
cursorBrush = SolidColor(HighOrLowlight),
|
cursorBrush = SolidColor(HighOrLowlight),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.background(MaterialTheme.colors.background),
|
Modifier.background(background),
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 5.dp, horizontal = 7.dp)
|
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
|
||||||
) {
|
) {
|
||||||
innerTextField()
|
innerTextField()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import android.R.attr.factor
|
|
||||||
import android.R.color
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
@@ -19,6 +17,7 @@ import android.view.ViewTreeObserver
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
@@ -30,7 +29,10 @@ import androidx.core.content.FileProvider
|
|||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.CIFile
|
import chat.simplex.app.model.CIFile
|
||||||
|
import chat.simplex.app.model.json
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import java.io.*
|
import java.io.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -41,6 +43,9 @@ fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalSc
|
|||||||
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
||||||
scope.launch { withContext(Dispatchers.Main, action) }
|
scope.launch { withContext(Dispatchers.Main, action) }
|
||||||
|
|
||||||
|
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||||
|
CoroutineScope(Dispatchers.Default).launch(block = action)
|
||||||
|
|
||||||
enum class KeyboardState {
|
enum class KeyboardState {
|
||||||
Opened, Closed
|
Opened, Closed
|
||||||
}
|
}
|
||||||
@@ -220,6 +225,11 @@ private fun spannableStringToAnnotatedString(
|
|||||||
// maximum image file size to be auto-accepted
|
// maximum image file size to be auto-accepted
|
||||||
const val MAX_IMAGE_SIZE: Long = 236700
|
const val MAX_IMAGE_SIZE: Long = 236700
|
||||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||||
|
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
||||||
|
|
||||||
|
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||||
|
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
|
||||||
|
|
||||||
const val MAX_FILE_SIZE: Long = 8000000
|
const val MAX_FILE_SIZE: Long = 8000000
|
||||||
|
|
||||||
fun getFilesDirectory(context: Context): String {
|
fun getFilesDirectory(context: Context): String {
|
||||||
@@ -449,3 +459,15 @@ fun Color.darker(factor: Float = 0.1f): Color =
|
|||||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||||
|
|
||||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val LongRange.Companion.saver
|
||||||
|
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
|
||||||
|
save = { it.value.first to it.value.last },
|
||||||
|
restore = { mutableStateOf(it.first..it.second) }
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Make sure that T class has @Serializable annotation */
|
||||||
|
inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||||
|
save = { json.encodeToString(it) },
|
||||||
|
restore = { json.decodeFromString(it) }
|
||||||
|
)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
setGroupMembers(groupInfo, chatModel)
|
setGroupMembers(groupInfo, chatModel)
|
||||||
close.invoke()
|
close.invoke()
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
AddGroupMembersView(groupInfo, chatModel, close)
|
AddGroupMembersView(groupInfo, true, chatModel, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ fun CallSettingsView(m: ChatModel,
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CallSettingsLayout(
|
fun CallSettingsLayout(
|
||||||
webrtcPolicyRelay: Preference<Boolean>,
|
webrtcPolicyRelay: SharedPreference<Boolean>,
|
||||||
callOnLockScreen: Preference<CallOnLockScreen>,
|
callOnLockScreen: SharedPreference<CallOnLockScreen>,
|
||||||
editIceServers: () -> Unit,
|
editIceServers: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -79,9 +79,10 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
|
|||||||
@Composable
|
@Composable
|
||||||
fun SharedPreferenceToggle(
|
fun SharedPreferenceToggle(
|
||||||
text: String,
|
text: String,
|
||||||
preference: Preference<Boolean>,
|
preference: SharedPreference<Boolean>,
|
||||||
preferenceState: MutableState<Boolean>? = null
|
preferenceState: MutableState<Boolean>? = null,
|
||||||
) {
|
onChange: ((Boolean) -> Unit)? = null,
|
||||||
|
) {
|
||||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(text, Modifier.padding(end = 24.dp))
|
Text(text, Modifier.padding(end = 24.dp))
|
||||||
@@ -91,6 +92,7 @@ fun SharedPreferenceToggle(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
preference.set(it)
|
preference.set(it)
|
||||||
prefState.value = it
|
prefState.value = it
|
||||||
|
onChange?.invoke(it)
|
||||||
},
|
},
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
@@ -106,7 +108,7 @@ fun SharedPreferenceToggleWithIcon(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
stopped: Boolean = false,
|
stopped: Boolean = false,
|
||||||
onClickInfo: () -> Unit,
|
onClickInfo: () -> Unit,
|
||||||
preference: Preference<Boolean>,
|
preference: SharedPreference<Boolean>,
|
||||||
preferenceState: MutableState<Boolean>? = null
|
preferenceState: MutableState<Boolean>? = null
|
||||||
) {
|
) {
|
||||||
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
|
||||||
@@ -135,7 +137,7 @@ fun SharedPreferenceToggleWithIcon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: Preference<T>, value: T) {
|
fun <T>SharedPreferenceRadioButton(text: String, prefState: MutableState<T>, preference: SharedPreference<T>, value: T) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(text)
|
Text(text)
|
||||||
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
|
val colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package chat.simplex.app.views.usersettings
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Log
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.views.helpers.AlertManager
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
@@ -31,7 +33,7 @@ internal class Cryptor {
|
|||||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
val spec = GCMParameterSpec(128, iv)
|
val spec = GCMParameterSpec(128, iv)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
|
||||||
return String(cipher.doFinal(data))
|
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
||||||
|
|||||||
+5
-1
@@ -32,6 +32,10 @@ fun NetworkAndServersView(
|
|||||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
chatModel.userSMPServersUnsaved.value = null
|
||||||
|
}
|
||||||
|
|
||||||
NetworkAndServersLayout(
|
NetworkAndServersLayout(
|
||||||
developerTools = developerTools,
|
developerTools = developerTools,
|
||||||
networkUseSocksProxy = networkUseSocksProxy,
|
networkUseSocksProxy = networkUseSocksProxy,
|
||||||
@@ -112,7 +116,7 @@ fun NetworkAndServersView(
|
|||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.network_and_servers))
|
AppBarTitle(stringResource(R.string.network_and_servers))
|
||||||
SectionView(generalGetString(R.string.settings_section_title_messages)) {
|
SectionView(generalGetString(R.string.settings_section_title_messages)) {
|
||||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
|
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showSettingsModal { SMPServersView(it) })
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
SectionItemView {
|
SectionItemView {
|
||||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ fun NotificationsSettingsView(
|
|||||||
if (mode == NotificationsMode.SERVICE)
|
if (mode == NotificationsMode.SERVICE)
|
||||||
SimplexService.start(SimplexApp.context)
|
SimplexService.start(SimplexApp.context)
|
||||||
else
|
else
|
||||||
SimplexService.stop(SimplexApp.context)
|
SimplexService.safeStopService(SimplexApp.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode != NotificationsMode.PERIODIC) {
|
if (mode != NotificationsMode.PERIODIC) {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import SectionDivider
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) {
|
||||||
|
var preferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(user.fullPreferences) }
|
||||||
|
var currentPreferences by rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||||
|
|
||||||
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
|
||||||
|
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
|
||||||
|
if (updatedProfile != null) {
|
||||||
|
val updatedUser = user.copy(
|
||||||
|
profile = updatedProfile.toLocalProfile(user.profile.profileId),
|
||||||
|
fullPreferences = preferences
|
||||||
|
)
|
||||||
|
currentPreferences = preferences
|
||||||
|
m.currentUser.value = updatedUser
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (preferences == currentPreferences) close()
|
||||||
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
|
},
|
||||||
|
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||||
|
) {
|
||||||
|
PreferencesLayout(
|
||||||
|
preferences,
|
||||||
|
currentPreferences,
|
||||||
|
applyPrefs = { preferences = it },
|
||||||
|
reset = { preferences = currentPreferences },
|
||||||
|
savePrefs = ::savePrefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PreferencesLayout(
|
||||||
|
preferences: FullChatPreferences,
|
||||||
|
currentPreferences: FullChatPreferences,
|
||||||
|
applyPrefs: (FullChatPreferences) -> Unit,
|
||||||
|
reset: () -> Unit,
|
||||||
|
savePrefs: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.your_preferences))
|
||||||
|
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
|
||||||
|
FeatureSection(ChatFeature.FullDelete, allowFullDeletion) {
|
||||||
|
applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
|
||||||
|
FeatureSection(ChatFeature.Voice, allowVoice) {
|
||||||
|
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
ResetSaveButtons(
|
||||||
|
reset = reset,
|
||||||
|
save = savePrefs,
|
||||||
|
disabled = preferences == currentPreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView {
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
feature.text,
|
||||||
|
FeatureAllowed.values().map { it to it.text },
|
||||||
|
allowFeature,
|
||||||
|
icon = feature.icon,
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionTextFooter(feature.allowDescription(allowFeature.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(reset, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_notify_contacts), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_contacts),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
+57
-8
@@ -1,39 +1,88 @@
|
|||||||
package chat.simplex.app.views.usersettings
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
import SectionDivider
|
import SectionDivider
|
||||||
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.views.helpers.AppBarTitle
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
fun PrivacySettingsView(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
setPerformLA: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.Start
|
horizontalAlignment = Alignment.Start
|
||||||
) {
|
) {
|
||||||
|
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
|
||||||
AppBarTitle(stringResource(R.string.your_privacy))
|
AppBarTitle(stringResource(R.string.your_privacy))
|
||||||
SectionView(stringResource(R.string.settings_section_title_device)) {
|
SectionView(stringResource(R.string.settings_section_title_device)) {
|
||||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||||
|
SectionDivider()
|
||||||
|
val context = LocalContext.current
|
||||||
|
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
|
||||||
|
if (on) {
|
||||||
|
(context as? FragmentActivity)?.window?.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView(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)
|
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
if (chatModel.controller.appPrefs.developerTools.get()) {
|
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
|
||||||
SettingsPreferenceItem(Icons.Outlined.ImageAspectRatio, stringResource(R.string.transfer_images_faster), chatModel.controller.appPrefs.privacyTransferImagesInline)
|
SectionDivider()
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
|
||||||
|
simplexLinkMode.set(it)
|
||||||
|
chatModel.simplexLinkMode.value = it
|
||||||
|
}) }
|
||||||
|
}
|
||||||
|
if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) {
|
||||||
|
SectionTextFooter(stringResource(R.string.simplex_link_mode_browser_warning))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
|
||||||
|
val values = remember {
|
||||||
|
SimplexLinkMode.values().map {
|
||||||
|
when (it) {
|
||||||
|
SimplexLinkMode.DESCRIPTION -> it to generalGetString(R.string.simplex_link_mode_description)
|
||||||
|
SimplexLinkMode.FULL -> it to generalGetString(R.string.simplex_link_mode_full)
|
||||||
|
SimplexLinkMode.BROWSER -> it to generalGetString(R.string.simplex_link_mode_browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.simplex_link_mode),
|
||||||
|
values,
|
||||||
|
simplexLinkModeState,
|
||||||
|
icon = null,
|
||||||
|
enabled = remember { mutableStateOf(true) },
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import SectionDivider
|
||||||
|
import SectionItemView
|
||||||
|
import SectionItemViewSpaceBetween
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.QRCode
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SMPServerView(m: ChatModel, server: ServerCfg, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
SMPServerLayout(
|
||||||
|
testing,
|
||||||
|
server,
|
||||||
|
testServer = {
|
||||||
|
testing = true
|
||||||
|
scope.launch {
|
||||||
|
val res = testServerConnection(server, m)
|
||||||
|
if (isActive) {
|
||||||
|
onUpdate(res.first)
|
||||||
|
testing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate,
|
||||||
|
onDelete
|
||||||
|
)
|
||||||
|
if (testing) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(30.dp),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SMPServerLayout(
|
||||||
|
testing: Boolean,
|
||||||
|
server: ServerCfg,
|
||||||
|
testServer: () -> Unit,
|
||||||
|
onUpdate: (ServerCfg) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(bottom = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(if (server.preset) R.string.smp_servers_preset_server else R.string.smp_servers_your_server))
|
||||||
|
|
||||||
|
if (server.preset) {
|
||||||
|
PresetServer(testing, server, testServer, onUpdate, onDelete)
|
||||||
|
} else {
|
||||||
|
CustomServer(testing, server, testServer, onUpdate, onDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PresetServer(
|
||||||
|
testing: Boolean,
|
||||||
|
server: ServerCfg,
|
||||||
|
testServer: () -> Unit,
|
||||||
|
onUpdate: (ServerCfg) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
SectionView(stringResource(R.string.smp_servers_preset_address).uppercase()) {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
server.server,
|
||||||
|
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
|
||||||
|
color = HighOrLowlight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CustomServer(
|
||||||
|
testing: Boolean,
|
||||||
|
server: ServerCfg,
|
||||||
|
testServer: () -> Unit,
|
||||||
|
onUpdate: (ServerCfg) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val serverAddress = remember { mutableStateOf(server.server) }
|
||||||
|
val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } }
|
||||||
|
SectionView(
|
||||||
|
stringResource(R.string.smp_servers_your_server_address).uppercase(),
|
||||||
|
icon = Icons.Outlined.ErrorOutline,
|
||||||
|
iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent,
|
||||||
|
) {
|
||||||
|
val testedPreviously = remember { mutableMapOf<String, Boolean?>() }
|
||||||
|
TextEditor(
|
||||||
|
Modifier.height(144.dp),
|
||||||
|
text = serverAddress,
|
||||||
|
border = false,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
background = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background
|
||||||
|
) {
|
||||||
|
testedPreviously[server.server] = server.tested
|
||||||
|
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
|
||||||
|
SectionSpacer()
|
||||||
|
|
||||||
|
if (valid.value) {
|
||||||
|
SectionView(stringResource(R.string.smp_servers_add_to_another_device).uppercase()) {
|
||||||
|
QRCode(serverAddress.value, Modifier.aspectRatio(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UseServerSection(
|
||||||
|
valid: Boolean,
|
||||||
|
testing: Boolean,
|
||||||
|
server: ServerCfg,
|
||||||
|
testServer: () -> Unit,
|
||||||
|
onUpdate: (ServerCfg) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
SectionView(stringResource(R.string.smp_servers_use_server).uppercase()) {
|
||||||
|
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
|
||||||
|
Text(stringResource(R.string.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||||
|
ShowTestStatus(server)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView {
|
||||||
|
val enabled = rememberUpdatedState(server.enabled)
|
||||||
|
PreferenceToggle(stringResource(R.string.smp_servers_use_server_for_new_conn), enabled.value) { onUpdate(server.copy(enabled = it)) }
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView(onDelete, disabled = testing) {
|
||||||
|
Text(stringResource(R.string.smp_servers_delete_server), color = if (testing) HighOrLowlight else MaterialTheme.colors.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
|
||||||
|
when (server.tested) {
|
||||||
|
true -> Icon(Icons.Outlined.Check, null, modifier, tint = SimplexGreen)
|
||||||
|
false -> Icon(Icons.Outlined.Clear, null, modifier, tint = MaterialTheme.colors.error)
|
||||||
|
else -> Icon(Icons.Outlined.Check, null, modifier, tint = Color.Transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, SMPTestFailure?> =
|
||||||
|
try {
|
||||||
|
val r = m.controller.testSMPServer(server.server)
|
||||||
|
server.copy(tested = r == null) to r
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "testServerConnection ${e.stackTraceToString()}")
|
||||||
|
server.copy(tested = false) to null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serverHostname(srv: String): String =
|
||||||
|
parseServerAddress(srv)?.hostnames?.firstOrNull() ?: srv
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package chat.simplex.app.views.usersettings
|
|
||||||
|
|
||||||
import SectionItemViewSpaceBetween
|
|
||||||
import androidx.compose.foundation.*
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.material.*
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.OpenInNew
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import chat.simplex.app.R
|
|
||||||
import chat.simplex.app.model.ChatModel
|
|
||||||
import chat.simplex.app.ui.theme.*
|
|
||||||
import chat.simplex.app.views.helpers.*
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SMPServersView(chatModel: ChatModel) {
|
|
||||||
val userSMPServers = chatModel.userSMPServers.value
|
|
||||||
if (userSMPServers != null) {
|
|
||||||
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
|
|
||||||
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
|
|
||||||
val userSMPServersStr = remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
|
|
||||||
fun saveSMPServers(smpServers: List<String>) {
|
|
||||||
withApi {
|
|
||||||
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
|
|
||||||
if (r) {
|
|
||||||
chatModel.userSMPServers.value = smpServers
|
|
||||||
if (smpServers.isEmpty()) {
|
|
||||||
isUserSMPServers = false
|
|
||||||
editSMPServers = true
|
|
||||||
} else {
|
|
||||||
editSMPServers = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SMPServersLayout(
|
|
||||||
isUserSMPServers = isUserSMPServers,
|
|
||||||
editSMPServers = editSMPServers,
|
|
||||||
userSMPServersStr = userSMPServersStr,
|
|
||||||
isUserSMPServersOnOff = { switch ->
|
|
||||||
if (switch) {
|
|
||||||
isUserSMPServers = true
|
|
||||||
} else {
|
|
||||||
val userSMPServers = chatModel.userSMPServers.value
|
|
||||||
if (userSMPServers != null) {
|
|
||||||
if (userSMPServers.isNotEmpty()) {
|
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
title = generalGetString(R.string.use_simplex_chat_servers__question),
|
|
||||||
text = generalGetString(R.string.saved_SMP_servers_will_be_removed),
|
|
||||||
confirmText = generalGetString(R.string.confirm_verb),
|
|
||||||
onConfirm = {
|
|
||||||
saveSMPServers(listOf())
|
|
||||||
isUserSMPServers = false
|
|
||||||
userSMPServersStr.value = ""
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
isUserSMPServers = false
|
|
||||||
userSMPServersStr.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancelEdit = {
|
|
||||||
val userSMPServers = chatModel.userSMPServers.value
|
|
||||||
if (userSMPServers != null) {
|
|
||||||
isUserSMPServers = userSMPServers.isNotEmpty()
|
|
||||||
editSMPServers = !isUserSMPServers
|
|
||||||
userSMPServersStr.value = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveSMPServers = { saveSMPServers(it) },
|
|
||||||
editOn = { editSMPServers = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SMPServersLayout(
|
|
||||||
isUserSMPServers: Boolean,
|
|
||||||
editSMPServers: Boolean,
|
|
||||||
userSMPServersStr: MutableState<String>,
|
|
||||||
isUserSMPServersOnOff: (Boolean) -> Unit,
|
|
||||||
cancelEdit: () -> Unit,
|
|
||||||
saveSMPServers: (List<String>) -> Unit,
|
|
||||||
editOn: () -> Unit,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
AppBarTitle(stringResource(R.string.your_SMP_servers))
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = DEFAULT_PADDING),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
SectionItemViewSpaceBetween(padding = PaddingValues()) {
|
|
||||||
Text(stringResource(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp))
|
|
||||||
Switch(
|
|
||||||
checked = isUserSMPServers,
|
|
||||||
onCheckedChange = isUserSMPServersOnOff,
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isUserSMPServers) {
|
|
||||||
Text(stringResource(R.string.using_simplex_chat_servers), lineHeight = 22.sp)
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.enter_one_SMP_server_per_line))
|
|
||||||
if (editSMPServers) {
|
|
||||||
TextEditor(Modifier.height(160.dp), text = userSMPServersStr)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.Start) {
|
|
||||||
Row {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.cancel_verb),
|
|
||||||
color = MaterialTheme.colors.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = cancelEdit)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.save_servers_button),
|
|
||||||
color = MaterialTheme.colors.primary,
|
|
||||||
modifier = Modifier.clickable(onClick = {
|
|
||||||
val servers = userSMPServersStr.value.split("\n")
|
|
||||||
saveSMPServers(servers)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
howToButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.height(160.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
|
|
||||||
) {
|
|
||||||
SelectionContainer(
|
|
||||||
Modifier.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
userSMPServersStr.value,
|
|
||||||
Modifier
|
|
||||||
.padding(vertical = 5.dp, horizontal = 7.dp),
|
|
||||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.Start) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.edit_verb),
|
|
||||||
color = MaterialTheme.colors.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = editOn)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
howToButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun howToButton() {
|
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.how_to), color = MaterialTheme.colors.primary)
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.OpenInNew, stringResource(R.string.how_to), tint = MaterialTheme.colors.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 5.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun PreviewSMPServersLayoutDefaultServers() {
|
|
||||||
SimpleXTheme {
|
|
||||||
SMPServersLayout(
|
|
||||||
isUserSMPServers = false,
|
|
||||||
editSMPServers = true,
|
|
||||||
userSMPServersStr = remember { mutableStateOf("") },
|
|
||||||
isUserSMPServersOnOff = {},
|
|
||||||
cancelEdit = {},
|
|
||||||
saveSMPServers = {},
|
|
||||||
editOn = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun PreviewSMPServersLayoutUserServersEditOn() {
|
|
||||||
SimpleXTheme {
|
|
||||||
SMPServersLayout(
|
|
||||||
isUserSMPServers = true,
|
|
||||||
editSMPServers = true,
|
|
||||||
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
|
|
||||||
isUserSMPServersOnOff = {},
|
|
||||||
cancelEdit = {},
|
|
||||||
saveSMPServers = {},
|
|
||||||
editOn = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun PreviewSMPServersLayoutUserServersEditOff() {
|
|
||||||
SimpleXTheme {
|
|
||||||
SMPServersLayout(
|
|
||||||
isUserSMPServers = true,
|
|
||||||
editSMPServers = false,
|
|
||||||
userSMPServersStr = remember { mutableStateOf("smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im") },
|
|
||||||
isUserSMPServersOnOff = {},
|
|
||||||
cancelEdit = {},
|
|
||||||
saveSMPServers = {},
|
|
||||||
editOn = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import SectionDivider
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SMPServersView(m: ChatModel) {
|
||||||
|
var servers by remember {
|
||||||
|
mutableStateOf(m.userSMPServersUnsaved.value ?: m.userSMPServers.value ?: emptyList())
|
||||||
|
}
|
||||||
|
val testing = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val serversUnchanged = remember { derivedStateOf { servers == m.userSMPServers.value || testing.value } }
|
||||||
|
val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
|
||||||
|
val saveDisabled = remember {
|
||||||
|
derivedStateOf {
|
||||||
|
servers.isEmpty() ||
|
||||||
|
servers == m.userSMPServers.value ||
|
||||||
|
testing.value ||
|
||||||
|
!servers.all { srv ->
|
||||||
|
val address = parseServerAddress(srv.server)
|
||||||
|
address != null && uniqueAddress(srv, address, servers)
|
||||||
|
} ||
|
||||||
|
allServersDisabled.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showServer(server: ServerCfg) {
|
||||||
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
|
var old by remember { mutableStateOf(server) }
|
||||||
|
val index = servers.indexOf(old)
|
||||||
|
SMPServerView(
|
||||||
|
m,
|
||||||
|
old,
|
||||||
|
onUpdate = { updated ->
|
||||||
|
val newServers = ArrayList(servers)
|
||||||
|
newServers.removeAt(index)
|
||||||
|
newServers.add(index, updated)
|
||||||
|
old = updated
|
||||||
|
servers = newServers
|
||||||
|
m.userSMPServersUnsaved.value = servers
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
val newServers = ArrayList(servers)
|
||||||
|
newServers.removeAt(index)
|
||||||
|
servers = newServers
|
||||||
|
m.userSMPServersUnsaved.value = servers
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
SMPServersLayout(
|
||||||
|
testing = testing.value,
|
||||||
|
servers = servers,
|
||||||
|
serversUnchanged = serversUnchanged.value,
|
||||||
|
saveDisabled = saveDisabled.value,
|
||||||
|
allServersDisabled = allServersDisabled.value,
|
||||||
|
addServer = {
|
||||||
|
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||||
|
title = generalGetString(R.string.smp_servers_add),
|
||||||
|
buttons = {
|
||||||
|
Column {
|
||||||
|
SectionItemView({
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
servers = servers + ServerCfg.empty
|
||||||
|
// No saving until something will be changed on the next screen to prevent blank servers on the list
|
||||||
|
showServer(servers.last())
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.smp_servers_enter_manually))
|
||||||
|
}
|
||||||
|
SectionItemView({
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
ModalManager.shared.showModalCloseable { close ->
|
||||||
|
ScanSMPServer {
|
||||||
|
close()
|
||||||
|
servers = servers + it
|
||||||
|
m.userSMPServersUnsaved.value = servers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.smp_servers_scan_qr))
|
||||||
|
}
|
||||||
|
val hasAllPresets = hasAllPresets(servers, m)
|
||||||
|
if (!hasAllPresets) {
|
||||||
|
SectionItemView({
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
servers = (servers + addAllPresets(servers, m)).sortedByDescending { it.preset }
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.smp_servers_preset_add), color = MaterialTheme.colors.onBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
testServers = {
|
||||||
|
scope.launch {
|
||||||
|
testServers(testing, servers, m) {
|
||||||
|
servers = it
|
||||||
|
m.userSMPServersUnsaved.value = servers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetServers = {
|
||||||
|
servers = m.userSMPServers.value ?: emptyList()
|
||||||
|
m.userSMPServersUnsaved.value = null
|
||||||
|
},
|
||||||
|
saveSMPServers = {
|
||||||
|
saveSMPServers(servers, m)
|
||||||
|
},
|
||||||
|
showServer = ::showServer,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (testing.value) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(30.dp),
|
||||||
|
color = HighOrLowlight,
|
||||||
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SMPServersLayout(
|
||||||
|
testing: Boolean,
|
||||||
|
servers: List<ServerCfg>,
|
||||||
|
serversUnchanged: Boolean,
|
||||||
|
saveDisabled: Boolean,
|
||||||
|
allServersDisabled: Boolean,
|
||||||
|
addServer: () -> Unit,
|
||||||
|
testServers: () -> Unit,
|
||||||
|
resetServers: () -> Unit,
|
||||||
|
saveSMPServers: () -> Unit,
|
||||||
|
showServer: (ServerCfg) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(bottom = DEFAULT_PADDING),
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.your_SMP_servers))
|
||||||
|
|
||||||
|
SectionView(stringResource(R.string.smp_servers).uppercase()) {
|
||||||
|
for (srv in servers) {
|
||||||
|
SectionItemView({ showServer(srv) }, disabled = testing) {
|
||||||
|
SmpServerView(srv, servers, testing)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
}
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.Add,
|
||||||
|
stringResource(R.string.smp_servers_add),
|
||||||
|
addServer,
|
||||||
|
disabled = testing,
|
||||||
|
textColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary,
|
||||||
|
iconColor = if (testing) HighOrLowlight else MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(resetServers, disabled = serversUnchanged) {
|
||||||
|
Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
val testServersDisabled = testing || allServersDisabled
|
||||||
|
SectionItemView(testServers, disabled = testServersDisabled) {
|
||||||
|
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||||
|
}
|
||||||
|
SectionDivider()
|
||||||
|
SectionItemView(saveSMPServers, disabled = saveDisabled) {
|
||||||
|
Text(stringResource(R.string.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else HighOrLowlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
SectionView {
|
||||||
|
HowToButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmpServerView(srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
|
||||||
|
val address = parseServerAddress(srv.server)
|
||||||
|
when {
|
||||||
|
address == null || !address.valid || !uniqueAddress(srv, address, servers) -> InvalidServer()
|
||||||
|
!srv.enabled -> Icon(Icons.Outlined.DoNotDisturb, null, tint = HighOrLowlight)
|
||||||
|
else -> ShowTestStatus(srv)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
val text = address?.hostnames?.firstOrNull() ?: srv.server
|
||||||
|
if (srv.enabled) {
|
||||||
|
Text(text, color = if (disabled) HighOrLowlight else MaterialTheme.colors.onBackground, maxLines = 1)
|
||||||
|
} else {
|
||||||
|
Text(text, maxLines = 1, color = HighOrLowlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HowToButton() {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.OpenInNew,
|
||||||
|
stringResource(R.string.how_to_use_your_servers),
|
||||||
|
{ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md") },
|
||||||
|
textColor = MaterialTheme.colors.primary,
|
||||||
|
iconColor = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InvalidServer() {
|
||||||
|
Icon(Icons.Outlined.ErrorOutline, null, tint = MaterialTheme.colors.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
|
||||||
|
address.hostnames.all { host ->
|
||||||
|
srv.id == s.id || !srv.server.contains(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAllPresets(servers: List<ServerCfg>, m: ChatModel): Boolean =
|
||||||
|
m.presetSMPServers.value?.all { hasPreset(it, servers) } ?: true
|
||||||
|
|
||||||
|
private fun addAllPresets(servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
|
||||||
|
val toAdd = ArrayList<ServerCfg>()
|
||||||
|
for (srv in m.presetSMPServers.value ?: emptyList()) {
|
||||||
|
if (!hasPreset(srv, servers)) {
|
||||||
|
toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toAdd
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPreset(srv: String, servers: List<ServerCfg>): Boolean =
|
||||||
|
servers.any { it.server == srv }
|
||||||
|
|
||||||
|
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
|
||||||
|
val resetStatus = resetTestStatus(servers)
|
||||||
|
onUpdated(resetStatus)
|
||||||
|
testing.value = true
|
||||||
|
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
|
||||||
|
testing.value = false
|
||||||
|
if (fs.isNotEmpty()) {
|
||||||
|
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.smp_servers_test_failed),
|
||||||
|
text = generalGetString(R.string.smp_servers_test_some_failed) + "\n" + msg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
|
||||||
|
val copy = ArrayList(servers)
|
||||||
|
for ((index, server) in servers.withIndex()) {
|
||||||
|
if (server.enabled) {
|
||||||
|
copy.removeAt(index)
|
||||||
|
copy.add(index, server.copy(tested = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, SMPTestFailure> {
|
||||||
|
val fs: MutableMap<String, SMPTestFailure> = mutableMapOf()
|
||||||
|
val updatedServers = ArrayList<ServerCfg>(servers)
|
||||||
|
for ((index, server) in servers.withIndex()) {
|
||||||
|
if (server.enabled) {
|
||||||
|
val (updatedServer, f) = testServerConnection(server, m)
|
||||||
|
updatedServers.removeAt(index)
|
||||||
|
updatedServers.add(index, updatedServer)
|
||||||
|
// toList() is important. Otherwise, Compose will not redraw the screen after first update
|
||||||
|
onUpdated(updatedServers.toList())
|
||||||
|
if (f != null) {
|
||||||
|
fs[serverHostname(updatedServer.server)] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSMPServers(servers: List<ServerCfg>, m: ChatModel) {
|
||||||
|
withApi {
|
||||||
|
if (m.controller.setUserSMPServers(servers)) {
|
||||||
|
m.userSMPServers.value = servers
|
||||||
|
m.userSMPServersUnsaved.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package chat.simplex.app.views.usersettings
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress
|
||||||
|
import chat.simplex.app.model.ServerCfg
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.QRCodeScanner
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScanSMPServer(onNext: (ServerCfg) -> Unit) {
|
||||||
|
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
cameraPermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
ScanSMPServerLayout(onNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScanSMPServerLayout(onNext: (ServerCfg) -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.smp_servers_scan_qr), false)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio = 1F)
|
||||||
|
.padding(bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
QRCodeScanner { text ->
|
||||||
|
val res = parseServerAddress(text)
|
||||||
|
if (res != null) {
|
||||||
|
onNext(ServerCfg(text, false, null, true))
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.smp_servers_invalid_address),
|
||||||
|
text = generalGetString(R.string.smp_servers_check_address)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-19
@@ -80,8 +80,8 @@ fun SettingsLayout(
|
|||||||
stopped: Boolean,
|
stopped: Boolean,
|
||||||
encrypted: Boolean,
|
encrypted: Boolean,
|
||||||
incognito: MutableState<Boolean>,
|
incognito: MutableState<Boolean>,
|
||||||
incognitoPref: Preference<Boolean>,
|
incognitoPref: SharedPreference<Boolean>,
|
||||||
developerTools: Preference<Boolean>,
|
developerTools: SharedPreference<Boolean>,
|
||||||
userDisplayName: String,
|
userDisplayName: String,
|
||||||
setPerformLA: (Boolean) -> Unit,
|
setPerformLA: (Boolean) -> Unit,
|
||||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||||
@@ -116,20 +116,22 @@ fun SettingsLayout(
|
|||||||
SectionDivider()
|
SectionDivider()
|
||||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
|
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
ChatPreferencesItem(showCustomModal)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||||
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
|
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
|
||||||
|
SectionDivider()
|
||||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
|
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView() }, disabled = stopped)
|
||||||
SectionDivider()
|
SectionDivider()
|
||||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped)
|
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
@@ -175,7 +177,7 @@ fun SettingsLayout(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsIncognitoActionItem(
|
fun SettingsIncognitoActionItem(
|
||||||
incognitoPref: Preference<Boolean>,
|
incognitoPref: SharedPreference<Boolean>,
|
||||||
incognito: MutableState<Boolean>,
|
incognito: MutableState<Boolean>,
|
||||||
stopped: Boolean,
|
stopped: Boolean,
|
||||||
onClickInfo: () -> Unit,
|
onClickInfo: () -> Unit,
|
||||||
@@ -237,6 +239,20 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
|
||||||
|
SettingsActionItem(
|
||||||
|
Icons.Outlined.ToggleOn,
|
||||||
|
stringResource(R.string.chat_preferences),
|
||||||
|
click = {
|
||||||
|
withApi {
|
||||||
|
showCustomModal { m, close ->
|
||||||
|
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||||
SectionItemView() {
|
SectionItemView() {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -365,12 +381,18 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference<Boolean>, prefState: MutableState<Boolean>? = null) {
|
fun SettingsPreferenceItem(
|
||||||
SectionItemView() {
|
icon: ImageVector,
|
||||||
|
text: String,
|
||||||
|
pref: SharedPreference<Boolean>,
|
||||||
|
prefState: MutableState<Boolean>? = null,
|
||||||
|
onChange: ((Boolean) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
SectionItemView {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(icon, text, tint = HighOrLowlight)
|
Icon(icon, text, tint = HighOrLowlight)
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
SharedPreferenceToggle(text, pref, prefState)
|
SharedPreferenceToggle(text, pref, prefState, onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,7 +404,7 @@ fun SettingsPreferenceItemWithInfo(
|
|||||||
text: String,
|
text: String,
|
||||||
stopped: Boolean,
|
stopped: Boolean,
|
||||||
onClickInfo: () -> Unit,
|
onClickInfo: () -> Unit,
|
||||||
pref: Preference<Boolean>,
|
pref: SharedPreference<Boolean>,
|
||||||
prefState: MutableState<Boolean>? = null
|
prefState: MutableState<Boolean>? = null
|
||||||
) {
|
) {
|
||||||
SectionItemView(onClickInfo) {
|
SectionItemView(onClickInfo) {
|
||||||
@@ -395,20 +417,42 @@ fun SettingsPreferenceItemWithInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferenceToggleWithIcon(
|
fun PreferenceToggle(
|
||||||
text: String,
|
text: String,
|
||||||
icon: ImageVector,
|
|
||||||
iconColor: Color = HighOrLowlight,
|
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onChange: (Boolean) -> Unit = {},
|
onChange: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Text(text)
|
||||||
icon,
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
null,
|
Switch(
|
||||||
tint = iconColor
|
checked = checked,
|
||||||
|
onCheckedChange = onChange,
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = HighOrLowlight
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceToggleWithIcon(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
iconColor: Color? = HighOrLowlight,
|
||||||
|
checked: Boolean,
|
||||||
|
onChange: (Boolean) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
null,
|
||||||
|
tint = iconColor ?: HighOrLowlight
|
||||||
|
)
|
||||||
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
|
}
|
||||||
Text(text)
|
Text(text)
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
Switch(
|
Switch(
|
||||||
@@ -438,8 +482,8 @@ fun PreviewSettingsLayout() {
|
|||||||
stopped = false,
|
stopped = false,
|
||||||
encrypted = false,
|
encrypted = false,
|
||||||
incognito = remember { mutableStateOf(false) },
|
incognito = remember { mutableStateOf(false) },
|
||||||
incognitoPref = Preference({ false }, {}),
|
incognitoPref = SharedPreference({ false }, {}),
|
||||||
developerTools = Preference({ false }, {}),
|
developerTools = SharedPreference({ false }, {}),
|
||||||
userDisplayName = "Alice",
|
userDisplayName = "Alice",
|
||||||
setPerformLA = {},
|
setPerformLA = {},
|
||||||
showModal = { {} },
|
showModal = { {} },
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<!-- Item Content - ChatModel.kt -->
|
<!-- Item Content - ChatModel.kt -->
|
||||||
<string name="deleted_description">Gelöscht</string>
|
<string name="deleted_description">Gelöscht</string>
|
||||||
|
<string name="marked_deleted_description">als gelöscht markiert</string>
|
||||||
<string name="sending_files_not_yet_supported">Das Senden von Dateien wird noch nicht unterstützt</string>
|
<string name="sending_files_not_yet_supported">Das Senden von Dateien wird noch nicht unterstützt</string>
|
||||||
<string name="receiving_files_not_yet_supported">Der Empfang von Dateien wird noch nicht unterstützt</string>
|
<string name="receiving_files_not_yet_supported">Der Empfang von Dateien wird noch nicht unterstützt</string>
|
||||||
<string name="sender_you_pronoun">Meine Daten</string>
|
<string name="sender_you_pronoun">Meine Daten</string>
|
||||||
@@ -41,6 +42,17 @@
|
|||||||
<string name="description_via_one_time_link">über einen Einmal-Link</string>
|
<string name="description_via_one_time_link">über einen Einmal-Link</string>
|
||||||
<string name="description_via_one_time_link_incognito">Inkognito über einen Einmal-Link</string>
|
<string name="description_via_one_time_link_incognito">Inkognito über einen Einmal-Link</string>
|
||||||
|
|
||||||
|
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||||
|
<string name="simplex_link_contact">SimpleX Kontaktadressen-Link</string>
|
||||||
|
<string name="simplex_link_invitation">SimpleX Einmal-Link</string>
|
||||||
|
<string name="simplex_link_group">SimpleX Gruppen-Link</string>
|
||||||
|
<string name="simplex_link_connection">über <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||||
|
<string name="simplex_link_mode">SimpleX-Links</string>
|
||||||
|
<string name="simplex_link_mode_description">Beschreibung</string>
|
||||||
|
<string name="simplex_link_mode_full">Vollständiger Link</string>
|
||||||
|
<string name="simplex_link_mode_browser">Über den Browser</string>
|
||||||
|
<string name="simplex_link_mode_browser_warning">Das Öffnen des Links über den Browser kann die Privatsphäre und Sicherheit der Verbindung reduzieren. SimpleX-Links, denen nicht vertraut wird, werden Rot sein.</string>
|
||||||
|
|
||||||
<!-- SimpleXAPI.kt -->
|
<!-- SimpleXAPI.kt -->
|
||||||
<string name="error_saving_smp_servers">Fehler beim Speichern der SMP-Server</string>
|
<string name="error_saving_smp_servers">Fehler beim Speichern der SMP-Server</string>
|
||||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Stellen Sie sicher, dass die SMP-Server Adressen das richtige Format haben, zeilenweise angeordnet und nicht kopiert sind.</string>
|
<string name="ensure_smp_server_address_are_correct_format_and_unique">Stellen Sie sicher, dass die SMP-Server Adressen das richtige Format haben, zeilenweise angeordnet und nicht kopiert sind.</string>
|
||||||
@@ -49,7 +61,7 @@
|
|||||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||||
<string name="connection_timeout">Verbindungszeitüberschreitung</string>
|
<string name="connection_timeout">Verbindungszeitüberschreitung</string>
|
||||||
<string name="connection_error">Verbindungsfehler</string>
|
<string name="connection_error">Verbindungsfehler</string>
|
||||||
<string name="network_error_desc">Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.</string>
|
<string name="network_error_desc">Bitte überprüfen Sie Ihre Netzwerkverbindung mit <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> und versuchen Sie es erneut.</string>
|
||||||
<string name="error_sending_message">Fehler beim Senden der Nachricht</string>
|
<string name="error_sending_message">Fehler beim Senden der Nachricht</string>
|
||||||
<string name="error_adding_members">Fehler beim Hinzufügen von Mitgliedern</string>
|
<string name="error_adding_members">Fehler beim Hinzufügen von Mitgliedern</string>
|
||||||
<string name="error_joining_group">Fehler beim Beitritt zur Gruppe</string>
|
<string name="error_joining_group">Fehler beim Beitritt zur Gruppe</string>
|
||||||
@@ -70,6 +82,14 @@
|
|||||||
<string name="error_deleting_contact_request">Fehler beim Löschen der Kontaktanfrage</string>
|
<string name="error_deleting_contact_request">Fehler beim Löschen der Kontaktanfrage</string>
|
||||||
<string name="error_deleting_pending_contact_connection">Fehler beim Löschen der anstehenden Kontaktaufnahme</string>
|
<string name="error_deleting_pending_contact_connection">Fehler beim Löschen der anstehenden Kontaktaufnahme</string>
|
||||||
<string name="error_changing_address">Fehler beim Wechseln der Adresse</string>
|
<string name="error_changing_address">Fehler beim Wechseln der Adresse</string>
|
||||||
|
<string name="error_smp_test_failed_at_step">Der Test ist beim Schritt %s fehlgeschlagen.</string>
|
||||||
|
<string name="error_smp_test_server_auth">Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort.</string>
|
||||||
|
<string name="error_smp_test_certificate">Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig.</string>
|
||||||
|
<string name="smp_server_test_connect">Verbinde</string>
|
||||||
|
<string name="smp_server_test_create_queue">Erzeuge Warteschlange</string>
|
||||||
|
<string name="smp_server_test_secure_queue">Sichere Warteschlange</string>
|
||||||
|
<string name="smp_server_test_delete_queue">Lösche Warteschlange</string>
|
||||||
|
<string name="smp_server_test_disconnect">Trenne Verbindung</string>
|
||||||
|
|
||||||
<!-- background service notice - SimpleXAPI.kt -->
|
<!-- background service notice - SimpleXAPI.kt -->
|
||||||
<string name="icon_descr_instant_notifications">Sofortige Benachrichtigungen</string>
|
<string name="icon_descr_instant_notifications">Sofortige Benachrichtigungen</string>
|
||||||
@@ -95,7 +115,6 @@
|
|||||||
<!-- Notification channels -->
|
<!-- Notification channels -->
|
||||||
<string name="ntf_channel_messages">SimpleX Chat Nachrichten</string>
|
<string name="ntf_channel_messages">SimpleX Chat Nachrichten</string>
|
||||||
<string name="ntf_channel_calls">SimpleX Chat Anrufe</string>
|
<string name="ntf_channel_calls">SimpleX Chat Anrufe</string>
|
||||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat Anrufe (Sperrbildschirm)</string>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="settings_notifications_mode_title">Benachrichtigungsdienst</string>
|
<string name="settings_notifications_mode_title">Benachrichtigungsdienst</string>
|
||||||
@@ -148,9 +167,13 @@
|
|||||||
<string name="save_verb">Speichern</string>
|
<string name="save_verb">Speichern</string>
|
||||||
<string name="edit_verb">Bearbeiten</string>
|
<string name="edit_verb">Bearbeiten</string>
|
||||||
<string name="delete_verb">Löschen</string>
|
<string name="delete_verb">Löschen</string>
|
||||||
|
<string name="reveal_verb">Aufdecken</string>
|
||||||
|
<string name="hide_verb">Verbergen</string>
|
||||||
|
<string name="allow_verb">Erlauben</string>
|
||||||
<string name="delete_message__question">Die Nachricht löschen?</string>
|
<string name="delete_message__question">Die Nachricht löschen?</string>
|
||||||
<string name="delete_message_cannot_be_undone_warning">Nachricht wird gelöscht - dies kann nicht rückgängig gemacht werden!</string>
|
<string name="delete_message_cannot_be_undone_warning">Nachricht wird gelöscht - dies kann nicht rückgängig gemacht werden!</string>
|
||||||
<string name="for_me_only">Nur für mich</string>
|
<string name="delete_message_mark_deleted_warning">Die Nachricht wird zum Löschen markiert. Der/die Empfänger kann/können diese Nachricht aufdecken.</string>
|
||||||
|
<string name="for_me_only">Für mich löschen</string>
|
||||||
<string name="for_everybody">Für alle</string>
|
<string name="for_everybody">Für alle</string>
|
||||||
|
|
||||||
<!-- CIMetaView.kt -->
|
<!-- CIMetaView.kt -->
|
||||||
@@ -208,6 +231,11 @@
|
|||||||
<string name="file_not_found">Datei nicht gefunden</string>
|
<string name="file_not_found">Datei nicht gefunden</string>
|
||||||
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Sprachnachricht</string>
|
||||||
|
<string name="voice_message_with_duration">Sprachnachricht (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||||
|
<string name="voice_message_send_text">Sprachnachricht…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Benachrichtigungen</string>
|
<string name="notifications">Benachrichtigungen</string>
|
||||||
|
|
||||||
@@ -221,15 +249,22 @@
|
|||||||
<string name="icon_descr_server_status_error">Fehler</string>
|
<string name="icon_descr_server_status_error">Fehler</string>
|
||||||
<string name="icon_descr_server_status_pending">Ausstehend</string>
|
<string name="icon_descr_server_status_pending">Ausstehend</string>
|
||||||
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
|
<string name="switch_receiving_address_question">Empfängeradresse wechseln?</string>
|
||||||
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls die SimpleX-Version 4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
<string name="switch_receiving_address_desc">Diese Funktion ist experimentell! Sie wird nur funktionieren, wenn der Kontakt ebenfalls eine SimpleX-Version ab v4.2 installiert hat. Sobald der Adress-Wechsel abgeschlossen ist, sollten Sie die Nachricht im Chat-Verlauf sehen. Bitte prüfen Sie, ob Sie weiterhin Nachrichten von diesem Kontakt (oder Gruppenmitglied) empfangen können.</string>
|
||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Nachricht senden</string>
|
<string name="icon_descr_send_message">Nachricht senden</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Nehme Sprachnachricht auf</string>
|
||||||
|
<string name="allow_voice_messages_question">Sprachnachrichten erlauben?</string>
|
||||||
|
<string name="you_need_to_allow_to_send_voice">Sie müssen Ihrem Kontakt das Senden von Sprachnachrichten erlauben, damit Sie sie senden können.</string>
|
||||||
|
<string name="voice_messages_prohibited">Sprachnachrichten unzulässig!</string>
|
||||||
|
<string name="ask_your_contact_to_enable_voice">Bitten Sie Ihren Kontakt darum, das Senden von Sprachnachrichten zu aktivieren.</string>
|
||||||
|
<string name="only_group_owners_can_enable_voice">Sprachnachrichten können nur von Gruppen-Eigentümern aktiviert werden.</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Zurück</string>
|
<string name="back">Zurück</string>
|
||||||
<string name="cancel_verb">Abbrechen</string>
|
<string name="cancel_verb">Abbrechen</string>
|
||||||
<string name="confirm_verb">Bestätigen</string>
|
<string name="confirm_verb">Bestätigen</string>
|
||||||
|
<string name="reset_verb">Zurücksetzen</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
<string name="no_details">Keine Details</string>
|
<string name="no_details">Keine Details</string>
|
||||||
<string name="add_contact">Kontakt hinzufügen</string>
|
<string name="add_contact">Kontakt hinzufügen</string>
|
||||||
@@ -363,17 +398,34 @@
|
|||||||
<string name="chat_lock">SimpleX Sperre</string>
|
<string name="chat_lock">SimpleX Sperre</string>
|
||||||
<string name="chat_console">Chat Konsole</string>
|
<string name="chat_console">Chat Konsole</string>
|
||||||
<string name="smp_servers">SMP-Server</string>
|
<string name="smp_servers">SMP-Server</string>
|
||||||
|
<string name="smp_servers_preset_address">Voreingestellte Serveradresse</string>
|
||||||
|
<string name="smp_servers_preset_add">Füge voreingestellte Server hinzu</string>
|
||||||
|
<string name="smp_servers_add">Füge Server hinzu…</string>
|
||||||
|
<string name="smp_servers_test_server">Teste Server</string>
|
||||||
|
<string name="smp_servers_test_servers">Teste alle Server</string>
|
||||||
|
<string name="smp_servers_save">Sichere alle Server</string>
|
||||||
|
<string name="smp_servers_test_failed">Server Test ist fehlgeschlagen!</string>
|
||||||
|
<string name="smp_servers_test_some_failed">Einige Server haben den Test nicht bestanden:</string>
|
||||||
|
<string name="smp_servers_scan_qr">Scannen Sie den QR-Code des Servers</string>
|
||||||
|
<string name="smp_servers_enter_manually">Geben Sie den Server manuell ein</string>
|
||||||
|
<string name="smp_servers_preset_server">Voreingestellter Server</string>
|
||||||
|
<string name="smp_servers_your_server">Ihr Server</string>
|
||||||
|
<string name="smp_servers_your_server_address">Ihre Serveradresse</string>
|
||||||
|
<string name="smp_servers_use_server">Server nutzen</string>
|
||||||
|
<string name="smp_servers_use_server_for_new_conn">Für neue Verbindungen nutzen</string>
|
||||||
|
<string name="smp_servers_add_to_another_device">Einem anderen Gerät hinzufügen</string>
|
||||||
|
<string name="smp_servers_invalid_address">Ungültige Serveradresse!</string>
|
||||||
|
<string name="smp_servers_check_address">Überprüfen Sie die Serveradresse und versuchen Sie es nochmal.</string>
|
||||||
|
<string name="smp_servers_delete_server">Server löschen</string>
|
||||||
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
|
<string name="install_simplex_chat_for_terminal">Installieren Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> als Terminalanwendung</string>
|
||||||
<string name="star_on_github">Stern auf GitHub</string>
|
<string name="star_on_github">Stern auf GitHub vergeben</string>
|
||||||
<string name="contribute">Beitragen</string>
|
<string name="contribute">Unterstützen Sie uns</string>
|
||||||
<string name="rate_the_app">Bewerte die App</string>
|
<string name="rate_the_app">Bewerten Sie die App</string>
|
||||||
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
|
<string name="use_simplex_chat_servers__question">Verwenden Sie <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Server?</string>
|
||||||
<string name="saved_SMP_servers_will_be_removed">Gespeicherte SMP-Server werden entfernt.</string>
|
|
||||||
<string name="your_SMP_servers">Ihre SMP-Server</string>
|
<string name="your_SMP_servers">Ihre SMP-Server</string>
|
||||||
<string name="configure_SMP_servers">SMP-Server konfigurieren</string>
|
|
||||||
<string name="using_simplex_chat_servers">Verwendung von <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Servern.</string>
|
<string name="using_simplex_chat_servers">Verwendung von <xliff:g id="appNameFull">SimpleX Chat</xliff:g> Servern.</string>
|
||||||
<string name="enter_one_SMP_server_per_line">SMP-Server (einer pro Zeile)</string>
|
|
||||||
<string name="how_to">Anleitung</string>
|
<string name="how_to">Anleitung</string>
|
||||||
|
<string name="how_to_use_your_servers">Wie Sie Ihre Server nutzen</string>
|
||||||
<string name="saved_ICE_servers_will_be_removed">Gespeicherte WebRTC ICE-Server werden entfernt.</string>
|
<string name="saved_ICE_servers_will_be_removed">Gespeicherte WebRTC ICE-Server werden entfernt.</string>
|
||||||
<string name="your_ICE_servers">Ihre ICE-Server</string>
|
<string name="your_ICE_servers">Ihre ICE-Server</string>
|
||||||
<string name="configure_ICE_servers">Konfigurieren Sie ICE-Server</string>
|
<string name="configure_ICE_servers">Konfigurieren Sie ICE-Server</string>
|
||||||
@@ -423,7 +475,11 @@
|
|||||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\n<xliff:g id="appName">SimpleX</xliff:g>-Server können Ihr Profil nicht sehen.</string>
|
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt.\n\n<xliff:g id="appName">SimpleX</xliff:g>-Server können Ihr Profil nicht sehen.</string>
|
||||||
<string name="edit_image">Bild bearbeiten</string>
|
<string name="edit_image">Bild bearbeiten</string>
|
||||||
<string name="delete_image">Bild löschen</string>
|
<string name="delete_image">Bild löschen</string>
|
||||||
<string name="save_and_notify_contacts">Speichern (und Kontakte benachrichtigen)</string>
|
<string name="save_preferences_question">Präferenzen speichern?</string>
|
||||||
|
<string name="save_and_notify_contact">Speichern und Kontakt benachrichtigen</string>
|
||||||
|
<string name="save_and_notify_contacts">Speichern und Kontakte benachrichtigen</string>
|
||||||
|
<string name="save_and_notify_group_members">Speichern und Gruppenmitglieder benachrichtigen</string>
|
||||||
|
<string name="exit_without_saving">Beenden ohne Speichern</string>
|
||||||
|
|
||||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||||
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
|
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
|
||||||
@@ -482,12 +538,12 @@
|
|||||||
|
|
||||||
<!-- How SimpleX Works -->
|
<!-- How SimpleX Works -->
|
||||||
<string name="how_simplex_works">Wie <xliff:g id="appName">SimpleX</xliff:g> funktioniert</string>
|
<string name="how_simplex_works">Wie <xliff:g id="appName">SimpleX</xliff:g> funktioniert</string>
|
||||||
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wenn <xliff:g id="appName">SimpleX</xliff:g> keine Benutzerkennungen hat, wie kann es dann Nachrichten zustellen?</i></string>
|
<string name="many_people_asked_how_can_it_deliver">Viele Menschen haben gefragt: <i>Wie kann <xliff:g id="appName">SimpleX</xliff:g> Nachrichten zustellen, wenn es keine Benutzerkennungen gibt?</i></string>
|
||||||
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzer-IDs, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
|
<string name="to_protect_privacy_simplex_has_ids_for_queues">Zum Schutz Ihrer Privatsphäre verwendet <xliff:g id="appName">SimpleX</xliff:g> an Stelle von Benutzerkennungen, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind.</string>
|
||||||
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie legen fest, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b>.</string>
|
<string name="you_control_servers_to_receive_your_contacts_to_send">Sie können selbst festlegen, über welche Server Sie Ihre Nachrichten <b>empfangen</b> und an Ihre Kontakte <b>senden</b> wollen.</string>
|
||||||
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern Benutzerprofile, Kontakte, Gruppen und Nachrichten, die über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
|
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages">Nur die Endgeräte speichern die Benutzerprofile, Kontakte, Gruppen und Nachrichten, welche über eine <b>2-Schichten Ende-zu-Ende-Verschlüsselung</b> gesendet werden.</string>
|
||||||
<string name="read_more_in_github">Erfahren Sie mehr dazu in unserem GitHub-Repository.</string>
|
<string name="read_more_in_github">Erfahren Sie in unserem GitHub-Repository mehr dazu.</string>
|
||||||
<string name="read_more_in_github_with_link">Erfahren Sie mehr dazu in unserem <font color="#0088ff">GitHub-Repository</font>.</string>
|
<string name="read_more_in_github_with_link">Erfahren Sie in unserem <font color="#0088ff">GitHub-Repository</font> mehr dazu.</string>
|
||||||
|
|
||||||
<!-- MakeConnection -->
|
<!-- MakeConnection -->
|
||||||
<string name="paste_the_link_you_received">Fügen Sie den erhaltenen Link ein</string>
|
<string name="paste_the_link_you_received">Fügen Sie den erhaltenen Link ein</string>
|
||||||
@@ -559,15 +615,17 @@
|
|||||||
<!-- Privacy settings -->
|
<!-- Privacy settings -->
|
||||||
<string name="privacy_and_security">Datenschutz & Sicherheit</string>
|
<string name="privacy_and_security">Datenschutz & Sicherheit</string>
|
||||||
<string name="your_privacy">Meine Privatsphäre</string>
|
<string name="your_privacy">Meine Privatsphäre</string>
|
||||||
|
<string name="protect_app_screen">App-Bildschirm schützen</string>
|
||||||
<string name="auto_accept_images">Bilder automatisch akzeptieren</string>
|
<string name="auto_accept_images">Bilder automatisch akzeptieren</string>
|
||||||
<string name="transfer_images_faster">Bilder schneller übertragen (BETA)</string>
|
<string name="transfer_images_faster">Bilder schneller übertragen</string>
|
||||||
<string name="send_link_previews">Link-Vorschau senden</string>
|
<string name="send_link_previews">Link-Vorschau senden</string>
|
||||||
|
<string name="full_backup">App-Datensicherung</string>
|
||||||
|
|
||||||
<!-- Settings sections -->
|
<!-- Settings sections -->
|
||||||
<string name="settings_section_title_you">MEINE DATEN</string>
|
<string name="settings_section_title_you">MEINE DATEN</string>
|
||||||
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
|
<string name="settings_section_title_settings">EINSTELLUNGEN</string>
|
||||||
<string name="settings_section_title_help">HILFE</string>
|
<string name="settings_section_title_help">HILFE</string>
|
||||||
<string name="settings_section_title_support">UNTERSTÜTZEN SIMPLEX CHAT</string>
|
<string name="settings_section_title_support">UNTERSTÜTZUNG VON SIMPLEX CHAT</string>
|
||||||
<string name="settings_section_title_develop">ENTWICKLUNG</string>
|
<string name="settings_section_title_develop">ENTWICKLUNG</string>
|
||||||
<string name="settings_section_title_device">GERÄT</string>
|
<string name="settings_section_title_device">GERÄT</string>
|
||||||
<string name="settings_section_title_chats">CHATS</string>
|
<string name="settings_section_title_chats">CHATS</string>
|
||||||
@@ -682,7 +740,7 @@
|
|||||||
<string name="restore_database_alert_desc">Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
<string name="restore_database_alert_desc">Bitte geben Sie das vorherige Passwort ein, nachdem Sie die Datenbanksicherung wiederhergestellt haben. Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
||||||
<string name="restore_database_alert_confirm">Wiederherstellen</string>
|
<string name="restore_database_alert_confirm">Wiederherstellen</string>
|
||||||
<string name="database_restore_error">Fehler bei der Wiederherstellung der Datenbank</string>
|
<string name="database_restore_error">Fehler bei der Wiederherstellung der Datenbank</string>
|
||||||
<string name="restore_passphrase_not_found_desc">***Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers.</string>
|
<string name="restore_passphrase_not_found_desc">Das Passwort wurde nicht im Schlüsselbund gefunden. Bitte geben Sie es manuell ein. Das kann passieren, wenn Sie die App-Daten mit einem Backup-Programm wieder hergestellt haben. Bitte nehmen Sie Kontakt mit den Entwicklern auf, wenn das nicht der Fall ist.</string>
|
||||||
|
|
||||||
<!-- ChatModel.chatRunning interactions -->
|
<!-- ChatModel.chatRunning interactions -->
|
||||||
<string name="chat_is_stopped_indication">Chat wurde beendet</string>
|
<string name="chat_is_stopped_indication">Chat wurde beendet</string>
|
||||||
@@ -775,6 +833,8 @@
|
|||||||
<string name="new_member_role">Neue Mitgliedsrolle</string>
|
<string name="new_member_role">Neue Mitgliedsrolle</string>
|
||||||
<string name="icon_descr_expand_role">Rollenauswahl erweitern</string>
|
<string name="icon_descr_expand_role">Rollenauswahl erweitern</string>
|
||||||
<string name="invite_to_group_button">In Gruppe einladen</string>
|
<string name="invite_to_group_button">In Gruppe einladen</string>
|
||||||
|
<string name="skip_inviting_button">Mitgliedereinladungen überspringen</string>
|
||||||
|
<string name="select_contacts">Kontakte auswählen</string>
|
||||||
<string name="icon_descr_contact_checked">Kontakt geprüft</string>
|
<string name="icon_descr_contact_checked">Kontakt geprüft</string>
|
||||||
<string name="clear_contacts_selection_button">Löschen</string>
|
<string name="clear_contacts_selection_button">Löschen</string>
|
||||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> Kontakt(e) ausgewählt</string>
|
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> Kontakt(e) ausgewählt</string>
|
||||||
@@ -800,6 +860,7 @@
|
|||||||
<string name="all_group_members_will_remain_connected">Alle Gruppenmitglieder bleiben verbunden.</string>
|
<string name="all_group_members_will_remain_connected">Alle Gruppenmitglieder bleiben verbunden.</string>
|
||||||
<string name="error_creating_link_for_group">Fehler beim Erzeugen des Gruppen-Links</string>
|
<string name="error_creating_link_for_group">Fehler beim Erzeugen des Gruppen-Links</string>
|
||||||
<string name="error_deleting_link_for_group">Fehler beim Löschen des Gruppen-Links</string>
|
<string name="error_deleting_link_for_group">Fehler beim Löschen des Gruppen-Links</string>
|
||||||
|
<string name="only_group_owners_can_change_prefs">Gruppenpräferenzen können nur von Gruppen-Eigentümern geändert werden.</string>
|
||||||
|
|
||||||
<!-- For Console chat info section -->
|
<!-- For Console chat info section -->
|
||||||
<string name="section_title_for_console">FÜR KONSOLE</string>
|
<string name="section_title_for_console">FÜR KONSOLE</string>
|
||||||
@@ -831,7 +892,7 @@
|
|||||||
<string name="receiving_via">Empfangen über</string>
|
<string name="receiving_via">Empfangen über</string>
|
||||||
<string name="sending_via">Senden über</string>
|
<string name="sending_via">Senden über</string>
|
||||||
<string name="network_status">Netzwerkstatus</string>
|
<string name="network_status">Netzwerkstatus</string>
|
||||||
<string name="switch_receiving_address">Wechseln der Empfängeradresse (BETA)</string>
|
<string name="switch_receiving_address">Wechseln der Empfängeradresse</string>
|
||||||
|
|
||||||
<!-- AddGroupView.kt -->
|
<!-- AddGroupView.kt -->
|
||||||
<string name="create_secret_group_title">Geheime Gruppe erstellen</string>
|
<string name="create_secret_group_title">Geheime Gruppe erstellen</string>
|
||||||
@@ -881,4 +942,54 @@
|
|||||||
<string name="save_color">Farbe speichern</string>
|
<string name="save_color">Farbe speichern</string>
|
||||||
<string name="reset_color">Farben zurücksetzen</string>
|
<string name="reset_color">Farben zurücksetzen</string>
|
||||||
<string name="color_primary">Akzent</string>
|
<string name="color_primary">Akzent</string>
|
||||||
|
|
||||||
|
<!-- Preferences.kt -->
|
||||||
|
<string name="chat_preferences_you_allow">Sie erlauben</string>
|
||||||
|
<string name="chat_preferences_contact_allows">Der Kontakt erlaubt</string>
|
||||||
|
<string name="chat_preferences_default">Voreinstellung (%s)</string>
|
||||||
|
<string name="chat_preferences_yes">Ja</string>
|
||||||
|
<string name="chat_preferences_no">Nein</string>
|
||||||
|
<string name="chat_preferences_always">Immer</string>
|
||||||
|
<string name="chat_preferences_on">Ein</string>
|
||||||
|
<string name="chat_preferences_off">Aus</string>
|
||||||
|
<string name="chat_preferences">Chat Präferenzen</string>
|
||||||
|
<string name="contact_preferences">Kontakt Präferenzen</string>
|
||||||
|
<string name="group_preferences">Gruppen Präferenzen</string>
|
||||||
|
<string name="set_group_preferences">Gruppenpräferenzen einstellen</string>
|
||||||
|
<string name="your_preferences">Ihre Präferenzen</string>
|
||||||
|
<string name="direct_messages">Direkte Nachrichten</string>
|
||||||
|
<string name="full_deletion">Für Alle löschen</string>
|
||||||
|
<string name="voice_messages">Sprachnachrichten</string>
|
||||||
|
<string name="feature_enabled">aktiviert</string>
|
||||||
|
<string name="feature_enabled_for_you">Für Sie aktiviert</string>
|
||||||
|
<string name="feature_enabled_for_contact">FÜr Kontakt aktiviert</string>
|
||||||
|
<string name="feature_off">Aus</string>
|
||||||
|
<string name="feature_received_prohibited">empfangen, nicht erlaubt</string>
|
||||||
|
<string name="allow_your_contacts_irreversibly_delete">Erlauben Sie Ihren Kontakten gesendete Nachrichten unwiederbringlich zu löschen.</string>
|
||||||
|
<string name="allow_irreversible_message_deletion_only_if">Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.</string>
|
||||||
|
<string name="contacts_can_mark_messages_for_deletion">Ihre Kontakte können Nachrichten zum Löschen markieren. Sie können diese Nachrichten trotzdem anschauen.</string>
|
||||||
|
<string name="allow_your_contacts_to_send_voice_messages">Erlauben Sie Ihre Kontakten Sprachnachrichten zu senden.</string>
|
||||||
|
<string name="allow_voice_messages_only_if">Erlauben Sie Sprachnachrichten nur dann, wenn Ihr Kontakt diese ebenfalls erlaubt.</string>
|
||||||
|
<string name="prohibit_sending_voice_messages">Das Senden von Sprachnachrichten verbieten.</string>
|
||||||
|
<string name="both_you_and_your_contacts_can_delete">Sowohl Ihr Kontakt, als auch Sie können Nachrichten unwiederbringlich löschen.</string>
|
||||||
|
<string name="only_you_can_delete_messages">Nur Sie können Nachrichten unwiederbringlich löschen (Ihr Kontakt kann sie zum Löschen markieren).</string>
|
||||||
|
<string name="only_your_contact_can_delete">Nur Ihr Kontakt kann Nachrichten unwiederbringlich löschen (Sie können sie zum Löschen markieren).</string>
|
||||||
|
<string name="message_deletion_prohibited">In dieser Gruppe ist das unwiederbringliche Löschen von Nachrichten nicht erlaubt.</string>
|
||||||
|
<string name="both_you_and_your_contact_can_send_voice">Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden.</string>
|
||||||
|
<string name="only_you_can_send_voice">Nur Sie können Sprachnachrichten senden.</string>
|
||||||
|
<string name="only_your_contact_can_send_voice">Nur Ihr Kontakt kann Sprachnachrichten senden.</string>
|
||||||
|
<string name="voice_prohibited_in_this_chat">In diesem Chat sind Sprachnachrichten untersagt.</string>
|
||||||
|
<string name="allow_direct_messages">Das Senden von Direktnachrichten an Mitglieder erlauben.</string>
|
||||||
|
<string name="prohibit_direct_messages">Das Senden von Direktnachrichten an Mitglieder verbieten.</string>
|
||||||
|
<string name="allow_to_delete_messages">Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.</string>
|
||||||
|
<string name="prohibit_message_deletion">Unwiederbringliches Löschen von Nachrichten verbieten.</string>
|
||||||
|
<string name="allow_to_send_voice">Senden von Sprachnachrichten erlauben.</string>
|
||||||
|
<string name="prohibit_sending_voice">Senden von Sprachnachrichten untersagen.</string>
|
||||||
|
<string name="group_members_can_send_dms">Gruppenmitglieder können Direktnachrichten versenden.</string>
|
||||||
|
<string name="direct_messages_are_prohibited_in_chat">In dieser Gruppe sind Direktnachrichten zwischen Mitgliedern nicht möglich.</string>
|
||||||
|
<string name="group_members_can_delete">Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.</string>
|
||||||
|
<string name="message_deletion_prohibited_in_chat">In dieser Gruppe ist das unwiederbringliche Löschen von Nachrichten verboten.</string>
|
||||||
|
<string name="group_members_can_send_voice">Gruppenmitglieder können Sprachnachrichten senden.</string>
|
||||||
|
<string name="voice_messages_are_prohibited">In dieser Gruppe sind Sprachnachrichten untersagt.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<!-- Item Content - ChatModel.kt -->
|
<!-- Item Content - ChatModel.kt -->
|
||||||
<string name="deleted_description">удалено</string>
|
<string name="deleted_description">удалено</string>
|
||||||
|
<string name="marked_deleted_description">помечено к удалению</string>
|
||||||
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
|
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
|
||||||
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
|
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
|
||||||
<string name="sender_you_pronoun">вы</string>
|
<string name="sender_you_pronoun">вы</string>
|
||||||
@@ -41,6 +42,17 @@
|
|||||||
<string name="description_via_one_time_link">через одноразовую ссылку</string>
|
<string name="description_via_one_time_link">через одноразовую ссылку</string>
|
||||||
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
|
<string name="description_via_one_time_link_incognito">инкогнито через одноразовую ссылку</string>
|
||||||
|
|
||||||
|
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||||
|
<string name="simplex_link_contact">SimpleX ссылка-контакт</string>
|
||||||
|
<string name="simplex_link_invitation">SimpleX одноразовая ссылка</string>
|
||||||
|
<string name="simplex_link_group">SimpleX ссылка группы</string>
|
||||||
|
<string name="simplex_link_connection">через <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||||
|
<string name="simplex_link_mode">SimpleX ссылки</string>
|
||||||
|
<string name="simplex_link_mode_description">Описание</string>
|
||||||
|
<string name="simplex_link_mode_full">Полная ссылка</string>
|
||||||
|
<string name="simplex_link_mode_browser">В браузере</string>
|
||||||
|
<string name="simplex_link_mode_browser_warning">Использование ссылки в браузере может уменьшить конфиденциальность и безопасность соединения. Ссылки на неизвестные сайты будут красными.</string>
|
||||||
|
|
||||||
<!-- SimpleXAPI.kt -->
|
<!-- SimpleXAPI.kt -->
|
||||||
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
|
<string name="error_saving_smp_servers">Ошибка при сохранении SMP серверов</string>
|
||||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
|
<string name="ensure_smp_server_address_are_correct_format_and_unique">Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется.</string>
|
||||||
@@ -49,7 +61,7 @@
|
|||||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||||
<string name="connection_timeout">Превышено время соединения</string>
|
<string name="connection_timeout">Превышено время соединения</string>
|
||||||
<string name="connection_error">Ошибка соединения</string>
|
<string name="connection_error">Ошибка соединения</string>
|
||||||
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</string>
|
<string name="network_error_desc">Пожалуйста, проверьте ваше соединение с сервером <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> и попробуйте еще раз.</string>
|
||||||
<string name="error_sending_message">Ошибка при отправке сообщения</string>
|
<string name="error_sending_message">Ошибка при отправке сообщения</string>
|
||||||
<string name="error_adding_members">Ошибка при добавлении членов группы</string>
|
<string name="error_adding_members">Ошибка при добавлении членов группы</string>
|
||||||
<string name="error_joining_group">Ошибка при вступлении в группу</string>
|
<string name="error_joining_group">Ошибка при вступлении в группу</string>
|
||||||
@@ -70,6 +82,14 @@
|
|||||||
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
|
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
|
||||||
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
|
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
|
||||||
<string name="error_changing_address">Ошибка при изменении адреса</string>
|
<string name="error_changing_address">Ошибка при изменении адреса</string>
|
||||||
|
<string name="error_smp_test_failed_at_step">Ошибка теста на шаге %s.</string>
|
||||||
|
<string name="error_smp_test_server_auth">Сервер требует авторизации для создания очередей, проверьте пароль</string>
|
||||||
|
<string name="error_smp_test_certificate">Возможно, хэш сертификата в адресе сервера неверный</string>
|
||||||
|
<string name="smp_server_test_connect">Соединение</string>
|
||||||
|
<string name="smp_server_test_create_queue">Создание очереди</string>
|
||||||
|
<string name="smp_server_test_secure_queue">Защита очереди</string>
|
||||||
|
<string name="smp_server_test_delete_queue">Удаление очереди</string>
|
||||||
|
<string name="smp_server_test_disconnect">Разрыв соединения</string>
|
||||||
|
|
||||||
<!-- background service notice - SimpleXAPI.kt -->
|
<!-- background service notice - SimpleXAPI.kt -->
|
||||||
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
|
||||||
@@ -95,7 +115,6 @@
|
|||||||
<!-- Notification channels -->
|
<!-- Notification channels -->
|
||||||
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
|
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
|
||||||
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
|
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
|
||||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat звонки (экран блокировки)</string>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
|
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
|
||||||
@@ -148,9 +167,13 @@
|
|||||||
<string name="save_verb">Сохранить</string>
|
<string name="save_verb">Сохранить</string>
|
||||||
<string name="edit_verb">Редактировать</string>
|
<string name="edit_verb">Редактировать</string>
|
||||||
<string name="delete_verb">Удалить</string>
|
<string name="delete_verb">Удалить</string>
|
||||||
|
<string name="reveal_verb">Показать</string>
|
||||||
|
<string name="hide_verb">Спрятать</string>
|
||||||
|
<string name="allow_verb">Разрешить</string>
|
||||||
<string name="delete_message__question">Удалить сообщение?</string>
|
<string name="delete_message__question">Удалить сообщение?</string>
|
||||||
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено – это действие нельзя отменить!</string>
|
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено – это действие нельзя отменить!</string>
|
||||||
<string name="for_me_only">Только для меня</string>
|
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
|
||||||
|
<string name="for_me_only">Удалить для меня</string>
|
||||||
<string name="for_everybody">Для всех</string>
|
<string name="for_everybody">Для всех</string>
|
||||||
|
|
||||||
<!-- CIMetaView.kt -->
|
<!-- CIMetaView.kt -->
|
||||||
@@ -208,6 +231,11 @@
|
|||||||
<string name="file_not_found">Файл не найден</string>
|
<string name="file_not_found">Файл не найден</string>
|
||||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Голосовое сообщение</string>
|
||||||
|
<string name="voice_message_with_duration">Голосовое сообщение (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||||
|
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Уведомления</string>
|
<string name="notifications">Уведомления</string>
|
||||||
|
|
||||||
@@ -225,11 +253,18 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||||
|
<string name="allow_voice_messages_question">Разрешить голосовые сообщения?</string>
|
||||||
|
<string name="you_need_to_allow_to_send_voice">Чтобы включить отправку голосовых сообщений, разрешите их вашему контакту.</string>
|
||||||
|
<string name="voice_messages_prohibited">Голосовые сообщения запрещены!</string>
|
||||||
|
<string name="ask_your_contact_to_enable_voice">Попросите вашего контакта разрешить отправку голосовых сообщений.</string>
|
||||||
|
<string name="only_group_owners_can_enable_voice">Только владельцы группы могут разрешить голосовые сообщения.</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Назад</string>
|
<string name="back">Назад</string>
|
||||||
<string name="cancel_verb">Отменить</string>
|
<string name="cancel_verb">Отменить</string>
|
||||||
<string name="confirm_verb">Подтвердить</string>
|
<string name="confirm_verb">Подтвердить</string>
|
||||||
|
<string name="reset_verb">Сбросить</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
<string name="no_details">нет описания</string>
|
<string name="no_details">нет описания</string>
|
||||||
<string name="add_contact">Одноразовая ссылка</string>
|
<string name="add_contact">Одноразовая ссылка</string>
|
||||||
@@ -360,17 +395,34 @@
|
|||||||
<string name="chat_lock">Блокировка SimpleX</string>
|
<string name="chat_lock">Блокировка SimpleX</string>
|
||||||
<string name="chat_console">Консоль</string>
|
<string name="chat_console">Консоль</string>
|
||||||
<string name="smp_servers">SMP серверы</string>
|
<string name="smp_servers">SMP серверы</string>
|
||||||
|
<string name="smp_servers_preset_address">Адрес сервера по умолчанию</string>
|
||||||
|
<string name="smp_servers_preset_add">Добавить серверы по умолчанию</string>
|
||||||
|
<string name="smp_servers_add">Добавить сервер…</string>
|
||||||
|
<string name="smp_servers_test_server">Тестировать сервер</string>
|
||||||
|
<string name="smp_servers_test_servers">Тестировать серверы</string>
|
||||||
|
<string name="smp_servers_save">Сохранить серверы</string>
|
||||||
|
<string name="smp_servers_test_failed">Ошибка теста сервера!</string>
|
||||||
|
<string name="smp_servers_test_some_failed">Серверы не прошли тест:</string>
|
||||||
|
<string name="smp_servers_scan_qr">Сканировать QR код сервера</string>
|
||||||
|
<string name="smp_servers_enter_manually">Ввести сервер вручную</string>
|
||||||
|
<string name="smp_servers_preset_server">Сервер по умолчанию</string>
|
||||||
|
<string name="smp_servers_your_server">Ваш сервер</string>
|
||||||
|
<string name="smp_servers_your_server_address">Адрес вашего сервера</string>
|
||||||
|
<string name="smp_servers_use_server">Использовать сервер</string>
|
||||||
|
<string name="smp_servers_use_server_for_new_conn">Использовать для новых соединений</string>
|
||||||
|
<string name="smp_servers_add_to_another_device">Добавить на другое устройство</string>
|
||||||
|
<string name="smp_servers_invalid_address">Ошибка в адресе сервера!</string>
|
||||||
|
<string name="smp_servers_check_address">Проверьте адрес сервера и попробуйте снова.</string>
|
||||||
|
<string name="smp_servers_delete_server">Удалить сервер</string>
|
||||||
<string name="install_simplex_chat_for_terminal"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</string>
|
<string name="install_simplex_chat_for_terminal"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</string>
|
||||||
<string name="star_on_github">Поставить звездочку в GitHub</string>
|
<string name="star_on_github">Поставить звездочку в GitHub</string>
|
||||||
<string name="contribute">Внести свой вклад</string>
|
<string name="contribute">Внести свой вклад</string>
|
||||||
<string name="rate_the_app">Оценить приложение</string>
|
<string name="rate_the_app">Оценить приложение</string>
|
||||||
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
|
<string name="use_simplex_chat_servers__question">Использовать серверы предосталенные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>?</string>
|
||||||
<string name="saved_SMP_servers_will_be_removed">Сохраненные SMP серверы будут удалены.</string>
|
|
||||||
<string name="your_SMP_servers">Ваши SMP серверы</string>
|
<string name="your_SMP_servers">Ваши SMP серверы</string>
|
||||||
<string name="configure_SMP_servers">Настройка SMP серверов</string>
|
|
||||||
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
|
||||||
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
|
|
||||||
<string name="how_to">Инфо</string>
|
<string name="how_to">Инфо</string>
|
||||||
|
<string name="how_to_use_your_servers">Как использовать серверы</string>
|
||||||
<string name="saved_ICE_servers_will_be_removed">Сохраненные WebRTC ICE серверы будут удалены.</string>
|
<string name="saved_ICE_servers_will_be_removed">Сохраненные WebRTC ICE серверы будут удалены.</string>
|
||||||
<string name="your_ICE_servers">Ваши ICE серверы</string>
|
<string name="your_ICE_servers">Ваши ICE серверы</string>
|
||||||
<string name="configure_ICE_servers">Настройка ICE серверов</string>
|
<string name="configure_ICE_servers">Настройка ICE серверов</string>
|
||||||
@@ -420,7 +472,11 @@
|
|||||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
|
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\n<xliff:g id="appName">SimpleX</xliff:g> серверы не могут получить доступ к вашему профилю.</string>
|
||||||
<string name="edit_image">Поменять аватар</string>
|
<string name="edit_image">Поменять аватар</string>
|
||||||
<string name="delete_image">Удалить аватар</string>
|
<string name="delete_image">Удалить аватар</string>
|
||||||
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
|
<string name="save_preferences_question">Сохранить предпочтения?</string>
|
||||||
|
<string name="save_and_notify_contact">Сохранить и уведомить контакт</string>
|
||||||
|
<string name="save_and_notify_contacts">Сохранить и уведомить контакты</string>
|
||||||
|
<string name="save_and_notify_group_members">Сохранить и уведомить членов группы</string>
|
||||||
|
<string name="exit_without_saving">Выйти без сохранения</string>
|
||||||
|
|
||||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||||
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
|
||||||
@@ -559,9 +615,11 @@
|
|||||||
<!-- Privacy settings -->
|
<!-- Privacy settings -->
|
||||||
<string name="privacy_and_security">Конфиденциальность</string>
|
<string name="privacy_and_security">Конфиденциальность</string>
|
||||||
<string name="your_privacy">Конфиденциальность</string>
|
<string name="your_privacy">Конфиденциальность</string>
|
||||||
|
<string name="protect_app_screen">Защитить экран приложения</string>
|
||||||
<string name="auto_accept_images">Автоприем изображений</string>
|
<string name="auto_accept_images">Автоприем изображений</string>
|
||||||
<string name="transfer_images_faster">Передавать изображения быстрее (BETA)</string>
|
<string name="transfer_images_faster">Передавать изображения быстрее</string>
|
||||||
<string name="send_link_previews">Отправлять картинки ссылок</string>
|
<string name="send_link_previews">Отправлять картинки ссылок</string>
|
||||||
|
<string name="full_backup">Резервная копия данных</string>
|
||||||
|
|
||||||
<!-- Settings sections -->
|
<!-- Settings sections -->
|
||||||
<string name="settings_section_title_you">ВЫ</string>
|
<string name="settings_section_title_you">ВЫ</string>
|
||||||
@@ -775,6 +833,8 @@
|
|||||||
<string name="new_member_role">Роль члена группы</string>
|
<string name="new_member_role">Роль члена группы</string>
|
||||||
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
|
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
|
||||||
<string name="invite_to_group_button">Пригласить в группу</string>
|
<string name="invite_to_group_button">Пригласить в группу</string>
|
||||||
|
<string name="skip_inviting_button">Не приглашать членов</string>
|
||||||
|
<string name="select_contacts">Выберите контакты</string>
|
||||||
<string name="icon_descr_contact_checked">Контакт выбран</string>
|
<string name="icon_descr_contact_checked">Контакт выбран</string>
|
||||||
<string name="clear_contacts_selection_button">Очистить</string>
|
<string name="clear_contacts_selection_button">Очистить</string>
|
||||||
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
|
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
|
||||||
@@ -800,6 +860,7 @@
|
|||||||
<string name="all_group_members_will_remain_connected">Все члены группы, которые соединились через эту ссылку, останутся в группе.</string>
|
<string name="all_group_members_will_remain_connected">Все члены группы, которые соединились через эту ссылку, останутся в группе.</string>
|
||||||
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
|
<string name="error_creating_link_for_group">Ошибка при создании ссылки группы</string>
|
||||||
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
|
<string name="error_deleting_link_for_group">Ошибка при удалении ссылки группы</string>
|
||||||
|
<string name="only_group_owners_can_change_prefs">Только владельцы группы могут изменять предпочтения группы.</string>
|
||||||
|
|
||||||
<!-- For Console chat info section -->
|
<!-- For Console chat info section -->
|
||||||
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
|
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
|
||||||
@@ -831,7 +892,7 @@
|
|||||||
<string name="receiving_via">Получение через</string>
|
<string name="receiving_via">Получение через</string>
|
||||||
<string name="sending_via">Отправка через</string>
|
<string name="sending_via">Отправка через</string>
|
||||||
<string name="network_status">Состояние сети</string>
|
<string name="network_status">Состояние сети</string>
|
||||||
<string name="switch_receiving_address">Переключить адрес получения (BETA)</string>
|
<string name="switch_receiving_address">Переключить адрес получения</string>
|
||||||
|
|
||||||
<!-- AddGroupView.kt -->
|
<!-- AddGroupView.kt -->
|
||||||
<string name="create_secret_group_title">Создать скрытую группу</string>
|
<string name="create_secret_group_title">Создать скрытую группу</string>
|
||||||
@@ -880,4 +941,54 @@
|
|||||||
<string name="save_color">Сохранить цвет</string>
|
<string name="save_color">Сохранить цвет</string>
|
||||||
<string name="reset_color">Сбросить цвета</string>
|
<string name="reset_color">Сбросить цвета</string>
|
||||||
<string name="color_primary">Акцент</string>
|
<string name="color_primary">Акцент</string>
|
||||||
|
|
||||||
|
<!-- Preferences.kt -->
|
||||||
|
<string name="chat_preferences_you_allow">Вы разрешаете</string>
|
||||||
|
<string name="chat_preferences_contact_allows">Контакт разрешает</string>
|
||||||
|
<string name="chat_preferences_default">по умолчанию (%s)</string>
|
||||||
|
<string name="chat_preferences_yes">да</string>
|
||||||
|
<string name="chat_preferences_no">нет</string>
|
||||||
|
<string name="chat_preferences_always">всегда</string>
|
||||||
|
<string name="chat_preferences_on">да</string>
|
||||||
|
<string name="chat_preferences_off">нет</string>
|
||||||
|
<string name="chat_preferences">Предпочтения</string>
|
||||||
|
<string name="contact_preferences">Предпочтения контакта</string>
|
||||||
|
<string name="group_preferences">Предпочтения группы</string>
|
||||||
|
<string name="set_group_preferences">Предпочтения группы</string>
|
||||||
|
<string name="your_preferences">Ваши предпочтения</string>
|
||||||
|
<string name="direct_messages">Прямые сообщения</string>
|
||||||
|
<string name="full_deletion">Удаление для всех</string>
|
||||||
|
<string name="voice_messages">Голосовые сообщения</string>
|
||||||
|
<string name="feature_enabled">включено</string>
|
||||||
|
<string name="feature_enabled_for_you">включено для вас</string>
|
||||||
|
<string name="feature_enabled_for_contact">включено для контакта</string>
|
||||||
|
<string name="feature_off">выключено</string>
|
||||||
|
<string name="feature_received_prohibited">получено, не разрешено</string>
|
||||||
|
<string name="allow_your_contacts_irreversibly_delete">Разрешить вашим контактам необратимо удалять отправленные сообщения.</string>
|
||||||
|
<string name="allow_irreversible_message_deletion_only_if">Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</string>
|
||||||
|
<string name="contacts_can_mark_messages_for_deletion">Контакты могут помечать сообщения для удаления; вы сможете просмотреть их.</string>
|
||||||
|
<string name="allow_your_contacts_to_send_voice_messages">Разрешить вашим контактам отправлять голосовые сообщения.</string>
|
||||||
|
<string name="allow_voice_messages_only_if">Разрешить голосовые сообщения, только если их разрешает ваш контакт.</string>
|
||||||
|
<string name="prohibit_sending_voice_messages">Запретить отправлять голосовые сообщений.</string>
|
||||||
|
<string name="both_you_and_your_contacts_can_delete">Вы и ваш контакт можете необратимо удалять отправленные сообщения.</string>
|
||||||
|
<string name="only_you_can_delete_messages">Только вы можете необратимо удалять сообщения (ваш контакт может помечать их на удаление).</string>
|
||||||
|
<string name="only_your_contact_can_delete">Только ваш контакт может необратимо удалять сообщения (вы можете помечать их на удаление).</string>
|
||||||
|
<string name="message_deletion_prohibited">Необратимое удаление сообщений запрещено в этой группе.</string>
|
||||||
|
<string name="both_you_and_your_contact_can_send_voice">Вы и ваш контакт можете отправлять голосовые сообщения.</string>
|
||||||
|
<string name="only_you_can_send_voice">Только вы можете отправлять голосовые сообщения.</string>
|
||||||
|
<string name="only_your_contact_can_send_voice">Только ваш контакт может отправлять голосовые сообщения.</string>
|
||||||
|
<string name="voice_prohibited_in_this_chat">Голосовые сообщения запрещены в этом чате.</string>
|
||||||
|
<string name="allow_direct_messages">Разрешить посылать прямые сообщения членам группы.</string>
|
||||||
|
<string name="prohibit_direct_messages">Запретить посылать прямые сообщения членам группы.</string>
|
||||||
|
<string name="allow_to_delete_messages">Разрешить необратимо удалять отправленные сообщения.</string>
|
||||||
|
<string name="prohibit_message_deletion">Запретить необратимое удаление сообщений.</string>
|
||||||
|
<string name="allow_to_send_voice">Разрешить отправлять голосовые сообщения.</string>
|
||||||
|
<string name="prohibit_sending_voice">Запретить отправлять голосовые сообщений.</string>
|
||||||
|
<string name="group_members_can_send_dms">Члены группы могут посылать прямые сообщения.</string>
|
||||||
|
<string name="direct_messages_are_prohibited_in_chat">Прямые сообщения между членами группы запрещены.</string>
|
||||||
|
<string name="group_members_can_delete">Члены группы могут необратимо удалять отправленные сообщения.</string>
|
||||||
|
<string name="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этой группе.</string>
|
||||||
|
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
|
||||||
|
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этой группе.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<!-- Item Content - ChatModel.kt -->
|
<!-- Item Content - ChatModel.kt -->
|
||||||
<string name="deleted_description">deleted</string>
|
<string name="deleted_description">deleted</string>
|
||||||
|
<string name="marked_deleted_description">marked deleted</string>
|
||||||
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
|
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
|
||||||
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
|
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
|
||||||
<string name="sender_you_pronoun">you</string>
|
<string name="sender_you_pronoun">you</string>
|
||||||
@@ -41,6 +42,17 @@
|
|||||||
<string name="description_via_one_time_link">via one-time link</string>
|
<string name="description_via_one_time_link">via one-time link</string>
|
||||||
<string name="description_via_one_time_link_incognito">incognito via one-time link</string>
|
<string name="description_via_one_time_link_incognito">incognito via one-time link</string>
|
||||||
|
|
||||||
|
<!-- FormattedText, SimpleX links - ChatModel.kt -->
|
||||||
|
<string name="simplex_link_contact">SimpleX contact address</string>
|
||||||
|
<string name="simplex_link_invitation">SimpleX one-time invitation</string>
|
||||||
|
<string name="simplex_link_group">SimpleX group link</string>
|
||||||
|
<string name="simplex_link_connection">via <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g></string>
|
||||||
|
<string name="simplex_link_mode">SimpleX links</string>
|
||||||
|
<string name="simplex_link_mode_description">Description</string>
|
||||||
|
<string name="simplex_link_mode_full">Full link</string>
|
||||||
|
<string name="simplex_link_mode_browser">Via browser</string>
|
||||||
|
<string name="simplex_link_mode_browser_warning">Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.</string>
|
||||||
|
|
||||||
<!-- SimpleXAPI.kt -->
|
<!-- SimpleXAPI.kt -->
|
||||||
<string name="error_saving_smp_servers">Error saving SMP servers</string>
|
<string name="error_saving_smp_servers">Error saving SMP servers</string>
|
||||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
|
<string name="ensure_smp_server_address_are_correct_format_and_unique">Make sure SMP server addresses are in correct format, line separated and are not duplicated.</string>
|
||||||
@@ -49,7 +61,7 @@
|
|||||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||||
<string name="connection_timeout">Connection timeout</string>
|
<string name="connection_timeout">Connection timeout</string>
|
||||||
<string name="connection_error">Connection error</string>
|
<string name="connection_error">Connection error</string>
|
||||||
<string name="network_error_desc">Please check your network connection and try again.</string>
|
<string name="network_error_desc">Please check your network connection with <xliff:g id="serverHost" example="smp.simplex.im">%1$s</xliff:g> and try again.</string>
|
||||||
<string name="error_sending_message">Error sending message</string>
|
<string name="error_sending_message">Error sending message</string>
|
||||||
<string name="error_adding_members">Error adding member(s)</string>
|
<string name="error_adding_members">Error adding member(s)</string>
|
||||||
<string name="error_joining_group">Error joining group</string>
|
<string name="error_joining_group">Error joining group</string>
|
||||||
@@ -70,6 +82,14 @@
|
|||||||
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
<string name="error_deleting_contact_request">Error deleting contact request</string>
|
||||||
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
|
||||||
<string name="error_changing_address">Error changing address</string>
|
<string name="error_changing_address">Error changing address</string>
|
||||||
|
<string name="error_smp_test_failed_at_step">Test failed at step %s.</string>
|
||||||
|
<string name="error_smp_test_server_auth">Server requires authorization to create queues, check password</string>
|
||||||
|
<string name="error_smp_test_certificate">Possibly, certificate fingerprint in server address is incorrect</string>
|
||||||
|
<string name="smp_server_test_connect">Connect</string>
|
||||||
|
<string name="smp_server_test_create_queue">Create queue</string>
|
||||||
|
<string name="smp_server_test_secure_queue">Secure queue</string>
|
||||||
|
<string name="smp_server_test_delete_queue">Delete queue</string>
|
||||||
|
<string name="smp_server_test_disconnect">Disconnect</string>
|
||||||
|
|
||||||
<!-- background service notice - SimpleXAPI.kt -->
|
<!-- background service notice - SimpleXAPI.kt -->
|
||||||
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
||||||
@@ -95,7 +115,6 @@
|
|||||||
<!-- Notification channels -->
|
<!-- Notification channels -->
|
||||||
<string name="ntf_channel_messages">SimpleX Chat messages</string>
|
<string name="ntf_channel_messages">SimpleX Chat messages</string>
|
||||||
<string name="ntf_channel_calls">SimpleX Chat calls</string>
|
<string name="ntf_channel_calls">SimpleX Chat calls</string>
|
||||||
<string name="ntf_channel_calls_lockscreen">SimpleX Chat calls (lock screen)</string>
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="settings_notifications_mode_title">Notification service</string>
|
<string name="settings_notifications_mode_title">Notification service</string>
|
||||||
@@ -148,9 +167,13 @@
|
|||||||
<string name="save_verb">Save</string>
|
<string name="save_verb">Save</string>
|
||||||
<string name="edit_verb">Edit</string>
|
<string name="edit_verb">Edit</string>
|
||||||
<string name="delete_verb">Delete</string>
|
<string name="delete_verb">Delete</string>
|
||||||
|
<string name="reveal_verb">Reveal</string>
|
||||||
|
<string name="hide_verb">Hide</string>
|
||||||
|
<string name="allow_verb">Allow</string>
|
||||||
<string name="delete_message__question">Delete message?</string>
|
<string name="delete_message__question">Delete message?</string>
|
||||||
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
|
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
|
||||||
<string name="for_me_only">For me only</string>
|
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
|
||||||
|
<string name="for_me_only">Delete for me</string>
|
||||||
<string name="for_everybody">For everyone</string>
|
<string name="for_everybody">For everyone</string>
|
||||||
|
|
||||||
<!-- CIMetaView.kt -->
|
<!-- CIMetaView.kt -->
|
||||||
@@ -208,6 +231,11 @@
|
|||||||
<string name="file_not_found">File not found</string>
|
<string name="file_not_found">File not found</string>
|
||||||
<string name="error_saving_file">Error saving file</string>
|
<string name="error_saving_file">Error saving file</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Voice message</string>
|
||||||
|
<string name="voice_message_with_duration">Voice message (<xliff:g id="duration">%1$s</xliff:g>)</string>
|
||||||
|
<string name="voice_message_send_text">Voice message…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Notifications</string>
|
<string name="notifications">Notifications</string>
|
||||||
|
|
||||||
@@ -225,11 +253,18 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Send Message</string>
|
<string name="icon_descr_send_message">Send Message</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Record voice message</string>
|
||||||
|
<string name="allow_voice_messages_question">Allow voice messages?</string>
|
||||||
|
<string name="you_need_to_allow_to_send_voice">You need to allow your contact to send voice messages to be able to send them.</string>
|
||||||
|
<string name="voice_messages_prohibited">Voice messages prohibited!</string>
|
||||||
|
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
|
||||||
|
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Back</string>
|
<string name="back">Back</string>
|
||||||
<string name="cancel_verb">Cancel</string>
|
<string name="cancel_verb">Cancel</string>
|
||||||
<string name="confirm_verb">Confirm</string>
|
<string name="confirm_verb">Confirm</string>
|
||||||
|
<string name="reset_verb">Reset</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
<string name="no_details">no details</string>
|
<string name="no_details">no details</string>
|
||||||
<string name="add_contact">One-time invitation link</string>
|
<string name="add_contact">One-time invitation link</string>
|
||||||
@@ -363,17 +398,34 @@
|
|||||||
<string name="chat_lock">SimpleX Lock</string>
|
<string name="chat_lock">SimpleX Lock</string>
|
||||||
<string name="chat_console">Chat console</string>
|
<string name="chat_console">Chat console</string>
|
||||||
<string name="smp_servers">SMP servers</string>
|
<string name="smp_servers">SMP servers</string>
|
||||||
|
<string name="smp_servers_preset_address">Preset server address</string>
|
||||||
|
<string name="smp_servers_preset_add">Add preset servers</string>
|
||||||
|
<string name="smp_servers_add">Add server…</string>
|
||||||
|
<string name="smp_servers_test_server">Test server</string>
|
||||||
|
<string name="smp_servers_test_servers">Test servers</string>
|
||||||
|
<string name="smp_servers_save">Save servers</string>
|
||||||
|
<string name="smp_servers_test_failed">Server test failed!</string>
|
||||||
|
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
|
||||||
|
<string name="smp_servers_scan_qr">Scan server QR code</string>
|
||||||
|
<string name="smp_servers_enter_manually">Enter server manually</string>
|
||||||
|
<string name="smp_servers_preset_server">Preset server</string>
|
||||||
|
<string name="smp_servers_your_server">Your server</string>
|
||||||
|
<string name="smp_servers_your_server_address">Your server address</string>
|
||||||
|
<string name="smp_servers_use_server">Use server</string>
|
||||||
|
<string name="smp_servers_use_server_for_new_conn">Use for new connections</string>
|
||||||
|
<string name="smp_servers_add_to_another_device">Add to another device</string>
|
||||||
|
<string name="smp_servers_invalid_address">Invalid server address!</string>
|
||||||
|
<string name="smp_servers_check_address">Check server address and try again.</string>
|
||||||
|
<string name="smp_servers_delete_server">Delete server</string>
|
||||||
<string name="install_simplex_chat_for_terminal">Install <xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</string>
|
<string name="install_simplex_chat_for_terminal">Install <xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</string>
|
||||||
<string name="star_on_github">Star on GitHub</string>
|
<string name="star_on_github">Star on GitHub</string>
|
||||||
<string name="contribute">Contribute</string>
|
<string name="contribute">Contribute</string>
|
||||||
<string name="rate_the_app">Rate the app</string>
|
<string name="rate_the_app">Rate the app</string>
|
||||||
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
|
<string name="use_simplex_chat_servers__question">Use <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers?</string>
|
||||||
<string name="saved_SMP_servers_will_be_removed">Saved SMP servers will be removed.</string>
|
|
||||||
<string name="your_SMP_servers">Your SMP servers</string>
|
<string name="your_SMP_servers">Your SMP servers</string>
|
||||||
<string name="configure_SMP_servers">Configure SMP servers</string>
|
|
||||||
<string name="using_simplex_chat_servers">Using <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers.</string>
|
<string name="using_simplex_chat_servers">Using <xliff:g id="appNameFull">SimpleX Chat</xliff:g> servers.</string>
|
||||||
<string name="enter_one_SMP_server_per_line">SMP servers (one per line)</string>
|
|
||||||
<string name="how_to">How to</string>
|
<string name="how_to">How to</string>
|
||||||
|
<string name="how_to_use_your_servers">How to use your servers</string>
|
||||||
<string name="saved_ICE_servers_will_be_removed">Saved WebRTC ICE servers will be removed.</string>
|
<string name="saved_ICE_servers_will_be_removed">Saved WebRTC ICE servers will be removed.</string>
|
||||||
<string name="your_ICE_servers">Your ICE servers</string>
|
<string name="your_ICE_servers">Your ICE servers</string>
|
||||||
<string name="configure_ICE_servers">Configure ICE servers</string>
|
<string name="configure_ICE_servers">Configure ICE servers</string>
|
||||||
@@ -423,7 +475,11 @@
|
|||||||
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
|
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts.\n\n<xliff:g id="appName">SimpleX</xliff:g> servers cannot see your profile.</string>
|
||||||
<string name="edit_image">Edit image</string>
|
<string name="edit_image">Edit image</string>
|
||||||
<string name="delete_image">Delete image</string>
|
<string name="delete_image">Delete image</string>
|
||||||
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
|
<string name="save_preferences_question">Save preferences?</string>
|
||||||
|
<string name="save_and_notify_contact">Save and notify contact</string>
|
||||||
|
<string name="save_and_notify_contacts">Save and notify contacts</string>
|
||||||
|
<string name="save_and_notify_group_members">Save and notify group members</string>
|
||||||
|
<string name="exit_without_saving">Exit without saving</string>
|
||||||
|
|
||||||
<!-- Welcome Prompts - WelcomeView.kt -->
|
<!-- Welcome Prompts - WelcomeView.kt -->
|
||||||
<string name="you_control_your_chat">You control your chat!</string>
|
<string name="you_control_your_chat">You control your chat!</string>
|
||||||
@@ -559,9 +615,11 @@
|
|||||||
<!-- Privacy settings -->
|
<!-- Privacy settings -->
|
||||||
<string name="privacy_and_security">Privacy & security</string>
|
<string name="privacy_and_security">Privacy & security</string>
|
||||||
<string name="your_privacy">Your privacy</string>
|
<string name="your_privacy">Your privacy</string>
|
||||||
|
<string name="protect_app_screen">Protect app screen</string>
|
||||||
<string name="auto_accept_images">Auto-accept images</string>
|
<string name="auto_accept_images">Auto-accept images</string>
|
||||||
<string name="transfer_images_faster">Transfer images faster (BETA)</string>
|
<string name="transfer_images_faster">Transfer images faster</string>
|
||||||
<string name="send_link_previews">Send link previews</string>
|
<string name="send_link_previews">Send link previews</string>
|
||||||
|
<string name="full_backup">App data backup</string>
|
||||||
|
|
||||||
<!-- Settings sections -->
|
<!-- Settings sections -->
|
||||||
<string name="settings_section_title_you">YOU</string>
|
<string name="settings_section_title_you">YOU</string>
|
||||||
@@ -775,6 +833,8 @@
|
|||||||
<string name="new_member_role">New member role</string>
|
<string name="new_member_role">New member role</string>
|
||||||
<string name="icon_descr_expand_role">Expand role selection</string>
|
<string name="icon_descr_expand_role">Expand role selection</string>
|
||||||
<string name="invite_to_group_button">Invite to group</string>
|
<string name="invite_to_group_button">Invite to group</string>
|
||||||
|
<string name="skip_inviting_button">Skip inviting members</string>
|
||||||
|
<string name="select_contacts">Select contacts</string>
|
||||||
<string name="icon_descr_contact_checked">Contact checked</string>
|
<string name="icon_descr_contact_checked">Contact checked</string>
|
||||||
<string name="clear_contacts_selection_button">Clear</string>
|
<string name="clear_contacts_selection_button">Clear</string>
|
||||||
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
|
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
|
||||||
@@ -800,6 +860,7 @@
|
|||||||
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
|
<string name="all_group_members_will_remain_connected">All group members will remain connected.</string>
|
||||||
<string name="error_creating_link_for_group">Error creating group link</string>
|
<string name="error_creating_link_for_group">Error creating group link</string>
|
||||||
<string name="error_deleting_link_for_group">Error deleting group link</string>
|
<string name="error_deleting_link_for_group">Error deleting group link</string>
|
||||||
|
<string name="only_group_owners_can_change_prefs">Only group owners can change group preferences.</string>
|
||||||
|
|
||||||
<!-- For Console chat info section -->
|
<!-- For Console chat info section -->
|
||||||
<string name="section_title_for_console">FOR CONSOLE</string>
|
<string name="section_title_for_console">FOR CONSOLE</string>
|
||||||
@@ -831,7 +892,7 @@
|
|||||||
<string name="receiving_via">Receiving via</string>
|
<string name="receiving_via">Receiving via</string>
|
||||||
<string name="sending_via">Sending via</string>
|
<string name="sending_via">Sending via</string>
|
||||||
<string name="network_status">Network status</string>
|
<string name="network_status">Network status</string>
|
||||||
<string name="switch_receiving_address">Switch receiving address (BETA)</string>
|
<string name="switch_receiving_address">Switch receiving address</string>
|
||||||
|
|
||||||
<!-- AddGroupView.kt -->
|
<!-- AddGroupView.kt -->
|
||||||
<string name="create_secret_group_title">Create secret group</string>
|
<string name="create_secret_group_title">Create secret group</string>
|
||||||
@@ -881,4 +942,54 @@
|
|||||||
<string name="save_color">Save color</string>
|
<string name="save_color">Save color</string>
|
||||||
<string name="reset_color">Reset colors</string>
|
<string name="reset_color">Reset colors</string>
|
||||||
<string name="color_primary">Accent</string>
|
<string name="color_primary">Accent</string>
|
||||||
|
|
||||||
|
<!-- Preferences.kt -->
|
||||||
|
<string name="chat_preferences_you_allow">You allow</string>
|
||||||
|
<string name="chat_preferences_contact_allows">Contact allows</string>
|
||||||
|
<string name="chat_preferences_default">default (%s)</string>
|
||||||
|
<string name="chat_preferences_yes">yes</string>
|
||||||
|
<string name="chat_preferences_no">no</string>
|
||||||
|
<string name="chat_preferences_always">always</string>
|
||||||
|
<string name="chat_preferences_on">on</string>
|
||||||
|
<string name="chat_preferences_off">off</string>
|
||||||
|
<string name="chat_preferences">Chat preferences</string>
|
||||||
|
<string name="contact_preferences">Contact preferences</string>
|
||||||
|
<string name="group_preferences">Group preferences</string>
|
||||||
|
<string name="set_group_preferences">Set group preferences</string>
|
||||||
|
<string name="your_preferences">Your preferences</string>
|
||||||
|
<string name="direct_messages">Direct messages</string>
|
||||||
|
<string name="full_deletion">Delete for everyone</string>
|
||||||
|
<string name="voice_messages">Voice messages</string>
|
||||||
|
<string name="feature_enabled">enabled</string>
|
||||||
|
<string name="feature_enabled_for_you">enabled for you</string>
|
||||||
|
<string name="feature_enabled_for_contact">enabled for contact</string>
|
||||||
|
<string name="feature_off">off</string>
|
||||||
|
<string name="feature_received_prohibited">received, prohibited</string>
|
||||||
|
<string name="allow_your_contacts_irreversibly_delete">Allow your contacts to irreversibly delete sent messages.</string>
|
||||||
|
<string name="allow_irreversible_message_deletion_only_if">Allow irreversible message deletion only if your contact allows it to you.</string>
|
||||||
|
<string name="contacts_can_mark_messages_for_deletion">Contacts can mark messages for deletion; you will be able to view them.</string>
|
||||||
|
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
|
||||||
|
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
|
||||||
|
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
|
||||||
|
<string name="both_you_and_your_contacts_can_delete">Both you and your contact can irreversibly delete sent messages.</string>
|
||||||
|
<string name="only_you_can_delete_messages">Only you can irreversibly delete messages (your contact can mark them for deletion).</string>
|
||||||
|
<string name="only_your_contact_can_delete">Only your contact can irreversibly delete messages (you can mark them for deletion).</string>
|
||||||
|
<string name="message_deletion_prohibited">Irreversible message deletion is prohibited in this chat.</string>
|
||||||
|
<string name="both_you_and_your_contact_can_send_voice">Both you and your contact can send voice messages.</string>
|
||||||
|
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
|
||||||
|
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
|
||||||
|
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
|
||||||
|
<string name="allow_direct_messages">Allow sending direct messages to members.</string>
|
||||||
|
<string name="prohibit_direct_messages">Prohibit sending direct messages to members.</string>
|
||||||
|
<string name="allow_to_delete_messages">Allow to irreversibly delete sent messages.</string>
|
||||||
|
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
|
||||||
|
<string name="allow_to_send_voice">Allow to send voice messages.</string>
|
||||||
|
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
|
||||||
|
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
|
||||||
|
<string name="direct_messages_are_prohibited_in_chat">Direct messages between members are prohibited in this group.</string>
|
||||||
|
<string name="group_members_can_delete">Group members can irreversibly delete sent messages.</string>
|
||||||
|
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this group.</string>
|
||||||
|
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
|
||||||
|
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this group.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ struct ContentView: View {
|
|||||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
|
||||||
|
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -29,7 +31,7 @@ struct ContentView: View {
|
|||||||
} else if let step = chatModel.onboardingStage {
|
} else if let step = chatModel.onboardingStage {
|
||||||
if case .onboardingComplete = step,
|
if case .onboardingComplete = step,
|
||||||
chatModel.currentUser != nil {
|
chatModel.currentUser != nil {
|
||||||
mainView()
|
mainView().privacySensitive(protectScreen)
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(onboarding: step)
|
OnboardingView(onboarding: step)
|
||||||
}
|
}
|
||||||
@@ -46,9 +48,15 @@ struct ContentView: View {
|
|||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ChatListView()
|
ChatListView()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NtfManager.shared.requestAuthorization(onDeny: {
|
NtfManager.shared.requestAuthorization(
|
||||||
alertManager.showAlert(notificationAlert())
|
onDeny: {
|
||||||
})
|
if (!notificationAlertShown) {
|
||||||
|
notificationAlertShown = true
|
||||||
|
alertManager.showAlert(notificationAlert())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAuthorized: { notificationAlertShown = false }
|
||||||
|
)
|
||||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||||
if (!prefLANoticeShown && prefShowLANotice) {
|
if (!prefLANoticeShown && prefShowLANotice) {
|
||||||
prefLANoticeShown = true
|
prefLANoticeShown = true
|
||||||
@@ -68,9 +76,9 @@ struct ContentView: View {
|
|||||||
userAuthorized = true
|
userAuthorized = true
|
||||||
} else {
|
} else {
|
||||||
dismissAllSheets(animated: false) {
|
dismissAllSheets(animated: false) {
|
||||||
|
chatModel.chatId = nil
|
||||||
justAuthenticate()
|
justAuthenticate()
|
||||||
}
|
}
|
||||||
chatModel.chatId = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,14 +124,14 @@ struct ContentView: View {
|
|||||||
func notificationAlert() -> Alert {
|
func notificationAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Notifications are disabled!"),
|
title: Text("Notifications are disabled!"),
|
||||||
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
|
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
|
||||||
primaryButton: .default(Text("Open Settings")) {
|
primaryButton: .default(Text("Open Settings")) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// AudioRecPlay.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 19/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
class AudioRecorder {
|
||||||
|
var onTimer: ((TimeInterval) -> Void)?
|
||||||
|
var onFinishRecording: (() -> Void)?
|
||||||
|
|
||||||
|
var audioRecorder: AVAudioRecorder?
|
||||||
|
var recordingTimer: Timer?
|
||||||
|
|
||||||
|
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishRecording: @escaping (() -> Void)) {
|
||||||
|
self.onTimer = onTimer
|
||||||
|
self.onFinishRecording = onFinishRecording
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StartError {
|
||||||
|
case permission
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(fileName: String) async -> StartError? {
|
||||||
|
let av = AVAudioSession.sharedInstance()
|
||||||
|
if !(await checkPermission()) { return .permission }
|
||||||
|
do {
|
||||||
|
try av.setCategory(AVAudioSession.Category.playAndRecord, options: .defaultToSpeaker)
|
||||||
|
try av.setActive(true)
|
||||||
|
let settings: [String : Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 12000,
|
||||||
|
AVEncoderBitRateKey: 12000,
|
||||||
|
AVNumberOfChannelsKey: 1
|
||||||
|
]
|
||||||
|
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
|
||||||
|
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||||
|
guard let time = self.audioRecorder?.currentTime else { return }
|
||||||
|
self.onTimer?(time)
|
||||||
|
if time >= MAX_VOICE_MESSAGE_LENGTH {
|
||||||
|
self.stop()
|
||||||
|
self.onFinishRecording?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} catch let error {
|
||||||
|
logger.error("AudioRecorder startAudioRecording error \(error.localizedDescription)")
|
||||||
|
return .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if let recorder = audioRecorder {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
audioRecorder = nil
|
||||||
|
if let timer = recordingTimer {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
recordingTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkPermission() async -> Bool {
|
||||||
|
let av = AVAudioSession.sharedInstance()
|
||||||
|
switch av.recordPermission {
|
||||||
|
case .granted: return true
|
||||||
|
case .denied: return false
|
||||||
|
case .undetermined:
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
av.requestRecordPermission { allowed in
|
||||||
|
cont.resume(returning: allowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@unknown default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||||
|
var onTimer: ((TimeInterval) -> Void)?
|
||||||
|
var onFinishPlayback: (() -> Void)?
|
||||||
|
|
||||||
|
var audioPlayer: AVAudioPlayer?
|
||||||
|
var playbackTimer: Timer?
|
||||||
|
|
||||||
|
init(onTimer: @escaping ((TimeInterval) -> Void), onFinishPlayback: @escaping (() -> Void)) {
|
||||||
|
self.onTimer = onTimer
|
||||||
|
self.onFinishPlayback = onFinishPlayback
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(fileName: String) {
|
||||||
|
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
|
||||||
|
audioPlayer?.delegate = self
|
||||||
|
audioPlayer?.prepareToPlay()
|
||||||
|
audioPlayer?.play()
|
||||||
|
|
||||||
|
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||||
|
if self.audioPlayer?.isPlaying ?? false {
|
||||||
|
guard let time = self.audioPlayer?.currentTime else { return }
|
||||||
|
self.onTimer?(time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
audioPlayer?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
if let player = audioPlayer {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
audioPlayer = nil
|
||||||
|
if let timer = playbackTimer {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
playbackTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||||
|
stop()
|
||||||
|
self.onFinishPlayback?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ final class ChatModel: ObservableObject {
|
|||||||
// items in the terminal view
|
// items in the terminal view
|
||||||
@Published var terminalItems: [TerminalItem] = []
|
@Published var terminalItems: [TerminalItem] = []
|
||||||
@Published var userAddress: UserContactLink?
|
@Published var userAddress: UserContactLink?
|
||||||
@Published var userSMPServers: [String]?
|
@Published var userSMPServers: [ServerCfg]?
|
||||||
|
@Published var presetSMPServers: [String]?
|
||||||
@Published var chatItemTTL: ChatItemTTL = .none
|
@Published var chatItemTTL: ChatItemTTL = .none
|
||||||
@Published var appOpenUrl: URL?
|
@Published var appOpenUrl: URL?
|
||||||
@Published var deviceToken: DeviceToken?
|
@Published var deviceToken: DeviceToken?
|
||||||
@@ -51,6 +52,8 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var showCallView = false
|
@Published var showCallView = false
|
||||||
// currently showing QR code
|
// currently showing QR code
|
||||||
@Published var connReqInv: String?
|
@Published var connReqInv: String?
|
||||||
|
// audio recording and playback
|
||||||
|
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
|
||||||
var callWebView: WKWebView?
|
var callWebView: WKWebView?
|
||||||
|
|
||||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||||
@@ -98,7 +101,7 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateContact(_ contact: Contact) {
|
func updateContact(_ contact: Contact) {
|
||||||
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact && !contact.viaGroupLink)
|
updateChat(.direct(contact: contact), addMissing: contact.directContact)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateGroup(_ groupInfo: GroupInfo) {
|
func updateGroup(_ groupInfo: GroupInfo) {
|
||||||
@@ -219,8 +222,10 @@ final class ChatModel: ObservableObject {
|
|||||||
withAnimation(.default) {
|
withAnimation(.default) {
|
||||||
self.reversedChatItems[i] = cItem
|
self.reversedChatItems[i] = cItem
|
||||||
self.reversedChatItems[i].viewTimestamp = .now
|
self.reversedChatItems[i].viewTimestamp = .now
|
||||||
|
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||||
|
// arrives earlier than the response from API, and item remains without tick
|
||||||
if case .sndNew = cItem.meta.itemStatus {
|
if case .sndNew = cItem.meta.itemStatus {
|
||||||
self.reversedChatItems[i].meta = ci.meta
|
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -231,16 +236,19 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
|
if cItem.isRcvNew {
|
||||||
|
decreaseUnreadCounter(cInfo)
|
||||||
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
|
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
|
||||||
chat.chatItems = [cItem]
|
chat.chatItems = [ChatItem.deletedItemDummy()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// remove from current chat
|
// remove from current chat
|
||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||||
if reversedChatItems[i].isRcvNew() == true {
|
if reversedChatItems[i].isRcvNew {
|
||||||
NtfManager.shared.decNtfBadgeCount()
|
NtfManager.shared.decNtfBadgeCount()
|
||||||
}
|
}
|
||||||
_ = withAnimation {
|
_ = withAnimation {
|
||||||
@@ -337,9 +345,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
// update preview
|
// update preview
|
||||||
if let i = getChatIndex(cInfo.id) {
|
decreaseUnreadCounter(cInfo)
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
|
||||||
}
|
|
||||||
// update current chat
|
// update current chat
|
||||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||||
@@ -347,6 +353,12 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
||||||
|
if let i = getChatIndex(cInfo.id) {
|
||||||
|
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func totalUnreadCount() -> Int {
|
func totalUnreadCount() -> Int {
|
||||||
chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount })
|
chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount })
|
||||||
}
|
}
|
||||||
@@ -413,7 +425,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var unreadBelow = 0
|
var unreadBelow = 0
|
||||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
||||||
totalBelow += 1
|
totalBelow += 1
|
||||||
if reversedChatItems[i].isRcvNew() {
|
if reversedChatItems[i].isRcvNew {
|
||||||
unreadBelow += 1
|
unreadBelow += 1
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
|
|||||||
@@ -165,22 +165,23 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
|
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
|
||||||
logger.debug("NtfManager.requestAuthorization")
|
logger.debug("NtfManager.requestAuthorization")
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
center.getNotificationSettings { settings in
|
center.getNotificationSettings { settings in
|
||||||
switch settings.authorizationStatus {
|
switch settings.authorizationStatus {
|
||||||
case .denied:
|
case .denied:
|
||||||
if let handler = handler { handler() }
|
denied?()
|
||||||
return
|
|
||||||
case .authorized:
|
case .authorized:
|
||||||
self.granted = true
|
self.granted = true
|
||||||
|
authorized?()
|
||||||
default:
|
default:
|
||||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
|
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
|
||||||
} else {
|
} else {
|
||||||
self.granted = granted
|
self.granted = granted
|
||||||
|
authorized?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,9 +261,9 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
|
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
||||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||||
if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
|
if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,16 +309,27 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
|||||||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserSMPServers() throws -> [String] {
|
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
||||||
let r = chatSendCmdSync(.getUserSMPServers)
|
let r = chatSendCmdSync(.getUserSMPServers)
|
||||||
if case let .userSMPServers(smpServers) = r { return smpServers }
|
if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUserSMPServers(smpServers: [String]) async throws {
|
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
||||||
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
|
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||||
|
let r = await chatSendCmd(.testSMPServer(smpServer: smpServer))
|
||||||
|
if case let .smpTestResult(testFailure) = r {
|
||||||
|
if let t = testFailure {
|
||||||
|
return .failure(t)
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func getChatItemTTL() throws -> ChatItemTTL {
|
func getChatItemTTL() throws -> ChatItemTTL {
|
||||||
let r = chatSendCmdSync(.apiGetChatItemTTL)
|
let r = chatSendCmdSync(.apiGetChatItemTTL)
|
||||||
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||||
@@ -476,6 +487,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
|
||||||
|
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
|
||||||
|
if case let .contactPrefsUpdated(_, toContact) = r { return toContact }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
|
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
|
||||||
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
|
let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
|
||||||
if case let .contactAliasUpdated(toContact) = r { return toContact }
|
if case let .contactAliasUpdated(toContact) = r { return toContact }
|
||||||
@@ -570,10 +587,15 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
|
|||||||
)
|
)
|
||||||
} else if !networkErrorAlert(r) {
|
} else if !networkErrorAlert(r) {
|
||||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||||
am.showAlertMsg(
|
switch r {
|
||||||
title: "Error receiving file",
|
case .chatCmdError(.error(.fileAlreadyReceiving)):
|
||||||
message: "Error: \(String(describing: r))"
|
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
|
||||||
)
|
default:
|
||||||
|
am.showAlertMsg(
|
||||||
|
title: "Error receiving file",
|
||||||
|
message: "Error: \(String(describing: r))"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -581,16 +603,16 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
|
|||||||
func networkErrorAlert(_ r: ChatResponse) -> Bool {
|
func networkErrorAlert(_ r: ChatResponse) -> Bool {
|
||||||
let am = AlertManager.shared
|
let am = AlertManager.shared
|
||||||
switch r {
|
switch r {
|
||||||
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
|
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Connection timeout",
|
title: "Connection timeout",
|
||||||
message: "Please check your network connection and try again."
|
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
|
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Connection error",
|
title: "Connection error",
|
||||||
message: "Please check your network connection and try again."
|
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -826,7 +848,7 @@ func startChat() throws {
|
|||||||
let justStarted = try apiStartChat()
|
let justStarted = try apiStartChat()
|
||||||
if justStarted {
|
if justStarted {
|
||||||
m.userAddress = try apiGetUserAddress()
|
m.userAddress = try apiGetUserAddress()
|
||||||
m.userSMPServers = try getUserSMPServers()
|
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
|
||||||
m.chatItemTTL = try getChatItemTTL()
|
m.chatItemTTL = try getChatItemTTL()
|
||||||
let chats = try apiGetChats()
|
let chats = try apiGetChats()
|
||||||
m.chats = chats.map { Chat.init($0) }
|
m.chats = chats.map { Chat.init($0) }
|
||||||
@@ -895,7 +917,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
case let .contactConnectionDeleted(connection):
|
case let .contactConnectionDeleted(connection):
|
||||||
m.removeChat(connection.id)
|
m.removeChat(connection.id)
|
||||||
case let .contactConnected(contact, _):
|
case let .contactConnected(contact, _):
|
||||||
if !contact.viaGroupLink {
|
if contact.directContact {
|
||||||
m.updateContact(contact)
|
m.updateContact(contact)
|
||||||
m.dismissConnReqView(contact.activeConn.id)
|
m.dismissConnReqView(contact.activeConn.id)
|
||||||
m.removeChat(contact.activeConn.id)
|
m.removeChat(contact.activeConn.id)
|
||||||
@@ -903,7 +925,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
NtfManager.shared.notifyContactConnected(contact)
|
NtfManager.shared.notifyContactConnected(contact)
|
||||||
}
|
}
|
||||||
case let .contactConnecting(contact):
|
case let .contactConnecting(contact):
|
||||||
if !contact.viaGroupLink {
|
if contact.directContact {
|
||||||
m.updateContact(contact)
|
m.updateContact(contact)
|
||||||
m.dismissConnReqView(contact.activeConn.id)
|
m.dismissConnReqView(contact.activeConn.id)
|
||||||
m.removeChat(contact.activeConn.id)
|
m.removeChat(contact.activeConn.id)
|
||||||
@@ -950,22 +972,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
let cInfo = aChatItem.chatInfo
|
let cInfo = aChatItem.chatInfo
|
||||||
let cItem = aChatItem.chatItem
|
let cItem = aChatItem.chatItem
|
||||||
m.addChatItem(cInfo, cItem)
|
m.addChatItem(cInfo, cItem)
|
||||||
if case .image = cItem.content.msgContent,
|
if let file = cItem.file,
|
||||||
let file = cItem.file,
|
let mc = cItem.content.msgContent,
|
||||||
file.fileSize <= maxImageSize,
|
file.fileSize <= MAX_IMAGE_SIZE {
|
||||||
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
|
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||||
Task {
|
if (mc.isImage && acceptImages)
|
||||||
await receiveFile(fileId: file.fileId)
|
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
|
||||||
|
Task {
|
||||||
|
await receiveFile(fileId: file.fileId) // TODO check inlineFileMode != IFMSent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !cItem.chatDir.sent && !cItem.isCall() && !cItem.isMutedMemberEvent {
|
if cItem.showNotification {
|
||||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||||
}
|
}
|
||||||
case let .chatItemStatusUpdated(aChatItem):
|
case let .chatItemStatusUpdated(aChatItem):
|
||||||
let cInfo = aChatItem.chatInfo
|
let cInfo = aChatItem.chatInfo
|
||||||
let cItem = aChatItem.chatItem
|
let cItem = aChatItem.chatItem
|
||||||
var res = false
|
var res = false
|
||||||
if !cItem.isDeletedContent() {
|
if !cItem.isDeletedContent {
|
||||||
res = m.upsertChatItem(cInfo, cItem)
|
res = m.upsertChatItem(cInfo, cItem)
|
||||||
}
|
}
|
||||||
if res {
|
if res {
|
||||||
@@ -980,14 +1005,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
case let .chatItemUpdated(aChatItem):
|
case let .chatItemUpdated(aChatItem):
|
||||||
chatItemSimpleUpdate(aChatItem)
|
chatItemSimpleUpdate(aChatItem)
|
||||||
case let .chatItemDeleted(_, toChatItem):
|
case let .chatItemDeleted(deletedChatItem, toChatItem, _):
|
||||||
let cInfo = toChatItem.chatInfo
|
if let toChatItem = toChatItem {
|
||||||
let cItem = toChatItem.chatItem
|
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||||
if cItem.meta.itemDeleted {
|
|
||||||
m.removeChatItem(cInfo, cItem)
|
|
||||||
} else {
|
} else {
|
||||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
||||||
_ = m.upsertChatItem(cInfo, cItem)
|
|
||||||
}
|
}
|
||||||
case let .receivedGroupInvitation(groupInfo, _, _):
|
case let .receivedGroupInvitation(groupInfo, _, _):
|
||||||
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
|
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
|
||||||
@@ -1119,7 +1141,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
|
|||||||
m.updateContact(contact)
|
m.updateContact(contact)
|
||||||
var err: String
|
var err: String
|
||||||
switch chatError {
|
switch chatError {
|
||||||
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
|
case .errorAgent(agentError: .BROKER(_, .NETWORK)): err = "network"
|
||||||
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
||||||
default: err = String(describing: chatError)
|
default: err = String(describing: chatError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct SimpleXApp: App {
|
|||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
@State private var userAuthorized: Bool?
|
@State private var userAuthorized: Bool?
|
||||||
@State private var doAuthenticate = false
|
@State private var doAuthenticate = false
|
||||||
@State private var enteredBackground: Double? = nil
|
@State private var enteredBackground: TimeInterval? = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
hs_init(0, nil)
|
hs_init(0, nil)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
func infoRow<S>(_ title: S, _ value: String) -> some View where S: StringProtocol {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -53,7 +53,7 @@ struct ChatInfoView: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var contact: Contact
|
@State var contact: Contact
|
||||||
@Binding var connectionStats: ConnectionStats?
|
@Binding var connectionStats: ConnectionStats?
|
||||||
var customUserProfile: Profile?
|
var customUserProfile: Profile?
|
||||||
@State var localAlias: String
|
@State var localAlias: String
|
||||||
@@ -99,15 +99,17 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
contactPreferencesButton()
|
||||||
|
}
|
||||||
|
|
||||||
Section("Servers") {
|
Section("Servers") {
|
||||||
networkStatusRow()
|
networkStatusRow()
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
alert = .networkStatusAlert
|
alert = .networkStatusAlert
|
||||||
}
|
}
|
||||||
if developerTools {
|
Button("Change receiving address") {
|
||||||
Button("Change receiving address (BETA)") {
|
alert = .switchAddressAlert
|
||||||
alert = .switchAddressAlert
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let connStats = connectionStats {
|
if let connStats = connectionStats {
|
||||||
smpServers("Receiving via", connStats.rcvServers)
|
smpServers("Receiving via", connStats.rcvServers)
|
||||||
@@ -192,6 +194,20 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contactPreferencesButton() -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
ContactPreferencesView(
|
||||||
|
contact: $contact,
|
||||||
|
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||||
|
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Contact preferences")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
Label("Contact preferences", systemImage: "switch.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func networkStatusRow() -> some View {
|
func networkStatusRow() -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Network status")
|
Text("Network status")
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ struct CICallItemView: View {
|
|||||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "phone.down")
|
Image(systemName: "phone.down")
|
||||||
Text(CICallStatus.durationText(duration)).foregroundColor(.secondary)
|
Text(durationText(duration)).foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// CIChatFeatureView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 21/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct CIChatFeatureView: View {
|
||||||
|
var chatItem: ChatItem
|
||||||
|
var feature: Feature
|
||||||
|
var iconColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .bottom, spacing: 4) {
|
||||||
|
Image(systemName: feature.iconFilled)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
chatEventText(chatItem)
|
||||||
|
}
|
||||||
|
.padding(.leading, 6)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.textSelection(.disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||||
|
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,27 +20,27 @@ struct CIEventView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ eventText()
|
+ chatEventText(chatItem)
|
||||||
} else {
|
} else {
|
||||||
eventText()
|
chatEventText(chatItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 6)
|
.padding(.leading, 6)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func eventText() -> Text {
|
func chatEventText(_ ci: ChatItem) -> Text {
|
||||||
Text(chatItem.content.text)
|
Text(ci.content.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ chatItem.timestampText
|
+ ci.timestampText
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(Color.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIEventView_Previews: PreviewProvider {
|
struct CIEventView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ struct CIFileView: View {
|
|||||||
|
|
||||||
func fileSizeValid() -> Bool {
|
func fileSizeValid() -> Bool {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
return file.fileSize <= maxFileSize
|
return file.fileSize <= MAX_FILE_SIZE
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ struct CIFileView: View {
|
|||||||
await receiveFile(fileId: file.fileId)
|
await receiveFile(fileId: file.fileId)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
|
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Large file!",
|
title: "Large file!",
|
||||||
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
|
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
|
||||||
@@ -79,7 +79,7 @@ struct CIFileView: View {
|
|||||||
)
|
)
|
||||||
case .rcvComplete:
|
case .rcvComplete:
|
||||||
logger.debug("CIFileView fileAction - in .rcvComplete")
|
logger.debug("CIFileView fileAction - in .rcvComplete")
|
||||||
if let filePath = getLoadedFilePath(file){
|
if let filePath = getLoadedFilePath(file) {
|
||||||
let url = URL(fileURLWithPath: filePath)
|
let url = URL(fileURLWithPath: filePath)
|
||||||
showShareSheet(items: [url])
|
showShareSheet(items: [url])
|
||||||
}
|
}
|
||||||
@@ -148,17 +148,17 @@ struct CIFileView_Previews: PreviewProvider {
|
|||||||
quotedItem: nil,
|
quotedItem: nil,
|
||||||
file: nil
|
file: nil
|
||||||
)
|
)
|
||||||
Group{
|
Group {
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct CIMetaView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
if !chatItem.isDeletedContent() {
|
if !chatItem.isDeletedContent {
|
||||||
if chatItem.meta.itemEdited {
|
if chatItem.meta.itemEdited {
|
||||||
statusImage("pencil", metaColor, 9)
|
statusImage("pencil", metaColor, 9)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ struct CIMetaView: View {
|
|||||||
|
|
||||||
struct CIMetaView_Previews: PreviewProvider {
|
struct CIMetaView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
return Group {
|
Group {
|
||||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
|
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
|
||||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true))
|
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true))
|
||||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// CIVoiceView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by JRoberts on 22.11.2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct CIVoiceView: View {
|
||||||
|
var chatItem: ChatItem
|
||||||
|
let recordingFile: CIFile?
|
||||||
|
let duration: Int
|
||||||
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
|
@State var playbackTime: TimeInterval?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack (
|
||||||
|
alignment: chatItem.chatDir.sent ? .trailing : .leading,
|
||||||
|
spacing: 6
|
||||||
|
) {
|
||||||
|
HStack {
|
||||||
|
if chatItem.chatDir.sent {
|
||||||
|
playerTime()
|
||||||
|
.frame(width: 50, alignment: .leading)
|
||||||
|
player()
|
||||||
|
} else {
|
||||||
|
player()
|
||||||
|
playerTime()
|
||||||
|
.frame(width: 50, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CIMetaView(chatItem: chatItem)
|
||||||
|
.padding(.leading, chatItem.chatDir.sent ? 0 : 12)
|
||||||
|
.padding(.trailing, chatItem.chatDir.sent ? 12 : 0)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func player() -> some View {
|
||||||
|
VoiceMessagePlayer(
|
||||||
|
chatItem: chatItem,
|
||||||
|
recordingFile: recordingFile,
|
||||||
|
recordingTime: TimeInterval(duration),
|
||||||
|
showBackground: true,
|
||||||
|
playbackState: $playbackState,
|
||||||
|
playbackTime: $playbackTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playerTime() -> some View {
|
||||||
|
VoiceMessagePlayerTime(
|
||||||
|
recordingTime: TimeInterval(duration),
|
||||||
|
playbackState: $playbackState,
|
||||||
|
playbackTime: $playbackTime
|
||||||
|
)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VoiceMessagePlayerTime: View {
|
||||||
|
var recordingTime: TimeInterval
|
||||||
|
@Binding var playbackState: VoiceMessagePlaybackState
|
||||||
|
@Binding var playbackTime: TimeInterval?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch playbackState {
|
||||||
|
case .noPlayback:
|
||||||
|
Text(voiceMessageTime(recordingTime))
|
||||||
|
case .playing:
|
||||||
|
Text(voiceMessageTime_(playbackTime))
|
||||||
|
case .paused:
|
||||||
|
Text(voiceMessageTime_(playbackTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VoiceMessagePlayer: View {
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
var chatItem: ChatItem
|
||||||
|
var recordingFile: CIFile?
|
||||||
|
var recordingTime: TimeInterval
|
||||||
|
var showBackground: Bool
|
||||||
|
|
||||||
|
@State private var audioPlayer: AudioPlayer?
|
||||||
|
@Binding var playbackState: VoiceMessagePlaybackState
|
||||||
|
@Binding var playbackTime: TimeInterval?
|
||||||
|
@State private var startingPlayback: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if let recordingFile = recordingFile {
|
||||||
|
switch recordingFile.fileStatus {
|
||||||
|
case .sndStored: playbackButton()
|
||||||
|
case .sndTransfer: playbackButton()
|
||||||
|
case .sndComplete: playbackButton()
|
||||||
|
case .sndCancelled: playbackButton()
|
||||||
|
case .rcvInvitation: loadingIcon()
|
||||||
|
case .rcvAccepted: loadingIcon()
|
||||||
|
case .rcvTransfer: loadingIcon()
|
||||||
|
case .rcvComplete: playbackButton()
|
||||||
|
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||||
|
if !startingPlayback {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
playbackState = .noPlayback
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
} else {
|
||||||
|
startingPlayback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func playbackButton() -> some View {
|
||||||
|
switch playbackState {
|
||||||
|
case .noPlayback:
|
||||||
|
Button {
|
||||||
|
if let recordingFileName = getLoadedFileName(recordingFile) {
|
||||||
|
startPlayback(recordingFileName)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
case .playing:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
playbackState = .paused
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("pause.fill")
|
||||||
|
}
|
||||||
|
case .paused:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.play()
|
||||||
|
playbackState = .playing
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
||||||
|
ZStack {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.foregroundColor(color)
|
||||||
|
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
if recordingTime > 0 {
|
||||||
|
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||||
|
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProgressCircle: View {
|
||||||
|
var length: TimeInterval
|
||||||
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||||
|
.stroke(
|
||||||
|
Color.accentColor,
|
||||||
|
style: StrokeStyle(lineWidth: 3)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.linear, value: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadingIcon() -> some View {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPlayback(_ recordingFileName: String) {
|
||||||
|
startingPlayback = true
|
||||||
|
chatModel.stopPreviousRecPlay.toggle()
|
||||||
|
audioPlayer = AudioPlayer(
|
||||||
|
onTimer: { playbackTime = $0 },
|
||||||
|
onFinishPlayback: {
|
||||||
|
playbackState = .noPlayback
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
audioPlayer?.start(fileName: recordingFileName)
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
playbackState = .playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CIVoiceView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let sentVoiceMessage: ChatItem = ChatItem(
|
||||||
|
chatDir: .directSnd,
|
||||||
|
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||||
|
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||||
|
quotedItem: nil,
|
||||||
|
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||||
|
)
|
||||||
|
let voiceMessageWtFile = ChatItem(
|
||||||
|
chatDir: .directRcv,
|
||||||
|
meta: CIMeta.getSample(1, .now, "", .rcvRead, false, false, false),
|
||||||
|
content: .rcvMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||||
|
quotedItem: nil,
|
||||||
|
file: nil
|
||||||
|
)
|
||||||
|
Group {
|
||||||
|
CIVoiceView(
|
||||||
|
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||||
|
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||||
|
duration: 30,
|
||||||
|
playbackState: .playing,
|
||||||
|
playbackTime: TimeInterval(20)
|
||||||
|
)
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
}
|
||||||
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// FramedCIVoiceView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by JRoberts on 22.11.2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct FramedCIVoiceView: View {
|
||||||
|
var chatItem: ChatItem
|
||||||
|
let recordingFile: CIFile?
|
||||||
|
let duration: Int
|
||||||
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
|
@State var playbackTime: TimeInterval?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VoiceMessagePlayer(
|
||||||
|
chatItem: chatItem,
|
||||||
|
recordingFile: recordingFile,
|
||||||
|
recordingTime: TimeInterval(duration),
|
||||||
|
showBackground: false,
|
||||||
|
playbackState: $playbackState,
|
||||||
|
playbackTime: $playbackTime
|
||||||
|
)
|
||||||
|
VoiceMessagePlayerTime(
|
||||||
|
recordingTime: TimeInterval(duration),
|
||||||
|
playbackState: $playbackState,
|
||||||
|
playbackTime: $playbackTime
|
||||||
|
)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 50, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 6)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let sentVoiceMessage: ChatItem = ChatItem(
|
||||||
|
chatDir: .directSnd,
|
||||||
|
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||||
|
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
|
||||||
|
quotedItem: nil,
|
||||||
|
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||||
|
)
|
||||||
|
let voiceMessageWithQuote: ChatItem = ChatItem(
|
||||||
|
chatDir: .directSnd,
|
||||||
|
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||||
|
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||||
|
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
|
||||||
|
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||||
|
)
|
||||||
|
Group {
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer), revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
}
|
||||||
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// MarkedDeletedItemView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by JRoberts on 30.11.2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct MarkedDeletedItemView: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
var chatItem: ChatItem
|
||||||
|
var showMember = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
|
if showMember, let member = chatItem.memberDisplayName {
|
||||||
|
Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
|
||||||
|
}
|
||||||
|
Text("marked deleted")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.italic()
|
||||||
|
CIMetaView(chatItem: chatItem)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(chatItemFrameColor(chatItem, colorScheme))
|
||||||
|
.cornerRadius(18)
|
||||||
|
.textSelection(.disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false))
|
||||||
|
}
|
||||||
|
.previewLayout(.fixed(width: 360, height: 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||||
private let linkColor = Color(uiColor: uiLinkColor)
|
|
||||||
|
|
||||||
struct MsgContentView: View {
|
struct MsgContentView: View {
|
||||||
var text: String
|
var text: String
|
||||||
@@ -70,6 +69,14 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
|||||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
||||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||||
case .uri: return linkText(t, t, preview, prefix: "")
|
case .uri: return linkText(t, t, preview, prefix: "")
|
||||||
|
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
|
||||||
|
switch privacySimplexLinkModeDefault.get() {
|
||||||
|
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
|
||||||
|
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
||||||
|
case .browser: return trustedUri
|
||||||
|
? linkText(t, t, preview, prefix: "")
|
||||||
|
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
|
||||||
|
}
|
||||||
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
||||||
}
|
}
|
||||||
@@ -78,16 +85,19 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func linkText(_ s: String, _ link: String,
|
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
|
||||||
_ preview: Bool, prefix: String) -> Text {
|
|
||||||
preview
|
preview
|
||||||
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
|
? Text(s).foregroundColor(color).underline(color: color)
|
||||||
: Text(AttributedString(s, attributes: AttributeContainer([
|
: Text(AttributedString(s, attributes: AttributeContainer([
|
||||||
.link: NSURL(string: prefix + link) as Any,
|
.link: NSURL(string: prefix + link) as Any,
|
||||||
.foregroundColor: uiLinkColor as Any
|
.foregroundColor: uiColor as Any
|
||||||
]))).underline()
|
]))).underline()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||||
|
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
|
||||||
|
}
|
||||||
|
|
||||||
struct MsgContentView_Previews: PreviewProvider {
|
struct MsgContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||||
|
|||||||
@@ -15,11 +15,42 @@ struct ChatItemView: View {
|
|||||||
var showMember = false
|
var showMember = false
|
||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@State var scrollProxy: ScrollViewProxy? = nil
|
@State var scrollProxy: ScrollViewProxy? = nil
|
||||||
|
@Binding var revealed: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let ci = chatItem
|
||||||
|
if chatItem.meta.itemDeleted && !revealed {
|
||||||
|
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||||
|
} else if ci.quotedItem == nil && !ci.meta.itemDeleted {
|
||||||
|
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||||
|
EmojiItemView(chatItem: ci)
|
||||||
|
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||||
|
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration)
|
||||||
|
} else if ci.content.msgContent == nil {
|
||||||
|
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func framedItemView() -> some View {
|
||||||
|
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatItemContentView<Content: View>: View {
|
||||||
|
var chatInfo: ChatInfo
|
||||||
|
var chatItem: ChatItem
|
||||||
|
var showMember: Bool
|
||||||
|
var msgContentView: () -> Content
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch chatItem.content {
|
switch chatItem.content {
|
||||||
case .sndMsgContent: contentItemView()
|
case .sndMsgContent: msgContentView()
|
||||||
case .rcvMsgContent: contentItemView()
|
case .rcvMsgContent: msgContentView()
|
||||||
case .sndDeleted: deletedItemView()
|
case .sndDeleted: deletedItemView()
|
||||||
case .rcvDeleted: deletedItemView()
|
case .rcvDeleted: deletedItemView()
|
||||||
case let .sndCall(status, duration): callItemView(status, duration)
|
case let .sndCall(status, duration): callItemView(status, duration)
|
||||||
@@ -31,14 +62,12 @@ struct ChatItemView: View {
|
|||||||
case .sndGroupEvent: eventItemView()
|
case .sndGroupEvent: eventItemView()
|
||||||
case .rcvConnEvent: eventItemView()
|
case .rcvConnEvent: eventItemView()
|
||||||
case .sndConnEvent: eventItemView()
|
case .sndConnEvent: eventItemView()
|
||||||
}
|
case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
||||||
}
|
case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor)
|
||||||
|
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
||||||
@ViewBuilder private func contentItemView() -> some View {
|
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
|
||||||
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
|
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
EmojiItemView(chatItem: chatItem)
|
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
} else {
|
|
||||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +86,76 @@ struct ChatItemView: View {
|
|||||||
private func eventItemView() -> some View {
|
private func eventItemView() -> some View {
|
||||||
CIEventView(chatItem: chatItem)
|
CIEventView(chatItem: chatItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||||
|
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemView_Previews: PreviewProvider {
|
struct ChatItemView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group{
|
Group{
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample())
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(false))
|
||||||
|
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(true))
|
||||||
|
}
|
||||||
|
.previewLayout(.fixed(width: 360, height: 70))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false))
|
||||||
|
Group{
|
||||||
|
ChatItemView(
|
||||||
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
|
chatItem: ChatItem(
|
||||||
|
chatDir: .directRcv,
|
||||||
|
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, true, false, false),
|
||||||
|
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
||||||
|
quotedItem: nil,
|
||||||
|
file: nil
|
||||||
|
),
|
||||||
|
revealed: Binding.constant(true)
|
||||||
|
)
|
||||||
|
ChatItemView(
|
||||||
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
|
chatItem: ChatItem(
|
||||||
|
chatDir: .directRcv,
|
||||||
|
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, true, false, false),
|
||||||
|
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
||||||
|
quotedItem: nil,
|
||||||
|
file: nil
|
||||||
|
),
|
||||||
|
revealed: Binding.constant(true)
|
||||||
|
)
|
||||||
|
ChatItemView(
|
||||||
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
|
chatItem: ChatItem(
|
||||||
|
chatDir: .directRcv,
|
||||||
|
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, true, false, false),
|
||||||
|
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||||
|
quotedItem: nil,
|
||||||
|
file: nil
|
||||||
|
),
|
||||||
|
revealed: Binding.constant(true)
|
||||||
|
)
|
||||||
|
ChatItemView(
|
||||||
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
|
chatItem: ChatItem(
|
||||||
|
chatDir: .directRcv,
|
||||||
|
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, true, false, false),
|
||||||
|
content: ciFeatureContent,
|
||||||
|
quotedItem: nil,
|
||||||
|
file: nil
|
||||||
|
),
|
||||||
|
revealed: Binding.constant(true)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 70))
|
.previewLayout(.fixed(width: 360, height: 70))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct ChatView: View {
|
|||||||
// opening GroupMemberInfoView on member icon
|
// opening GroupMemberInfoView on member icon
|
||||||
@State private var selectedMember: GroupMember? = nil
|
@State private var selectedMember: GroupMember? = nil
|
||||||
@State private var memberConnectionStats: ConnectionStats?
|
@State private var memberConnectionStats: ConnectionStats?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
return VStack(spacing: 0) {
|
return VStack(spacing: 0) {
|
||||||
@@ -48,9 +48,9 @@ struct ChatView: View {
|
|||||||
floatingButtons(proxy)
|
floatingButtons(proxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
ComposeView(
|
ComposeView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
@@ -103,7 +103,7 @@ struct ChatView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
ChatInfoToolbar(chat: chat)
|
ChatInfoToolbar(chat: chat)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
|
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
|
||||||
connectionStats = nil
|
connectionStats = nil
|
||||||
customUserProfile = nil
|
customUserProfile = nil
|
||||||
}) {
|
}) {
|
||||||
@@ -121,7 +121,7 @@ struct ChatView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
ChatInfoToolbar(chat: chat)
|
ChatInfoToolbar(chat: chat)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showChatInfoSheet) {
|
.appSheet(isPresented: $showChatInfoSheet) {
|
||||||
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
|
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ struct ChatView: View {
|
|||||||
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
|
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
|
||||||
} else {
|
} else {
|
||||||
addMembersButton()
|
addMembersButton()
|
||||||
.sheet(isPresented: $showAddMembersSheet) {
|
.appSheet(isPresented: $showAddMembersSheet) {
|
||||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,16 +170,16 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func searchToolbar() -> some View {
|
private func searchToolbar() -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
TextField("Search", text: $searchText)
|
TextField("Search", text: $searchText)
|
||||||
.focused($searchFocussed)
|
.focused($searchFocussed)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
} label: {
|
} label: {
|
||||||
@@ -190,7 +190,7 @@ struct ChatView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(10.0)
|
.cornerRadius(10.0)
|
||||||
|
|
||||||
Button ("Cancel") {
|
Button ("Cancel") {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
searchMode = false
|
searchMode = false
|
||||||
@@ -204,37 +204,37 @@ struct ChatView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatItemsList() -> some View {
|
private func chatItemsList() -> some View {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
return GeometryReader { g in
|
return GeometryReader { g in
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
let maxWidth =
|
let maxWidth =
|
||||||
cInfo.chatType == .group
|
cInfo.chatType == .group
|
||||||
? (g.size.width - 28) * 0.84 - 42
|
? (g.size.width - 28) * 0.84 - 42
|
||||||
: (g.size.width - 32) * 0.84
|
: (g.size.width - 32) * 0.84
|
||||||
LazyVStack(spacing: 5) {
|
LazyVStack(spacing: 5) {
|
||||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||||
chatItemView(ci, maxWidth)
|
chatItemView(ci, maxWidth)
|
||||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
itemsInView.insert(ci.viewId)
|
itemsInView.insert(ci.viewId)
|
||||||
loadChatItems(cInfo, ci, proxy)
|
loadChatItems(cInfo, ci, proxy)
|
||||||
if ci.isRcvNew() {
|
if ci.isRcvNew {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||||
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
|
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
|
||||||
Task {
|
Task {
|
||||||
await apiMarkChatItemRead(cInfo, ci)
|
await apiMarkChatItemRead(cInfo, ci)
|
||||||
NtfManager.shared.decNtfBadgeCount()
|
NtfManager.shared.decNtfBadgeCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.onDisappear {
|
||||||
.onDisappear {
|
itemsInView.remove(ci.viewId)
|
||||||
itemsInView.remove(ci.viewId)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
|
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
|
||||||
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
|
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
|
||||||
return VStack {
|
return VStack {
|
||||||
@@ -300,7 +300,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -309,7 +309,7 @@ struct ChatView: View {
|
|||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
|
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
|
||||||
Button {
|
Button {
|
||||||
CallController.shared.startCall(contact, media)
|
CallController.shared.startCall(contact, media)
|
||||||
@@ -317,7 +317,7 @@ struct ChatView: View {
|
|||||||
Image(systemName: imageName)
|
Image(systemName: imageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func searchButton() -> some View {
|
private func searchButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
searchMode = true
|
searchMode = true
|
||||||
@@ -327,7 +327,7 @@ struct ChatView: View {
|
|||||||
Label("Search", systemImage: "magnifyingglass")
|
Label("Search", systemImage: "magnifyingglass")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addMembersButton() -> some View {
|
private func addMembersButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
if case let .group(gInfo) = chat.chatInfo {
|
if case let .group(gInfo) = chat.chatInfo {
|
||||||
@@ -343,7 +343,7 @@ struct ChatView: View {
|
|||||||
Image(systemName: "person.crop.circle.badge.plus")
|
Image(systemName: "person.crop.circle.badge.plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
|
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
|
||||||
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
|
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
|
||||||
if loadingItems || firstPage { return }
|
if loadingItems || firstPage { return }
|
||||||
@@ -371,7 +371,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||||
if case let .groupRcv(member) = ci.chatDir,
|
if case let .groupRcv(member) = ci.chatDir,
|
||||||
case let .group(groupInfo) = chat.chatInfo {
|
case let .group(groupInfo) = chat.chatInfo {
|
||||||
@@ -392,7 +392,7 @@ struct ChatView: View {
|
|||||||
await MainActor.run { selectedMember = member }
|
await MainActor.run { selectedMember = member }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedMember, onDismiss: {
|
.appSheet(item: $selectedMember, onDismiss: {
|
||||||
selectedMember = nil
|
selectedMember = nil
|
||||||
memberConnectionStats = nil
|
memberConnectionStats = nil
|
||||||
}) { _ in
|
}) { _ in
|
||||||
@@ -402,117 +402,208 @@ struct ChatView: View {
|
|||||||
Rectangle().fill(.clear)
|
Rectangle().fill(.clear)
|
||||||
.frame(width: memberImageSize, height: memberImageSize)
|
.frame(width: memberImageSize, height: memberImageSize)
|
||||||
}
|
}
|
||||||
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
|
ChatItemWithMenu(
|
||||||
|
chat: chat,
|
||||||
|
ci: ci,
|
||||||
|
showMember: showMember,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
scrollProxy: scrollProxy,
|
||||||
|
deleteMessage: deleteMessage,
|
||||||
|
deletingItem: $deletingItem,
|
||||||
|
composeState: $composeState,
|
||||||
|
showDeleteMessage: $showDeleteMessage
|
||||||
|
).padding(.leading, 8)
|
||||||
}
|
}
|
||||||
.padding(.trailing)
|
.padding(.trailing)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
} else {
|
} else {
|
||||||
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
|
ChatItemWithMenu(
|
||||||
|
chat: chat,
|
||||||
|
ci: ci,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
scrollProxy: scrollProxy,
|
||||||
|
deleteMessage: deleteMessage,
|
||||||
|
deletingItem: $deletingItem,
|
||||||
|
composeState: $composeState,
|
||||||
|
showDeleteMessage: $showDeleteMessage
|
||||||
|
).padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
|
private struct ChatItemWithMenu: View {
|
||||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
var chat: Chat
|
||||||
var menu: [UIAction] = []
|
var ci: ChatItem
|
||||||
if ci.isMsgContent() {
|
var showMember: Bool = false
|
||||||
menu.append(contentsOf: [
|
var maxWidth: CGFloat
|
||||||
UIAction(
|
var scrollProxy: ScrollViewProxy?
|
||||||
title: NSLocalizedString("Reply", comment: "chat item action"),
|
var deleteMessage: (CIDeleteMode) -> Void
|
||||||
image: UIImage(systemName: "arrowshape.turn.up.left")
|
@Binding var deletingItem: ChatItem?
|
||||||
) { _ in
|
@Binding var composeState: ComposeState
|
||||||
withAnimation {
|
@Binding var showDeleteMessage: Bool
|
||||||
if composeState.editing() {
|
|
||||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
@State private var revealed = false
|
||||||
} else {
|
|
||||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
var body: some View {
|
||||||
|
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||||
|
|
||||||
|
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
|
||||||
|
.uiKitContextMenu(actions: menu())
|
||||||
|
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||||
|
Button("Delete for me", role: .destructive) {
|
||||||
|
deleteMessage(.cidmInternal)
|
||||||
|
}
|
||||||
|
if let di = deletingItem, di.meta.editable {
|
||||||
|
Button(broadcastDeleteButtonText, role: .destructive) {
|
||||||
|
deleteMessage(.cidmBroadcast)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
UIAction(
|
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||||
image: UIImage(systemName: "square.and.arrow.up")
|
}
|
||||||
) { _ in
|
|
||||||
var shareItems: [Any] = [ci.content.text]
|
private func menu() -> [UIAction] {
|
||||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
var menu: [UIAction] = []
|
||||||
shareItems.append(image)
|
if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed {
|
||||||
}
|
if !ci.meta.itemDeleted {
|
||||||
showShareSheet(items: shareItems)
|
menu.append(replyUIAction())
|
||||||
},
|
}
|
||||||
UIAction(
|
menu.append(shareUIAction())
|
||||||
title: NSLocalizedString("Copy", comment: "chat item action"),
|
menu.append(copyUIAction())
|
||||||
image: UIImage(systemName: "doc.on.doc")
|
if let filePath = getLoadedFilePath(ci.file) {
|
||||||
) { _ in
|
if case .image = ci.content.msgContent, let image = UIImage(contentsOfFile: filePath) {
|
||||||
if case let .image(text, _) = ci.content.msgContent,
|
menu.append(saveImageAction(image))
|
||||||
text == "",
|
|
||||||
let image = getLoadedImage(ci.file) {
|
|
||||||
UIPasteboard.general.image = image
|
|
||||||
} else {
|
} else {
|
||||||
UIPasteboard.general.string = ci.content.text
|
menu.append(saveFileAction(filePath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
])
|
if ci.meta.editable && !mc.isVoice {
|
||||||
if case .image = ci.content.msgContent,
|
menu.append(editAction())
|
||||||
let image = getLoadedImage(ci.file) {
|
}
|
||||||
menu.append(
|
if revealed {
|
||||||
UIAction(
|
menu.append(hideUIAction())
|
||||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
}
|
||||||
image: UIImage(systemName: "square.and.arrow.down")
|
menu.append(deleteUIAction())
|
||||||
) { _ in
|
} else if ci.meta.itemDeleted {
|
||||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
menu.append(revealUIAction())
|
||||||
}
|
menu.append(deleteUIAction())
|
||||||
)
|
} else if ci.isDeletedContent {
|
||||||
|
menu.append(deleteUIAction())
|
||||||
}
|
}
|
||||||
if ci.meta.editable {
|
return menu
|
||||||
menu.append(
|
}
|
||||||
UIAction(
|
|
||||||
title: NSLocalizedString("Edit", comment: "chat item action"),
|
private func replyUIAction() -> UIAction {
|
||||||
image: UIImage(systemName: "square.and.pencil")
|
UIAction(
|
||||||
) { _ in
|
title: NSLocalizedString("Reply", comment: "chat item action"),
|
||||||
withAnimation {
|
image: UIImage(systemName: "arrowshape.turn.up.left")
|
||||||
composeState = ComposeState(editingItem: ci)
|
) { _ in
|
||||||
}
|
withAnimation {
|
||||||
|
if composeState.editing {
|
||||||
|
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||||
|
} else {
|
||||||
|
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
menu.append(
|
}
|
||||||
UIAction(
|
|
||||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
private func shareUIAction() -> UIAction {
|
||||||
image: UIImage(systemName: "trash"),
|
UIAction(
|
||||||
attributes: [.destructive]
|
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||||
) { _ in
|
image: UIImage(systemName: "square.and.arrow.up")
|
||||||
showDeleteMessage = true
|
) { _ in
|
||||||
deletingItem = ci
|
var shareItems: [Any] = [ci.content.text]
|
||||||
|
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||||
|
shareItems.append(image)
|
||||||
}
|
}
|
||||||
)
|
showShareSheet(items: shareItems)
|
||||||
} else if ci.isDeletedContent() {
|
}
|
||||||
menu.append(
|
}
|
||||||
UIAction(
|
|
||||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
private func copyUIAction() -> UIAction {
|
||||||
image: UIImage(systemName: "trash"),
|
UIAction(
|
||||||
attributes: [.destructive]
|
title: NSLocalizedString("Copy", comment: "chat item action"),
|
||||||
) { _ in
|
image: UIImage(systemName: "doc.on.doc")
|
||||||
showDeleteMessage = true
|
) { _ in
|
||||||
deletingItem = ci
|
if case let .image(text, _) = ci.content.msgContent,
|
||||||
|
text == "",
|
||||||
|
let image = getLoadedImage(ci.file) {
|
||||||
|
UIPasteboard.general.image = image
|
||||||
|
} else {
|
||||||
|
UIPasteboard.general.string = ci.content.text
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveImageAction(_ image: UIImage) -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||||
|
image: UIImage(systemName: "square.and.arrow.down")
|
||||||
|
) { _ in
|
||||||
|
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveFileAction(_ filePath: String) -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||||
|
image: UIImage(systemName: "square.and.arrow.down")
|
||||||
|
) { _ in
|
||||||
|
let fileURL = URL(fileURLWithPath: filePath)
|
||||||
|
showShareSheet(items: [fileURL])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editAction() -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: NSLocalizedString("Edit", comment: "chat item action"),
|
||||||
|
image: UIImage(systemName: "square.and.pencil")
|
||||||
|
) { _ in
|
||||||
|
withAnimation {
|
||||||
|
composeState = ComposeState(editingItem: ci)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
|
private func hideUIAction() -> UIAction {
|
||||||
.uiKitContextMenu(actions: menu)
|
UIAction(
|
||||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
title: NSLocalizedString("Hide", comment: "chat item action"),
|
||||||
Button("Delete for me", role: .destructive) {
|
image: UIImage(systemName: "eye.slash")
|
||||||
deleteMessage(.cidmInternal)
|
) { _ in
|
||||||
}
|
withAnimation {
|
||||||
if let di = deletingItem, di.meta.editable {
|
revealed = false
|
||||||
Button("Delete for everyone",role: .destructive) {
|
|
||||||
deleteMessage(.cidmBroadcast)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
|
||||||
|
private func deleteUIAction() -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||||
|
image: UIImage(systemName: "trash"),
|
||||||
|
attributes: [.destructive]
|
||||||
|
) { _ in
|
||||||
|
showDeleteMessage = true
|
||||||
|
deletingItem = ci
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealUIAction() -> UIAction {
|
||||||
|
UIAction(
|
||||||
|
title: NSLocalizedString("Reveal", comment: "chat item action"),
|
||||||
|
image: UIImage(systemName: "eye")
|
||||||
|
) { _ in
|
||||||
|
withAnimation {
|
||||||
|
revealed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var broadcastDeleteButtonText: LocalizedStringKey {
|
||||||
|
chat.chatInfo.fullDeletionAllowed ? "Delete for everyone" : "Mark deleted for everyone"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||||
switch (prevItem?.chatDir) {
|
switch (prevItem?.chatDir) {
|
||||||
case .groupSnd: return true
|
case .groupSnd: return true
|
||||||
@@ -520,26 +611,26 @@ struct ChatView: View {
|
|||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
if let ci = chatModel.reversedChatItems.first {
|
if let ci = chatModel.reversedChatItems.first {
|
||||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollUp(_ proxy: ScrollViewProxy) {
|
private func scrollUp(_ proxy: ScrollViewProxy) {
|
||||||
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
|
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
|
||||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteMessage(_ mode: CIDeleteMode) {
|
private func deleteMessage(_ mode: CIDeleteMode) {
|
||||||
logger.debug("ChatView deleteMessage")
|
logger.debug("ChatView deleteMessage")
|
||||||
Task {
|
Task {
|
||||||
logger.debug("ChatView deleteMessage: in Task")
|
logger.debug("ChatView deleteMessage: in Task")
|
||||||
do {
|
do {
|
||||||
if let di = deletingItem {
|
if let di = deletingItem {
|
||||||
let toItem = try await apiDeleteChatItem(
|
let (deletedItem, toItem) = try await apiDeleteChatItem(
|
||||||
type: chat.chatInfo.chatType,
|
type: chat.chatInfo.chatType,
|
||||||
id: chat.chatInfo.apiId,
|
id: chat.chatInfo.apiId,
|
||||||
itemId: di.id,
|
itemId: di.id,
|
||||||
@@ -547,7 +638,11 @@ struct ChatView: View {
|
|||||||
)
|
)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
deletingItem = nil
|
deletingItem = nil
|
||||||
let _ = chatModel.removeChatItem(chat.chatInfo, toItem)
|
if let toItem = toItem {
|
||||||
|
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
|
||||||
|
} else {
|
||||||
|
chatModel.removeChatItem(chat.chatInfo, deletedItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum ComposePreview {
|
|||||||
case noPreview
|
case noPreview
|
||||||
case linkPreview(linkPreview: LinkPreview?)
|
case linkPreview(linkPreview: LinkPreview?)
|
||||||
case imagePreviews(imagePreviews: [String])
|
case imagePreviews(imagePreviews: [String])
|
||||||
|
case voicePreview(recordingFileName: String, duration: Int)
|
||||||
case filePreview(fileName: String)
|
case filePreview(fileName: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,10 +23,17 @@ enum ComposeContextItem {
|
|||||||
case editingItem(chatItem: ChatItem)
|
case editingItem(chatItem: ChatItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VoiceMessageRecordingState {
|
||||||
|
case noRecording
|
||||||
|
case recording
|
||||||
|
case finished
|
||||||
|
}
|
||||||
|
|
||||||
struct ComposeState {
|
struct ComposeState {
|
||||||
var message: String
|
var message: String
|
||||||
var preview: ComposePreview
|
var preview: ComposePreview
|
||||||
var contextItem: ComposeContextItem
|
var contextItem: ComposeContextItem
|
||||||
|
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||||
var inProgress = false
|
var inProgress = false
|
||||||
var disabled = false
|
var disabled = false
|
||||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||||
@@ -33,66 +41,91 @@ struct ComposeState {
|
|||||||
init(
|
init(
|
||||||
message: String = "",
|
message: String = "",
|
||||||
preview: ComposePreview = .noPreview,
|
preview: ComposePreview = .noPreview,
|
||||||
contextItem: ComposeContextItem = .noContextItem
|
contextItem: ComposeContextItem = .noContextItem,
|
||||||
|
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
|
||||||
) {
|
) {
|
||||||
self.message = message
|
self.message = message
|
||||||
self.preview = preview
|
self.preview = preview
|
||||||
self.contextItem = contextItem
|
self.contextItem = contextItem
|
||||||
|
self.voiceMessageRecordingState = voiceMessageRecordingState
|
||||||
}
|
}
|
||||||
|
|
||||||
init(editingItem: ChatItem) {
|
init(editingItem: ChatItem) {
|
||||||
self.message = editingItem.content.text
|
self.message = editingItem.content.text
|
||||||
self.preview = chatItemPreview(chatItem: editingItem)
|
self.preview = chatItemPreview(chatItem: editingItem)
|
||||||
self.contextItem = .editingItem(chatItem: editingItem)
|
self.contextItem = .editingItem(chatItem: editingItem)
|
||||||
|
if let emc = editingItem.content.msgContent,
|
||||||
|
case .voice = emc {
|
||||||
|
self.voiceMessageRecordingState = .finished
|
||||||
|
} else {
|
||||||
|
self.voiceMessageRecordingState = .noRecording
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copy(
|
func copy(
|
||||||
message: String? = nil,
|
message: String? = nil,
|
||||||
preview: ComposePreview? = nil,
|
preview: ComposePreview? = nil,
|
||||||
contextItem: ComposeContextItem? = nil
|
contextItem: ComposeContextItem? = nil,
|
||||||
|
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
|
||||||
) -> ComposeState {
|
) -> ComposeState {
|
||||||
ComposeState(
|
ComposeState(
|
||||||
message: message ?? self.message,
|
message: message ?? self.message,
|
||||||
preview: preview ?? self.preview,
|
preview: preview ?? self.preview,
|
||||||
contextItem: contextItem ?? self.contextItem
|
contextItem: contextItem ?? self.contextItem,
|
||||||
|
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func editing() -> Bool {
|
var editing: Bool {
|
||||||
switch contextItem {
|
switch contextItem {
|
||||||
case .editingItem: return true
|
case .editingItem: return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendEnabled() -> Bool {
|
var sendEnabled: Bool {
|
||||||
switch preview {
|
switch preview {
|
||||||
case .imagePreviews:
|
case .imagePreviews: return true
|
||||||
return true
|
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||||
case .filePreview:
|
case .filePreview: return true
|
||||||
return true
|
default: return !message.isEmpty
|
||||||
default:
|
|
||||||
return !message.isEmpty
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func linkPreviewAllowed() -> Bool {
|
var linkPreviewAllowed: Bool {
|
||||||
switch preview {
|
switch preview {
|
||||||
case .imagePreviews:
|
case .imagePreviews: return false
|
||||||
return false
|
case .voicePreview: return false
|
||||||
case .filePreview:
|
case .filePreview: return false
|
||||||
return false
|
default: return useLinkPreviews
|
||||||
default:
|
|
||||||
return useLinkPreviews
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func linkPreview() -> LinkPreview? {
|
var linkPreview: LinkPreview? {
|
||||||
switch preview {
|
switch preview {
|
||||||
case let .linkPreview(linkPreview):
|
case let .linkPreview(linkPreview): return linkPreview
|
||||||
return linkPreview
|
default: return nil
|
||||||
default:
|
}
|
||||||
return nil
|
}
|
||||||
|
|
||||||
|
var voiceMessageRecordingFileName: String? {
|
||||||
|
switch preview {
|
||||||
|
case let .voicePreview(recordingFileName: recordingFileName, _): return recordingFileName
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var noPreview: Bool {
|
||||||
|
switch preview {
|
||||||
|
case .noPreview: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var voicePreview: Bool {
|
||||||
|
switch preview {
|
||||||
|
case .voicePreview: return true
|
||||||
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,8 +137,10 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
|||||||
chatItemPreview = .noPreview
|
chatItemPreview = .noPreview
|
||||||
case let .link(_, preview: preview):
|
case let .link(_, preview: preview):
|
||||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||||
case let .image(_, image: image):
|
case let .image(_, image):
|
||||||
chatItemPreview = .imagePreviews(imagePreviews: [image])
|
chatItemPreview = .imagePreviews(imagePreviews: [image])
|
||||||
|
case let .voice(_, duration):
|
||||||
|
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||||
case .file:
|
case .file:
|
||||||
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
|
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
|
||||||
default:
|
default:
|
||||||
@@ -116,7 +151,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
|||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
let chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
@FocusState.Binding var keyboardVisible: Bool
|
@FocusState.Binding var keyboardVisible: Bool
|
||||||
|
|
||||||
@@ -131,12 +166,21 @@ struct ComposeView: View {
|
|||||||
@State var chosenImages: [UIImage] = []
|
@State var chosenImages: [UIImage] = []
|
||||||
@State private var showFileImporter = false
|
@State private var showFileImporter = false
|
||||||
@State var chosenFile: URL? = nil
|
@State var chosenFile: URL? = nil
|
||||||
|
|
||||||
|
@State private var audioRecorder: AudioRecorder?
|
||||||
|
@State private var voiceMessageRecordingTime: TimeInterval?
|
||||||
|
@State private var startingRecording: Bool = false
|
||||||
|
// for some reason voice message preview playback occasionally
|
||||||
|
// fails to stop on ComposeVoiceView.playbackMode().onDisappear,
|
||||||
|
// this is a workaround to fire an explicit event in certain cases
|
||||||
|
@State private var stopPlayback: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
contextItemView()
|
contextItemView()
|
||||||
switch (composeState.editing(), composeState.preview) {
|
switch (composeState.editing, composeState.preview) {
|
||||||
case (true, .filePreview): EmptyView()
|
case (true, .filePreview): EmptyView()
|
||||||
|
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
||||||
default: previewView()
|
default: previewView()
|
||||||
}
|
}
|
||||||
HStack (alignment: .bottom) {
|
HStack (alignment: .bottom) {
|
||||||
@@ -146,7 +190,7 @@ struct ComposeView: View {
|
|||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
.resizable()
|
.resizable()
|
||||||
}
|
}
|
||||||
.disabled(composeState.editing())
|
.disabled(composeState.editing || composeState.voiceMessageRecordingState != .noRecording)
|
||||||
.frame(width: 25, height: 25)
|
.frame(width: 25, height: 25)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
@@ -156,6 +200,15 @@ struct ComposeView: View {
|
|||||||
sendMessage()
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
|
voiceMessageAllowed: chat.chatInfo.voiceMessageAllowed,
|
||||||
|
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||||
|
startVoiceMessageRecording: {
|
||||||
|
Task {
|
||||||
|
await startVoiceMessageRecording()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finishVoiceMessageRecording: { finishVoiceMessageRecording() },
|
||||||
|
allowVoiceMessagesToContact: { allowVoiceMessagesToContact() },
|
||||||
keyboardVisible: $keyboardVisible
|
keyboardVisible: $keyboardVisible
|
||||||
)
|
)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
@@ -163,7 +216,7 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: composeState.message) { _ in
|
.onChange(of: composeState.message) { _ in
|
||||||
if composeState.linkPreviewAllowed() {
|
if composeState.linkPreviewAllowed {
|
||||||
if composeState.message.count > 0 {
|
if composeState.message.count > 0 {
|
||||||
showLinkPreview(composeState.message)
|
showLinkPreview(composeState.message)
|
||||||
} else {
|
} else {
|
||||||
@@ -235,11 +288,11 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
fileURL.stopAccessingSecurityScopedResource()
|
fileURL.stopAccessingSecurityScopedResource()
|
||||||
if let fileSize = fileSize,
|
if let fileSize = fileSize,
|
||||||
fileSize <= maxFileSize {
|
fileSize <= MAX_FILE_SIZE {
|
||||||
chosenFile = fileURL
|
chosenFile = fileURL
|
||||||
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
|
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
|
||||||
} else {
|
} else {
|
||||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
|
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: MAX_FILE_SIZE)
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Large file!",
|
title: "Large file!",
|
||||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||||
@@ -250,6 +303,26 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||||
|
cancelVoiceMessageRecording(fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||||
|
if !startingRecording {
|
||||||
|
if composeState.voiceMessageRecordingState == .recording {
|
||||||
|
finishVoiceMessageRecording()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startingRecording = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in
|
||||||
|
if !vmAllowed && composeState.voicePreview,
|
||||||
|
let fileName = composeState.voiceMessageRecordingFileName {
|
||||||
|
cancelVoiceMessageRecording(fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func previewView() -> some View {
|
@ViewBuilder func previewView() -> some View {
|
||||||
@@ -265,7 +338,16 @@ struct ComposeView: View {
|
|||||||
composeState = composeState.copy(preview: .noPreview)
|
composeState = composeState.copy(preview: .noPreview)
|
||||||
chosenImages = []
|
chosenImages = []
|
||||||
},
|
},
|
||||||
cancelEnabled: !composeState.editing())
|
cancelEnabled: !composeState.editing)
|
||||||
|
case let .voicePreview(recordingFileName, _):
|
||||||
|
ComposeVoiceView(
|
||||||
|
recordingFileName: recordingFileName,
|
||||||
|
recordingTime: $voiceMessageRecordingTime,
|
||||||
|
recordingState: $composeState.voiceMessageRecordingState,
|
||||||
|
cancelVoiceMessage: { cancelVoiceMessageRecording($0) },
|
||||||
|
cancelEnabled: !composeState.editing,
|
||||||
|
stopPlayback: $stopPlayback
|
||||||
|
)
|
||||||
case let .filePreview(fileName: fileName):
|
case let .filePreview(fileName: fileName):
|
||||||
ComposeFileView(
|
ComposeFileView(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -273,7 +355,7 @@ struct ComposeView: View {
|
|||||||
composeState = composeState.copy(preview: .noPreview)
|
composeState = composeState.copy(preview: .noPreview)
|
||||||
chosenFile = nil
|
chosenFile = nil
|
||||||
},
|
},
|
||||||
cancelEnabled: !composeState.editing())
|
cancelEnabled: !composeState.editing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +436,9 @@ struct ComposeView: View {
|
|||||||
if !sent {
|
if !sent {
|
||||||
await send(.text(composeState.message), quoted: quoted)
|
await send(.text(composeState.message), quoted: quoted)
|
||||||
}
|
}
|
||||||
|
case let .voicePreview(recordingFileName, duration):
|
||||||
|
stopPlayback.toggle()
|
||||||
|
await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName)
|
||||||
case .filePreview:
|
case .filePreview:
|
||||||
if let fileURL = chosenFile,
|
if let fileURL = chosenFile,
|
||||||
let savedFile = saveFileFromURL(fileURL) {
|
let savedFile = saveFileFromURL(fileURL) {
|
||||||
@@ -386,6 +471,94 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startVoiceMessageRecording() async {
|
||||||
|
startingRecording = true
|
||||||
|
chatModel.stopPreviousRecPlay.toggle()
|
||||||
|
let fileName = generateNewFileName("voice", "m4a")
|
||||||
|
audioRecorder = AudioRecorder(
|
||||||
|
onTimer: { voiceMessageRecordingTime = $0 },
|
||||||
|
onFinishRecording: {
|
||||||
|
updateComposeVMRFinished()
|
||||||
|
if let fileSize = fileSize(getAppFilePath(fileName)) {
|
||||||
|
logger.debug("onFinishRecording recording file size = \(fileSize)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if let recStartError = await audioRecorder?.start(fileName: fileName) {
|
||||||
|
switch recStartError {
|
||||||
|
case .permission:
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("No permission to record voice message"),
|
||||||
|
message: Text("To record voice message please grant permission to use Microphone."),
|
||||||
|
primaryButton: .default(Text("Open Settings")) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
))
|
||||||
|
case let .error(error):
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Unable to record voice message",
|
||||||
|
message: "Error: \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
composeState = composeState.copy(
|
||||||
|
preview: .voicePreview(recordingFileName: fileName, duration: 0),
|
||||||
|
voiceMessageRecordingState: .recording
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishVoiceMessageRecording() {
|
||||||
|
audioRecorder?.stop()
|
||||||
|
audioRecorder = nil
|
||||||
|
updateComposeVMRFinished()
|
||||||
|
if let fileName = composeState.voiceMessageRecordingFileName,
|
||||||
|
let fileSize = fileSize(getAppFilePath(fileName)) {
|
||||||
|
logger.debug("finishVoiceMessageRecording recording file size = \(fileSize)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowVoiceMessagesToContact() {
|
||||||
|
if case let .direct(contact) = chat.chatInfo {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences)
|
||||||
|
prefs.voice = Preference(allow: .yes)
|
||||||
|
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||||
|
await MainActor.run {
|
||||||
|
chatModel.updateContact(toContact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("ComposeView allowVoiceMessagesToContact, apiSetContactPrefs error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? maybe we shouldn't have duration in ComposePreview.voicePreview
|
||||||
|
private func updateComposeVMRFinished() {
|
||||||
|
var preview = composeState.preview
|
||||||
|
if let recordingFileName = composeState.voiceMessageRecordingFileName,
|
||||||
|
let recordingTime = voiceMessageRecordingTime {
|
||||||
|
preview = .voicePreview(recordingFileName: recordingFileName, duration: Int(recordingTime.rounded()))
|
||||||
|
}
|
||||||
|
composeState = composeState.copy(
|
||||||
|
preview: preview,
|
||||||
|
voiceMessageRecordingState: .finished
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelVoiceMessageRecording(_ fileName: String) {
|
||||||
|
stopPlayback.toggle()
|
||||||
|
audioRecorder?.stop()
|
||||||
|
removeFile(fileName)
|
||||||
|
clearState()
|
||||||
|
}
|
||||||
|
|
||||||
private func clearState() {
|
private func clearState() {
|
||||||
composeState = ComposeState()
|
composeState = ComposeState()
|
||||||
linkUrl = nil
|
linkUrl = nil
|
||||||
@@ -394,6 +567,9 @@ struct ComposeView: View {
|
|||||||
cancelledLinks = []
|
cancelledLinks = []
|
||||||
chosenImages = []
|
chosenImages = []
|
||||||
chosenFile = nil
|
chosenFile = nil
|
||||||
|
audioRecorder = nil
|
||||||
|
voiceMessageRecordingTime = nil
|
||||||
|
startingRecording = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
||||||
@@ -404,6 +580,8 @@ struct ComposeView: View {
|
|||||||
return checkLinkPreview()
|
return checkLinkPreview()
|
||||||
case .image(_, let image):
|
case .image(_, let image):
|
||||||
return .image(text: composeState.message, image: image)
|
return .image(text: composeState.message, image: image)
|
||||||
|
case .voice(_, let duration):
|
||||||
|
return .voice(text: composeState.message, duration: duration)
|
||||||
case .file:
|
case .file:
|
||||||
return .file(composeState.message)
|
return .file(composeState.message)
|
||||||
case .unknown(let type, _):
|
case .unknown(let type, _):
|
||||||
@@ -415,7 +593,7 @@ struct ComposeView: View {
|
|||||||
prevLinkUrl = linkUrl
|
prevLinkUrl = linkUrl
|
||||||
linkUrl = parseMessage(s)
|
linkUrl = parseMessage(s)
|
||||||
if let url = linkUrl {
|
if let url = linkUrl {
|
||||||
if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
|
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
||||||
pendingLinkUrl = url
|
pendingLinkUrl = url
|
||||||
if prevLinkUrl == url {
|
if prevLinkUrl == url {
|
||||||
loadLinkPreview(url)
|
loadLinkPreview(url)
|
||||||
@@ -444,7 +622,7 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cancelLinkPreview() {
|
private func cancelLinkPreview() {
|
||||||
if let uri = composeState.linkPreview()?.uri.absoluteString {
|
if let uri = composeState.linkPreview?.uri.absoluteString {
|
||||||
cancelledLinks.insert(uri)
|
cancelledLinks.insert(uri)
|
||||||
}
|
}
|
||||||
pendingLinkUrl = nil
|
pendingLinkUrl = nil
|
||||||
@@ -499,11 +677,13 @@ struct ComposeView_Previews: PreviewProvider {
|
|||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: $keyboardVisible
|
keyboardVisible: $keyboardVisible
|
||||||
)
|
)
|
||||||
|
.environmentObject(ChatModel())
|
||||||
ComposeView(
|
ComposeView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: $keyboardVisible
|
keyboardVisible: $keyboardVisible
|
||||||
)
|
)
|
||||||
|
.environmentObject(ChatModel())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// ComposeVoiceView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by JRoberts on 21.11.2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
enum VoiceMessagePlaybackState {
|
||||||
|
case noPlayback
|
||||||
|
case playing
|
||||||
|
case paused
|
||||||
|
}
|
||||||
|
|
||||||
|
func voiceMessageTime(_ time: TimeInterval) -> String {
|
||||||
|
let min = Int(time / 60)
|
||||||
|
let sec = Int(time.truncatingRemainder(dividingBy: 60))
|
||||||
|
return String(format: "%02d:%02d", min, sec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func voiceMessageTime_(_ time: TimeInterval?) -> String {
|
||||||
|
return voiceMessageTime(time ?? TimeInterval(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposeVoiceView: View {
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
var recordingFileName: String
|
||||||
|
@Binding var recordingTime: TimeInterval?
|
||||||
|
@Binding var recordingState: VoiceMessageRecordingState
|
||||||
|
let cancelVoiceMessage: ((String) -> Void)
|
||||||
|
let cancelEnabled: Bool
|
||||||
|
|
||||||
|
@Binding var stopPlayback: Bool // value is not taken into account, only the fact it switches
|
||||||
|
@State private var audioPlayer: AudioPlayer?
|
||||||
|
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
|
@State private var playbackTime: TimeInterval?
|
||||||
|
@State private var startingPlayback: Bool = false
|
||||||
|
|
||||||
|
private static let previewHeight: CGFloat = 50
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if recordingState != .finished {
|
||||||
|
recordingMode()
|
||||||
|
} else {
|
||||||
|
playbackMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.frame(height: ComposeVoiceView.previewHeight)
|
||||||
|
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordingMode() -> some View {
|
||||||
|
ZStack {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
|
Text(voiceMessageTime_(recordingTime))
|
||||||
|
Spacer()
|
||||||
|
if cancelEnabled {
|
||||||
|
cancelButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
|
||||||
|
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playbackMode() -> some View {
|
||||||
|
ZStack {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
switch playbackState {
|
||||||
|
case .noPlayback:
|
||||||
|
Button {
|
||||||
|
startPlayback()
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
Text(voiceMessageTime_(recordingTime))
|
||||||
|
case .playing:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
playbackState = .paused
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("pause.fill")
|
||||||
|
}
|
||||||
|
Text(voiceMessageTime_(playbackTime))
|
||||||
|
case .paused:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.play()
|
||||||
|
playbackState = .playing
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
Text(voiceMessageTime_(playbackTime))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if cancelEnabled {
|
||||||
|
cancelButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
|
||||||
|
if let recordingLength = recordingTime {
|
||||||
|
ProgressBar(length: recordingLength, progress: $playbackTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: stopPlayback) { _ in
|
||||||
|
audioPlayer?.stop()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||||
|
if !startingPlayback {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
playbackState = .noPlayback
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
} else {
|
||||||
|
startingPlayback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.foregroundColor(color)
|
||||||
|
.padding(.leading, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelButton() -> some View {
|
||||||
|
Button {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
cancelVoiceMessage(recordingFileName)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "multiply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProgressBar: View {
|
||||||
|
var length: TimeInterval
|
||||||
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 3)
|
||||||
|
.animation(.linear, value: progress)
|
||||||
|
}
|
||||||
|
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPlayback() {
|
||||||
|
startingPlayback = true
|
||||||
|
chatModel.stopPreviousRecPlay.toggle()
|
||||||
|
audioPlayer = AudioPlayer(
|
||||||
|
onTimer: { playbackTime = $0 },
|
||||||
|
onFinishPlayback: {
|
||||||
|
playbackState = .noPlayback
|
||||||
|
playbackTime = recordingTime // animate progress bar to the end
|
||||||
|
}
|
||||||
|
)
|
||||||
|
audioPlayer?.start(fileName: recordingFileName)
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
playbackState = .playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposeVoiceView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ComposeVoiceView(
|
||||||
|
recordingFileName: "voice.m4a",
|
||||||
|
recordingTime: Binding.constant(TimeInterval(20)),
|
||||||
|
recordingState: Binding.constant(VoiceMessageRecordingState.recording),
|
||||||
|
cancelVoiceMessage: { _ in },
|
||||||
|
cancelEnabled: true,
|
||||||
|
stopPlayback: Binding.constant(false)
|
||||||
|
)
|
||||||
|
.environmentObject(ChatModel())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ import SimpleXChat
|
|||||||
struct SendMessageView: View {
|
struct SendMessageView: View {
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
var sendMessage: () -> Void
|
var sendMessage: () -> Void
|
||||||
|
var showVoiceMessageButton: Bool = true
|
||||||
|
var voiceMessageAllowed: Bool = true
|
||||||
|
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||||
|
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||||
|
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||||
|
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||||
|
@State private var holdingVMR = false
|
||||||
@Namespace var namespace
|
@Namespace var namespace
|
||||||
@FocusState.Binding var keyboardVisible: Bool
|
@FocusState.Binding var keyboardVisible: Bool
|
||||||
@State private var teHeight: CGFloat = 42
|
@State private var teHeight: CGFloat = 42
|
||||||
@@ -23,24 +30,34 @@ struct SendMessageView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
HStack(alignment: .bottom) {
|
HStack(alignment: .bottom) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
if case .voicePreview = composeState.preview {
|
||||||
Text(composeState.message)
|
Text("Voice message…")
|
||||||
.lineLimit(10)
|
.font(teFont.italic())
|
||||||
.font(teFont)
|
.multilineTextAlignment(.leading)
|
||||||
.multilineTextAlignment(alignment)
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.clear)
|
.padding(.horizontal, 10)
|
||||||
.padding(.horizontal, 10)
|
.padding(.vertical, 8)
|
||||||
.padding(.vertical, 8)
|
.frame(maxWidth: .infinity)
|
||||||
.matchedGeometryEffect(id: "te", in: namespace)
|
} else {
|
||||||
.background(GeometryReader(content: updateHeight))
|
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
||||||
TextEditor(text: $composeState.message)
|
Text(composeState.message)
|
||||||
.focused($keyboardVisible)
|
.lineLimit(10)
|
||||||
.font(teFont)
|
.font(teFont)
|
||||||
.textInputAutocapitalization(.sentences)
|
.multilineTextAlignment(alignment)
|
||||||
.multilineTextAlignment(alignment)
|
.foregroundColor(.clear)
|
||||||
.padding(.horizontal, 5)
|
.padding(.horizontal, 10)
|
||||||
.allowsTightening(false)
|
.padding(.vertical, 8)
|
||||||
.frame(height: teHeight)
|
.matchedGeometryEffect(id: "te", in: namespace)
|
||||||
|
.background(GeometryReader(content: updateHeight))
|
||||||
|
TextEditor(text: $composeState.message)
|
||||||
|
.focused($keyboardVisible)
|
||||||
|
.font(teFont)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.multilineTextAlignment(alignment)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.allowsTightening(false)
|
||||||
|
.frame(height: teHeight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (composeState.inProgress) {
|
if (composeState.inProgress) {
|
||||||
@@ -49,14 +66,27 @@ struct SendMessageView: View {
|
|||||||
.frame(width: 31, height: 31, alignment: .center)
|
.frame(width: 31, height: 31, alignment: .center)
|
||||||
.padding([.bottom, .trailing], 3)
|
.padding([.bottom, .trailing], 3)
|
||||||
} else {
|
} else {
|
||||||
Button(action: { sendMessage() }) {
|
let vmrs = composeState.voiceMessageRecordingState
|
||||||
Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill")
|
if showVoiceMessageButton,
|
||||||
.resizable()
|
composeState.message.isEmpty,
|
||||||
.foregroundColor(.accentColor)
|
!composeState.editing,
|
||||||
|
(composeState.noPreview && vmrs == .noRecording)
|
||||||
|
|| (vmrs == .recording && holdingVMR) {
|
||||||
|
if voiceMessageAllowed {
|
||||||
|
RecordVoiceMessageButton(
|
||||||
|
startVoiceMessageRecording: startVoiceMessageRecording,
|
||||||
|
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||||
|
holdingVMR: $holdingVMR,
|
||||||
|
disabled: composeState.disabled
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
voiceMessageNotAllowedButton()
|
||||||
|
}
|
||||||
|
} else if vmrs == .recording && !holdingVMR {
|
||||||
|
finishVoiceMessageRecordingButton()
|
||||||
|
} else {
|
||||||
|
sendMessageButton()
|
||||||
}
|
}
|
||||||
.disabled(!composeState.sendEnabled() || composeState.disabled)
|
|
||||||
.frame(width: 29, height: 29)
|
|
||||||
.padding([.bottom, .trailing], 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +97,109 @@ struct SendMessageView: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHeight(_ g: GeometryProxy) -> Color {
|
private func sendMessageButton() -> some View {
|
||||||
|
Button(action: { sendMessage() }) {
|
||||||
|
Image(systemName: composeState.editing ? "checkmark.circle.fill" : "arrow.up.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.disabled(
|
||||||
|
!composeState.sendEnabled ||
|
||||||
|
composeState.disabled ||
|
||||||
|
(!voiceMessageAllowed && composeState.voicePreview)
|
||||||
|
)
|
||||||
|
.frame(width: 29, height: 29)
|
||||||
|
.padding([.bottom, .trailing], 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecordVoiceMessageButton: View {
|
||||||
|
var startVoiceMessageRecording: (() -> Void)?
|
||||||
|
var finishVoiceMessageRecording: (() -> Void)?
|
||||||
|
@Binding var holdingVMR: Bool
|
||||||
|
var disabled: Bool
|
||||||
|
@State private var pressed: TimeInterval? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {}) {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.disabled(disabled)
|
||||||
|
.frame(width: 29, height: 29)
|
||||||
|
.padding([.bottom, .trailing], 4)
|
||||||
|
._onButtonGesture { down in
|
||||||
|
if down {
|
||||||
|
holdingVMR = true
|
||||||
|
pressed = ProcessInfo.processInfo.systemUptime
|
||||||
|
startVoiceMessageRecording?()
|
||||||
|
} else {
|
||||||
|
let now = ProcessInfo.processInfo.systemUptime
|
||||||
|
if let pressed = pressed,
|
||||||
|
now - pressed >= 1 {
|
||||||
|
finishVoiceMessageRecording?()
|
||||||
|
}
|
||||||
|
holdingVMR = false
|
||||||
|
pressed = nil
|
||||||
|
}
|
||||||
|
} perform: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func voiceMessageNotAllowedButton() -> some View {
|
||||||
|
Button(action: {
|
||||||
|
switch showEnableVoiceMessagesAlert {
|
||||||
|
case .userEnable:
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Allow voice messages?"),
|
||||||
|
message: Text("You need to allow your contact to send voice messages to be able to send them."),
|
||||||
|
primaryButton: .default(Text("Allow")) {
|
||||||
|
allowVoiceMessagesToContact?()
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
))
|
||||||
|
case .askContact:
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Voice messages prohibited!",
|
||||||
|
message: "Please ask your contact to enable sending voice messages."
|
||||||
|
)
|
||||||
|
case .groupOwnerCan:
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Voice messages prohibited!",
|
||||||
|
message: "Only group owners can enable voice messages."
|
||||||
|
)
|
||||||
|
case .other:
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: "Voice messages prohibited!",
|
||||||
|
message: "Please check yours and your contact preferences."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "mic")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.disabled(composeState.disabled)
|
||||||
|
.frame(width: 29, height: 29)
|
||||||
|
.padding([.bottom, .trailing], 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishVoiceMessageRecordingButton() -> some View {
|
||||||
|
Button(action: { finishVoiceMessageRecording?() }) {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.disabled(composeState.disabled)
|
||||||
|
.frame(width: 29, height: 29)
|
||||||
|
.padding([.bottom, .trailing], 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHeight(_ g: GeometryProxy) -> Color {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
||||||
teFont = isShortEmoji(composeState.message)
|
teFont = isShortEmoji(composeState.message)
|
||||||
? composeState.message.count < 4
|
? composeState.message.count < 4
|
||||||
? largeEmojiFont
|
? largeEmojiFont
|
||||||
: mediumEmojiFont
|
: mediumEmojiFont
|
||||||
: .body
|
: .body
|
||||||
}
|
}
|
||||||
return Color.clear
|
return Color.clear
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// ContactPreferencesView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 13/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct ContactPreferencesView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Binding var contact: Contact
|
||||||
|
@State var featuresAllowed: ContactFeaturesAllowed
|
||||||
|
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||||
|
@State private var showSaveDialogue = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let user: User = chatModel.currentUser!
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
List {
|
||||||
|
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||||
|
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
|
||||||
|
Button("Save and notify contact") { savePreferences() }
|
||||||
|
}
|
||||||
|
.disabled(currentFeaturesAllowed == featuresAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(BackButton {
|
||||||
|
if currentFeaturesAllowed == featuresAllowed {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
showSaveDialogue = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.confirmationDialog("Save preferences?", isPresented: $showSaveDialogue) {
|
||||||
|
Button("Save and notify contact") {
|
||||||
|
savePreferences()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Exit without saving") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
|
||||||
|
let enabled = FeatureEnabled.enabled(
|
||||||
|
user: Preference(allow: allowFeature.wrappedValue.allowed),
|
||||||
|
contact: pref.contactPreference
|
||||||
|
)
|
||||||
|
return Section {
|
||||||
|
Picker("You allow", selection: allowFeature) {
|
||||||
|
ForEach(ContactFeatureAllowed.values(userDefault)) { allow in
|
||||||
|
Text(allow.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "\(feature.icon).fill")
|
||||||
|
.foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red)
|
||||||
|
Text(feature.text)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(feature.enabledDescription(enabled))
|
||||||
|
.frame(height: 36, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||||
|
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||||
|
await MainActor.run {
|
||||||
|
contact = toContact
|
||||||
|
chatModel.updateContact(toContact)
|
||||||
|
currentFeaturesAllowed = featuresAllowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ContactPreferencesView(
|
||||||
|
contact: Binding.constant(Contact.sampleData),
|
||||||
|
featuresAllowed: ContactFeaturesAllowed.sampleData,
|
||||||
|
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ struct AddGroupMembersView: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
var chat: Chat
|
var chat: Chat
|
||||||
var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
var showSkip: Bool = false
|
var creatingGroup: Bool = false
|
||||||
var showFooterCounter: Bool = true
|
var showFooterCounter: Bool = true
|
||||||
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
|
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
|
||||||
@State private var selectedContacts = Set<Int64>()
|
@State private var selectedContacts = Set<Int64>()
|
||||||
@@ -52,6 +52,9 @@ struct AddGroupMembersView: View {
|
|||||||
} else {
|
} else {
|
||||||
let count = selectedContacts.count
|
let count = selectedContacts.count
|
||||||
Section {
|
Section {
|
||||||
|
if creatingGroup {
|
||||||
|
groupPreferencesButton($groupInfo, true)
|
||||||
|
}
|
||||||
rolePicker()
|
rolePicker()
|
||||||
inviteMembersButton()
|
inviteMembersButton()
|
||||||
.disabled(count < 1)
|
.disabled(count < 1)
|
||||||
@@ -78,14 +81,10 @@ struct AddGroupMembersView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSkip) {
|
if creatingGroup {
|
||||||
v.toolbar {
|
v.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if showSkip {
|
Button ("Skip") { addedMembersCb?(selectedContacts) }
|
||||||
Button ("Skip") {
|
|
||||||
if let cb = addedMembersCb { cb(selectedContacts) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -142,6 +141,7 @@ struct AddGroupMembersView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(height: 36)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func contactCheckView(_ contact: Contact) -> some View {
|
private func contactCheckView(_ contact: Contact) -> some View {
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ struct GroupChatInfoView: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
@ObservedObject private var alertManager = AlertManager.shared
|
@ObservedObject private var alertManager = AlertManager.shared
|
||||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||||
@State private var groupLink: String?
|
@State private var groupLink: String?
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var showAddMembersSheet: Bool = false
|
||||||
@State private var selectedMember: GroupMember? = nil
|
@State private var selectedMember: GroupMember? = nil
|
||||||
@State private var showGroupProfile: Bool = false
|
|
||||||
@State private var connectionStats: ConnectionStats?
|
@State private var connectionStats: ConnectionStats?
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
@@ -42,6 +41,17 @@ struct GroupChatInfoView: View {
|
|||||||
groupInfoHeader()
|
groupInfoHeader()
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if groupInfo.canEdit {
|
||||||
|
editGroupButton()
|
||||||
|
}
|
||||||
|
groupPreferencesButton($groupInfo)
|
||||||
|
} header: {
|
||||||
|
Text("")
|
||||||
|
} footer: {
|
||||||
|
Text("Only group owners can change group preferences.")
|
||||||
|
}
|
||||||
|
|
||||||
Section("\(members.count + 1) members") {
|
Section("\(members.count + 1) members") {
|
||||||
if groupInfo.canAddMembers {
|
if groupInfo.canAddMembers {
|
||||||
groupLinkButton()
|
groupLinkButton()
|
||||||
@@ -68,23 +78,17 @@ struct GroupChatInfoView: View {
|
|||||||
} label: { memberView(member) }
|
} label: { memberView(member) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddMembersSheet) {
|
.appSheet(isPresented: $showAddMembersSheet) {
|
||||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedMember, onDismiss: {
|
.appSheet(item: $selectedMember, onDismiss: {
|
||||||
selectedMember = nil
|
selectedMember = nil
|
||||||
connectionStats = nil
|
connectionStats = nil
|
||||||
}) { _ in
|
}) { _ in
|
||||||
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showGroupProfile) {
|
|
||||||
GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if groupInfo.canEdit {
|
|
||||||
editGroupButton()
|
|
||||||
}
|
|
||||||
clearChatButton()
|
clearChatButton()
|
||||||
if groupInfo.canDelete {
|
if groupInfo.canDelete {
|
||||||
deleteGroupButton()
|
deleteGroupButton()
|
||||||
@@ -189,16 +193,21 @@ struct GroupChatInfoView: View {
|
|||||||
private func groupLinkButton() -> some View {
|
private func groupLinkButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitle("Group link")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Group link", systemImage: "link")
|
Label("Group link", systemImage: "link")
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func editGroupButton() -> some View {
|
func editGroupButton() -> some View {
|
||||||
Button {
|
NavigationLink {
|
||||||
showGroupProfile = true
|
GroupProfileView(
|
||||||
|
groupInfo: $groupInfo,
|
||||||
|
groupProfile: groupInfo.groupProfile
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Group profile")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Edit group profile", systemImage: "pencil")
|
Label("Edit group profile", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
@@ -287,6 +296,25 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
GroupPreferencesView(
|
||||||
|
groupInfo: groupInfo,
|
||||||
|
preferences: groupInfo.wrappedValue.fullGroupPreferences,
|
||||||
|
currentPreferences: groupInfo.wrappedValue.fullGroupPreferences,
|
||||||
|
creatingGroup: creatingGroup
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Group preferences")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
if creatingGroup {
|
||||||
|
Text("Set group preferences")
|
||||||
|
} else {
|
||||||
|
Label("Group preferences", systemImage: "switch.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cantInviteIncognitoAlert() -> Alert {
|
func cantInviteIncognitoAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Can't invite contacts!"),
|
title: Text("Can't invite contacts!"),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ struct GroupLinkView: View {
|
|||||||
|
|
||||||
private enum GroupLinkAlert: Identifiable {
|
private enum GroupLinkAlert: Identifiable {
|
||||||
case deleteLink
|
case deleteLink
|
||||||
case error(title: LocalizedStringKey, error: String = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -29,10 +29,6 @@ struct GroupLinkView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack (alignment: .leading) {
|
VStack (alignment: .leading) {
|
||||||
Text("Group link")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.bold()
|
|
||||||
.padding(.bottom)
|
|
||||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
if let groupLink = groupLink {
|
if let groupLink = groupLink {
|
||||||
@@ -59,7 +55,7 @@ struct GroupLinkView: View {
|
|||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
|
logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))")
|
||||||
let a = getErrorAlert(error, "Error creating group link")
|
let a = getErrorAlert(error, "Error creating group link")
|
||||||
alert = .error(title: a.title, error: "\(a.message)")
|
alert = .error(title: a.title, error: a.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: { Label("Create link", systemImage: "link.badge.plus") }
|
} label: { Label("Create link", systemImage: "link.badge.plus") }
|
||||||
@@ -88,7 +84,7 @@ struct GroupLinkView: View {
|
|||||||
}, secondaryButton: .cancel()
|
}, secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text("\(error)"))
|
return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ struct GroupMemberInfoView: View {
|
|||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
if let contactId = member.memberContactId {
|
if let contactId = member.memberContactId {
|
||||||
Section {
|
if let chat = chatModel.getContactChat(contactId),
|
||||||
openDirectChatButton(contactId)
|
chat.chatInfo.contact?.directContact ?? false {
|
||||||
|
Section {
|
||||||
|
knownDirectChatButton(chat)
|
||||||
|
}
|
||||||
|
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||||
|
Section {
|
||||||
|
newDirectChatButton(contactId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +77,8 @@ struct GroupMemberInfoView: View {
|
|||||||
|
|
||||||
Section("Servers") {
|
Section("Servers") {
|
||||||
// TODO network connection status
|
// TODO network connection status
|
||||||
if developerTools {
|
Button("Change receiving address") {
|
||||||
Button("Change receiving address (BETA)") {
|
alert = .switchAddressAlert
|
||||||
alert = .switchAddressAlert
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let connStats = connectionStats {
|
if let connStats = connectionStats {
|
||||||
smpServers("Receiving via", connStats.rcvServers)
|
smpServers("Receiving via", connStats.rcvServers)
|
||||||
@@ -114,23 +119,10 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openDirectChatButton(_ contactId: Int64) -> some View {
|
func knownDirectChatButton(_ chat: Chat) -> some View {
|
||||||
Button {
|
Button {
|
||||||
var chat = chatModel.getContactChat(contactId)
|
dismissAllSheets(animated: true)
|
||||||
if chat == nil {
|
DispatchQueue.main.async {
|
||||||
do {
|
|
||||||
chat = try apiGetChat(type: .direct, id: contactId)
|
|
||||||
if let chat = chat {
|
|
||||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
|
||||||
chat.serverInfo = Chat.ServerInfo(networkStatus: .connected)
|
|
||||||
chatModel.addChat(chat)
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let chat = chat {
|
|
||||||
dismissAllSheets(animated: true)
|
|
||||||
chatModel.chatId = chat.id
|
chatModel.chatId = chat.id
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -138,6 +130,25 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newDirectChatButton(_ contactId: Int64) -> some View {
|
||||||
|
Button {
|
||||||
|
do {
|
||||||
|
let chat = try apiGetChat(type: .direct, id: contactId)
|
||||||
|
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
||||||
|
chat.serverInfo = Chat.ServerInfo(networkStatus: .connected)
|
||||||
|
chatModel.addChat(chat)
|
||||||
|
dismissAllSheets(animated: true)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
chatModel.chatId = chat.id
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Send direct message", systemImage: "message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
|
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// GroupPreferencesView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by JRoberts on 16.11.2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct GroupPreferencesView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Binding var groupInfo: GroupInfo
|
||||||
|
@State var preferences: FullGroupPreferences
|
||||||
|
@State var currentPreferences: FullGroupPreferences
|
||||||
|
let creatingGroup: Bool
|
||||||
|
@State private var showSaveDialogue = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
|
||||||
|
VStack {
|
||||||
|
List {
|
||||||
|
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||||
|
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||||
|
featureSection(.voice, $preferences.voice.enable)
|
||||||
|
|
||||||
|
if groupInfo.canEdit {
|
||||||
|
Section {
|
||||||
|
Button("Reset") { preferences = currentPreferences }
|
||||||
|
Button(saveText) { savePreferences() }
|
||||||
|
}
|
||||||
|
.disabled(currentPreferences == preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(BackButton {
|
||||||
|
if currentPreferences == preferences {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
showSaveDialogue = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.confirmationDialog("Save preferences?", isPresented: $showSaveDialogue) {
|
||||||
|
Button(saveText) {
|
||||||
|
savePreferences()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Exit without saving") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
|
||||||
|
Section {
|
||||||
|
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
||||||
|
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
settingsRow(icon, color: color) {
|
||||||
|
Picker(feature.text, selection: enableFeature) {
|
||||||
|
ForEach(GroupFeatureEnabled.values) { enable in
|
||||||
|
Text(enable.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
settingsRow(icon, color: color) {
|
||||||
|
infoRow(feature.text, enableFeature.wrappedValue.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||||
|
.frame(height: 36, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
var gp = groupInfo.groupProfile
|
||||||
|
gp.groupPreferences = toGroupPreferences(preferences)
|
||||||
|
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
|
||||||
|
await MainActor.run {
|
||||||
|
groupInfo = gInfo
|
||||||
|
chatModel.updateGroup(gInfo)
|
||||||
|
currentPreferences = preferences
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupPreferencesView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
GroupPreferencesView(
|
||||||
|
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||||
|
preferences: FullGroupPreferences.sampleData,
|
||||||
|
currentPreferences: FullGroupPreferences.sampleData,
|
||||||
|
creatingGroup: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import SimpleXChat
|
|||||||
struct GroupProfileView: View {
|
struct GroupProfileView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
var groupId: Int64
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var groupProfile: GroupProfile
|
@State var groupProfile: GroupProfile
|
||||||
@State private var showChooseSource = false
|
@State private var showChooseSource = false
|
||||||
@State private var showImagePicker = false
|
@State private var showImagePicker = false
|
||||||
@@ -120,8 +120,9 @@ struct GroupProfileView: View {
|
|||||||
func saveProfile() {
|
func saveProfile() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let gInfo = try await apiUpdateGroup(groupId, groupProfile)
|
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
groupInfo = gInfo
|
||||||
chatModel.updateGroup(gInfo)
|
chatModel.updateGroup(gInfo)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
@@ -137,6 +138,6 @@ struct GroupProfileView: View {
|
|||||||
|
|
||||||
struct GroupProfileView_Previews: PreviewProvider {
|
struct GroupProfileView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupProfileView(groupId: 1, groupProfile: GroupProfile.sampleData)
|
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,10 +402,10 @@ struct ErrorAlert {
|
|||||||
|
|
||||||
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
||||||
switch error as? ChatResponse {
|
switch error as? ChatResponse {
|
||||||
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
|
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||||
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection and try again.")
|
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||||
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
|
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
|
||||||
return ErrorAlert(title: "Connection error", message: "Please check your network connection and try again.")
|
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||||
default:
|
default:
|
||||||
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ struct ChatPreviewView: View {
|
|||||||
|
|
||||||
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
|
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
|
||||||
if let cItem = cItem {
|
if let cItem = cItem {
|
||||||
|
let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
|
||||||
|
let itemFormattedText = !cItem.meta.itemDeleted ? cItem.formattedText : nil
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
|
(itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true))
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
@@ -204,6 +206,10 @@ struct ChatPreviewView_Previews: PreviewProvider {
|
|||||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||||
))
|
))
|
||||||
|
ChatPreviewView(chat: Chat(
|
||||||
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
|
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false)]
|
||||||
|
))
|
||||||
ChatPreviewView(chat: Chat(
|
ChatPreviewView(chat: Chat(
|
||||||
chatInfo: ChatInfo.sampleData.direct,
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ struct ContactConnectionView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.sheet(isPresented: $showContactConnectionInfo) {
|
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||||
ContactConnectionInfo(contactConnection: contactConnection)
|
ContactConnectionInfo(contactConnection: contactConnection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ enum DatabaseEncryptionAlert: Identifiable {
|
|||||||
case changeDatabaseKey
|
case changeDatabaseKey
|
||||||
case databaseEncrypted
|
case databaseEncrypted
|
||||||
case currentPassphraseError
|
case currentPassphraseError
|
||||||
case error(title: LocalizedStringKey, error: String = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -155,7 +155,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
|
if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
|
||||||
await operationEnded(.currentPassphraseError)
|
await operationEnded(.currentPassphraseError)
|
||||||
} else {
|
} else {
|
||||||
await operationEnded(.error(title: "Error encrypting database", error: responseError(error)))
|
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ struct DatabaseEncryptionView: View {
|
|||||||
message: Text("Please enter correct current passphrase.")
|
message: Text("Please enter correct current passphrase.")
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text("\(error)"))
|
return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ struct DatabaseView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Delete messages after", selection: $chatItemTTL) {
|
Picker("Delete messages after", selection: $chatItemTTL) {
|
||||||
ForEach([ChatItemTTL.none, ChatItemTTL.month, ChatItemTTL.week, ChatItemTTL.day]) { ttl in
|
ForEach(ChatItemTTL.values) { ttl in
|
||||||
Text(ttl.deleteAfterText).tag(ttl)
|
Text(ttl.deleteAfterText).tag(ttl)
|
||||||
}
|
}
|
||||||
if case .seconds = chatItemTTL {
|
if case .seconds = chatItemTTL {
|
||||||
@@ -289,7 +289,7 @@ struct DatabaseView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text("\(error)"))
|
return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//
|
||||||
|
// AppSheet.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 24/11/2022.
|
||||||
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private struct SheetIsPresented<C>: ViewModifier where C: View {
|
||||||
|
var isPresented: Binding<Bool>
|
||||||
|
var onDismiss: (() -> Void)?
|
||||||
|
var sheetContent: () -> C
|
||||||
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.sheet(isPresented: isPresented, onDismiss: onDismiss) {
|
||||||
|
sheetContent().modifier(PrivacySensitive())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SheetForItem<T, C>: ViewModifier where T: Identifiable, C: View {
|
||||||
|
var item: Binding<T?>
|
||||||
|
var onDismiss: (() -> Void)?
|
||||||
|
var sheetContent: (T) -> C
|
||||||
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.sheet(item: item, onDismiss: onDismiss) { it in
|
||||||
|
sheetContent(it).modifier(PrivacySensitive())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PrivacySensitive: ViewModifier {
|
||||||
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = true
|
||||||
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if case .active = scenePhase {
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
content.privacySensitive(protectScreen).redacted(reason: .privacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func appSheet<Content>(
|
||||||
|
isPresented: Binding<Bool>,
|
||||||
|
onDismiss: (() -> Void)? = nil,
|
||||||
|
content: @escaping () -> Content
|
||||||
|
) -> some View where Content: View {
|
||||||
|
modifier(SheetIsPresented(isPresented: isPresented, onDismiss: onDismiss, sheetContent: content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func appSheet<T, Content>(
|
||||||
|
item: Binding<T?>,
|
||||||
|
onDismiss: (() -> Void)? = nil,
|
||||||
|
content: @escaping (T) -> Content
|
||||||
|
) -> some View where T: Identifiable, Content: View {
|
||||||
|
modifier(SheetForItem(item: item, onDismiss: onDismiss, sheetContent: content))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user