Compare commits

...

74 Commits

Author SHA1 Message Date
JRoberts
e099d08325 docs: ephemeral conversations rfc 2022-12-10 14:09:55 +04:00
JRoberts
bcca0998d5 core: optimize group deletion (#1529) 2022-12-09 20:01:31 +04:00
Evgeny Poberezkin
95cc9e1e55 core: verify connection (#1530)
* core: verify connection

* update commands

* api to get/set verification code/status

* add migration

* refactor

* change command / response names

* reset verified status if code from agent doesn't match
2022-12-09 15:26:43 +00:00
sh
ab5ae2d2cb build-android: add skip flag and update logic (#1525)
* build-android: add skip flag and update logic

* build-android: change equal
2022-12-08 08:55:37 +00:00
JRoberts
40a91a7273 android: version 4.3.1 (77) 2022-12-08 10:48:13 +04:00
JRoberts
1240b31df8 ios: version 4.3.1 (100) 2022-12-08 10:41:59 +04:00
Evgeny Poberezkin
ff14730738 mobile, core: fix voice message reception in groups (#1524) 2022-12-07 22:18:22 +00:00
JRoberts
0cba3a4bb3 4.3.1 2022-12-07 21:10:45 +04:00
JRoberts
208f8a3346 android: version 4.3.1 (76) 2022-12-07 21:09:52 +04:00
JRoberts
caa3efb9ed ios: version 4.3.1 (99) 2022-12-07 21:04:43 +04:00
JRoberts
4beb916754 ios: deleted item preview; android: refactor removeChatItem (#1523) 2022-12-07 20:46:38 +04:00
Stanislav Dmitrenko
c1ee04eed1 android: Cancel notification after message deletion (#1512)
* android: Cancel notification after message deletion

* Improve

* Temporary chat item

* Better

* Changes

* cInfo, cItem

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-07 19:49:17 +04:00
JRoberts
0ad3bc9993 android: show open direct chat button for direct contacts (#1521) 2022-12-07 19:07:31 +04:00
JRoberts
9893aa665a core: don't mark contacts as used on api get chat (#1522) 2022-12-07 19:05:32 +04:00
JRoberts
fda8836ab8 ios: show open direct chat button for direct contacts (#1518) 2022-12-07 17:30:15 +04:00
Evgeny Poberezkin
05fdd07409 website: add SHA256 of the key signing GitHub android APK to open links in the app 2022-12-07 10:27:31 +00:00
Evgeny Poberezkin
fb8f5facd0 terminal: only set contact/group as active for terminal input if it is not muted (#1514) 2022-12-07 09:58:01 +00:00
Stanislav Dmitrenko
8bdb784a14 android: Added rememberSaveable in pref screens (fix merge) (#1517) 2022-12-07 09:57:23 +00:00
Stanislav Dmitrenko
5d785aad2e android: Added rememberSaveable in pref screens (#1509) 2022-12-06 21:04:15 +00:00
Stanislav Dmitrenko
ce11d58a76 android: Saving prefs alert on exit with unsaved changes (#1508)
* android: Saving prefs alert on exit with unsaved changes

* DIfferent implementation for AlertDialog with long buttons

* Braces

* Change

* Alignment

* Rename

* small changes

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-06 20:48:15 +00:00
Evgeny Poberezkin
887b374bfc readme: add mastodon link 2022-12-06 19:53:08 +00:00
Evgeny Poberezkin
94dc967197 readme: update screenshots 2022-12-06 17:30:32 +00:00
JRoberts
4319a581ca core: more test cases checking deletion of unused contacts and incognito profiles (#1513) 2022-12-06 20:19:01 +04:00
JRoberts
fb05218558 core: delete unused contacts after deleting group (#1503) 2022-12-06 17:12:39 +04:00
Evgeny Poberezkin
edf2d02a0d blog: v4.3 release announcement (#1510)
* blog: v4.3 release announcement

* add images

* update image URIs

* update post

* typos

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

* website preview, readme update

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-12-06 12:53:14 +00:00
Evgeny Poberezkin
87ba429dfd Merge branch 'stable' 2022-12-05 20:47:16 +00:00
Evgeny Poberezkin
7af1a7cf76 docs: update f-droid store info (#1507) 2022-12-05 20:46:11 +00:00
sh
df619acdd4 build-android: update nix install (#1506) 2022-12-05 18:45:18 +00:00
Evgeny Poberezkin
503d0cd451 android: make backup disabled by default (#1505) 2022-12-05 15:05:56 +00:00
Stanislav Dmitrenko
1294a00ee7 android: Vibration pattern (#1504)
* android: Vibration pattern

* update pattern

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-05 14:56:37 +00:00
Stanislav Dmitrenko
0a8069ada2 android: Notification sound (#1468)
* android: fix full screen call notification (#1466)

* android: Closing call means canceling notification too

* show full screen call when screen is off OR locked

* make notification non-silent and set category

* remove call notification category

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* android: Notification sound

* Log

* Ringtone channel

* rename call channel

* Non-hideable headsUp notification and reject button

* Removed LockScreenCallChannel

* call channel name

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-05 13:13:48 +00:00
Evgeny Poberezkin
c167f594b9 website: add .well-known folder to allow mobile apps process URLs 2022-12-05 11:28:22 +00:00
Evgeny Poberezkin
ce5124594d blog: permalink for v4.3 post (#1499) 2022-12-04 18:18:23 +00:00
Evgeny Poberezkin
5de96aa7c4 android: v4.3 (75) 2022-12-04 18:07:41 +00:00
Evgeny Poberezkin
cdbf8e2715 ios: v4.3 (98) 2022-12-04 17:36:41 +00:00
Evgeny Poberezkin
69b2f8f535 mobile: german translations (#1498) 2022-12-04 15:18:35 +00:00
Evgeny Poberezkin
ff17f89551 android: improve UX to create groups and UI of group preferences (#1496) 2022-12-04 15:16:41 +00:00
Evgeny Poberezkin
358712fa31 ios: translations (#1495) 2022-12-04 11:41:45 +00:00
Evgeny Poberezkin
75cad8a6bf ios: improve UX for contact/group preferences (#1494)
* ios: improve UX for contact/group preferences

* refactor
2022-12-04 11:30:51 +00:00
Evgeny Poberezkin
e5969e197a mobile: "delete for everyone" feature, translations (#1491) 2022-12-04 09:29:00 +00:00
Evgeny Poberezkin
a9ffe4e039 android: function to call api on background thread, use it for marking items read (#1493) 2022-12-04 08:36:19 +00:00
Stanislav Dmitrenko
bf2129c4ae android: Making full backup optional (#1477)
* android: Making full backup optional

* move to database settings

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-04 07:50:36 +00:00
Evgeny Poberezkin
04f10aede7 ios: fix screen protection in sheets, remove screen protection from settings and image pickers (#1492) 2022-12-03 21:42:12 +00:00
Evgeny Poberezkin
ffbff93374 ios: menu to hide revealed chat item (#1490) 2022-12-03 19:21:47 +00:00
JRoberts
f3630d934c android: marked deleted / reveal ui (#1488)
* android: marked deleted / reveal ui

* marked deleted, reveal

* fix ios

* different alerts
2022-12-03 18:21:32 +00:00
Evgeny Poberezkin
6f59df4e33 prohibit direct messages to group contacts unless group preferences allow them (#1476)
* prohibit direct messages to group contacts unless group preferences allow them

* tests

* refactor

* more test
2022-12-03 18:06:21 +00:00
Evgeny Poberezkin
e44e9a0940 mobile: broker error type (#1475)
* mobile: broker error type

* fix

* ios: update libraries

* change AgentErrorType to String
2022-12-03 18:05:32 +00:00
Evgeny Poberezkin
c43ba7bf23 ios: fix item deletion in groups (#1487) 2022-12-03 15:21:14 +00:00
JRoberts
9e48e1f74a android: refactor CIVoiceView usage in FramedItemView (latter accounts only for framed voice messages) (#1486) 2022-12-03 18:28:07 +04:00
JRoberts
0001885971 obsolete comment 2022-12-03 18:24:20 +04:00
Evgeny Poberezkin
e0c932c04e core: change AgentErrorType to String to preserve backward compatibility with stored errors (#1485) 2022-12-03 13:28:51 +00:00
JRoberts
01a86336c0 android: simplify logic for allowing voice messages on alert; fix non exhaustive when in SendMsgView (#1484) 2022-12-03 16:19:13 +04:00
JRoberts
48d24d3582 ios: simplify chat item context menu (#1483) 2022-12-03 15:53:46 +04:00
JRoberts
07ef6e4090 ios: marked deleted chat items, full deletion preference; android: types (#1473)
* ios: marked deleted chat items; full deletion preference

* text_, menu, backend

* android types

* more android types

* fix

* refactor ios

* restore previews

* box

* refactor menu

* revert unnecessary content.text changes

* Update apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* revert layered framed items

* clever framed view

* improve look

* restore previews

* restore previews

* refactor

* refactoring, almost looks good

* look

* add previews

* more previews

* remove preview of legacy item

* ChatItemDeleted

* flip if

* remove text_

* refactor

* abstract pref property

* move marked deleted

* revert pref change

* undo menu

* fix - change to constants

* undo pref logic

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-03 15:40:31 +04:00
Evgeny Poberezkin
19163776e3 android: fix 2022-12-03 09:06:39 +00:00
Stanislav Dmitrenko
62b1f786f1 android: Remove runningAppProcesses check (#1478)
* android: Remove runningAppProcesses check

* simplify

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-03 06:29:52 +00:00
Stanislav Dmitrenko
d479e9b2bf android: Change of launchMode in an activity and different behavior of back button (#1480)
* android: Change of launchMode in an activity and different behavior of back button
- Android versions <= 10 are vulnerable to StrandHogg 1. This commit fixes the behavior of the app on affected versions of Android

* simplify condition

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-12-02 23:07:21 +00:00
Evgeny Poberezkin
0beb260b00 android: json parsing settings explicitNulls = false 2022-12-02 21:36:58 +00:00
Evgeny Poberezkin
bc28568c63 core: update broker error type (#1474)
* core: update broker error type

* fix test

* fix test
2022-12-02 15:01:26 +00:00
Stanislav Dmitrenko
a4dd520248 android: Better shared preference handling (#1471)
* android: Better shared preference handling

* To make sure we return real value, not untransformed one

* Revert "To make sure we return real value, not untransformed one"

This reverts commit 5a268e2cf4.
2022-11-30 22:20:08 +00:00
JRoberts
9ad29aa17e core: full deletion by sender based on preference; don't overwrite item content on "mark deleted" (#1470) 2022-11-30 19:42:33 +04:00
Stanislav Dmitrenko
6f24281671 android: prevent crash when decrypting DB after restore (#1469) 2022-11-30 12:20:49 +00:00
Evgeny Poberezkin
eb81b62892 terminal: allow trailing spaces in terminal commands (e.g., to drag and drop files) (#1467) 2022-11-30 08:25:42 +00:00
Stanislav Dmitrenko
ef1133ee98 android: fix full screen call notification (#1466)
* android: Closing call means canceling notification too

* show full screen call when screen is off OR locked

* make notification non-silent and set category

* remove call notification category

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-29 15:53:47 +00:00
Evgeny Poberezkin
1872744543 core, mobile: add group feature to allow direct messages (#1465)
* core, mobile: split group features to a separate type (to add directAllowed later)

* add directMessages group feature, update tests
2022-11-29 15:19:20 +00:00
Stanislav Dmitrenko
303aeaaba5 android: Instantly apply screen protection (#1464) 2022-11-29 14:21:41 +00:00
Stanislav Dmitrenko
c5359d698c android: Voice messages enhancements (#1451)
* android: Vocie messages enhancements

* Canceling voice record when it was disabled in prefs

* Quote placement in voice message chat item

* Ordering of checks

* Showing progress logic was changed

* Showing progress logic was changed

* Update group prefs without reenter

* Optimization of voice chat items

* Stop audio playing and recoring when in call
2022-11-29 13:41:04 +00:00
JRoberts
acd72fb269 ios: remove voice message preview and stop rec/play if preference change prohibits it; more robust logic to stop playback on send (#1463) 2022-11-29 15:23:54 +04:00
sh
8d096f469d docs: corrections (#1456)
* docs: corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* docs: onion instead of hostname2

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
2022-11-29 09:17:26 +00:00
Stanislav Dmitrenko
b204d21d9e android: No crash when clicking on a link with unknown activity action (#1460)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-29 09:16:03 +00:00
JRoberts
c9620a594e ios: fix - stop voice message preview playback on send (#1461) 2022-11-29 13:06:45 +04:00
Stanislav Dmitrenko
538024de61 android: Show user's camera when system camera is unavailable (#1458)
* android: Show user's camera when system camera is unavailable

* Multiple places of camera launcher

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-11-29 08:50:54 +00:00
JRoberts
5c9a14fdb6 ios: show button for opening settings when asking for microphone permission to record voice message (#1459) 2022-11-29 12:41:48 +04:00
JRoberts
9295bdca3e ios, android: disable save and test buttons if all servers are disabled (#1457) 2022-11-29 12:28:26 +04:00
131 changed files with 4362 additions and 1574 deletions

View File

@@ -5,8 +5,9 @@
[![build](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml/badge.svg?branch=stable)](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/SimpleXChat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
[![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](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)
&nbsp;
@@ -85,16 +86,14 @@ You can use SimpleX with your own servers and still communicate with people usin
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)
[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)
[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)
## 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.
<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
@@ -187,19 +186,20 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Chat database encryption.
- ✅ Automatic chat history deletion.
- ✅ 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).
- 🏗 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.
- 🏗 Contact verification via a separate out-of-band channel.
- 🏗 Ephemeral/disappearing/OTR conversations with the existing contacts.
- Optionally avoid re-using the same TCP session for multiple connections.
- 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.
- Video messages.
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
- Multiple user profiles in the same chat database.
- Advanced server configuration.
- Feeds/broadcasts.
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
- Web widgets for custom interactivity in the chats.
@@ -246,8 +246,9 @@ It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin 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.
Thank you,

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 74
versionName "4.3-beta.3"
versionCode 77
versionName "4.3.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {

View File

@@ -24,6 +24,8 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:fullBackupOnly="true"
android:backupAgent="BackupAgent"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
@@ -102,7 +104,9 @@
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true"/>
android:showOnLockScreen="true"
android:exported="false"
android:launchMode="singleTask"/>
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -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)
}
}
}

View File

@@ -3,6 +3,7 @@ package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.os.SystemClock.elapsedRealtime
@@ -133,7 +134,15 @@ class MainActivity: FragmentActivity() {
}
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()) {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
clearAuthState()

View File

@@ -37,6 +37,8 @@ external fun chatParseServer(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
lateinit var chatController: ChatController
var isAppOnForeground: Boolean = false
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
@@ -96,6 +98,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
withApi {
when (event) {
Lifecycle.Event.ON_START -> {
isAppOnForeground = true
if (chatModel.chatRunning.value == true) {
kotlin.runCatching {
val chats = chatController.apiGetChats()
@@ -104,6 +107,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
@@ -115,7 +119,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
SimplexService.start(applicationContext)
}
else -> {}
else -> isAppOnForeground = false
}
}
}

View File

@@ -101,7 +101,7 @@ class ChatModel(val controller: ChatController) {
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))
@@ -215,6 +215,9 @@ class ChatModel(val controller: ChatController) {
}
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(cInfo.id)
}
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
@@ -222,7 +225,7 @@ class ChatModel(val controller: ChatController) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
}
}
// remove from current chat
@@ -392,6 +395,9 @@ interface SomeChat {
val ready: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
val voiceMessageAllowed: Boolean
val fullDeletionAllowed: Boolean
val createdAt: Instant
val updatedAt: Instant
}
@@ -442,7 +448,6 @@ data class Chat (
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
abstract val incognito: Boolean
@Serializable @SerialName("direct")
data class Direct(val contact: Contact): ChatInfo() {
@@ -452,8 +457,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.chatSettings.enableNtfs
override val incognito get() = contact.contactConnIncognito
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
override val voiceMessageAllowed get() = contact.voiceMessageAllowed
override val fullDeletionAllowed get() = contact.fullDeletionAllowed
override val createdAt get() = contact.createdAt
override val updatedAt get() = contact.updatedAt
override val displayName get() = contact.displayName
@@ -474,8 +481,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs
override val incognito get() = groupInfo.membership.memberIncognito
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
override val voiceMessageAllowed get() = groupInfo.voiceMessageAllowed
override val fullDeletionAllowed get() = groupInfo.fullDeletionAllowed
override val createdAt get() = groupInfo.createdAt
override val updatedAt get() = groupInfo.updatedAt
override val displayName get() = groupInfo.displayName
@@ -496,8 +505,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = false
override val incognito get() = false
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
override val voiceMessageAllowed get() = contactRequest.voiceMessageAllowed
override val fullDeletionAllowed get() = contactRequest.fullDeletionAllowed
override val createdAt get() = contactRequest.createdAt
override val updatedAt get() = contactRequest.updatedAt
override val displayName get() = contactRequest.displayName
@@ -518,8 +529,10 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = false
override val ntfsEnabled 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 updatedAt get() = contactConnection.updatedAt
override val displayName get() = contactConnection.displayName
@@ -541,6 +554,7 @@ data class Contact(
val profile: LocalProfile,
val activeConn: Connection,
val viaGroup: Long? = null,
val contactUsed: Boolean,
val chatSettings: ChatSettings,
val userPreferences: ChatPreferences,
val mergedPreferences: ContactUserPreferences,
@@ -553,16 +567,16 @@ data class Contact(
override val ready get() = activeConn.connStatus == ConnStatus.Ready
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = chatSettings.enableNtfs
override val incognito get() = contactConnIncognito
override val voiceMessageAllowed get() = mergedPreferences.voice.enabled.forUser
override val fullDeletionAllowed get() = mergedPreferences.fullDelete.enabled.forUser
override val displayName get() = localAlias.ifEmpty { profile.displayName }
override val fullName get() = profile.fullName
override val image get() = profile.image
override val localAlias get() = profile.localAlias
val isIndirectContact: Boolean get() =
activeConn.connLevel > 0 || viaGroup != null
val viaGroupLink: Boolean get() =
activeConn.viaGroupLink
val directContact: Boolean get() =
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
val contactConnIncognito =
activeConn.customUserProfileId != null
@@ -573,6 +587,7 @@ data class Contact(
localDisplayName = "alice",
profile = LocalProfile.sampleData,
activeConn = Connection.sampleData,
contactUsed = true,
chatSettings = ChatSettings(true),
userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData,
@@ -675,6 +690,9 @@ data class GroupInfo (
override val ready get() = membership.memberActive
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs
override val incognito get() = membership.memberIncognito
override val voiceMessageAllowed get() = fullGroupPreferences.voice.on
override val fullDeletionAllowed get() = fullGroupPreferences.fullDelete.on
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
@@ -918,6 +936,9 @@ class UserContactRequest (
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
override val voiceMessageAllowed get() = false
override val fullDeletionAllowed get() = false
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
@@ -953,6 +974,9 @@ class PendingContactConnection(
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = customUserProfileId != null
override val voiceMessageAllowed get() = false
override val fullDeletionAllowed get() = false
override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId)
override val displayName: String get() {
if (localAlias.isNotEmpty()) return localAlias
@@ -972,8 +996,6 @@ class PendingContactConnection(
val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri
val incognito = customUserProfileId != null
val description: String get() {
val initiated = pccConnStatus.initiated
return if (initiated == null) "" else generalGetString(
@@ -1043,14 +1065,14 @@ data class ChatItem (
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val text: String get() =
when {
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
}
val text: String get() {
val mc = content.msgContent
return when {
content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(R.string.voice_message_with_duration), durationText(mc.duration))
content.text == "" && file != null -> file.fileName
else -> content.text
}
}
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
@@ -1098,6 +1120,7 @@ data class ChatItem (
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))
@@ -1171,7 +1194,7 @@ data class ChatItem (
file = null
)
fun getChatFeatureSample(feature: Feature, enabled: FeatureEnabled): ChatItem {
fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem {
val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled)
return ChatItem(
chatDir = CIDirection.DirectRcv(),
@@ -1181,6 +1204,26 @@ data class ChatItem (
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
)
}
}
@@ -1245,7 +1288,7 @@ sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: AgentErrorType): CIStatus()
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
}
@@ -1277,11 +1320,12 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: Feature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: Feature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val feature: Feature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val feature: Feature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: Feature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when (this) {
is SndMsgContent -> msgContent.text
@@ -1299,9 +1343,10 @@ sealed class CIContent: ItemContent {
is SndConnEventContent -> sndConnEvent.text
is RcvChatFeature -> "${feature.text}: ${enabled.text}"
is SndChatFeature -> "${feature.text}: ${enabled.text}"
is RcvGroupFeature -> "${feature.text}: ${preference.enable.text}"
is SndGroupFeature -> "${feature.text}: ${preference.enable.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)}"
}
}
@@ -1315,8 +1360,8 @@ class CIQuote (
val formattedText: List<FormattedText>? = null
): ItemContent {
override val text: String by lazy {
if (content is MsgContent.MCVoice && content.text.isEmpty())
content.toTextWithDuration(true)
if (content.text == "" && content is MsgContent.MCVoice)
durationText(content.duration)
else
content.text
}
@@ -1403,11 +1448,6 @@ sealed class MsgContent {
}
}
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
val time = durationToString(duration)
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
}
@Serializable
class CIGroupInvitation (
val groupId: Long,
@@ -1641,11 +1681,13 @@ enum class CICallStatus {
Accepted -> generalGetString(R.string.callstatus_accepted)
Negotiated -> generalGetString(R.string.callstatus_connecting)
Progress -> generalGetString(R.string.callstatus_in_progress)
Ended -> String.format(generalGetString(R.string.callstatus_ended), durationToString(sec))
Ended -> String.format(generalGetString(R.string.callstatus_ended), durationText(sec))
Error -> generalGetString(R.string.callstatus_error)
}
}
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
@Serializable
sealed class MsgErrorType() {
@Serializable @SerialName("msgSkipped") class MsgSkipped(val fromMsgId: Long, val toMsgId: Long): MsgErrorType()

View File

@@ -3,9 +3,11 @@ package chat.simplex.app.model
import android.app.*
import android.content.*
import android.graphics.BitmapFactory
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.net.Uri
import android.util.Log
import android.view.Display
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
@@ -23,9 +25,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
// DO NOT change notification channel settings / names
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val CallNotificationId: Int = -1
private const val ChatIdKey: String = "chatId"
@@ -37,24 +39,29 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
init {
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())
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
// 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 {
ACCEPT_CONTACT_REQUEST
}
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
val attrs = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
Log.d(TAG,"callNotificationChannel sound: $soundUri")
callChannel.setSound(soundUri, attrs)
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
}
@@ -151,24 +158,34 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
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
Log.d(TAG, "notifyCallInvitation $contactId")
val keyguardManager = getKeyguardManager(context)
val image = invitation.contact.image
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, LockScreenCallChannel)
NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSilent(true)
} else {
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)
.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.reject), chatPendingIntent(RejectCallAction, contactId, true))
.setFullScreenIntent(fullScreenPendingIntent, true)
.setSound(soundUri)
}
val text = generalGetString(
@@ -197,8 +214,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
.setLargeIcon(largeIcon)
.setColor(0x88FFFF)
.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)) {
notify(CallNotificationId, ntfBuilder.build())
notify(CallNotificationId, notification)
}
}
@@ -206,33 +226,35 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
manager.cancel(CallNotificationId)
}
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
if (cItem.content.text != "") {
cItem.content.text
} else {
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
}
} else {
return if (md != null) {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
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")
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)
.setAction(intentAction)
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
return if (!broadcast) {
TaskStackBuilder.create(context).run {
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)
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 -> {
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
}

View File

@@ -1,8 +1,6 @@
package chat.simplex.app.model
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.app.Application
import android.content.*
import android.net.Uri
@@ -12,9 +10,9 @@ import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
@@ -30,8 +28,7 @@ import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ConnectViaLinkTab
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationPreviewMode
import chat.simplex.app.views.usersettings.NotificationsMode
import chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@@ -42,18 +39,6 @@ import java.util.Date
typealias ChatCtrl = Long
fun isAppOnForeground(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = context.packageName
for (appProcess in appProcesses) {
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
return true
}
}
return false
}
enum class CallOnLockScreen {
DISABLE,
SHOW,
@@ -118,6 +103,7 @@ class AppPreferences(val context: Context) {
},
set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) }
)
val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
@@ -193,7 +179,7 @@ class AppPreferences(val context: Context) {
)
companion object {
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
internal const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode"
@@ -210,6 +196,7 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
@@ -449,9 +436,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): AChatItem? {
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? {
val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
if (r is CR.ChatItemDeleted) return r.toChatItem
if (r is CR.ChatItemDeleted) return r
Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}")
return null
}
@@ -972,7 +959,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
&& r.chatError.agentError.brokerErr is BrokerErrorType.TIMEOUT -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_timeout),
generalGetString(R.string.network_error_desc)
String.format(generalGetString(R.string.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress))
)
true
}
@@ -981,7 +968,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
&& r.chatError.agentError.brokerErr is BrokerErrorType.NETWORK -> {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.connection_error),
generalGetString(R.string.network_error_desc)
String.format(generalGetString(R.string.network_error_desc), serverHostname(r.chatError.agentError.brokerAddress))
)
true
}
@@ -1006,7 +993,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChat(r.connection.id)
}
is CR.ContactConnected -> {
if (!r.contact.viaGroupLink) {
if (r.contact.directContact) {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
@@ -1015,7 +1002,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
is CR.ContactConnecting -> {
if (!r.contact.viaGroupLink) {
if (r.contact.directContact) {
chatModel.updateContact(r.contact)
chatModel.dismissConnReqView(r.contact.activeConn.id)
chatModel.removeChat(r.contact.activeConn.id)
@@ -1060,12 +1047,15 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
val mc = cItem.content.msgContent
if (file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) {
val acceptImages = appPrefs.privacyAcceptImages.get()
if ((mc is MsgContent.MCImage && acceptImages)
|| (mc is MsgContent.MCVoice && ((file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && acceptImages) || cInfo is ChatInfo.Group))) {
withApi { receiveFile(file.fileId) } // TODO check inlineFileMode != IFMSent
}
}
if (cItem.showNotification && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
@@ -1083,14 +1073,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(r.chatItem)
is CR.ChatItemDeleted -> {
val cInfo = r.toChatItem.chatInfo
val cItem = r.toChatItem.chatItem
if (cItem.meta.itemDeleted) {
val cInfo = r.deletedChatItem.chatInfo
val cItem = r.deletedChatItem.chatItem
AudioPlayer.stop(cItem)
val isLastChatItem = chatModel.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
ntfManager.cancelNotificationsForChat(cInfo.id)
ntfManager.notifyMessageReceived(
cInfo.id,
cInfo.displayName,
generalGetString(if (r.toChatItem != null) R.string.marked_deleted_description else R.string.deleted_description)
)
}
if (r.toChatItem == null) {
chatModel.removeChatItem(cInfo, cItem)
} else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
AudioPlayer.stop(cItem)
chatModel.upsertChatItem(cInfo, cItem)
chatModel.upsertChatItem(cInfo, r.toChatItem.chatItem)
}
}
is CR.ReceivedGroupInvitation -> {
@@ -1481,7 +1479,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
class SharedPreference<T>(val get: () -> T, val set: (T) -> Unit)
class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
val set: (T) -> Unit
private val _state: MutableState<T> by lazy { mutableStateOf(get()) }
val state: State<T> by lazy { _state }
init {
this.set = { value ->
set(value)
_state.value = value
}
}
}
// ChatCommand
sealed class CC {
@@ -1962,7 +1971,6 @@ data class FullChatPreferences(
val fullDelete: ChatPreference,
val voice: ChatPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice)
companion object {
@@ -1975,7 +1983,6 @@ data class ChatPreferences(
val fullDelete: ChatPreference? = null,
val voice: ChatPreference? = null,
) {
companion object {
val sampleData = ChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES))
}
@@ -1991,6 +1998,11 @@ data class ContactUserPreferences(
val fullDelete: ContactUserPreference,
val voice: ContactUserPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
fullDelete = fullDelete.userPreference.pref,
voice = voice.userPreference.pref
)
companion object {
val sampleData = ContactUserPreferences(
fullDelete = ContactUserPreference(
@@ -2044,16 +2056,30 @@ data class FeatureEnabled(
@Serializable
sealed class ContactUserPref {
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() // contact override is set
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() // global user default is used
abstract val pref: ChatPreference
// contact override is set
@Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() {
override val pref get() = preference
}
// global user default is used
@Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() {
override val pref get() = preference
}
}
interface Feature {
// val icon: ImageVector
val text: String
val iconFilled: ImageVector
}
@Serializable
enum class Feature {
enum class ChatFeature: Feature {
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
val text: String
override val text: String
get() = when(this) {
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
@@ -2065,7 +2091,7 @@ enum class Feature {
Voice -> Icons.Outlined.KeyboardVoice
}
val iconFilled: ImageVector
override val iconFilled: ImageVector
get() = when(this) {
FullDelete -> Icons.Filled.DeleteForever
Voice -> Icons.Filled.KeyboardVoice
@@ -2100,31 +2126,67 @@ enum class Feature {
else -> generalGetString(R.string.voice_prohibited_in_this_chat)
}
}
}
fun enableGroupPrefDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
if (canEdit) {
when(this) {
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
@Serializable
enum class GroupFeature: Feature {
@SerialName("directMessages") DirectMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("voice") Voice;
override val text: String
get() = when(this) {
DirectMessages -> generalGetString(R.string.direct_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Voice -> generalGetString(R.string.voice_messages)
}
val icon: ImageVector
get() = when(this) {
DirectMessages -> Icons.Outlined.SwapHorizontalCircle
FullDelete -> Icons.Outlined.DeleteForever
Voice -> Icons.Outlined.KeyboardVoice
}
override val iconFilled: ImageVector
get() = when(this) {
DirectMessages -> Icons.Filled.SwapHorizontalCircle
FullDelete -> Icons.Filled.DeleteForever
Voice -> Icons.Filled.KeyboardVoice
}
fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean): String =
if (canEdit) {
when(this) {
DirectMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_direct_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_direct_messages)
}
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
}
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
} else {
when(this) {
DirectMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_dms)
GroupFeatureEnabled.OFF -> generalGetString(R.string.direct_messages_are_prohibited_in_chat)
}
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
}
}
}
} else {
when(this) {
FullDelete -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
}
}
}
}
@Serializable
@@ -2154,6 +2216,7 @@ sealed class ContactFeatureAllowed {
}
}
@Serializable
data class ContactFeaturesAllowed(
val fullDelete: ContactFeatureAllowed,
val voice: ContactFeatureAllowed
@@ -2212,31 +2275,35 @@ enum class FeatureAllowed {
@Serializable
data class FullGroupPreferences(
val directMessages: GroupPreference,
val fullDelete: GroupPreference,
val voice: GroupPreference
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(fullDelete = fullDelete, voice = voice)
GroupPreferences(directMessages = directMessages, fullDelete = fullDelete, voice = voice)
companion object {
val sampleData = FullGroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON))
val sampleData = FullGroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
}
}
@Serializable
data class GroupPreferences(
val directMessages: GroupPreference?,
val fullDelete: GroupPreference?,
val voice: GroupPreference?
) {
companion object {
val sampleData = GroupPreferences(fullDelete = GroupPreference(enable = GroupFeatureEnabled.OFF), voice = GroupPreference(enable = GroupFeatureEnabled.ON))
val sampleData = GroupPreferences(directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON))
}
}
@Serializable
data class GroupPreference(
val enable: GroupFeatureEnabled
)
) {
val on: Boolean get() = enable == GroupFeatureEnabled.ON
}
@Serializable
enum class GroupFeatureEnabled {
@@ -2258,6 +2325,7 @@ val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Serializable
@@ -2330,7 +2398,7 @@ sealed class CR {
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
@Serializable @SerialName("contactsList") class ContactsList(val contacts: List<Contact>): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR()
@@ -2524,7 +2592,7 @@ sealed class CR {
is NewChatItem -> json.encodeToString(chatItem)
is ChatItemStatusUpdated -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}"
is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser"
is ContactsList -> json.encodeToString(contacts)
is GroupCreated -> json.encodeToString(groupInfo)
is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member"
@@ -2698,7 +2766,7 @@ sealed class AgentErrorType {
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.app.views.call
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
@@ -43,8 +44,7 @@ class IncomingCallActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activity = this
setContent { IncomingCallActivityView(vm.chatModel, activity) }
setContent { IncomingCallActivityView(vm.chatModel) }
unlockForIncomingCall()
}
@@ -83,11 +83,12 @@ fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
@Composable
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
fun IncomingCallActivityView(m: ChatModel) {
val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value
val call = m.activeCall.value
val showCallView = m.showCallView.value
val activity = LocalContext.current as Activity
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "IncomingCallActivityView: finishing activity")
@@ -105,36 +106,41 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
if (invitation != null) IncomingCallAlertView(invitation, m)
}
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m, activity)
IncomingCallLockScreenAlert(invitation, m)
}
}
}
}
@Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
val context = LocalContext.current
DisposableEffect(Unit) {
onDispose {
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
chatModel.controller.ntfManager.cancelCallNotification()
}
}
IncomingCallLockScreenAlertLayout(
invitation,
callOnLockScreen,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = { chatModel.activeCallInvitation.value = null },
ignoreCall = {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
SoundPlayer.shared.stop()
var intent = Intent(activity, MainActivity::class.java)
val intent = Intent(context, MainActivity::class.java)
.setAction(OpenChatAction)
.putExtra("chatId", invitation.contact.id)
activity.startActivity(intent)
activity.finish()
context.startActivity(intent)
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()
}
)
}

View File

@@ -33,7 +33,10 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
IncomingCallAlertLayout(
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) }
)
}

View File

@@ -63,8 +63,11 @@ fun ChatInfoView(
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.shared.showModal(true) {
ContactPreferencesView(chatModel, chatModel.currentUser.value ?: return@showModal, contact.contactId)
ModalManager.shared.showCustomModal { close ->
val user = chatModel.currentUser.value
if (user != null) {
ContactPreferencesView(chatModel, user, contact.contactId, close)
}
}
},
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },

View File

@@ -166,13 +166,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
deleteMessage = { itemId, mode ->
withApi {
val cInfo = chat.chatInfo
val toItem = chatModel.controller.apiDeleteChatItem(
val r = chatModel.controller.apiDeleteChatItem(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = itemId,
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 ->
@@ -203,14 +210,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
withBGApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
@@ -465,7 +472,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
ScrollToBottom(chat.id, listState)
ScrollToBottom(chat.id, listState, chatItems)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
@@ -503,7 +510,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -598,7 +605,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
@Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
@@ -610,6 +617,19 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false to chatId
}
/*
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
* When the first visible item (from bottom) is fully visible we can autoscroll to 0 item
* */
LaunchedEffect(Unit) {
snapshotFlow { chatItems.lastOrNull()?.id }
.distinctUntilChanged()
.filter { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 }
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
listState.animateScrollToItem(0)
}
}
}
@Composable

View File

@@ -150,7 +150,7 @@ fun ComposeView(
val textStyle = remember { mutableStateOf(smallFont) }
// attachments
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>>(
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
)
@@ -167,7 +167,7 @@ fun ComposeView(
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
} else {
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
@@ -239,7 +239,7 @@ fun ComposeView(
AttachmentOption.TakePhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
@@ -457,15 +457,15 @@ fun ComposeView(
fun allowVoiceToContact() {
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
val featuresAllowed = contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
val prefs = contact.mergedPreferences.toPreferences().copy(voice = ChatPreference(allow = FeatureAllowed.YES))
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed).copy(voice = ChatPreference(FeatureAllowed.YES))
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),
@@ -494,7 +494,7 @@ fun ComposeView(
fun cancelVoice() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenContent.value = emptyList()
chosenAudio.value = null
}
fun cancelFile() {
@@ -575,13 +575,7 @@ fun ComposeView(
.clip(CircleShape)
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) {
when (chat.chatInfo) {
is ChatInfo.Direct -> chat.chatInfo.contact.mergedPreferences.voice.enabled.forUser
is ChatInfo.Group -> chat.chatInfo.groupInfo.fullGroupPreferences.voice.enable == GroupFeatureEnabled.ON
else -> false
}
}
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) {
@@ -591,6 +585,12 @@ fun ComposeView(
else -> false
}
}
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.durationText
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.*
@@ -39,9 +40,7 @@ fun ComposeVoiceView(
.distinctUntilChanged()
.collect {
val startTime = when {
audioPlaying.value -> progress.value
finishedRecording && progress.value == duration.value -> progress.value
finishedRecording -> 0
finishedRecording -> progress.value
else -> recordedDurationMs
}
val endTime = when {
@@ -71,7 +70,7 @@ fun ComposeVoiceView(
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration)
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
@@ -87,10 +86,16 @@ fun ComposeVoiceView(
)
}
val numberInText = remember(recordedDurationMs, progress.value) {
derivedStateOf { if (audioPlaying.value) progress.value / 1000 else recordedDurationMs / 1000 }
derivedStateOf {
when {
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
finishedRecording -> progress.value / 1000
else -> recordedDurationMs / 1000
}
}
}
Text(
durationToString(numberInText.value),
durationText(numberInText.value),
fontSize = 18.sp,
color = HighOrLowlight,
)

View File

@@ -11,6 +11,7 @@ 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
@@ -25,33 +26,45 @@ 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 remember(ct) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
var currentFeaturesAllowed by remember(ct) { mutableStateOf(featuresAllowed) }
ContactPreferencesLayout(
featuresAllowed,
currentFeaturesAllowed,
user,
ct,
applyPrefs = { prefs ->
featuresAllowed = prefs
},
reset = {
featuresAllowed = currentFeaturesAllowed
},
savePrefs = {
withApi {
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
if (toContact != null) {
m.updateContact(toContact)
currentFeaturesAllowed = featuresAllowed
}
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
@@ -72,13 +85,13 @@ private fun ContactPreferencesLayout(
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.contact_preferences))
// val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
// FeatureSection(Feature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
// applyPrefs(featuresAllowed.copy(fullDelete = it))
// }
// SectionSpacer()
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(Feature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
}
SectionSpacer()
@@ -92,7 +105,7 @@ private fun ContactPreferencesLayout(
@Composable
private fun FeatureSection(
feature: Feature,
feature: ChatFeature,
userDefault: FeatureAllowed,
pref: ContactUserPreference,
allowFeature: State<ContactFeatureAllowed>,
@@ -139,3 +152,13 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
}
}
}
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,
)
}

View File

@@ -113,7 +113,6 @@ fun SendMsgView(
}
val startStopRecording: () -> Unit = {
when {
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
needToAllowVoiceToContact -> {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
@@ -124,6 +123,7 @@ fun SendMsgView(
)
}
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
@@ -237,16 +237,13 @@ private fun NativeKeyboard(
var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) {
when (cs.contextItem) {
is ComposeContextItem.QuotedItem -> {
delay(100)
showKeyboard = true
}
is ComposeContextItem.EditingItem -> {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
}
if (cs.contextItem is ComposeContextItem.QuotedItem) {
delay(100)
showKeyboard = true
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
// Keyboard will not show up if we try to show it too fast
delay(300)
showKeyboard = true
}
}

View File

@@ -31,17 +31,23 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
@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 selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
var allowModifyMembers by remember { mutableStateOf(true) }
BackHandler(onBack = close)
AddGroupMembersLayout(
groupInfo = groupInfo,
creatingGroup = creatingGroup,
contactsToAdd = getContactsToAdd(chatModel),
selectedContacts = selectedContacts,
selectedRole = selectedRole,
allowModifyMembers = allowModifyMembers,
openPreferences = {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(chatModel, groupInfo.id, close)
}
},
inviteMembers = {
allowModifyMembers = false
withApi {
@@ -59,6 +65,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
clearSelection = { selectedContacts.clear() },
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
}
@@ -79,14 +86,17 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
creatingGroup: Boolean,
contactsToAdd: List<Contact>,
selectedContacts: List<Long>,
selectedRole: MutableState<GroupMemberRole>,
allowModifyMembers: Boolean,
openPreferences: () -> Unit,
inviteMembers: () -> Unit,
clearSelection: () -> Unit,
addContact: (Long) -> Unit,
removeContact: (Long) -> Unit,
close: () -> Unit,
) {
Column(
Modifier
@@ -120,18 +130,28 @@ fun AddGroupMembersLayout(
}
} else {
SectionView {
if (creatingGroup) {
SectionItemView(openPreferences) {
Text(stringResource(R.string.set_group_preferences))
}
SectionDivider()
}
SectionItemView {
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
}
SectionDivider()
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
if (creatingGroup && selectedContacts.isEmpty()) {
SkipInvitingButton(close)
} else {
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
}
}
SectionCustomFooter {
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
}
SectionSpacer()
SectionView {
SectionView(stringResource(R.string.select_contacts)) {
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
}
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
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
Row(
@@ -288,14 +319,17 @@ fun PreviewAddGroupMembersLayout() {
SimpleXTheme {
AddGroupMembersLayout(
groupInfo = GroupInfo.sampleData,
creatingGroup = false,
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
selectedContacts = remember { mutableStateListOf() },
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
allowModifyMembers = true,
openPreferences = {},
inviteMembers = {},
clearSelection = {},
addContact = {},
removeContact = {}
removeContact = {},
close = {},
)
}
}

View File

@@ -49,7 +49,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
},
@@ -65,10 +65,11 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
openPreferences = {
ModalManager.shared.showModal(true) {
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(
chatModel,
groupInfo
chat.id,
close
)
}
},

View File

@@ -49,20 +49,26 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
openDirectChat = {
getContactChat = { chatModel.getContactChat(it) },
knownDirectChat = {
withApi {
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
if (oldChat != null) {
openChat(oldChat.chatInfo, chatModel)
} else {
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
chatModel.chatItems.clear()
chatModel.chatItems.addAll(it.chatItems)
chatModel.chatId.value = it.chatInfo.id
closeAll()
}
},
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
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.chatItems.clear()
chatModel.chatId.value = newChat.id
closeAll()
}
closeAll()
}
},
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
@@ -114,7 +120,9 @@ fun GroupMemberInfoLayout(
connStats: ConnectionStats?,
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
openDirectChat: () -> Unit,
getContactChat: (Long) -> Chat?,
knownDirectChat: (Chat) -> Unit,
newDirectChat: (Long) -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
@@ -133,10 +141,21 @@ fun GroupMemberInfoLayout(
}
SectionSpacer()
SectionView {
OpenChatButton(openDirectChat)
val contactId = member.memberContactId
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)) {
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
@@ -300,7 +319,9 @@ fun PreviewGroupMemberInfoLayout() {
connStats = null,
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
openDirectChat = {},
getContactChat = { Chat.sampleData },
knownDirectChat = {},
newDirectChat = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},

View File

@@ -11,39 +11,53 @@ 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.HighOrLowlight
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun GroupPreferencesView(m: ChatModel, groupInfo: GroupInfo) {
var preferences by remember { mutableStateOf(groupInfo.fullGroupPreferences) }
var currentPreferences by remember { mutableStateOf(preferences) }
GroupPreferencesLayout(
preferences,
currentPreferences,
groupInfo,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = {
withApi {
val gp = groupInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val gInfo = m.controller.apiUpdateGroup(groupInfo.groupId, gp)
if (gInfo != null) {
m.updateGroup(gInfo)
currentPreferences = preferences
}
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
@@ -60,13 +74,18 @@ private fun GroupPreferencesLayout(
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.group_preferences))
// val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
// FeatureSection(Feature.FullDelete, allowFullDeletion, groupInfo) {
// applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
// }
// SectionSpacer()
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(Feature.Voice, allowVoice, groupInfo) {
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
}
if (groupInfo.canEdit) {
@@ -81,26 +100,32 @@ private fun GroupPreferencesLayout(
}
@Composable
private fun FeatureSection(feature: Feature, enableFeature: State<GroupFeatureEnabled>, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) {
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 = feature.icon,
icon = icon,
iconTint = iconTint,
onSelected = onSelected
)
}
} else {
InfoRow(
feature.text,
enableFeature.value.text
enableFeature.value.text,
icon = icon,
iconTint = iconTint,
)
}
}
SectionTextFooter(feature.enableGroupPrefDescription(enableFeature.value, groupInfo.canEdit))
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
}
@Composable
@@ -115,3 +140,13 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
}
}
}
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,
)
}

View File

@@ -16,7 +16,6 @@ import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.durationToString
@Composable
fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, duration: Int, acceptCall: (Contact) -> Unit) {
@@ -39,7 +38,7 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
CICallStatus.Ended -> Row {
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
Text(durationToString(duration), color = HighOrLowlight)
Text(durationText(duration), color = HighOrLowlight)
}
CICallStatus.Error -> {}
}

View File

@@ -88,7 +88,7 @@ fun PreviewCIMetaViewSendFailed() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
status = CIStatus.SndError("CMD SYNTAX")
)
)
}

View File

@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -20,12 +19,13 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@Composable
fun CIVoiceView(
providedDurationSec: Int,
@@ -34,11 +34,10 @@ fun CIVoiceView(
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
metaColor: Color,
longClick: () -> Unit,
) {
Row(
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
@@ -55,67 +54,16 @@ fun CIVoiceView(
val pause = {
AudioPlayer.pause(audioPlaying, progress)
}
val time = if (audioPlaying.value) progress.value else duration.value
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
val text = durationToString(time / 1000)
if (hasText) {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Text(
text,
Modifier
.padding(start = 12.dp, end = 5.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
textAlign = TextAlign.Start,
maxLines = 1
)
} else {
if (sent) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
Text(
text,
Modifier
.padding(end = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
}
} 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, metaColor)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
Modifier
.padding(start = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.height(56.dp))
}
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)
@@ -127,6 +75,72 @@ fun CIVoiceView(
}
}
@Composable
private fun VoiceLayout(
file: CIFile,
ci: ChatItem,
text: State<String>,
audioPlaying: State<Boolean>,
progress: State<Int>,
duration: State<Int>,
brokenAudio: Boolean,
sent: Boolean,
hasText: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
) {
when {
hasText -> {
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
DurationText(text, PaddingValues(start = 12.dp))
}
sent -> {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci)
}
}
}
}
else -> {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Spacer(Modifier.height(56.dp))
}
}
}
}
}
@Composable
private fun DurationText(text: State<String>, padding: PaddingValues) {
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
Text(
text.value,
Modifier
.padding(padding)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
@Composable
private fun PlayPauseButton(
audioPlaying: Boolean,
@@ -177,12 +191,12 @@ private fun VoiceMsgIndicator(
pause: () -> Unit,
longClick: () -> Unit
) {
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
if (file != null && file.loaded && progress != null && duration != null) {
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
if (hasText) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
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,
@@ -196,7 +210,8 @@ private fun VoiceMsgIndicator(
} else {
if (file?.fileStatus == CIFileStatus.RcvInvitation
|| file?.fileStatus == CIFileStatus.RcvTransfer
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
|| file?.fileStatus == CIFileStatus.RcvAccepted
) {
Box(
Modifier
.size(56.dp)

View File

@@ -26,6 +26,8 @@ import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
@Composable
fun ChatItemView(
cInfo: ChatInfo,
@@ -46,7 +48,11 @@ fun ChatItemView(
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.fullDeletionAllowed }
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
val onLinkLongClick = { _: String -> showMenu.value = true }
Box(
modifier = Modifier
.padding(bottom = 4.dp)
@@ -59,7 +65,7 @@ fun ChatItemView(
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
}
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 -> {}
}
@@ -69,26 +75,36 @@ fun ChatItemView(
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
} else {
val onLinkLongClick = { _: String -> showMenu.value = true }
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
generalGetString(R.string.delete_message_mark_deleted_warning)
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
if (!cItem.meta.itemDeleted) {
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
@@ -121,15 +137,59 @@ fun ChatItemView(
showMenu.value = false
})
}
if (cItem.meta.itemDeleted && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
Icons.Outlined.VisibilityOff,
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
stringResource(R.string.reveal_verb),
Icons.Outlined.Visibility,
onClick = {
revealed.value = true
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
}
)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted && !revealed.value) {
MarkedDeletedItemView(cItem, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else if (cItem.quotedItem == null && !cItem.meta.itemDeleted) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
MsgContentItemDropdownMenu()
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, longClick = { onLinkLongClick("") })
MsgContentItemDropdownMenu()
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
}
@@ -140,15 +200,7 @@ fun ChatItemView(
onDismissRequest = { showMenu.value = false },
Modifier.width(220.dp)
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
},
color = Color.Red
)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@@ -172,14 +224,33 @@ fun ChatItemView(
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.feature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.feature, c.preference.enable.iconColor)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
}
}
}
}
@Composable
fun DeleteItemAction(
cItem: ChatItem,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit
) {
ItemAction(
stringResource(R.string.delete_verb),
Icons.Outlined.Delete,
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
},
color = Color.Red
)
}
@Composable
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
DropdownMenuItem(onClick) {
@@ -197,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(
title = generalGetString(R.string.delete_message__question),
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
text = questionText,
buttons = {
Row(
Modifier

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -16,15 +17,15 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastMap
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ChatItemLinkView
import chat.simplex.app.views.helpers.base64ToBitmap
import chat.simplex.app.views.helpers.*
import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
@@ -65,6 +66,33 @@ fun FramedItemView(
}
}
@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),
)
}
}
@Composable
fun ciQuoteView(qi: CIQuote) {
Row(
@@ -115,7 +143,7 @@ fun FramedItemView(
}
}
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier
.clip(RoundedCornerShape(18.dp))
@@ -130,6 +158,7 @@ fun FramedItemView(
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted) { ciDeletedView() }
ci.quotedItem?.let { ciQuoteView(it) }
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
@@ -154,7 +183,7 @@ fun FramedItemView(
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor, longClick = { onLinkLongClick("") })
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, longClick = { onLinkLongClick("") })
if (mc.text != "") {
CIMarkdownText(ci, showMember, linkMode, uriHandler)
}
@@ -175,10 +204,8 @@ fun FramedItemView(
}
}
}
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -1,5 +1,7 @@
package chat.simplex.app.views.chat.item
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -14,6 +16,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.core.text.BidiFormatter
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.detectGesture
@@ -110,7 +113,15 @@ fun MarkdownText (
},
onClick = { 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 ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()

View File

@@ -33,7 +33,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
}
val stopped = chatModel.chatRunning.value == false
val linkMode = chatModel.controller.appPrefs.simplexLinkMode.get()
val linkMode by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)

View File

@@ -83,8 +83,8 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.text,
ci.formattedText,
if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description),
if (!ci.meta.itemDeleted) ci.formattedText else null,
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
linkMode = linkMode,
senderBold = true,

View File

@@ -84,6 +84,7 @@ fun DatabaseView(
chatArchiveTime,
chatLastStart,
chatDbDeleted.value,
m.controller.appPrefs.privacyFullBackup,
appFilesCountAndSize,
chatItemTTL,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
@@ -132,6 +133,7 @@ fun DatabaseLayout(
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
chatDbDeleted: Boolean,
privacyFullBackup: SharedPreference<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
chatItemTTL: MutableState<ChatItemTTL>,
startChat: () -> Unit,
@@ -165,6 +167,8 @@ fun DatabaseLayout(
disabled = operationsDisabled
)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.Backup, stringResource(R.string.full_backup), privacyFullBackup)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
@@ -689,6 +693,7 @@ fun PreviewDatabaseLayout() {
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
chatDbDeleted = false,
privacyFullBackup = SharedPreference({ true }, {}),
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
startChat = {},

View File

@@ -5,9 +5,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Dialog
import chat.simplex.app.R
import chat.simplex.app.TAG
@@ -93,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(
title: String, text: String? = null,
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
@@ -128,4 +164,4 @@ class AlertManager {
companion object {
val shared = AlertManager()
}
}
}

View File

@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
@@ -32,11 +33,9 @@ import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.json
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.PickFromGallery
import chat.simplex.app.views.newchat.ActionButton
import kotlinx.serialization.builtins.*
import kotlinx.serialization.decodeFromString
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.min
@@ -177,6 +176,25 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
try {
launch(null)
} catch (e: ActivityNotFoundException) {
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
// Means, no system camera app (Android 11+ requirement)
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
try {
// Try to open any camera just to capture an image, will not be returned like with previous intent
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
} catch (e: ActivityNotFoundException) {
// No camera apps available at all
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
}
}
}
@Composable
fun GetImageBottomSheet(
imageBitmap: MutableState<Uri?>,
@@ -204,7 +222,7 @@ fun GetImageBottomSheet(
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
hideBottomSheet()
} else {
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) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
cameraLauncher.launchWithFallback()
hideBottomSheet()
}
else -> {

View File

@@ -1,6 +1,8 @@
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
@@ -94,6 +96,17 @@ object AudioPlayer {
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
// In a process of making a call
RecorderNative.stopRecording?.invoke()
stop()
}
super.onPlaybackConfigChanged(configs)
}
}, null)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
@@ -104,12 +117,15 @@ object AudioPlayer {
)
}
// Filepath: String, onProgressUpdate
// onProgressUpdate(null) means stop
private val currentlyPlaying: MutableState<Pair<String, (position: Int?) -> Unit>?> = mutableStateOf(null)
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?) -> Unit): Int? {
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
@@ -138,16 +154,16 @@ object AudioPlayer {
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition)
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)
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
break
}
delay(50)
onProgressUpdate(player.currentPosition)
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
}
/*
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
@@ -155,9 +171,9 @@ object AudioPlayer {
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
* */
if (isActive) {
onProgressUpdate(player.duration)
onProgressUpdate(player.duration, TrackState.PAUSED)
}
onProgressUpdate(null)
onProgressUpdate(null, TrackState.PAUSED)
}
return player.duration
}
@@ -170,7 +186,7 @@ object AudioPlayer {
}
fun stop() {
if (!player.isPlaying) return
if (currentlyPlaying.value == null) return
player.stop()
stopListener()
}
@@ -185,11 +201,21 @@ object AudioPlayer {
}
private fun stopListener() {
val afterCoroutineCancel: CompletionHandler = {
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED)
currentlyPlaying.value = null
}
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
* */
if (progressJob != null) {
progressJob?.invokeOnCompletion(afterCoroutineCancel)
} else {
afterCoroutineCancel(null)
}
progressJob?.cancel()
progressJob = null
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke(null)
currentlyPlaying.value = null
}
fun play(
@@ -197,21 +223,21 @@ object AudioPlayer {
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnStop: Boolean = false
resetOnEnd: Boolean,
) {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro ->
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 (resetOnStop) {
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
} else if (state == TrackState.REPLACED) {
progress.value = 0
} else if (pro == duration.value) {
progress.value = duration.value
}
}
}

View File

@@ -186,9 +186,13 @@ fun SectionSpacer() {
}
@Composable
fun InfoRow(title: String, value: String) {
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
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)
}
}

View File

@@ -29,7 +29,10 @@ import androidx.core.content.FileProvider
import androidx.core.text.HtmlCompat
import chat.simplex.app.*
import chat.simplex.app.model.CIFile
import chat.simplex.app.model.json
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
@@ -40,6 +43,9 @@ fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalSc
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
scope.launch { withContext(Dispatchers.Main, action) }
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(Dispatchers.Default).launch(block = action)
enum class KeyboardState {
Opened, Closed
}
@@ -447,8 +453,6 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
return fileCount to bytes
}
fun durationToString(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
fun Color.darker(factor: Float = 0.1f): Color =
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
@@ -461,3 +465,9 @@ val LongRange.Companion.saver
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) }
)

View File

@@ -47,7 +47,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
setGroupMembers(groupInfo, chatModel)
close.invoke()
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, chatModel, close)
AddGroupMembersView(groupInfo, true, chatModel, close)
}
}
}

View File

@@ -80,8 +80,9 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
fun SharedPreferenceToggle(
text: String,
preference: SharedPreference<Boolean>,
preferenceState: MutableState<Boolean>? = null
) {
preferenceState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.padding(end = 24.dp))
@@ -91,6 +92,7 @@ fun SharedPreferenceToggle(
onCheckedChange = {
preference.set(it)
prefState.value = it
onChange?.invoke(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,

View File

@@ -3,7 +3,9 @@ package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import java.security.KeyStore
@@ -31,7 +33,7 @@ internal class Cryptor {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
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> {

View File

@@ -10,6 +10,7 @@ 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
@@ -19,33 +20,40 @@ import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun PreferencesView(m: ChatModel, user: User) {
var preferences by remember { mutableStateOf(user.fullPreferences) }
var currentPreferences by remember { mutableStateOf(preferences) }
PreferencesLayout(
preferences,
currentPreferences,
applyPrefs = { prefs ->
preferences = prefs
},
reset = {
preferences = currentPreferences
},
savePrefs = {
withApi {
val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences())
val updatedProfile = m.controller.apiUpdateProfile(newProfile)
if (updatedProfile != null) {
val updatedUser = user.copy(
profile = updatedProfile.toLocalProfile(user.profile.profileId),
fullPreferences = preferences
)
currentPreferences = preferences
m.currentUser.value = updatedUser
}
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
@@ -61,13 +69,13 @@ private fun PreferencesLayout(
horizontalAlignment = Alignment.Start,
) {
AppBarTitle(stringResource(R.string.your_preferences))
// val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) }
// FeatureSection(Feature.FullDelete, allowFullDeletion) {
// applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it)))
// }
// SectionSpacer()
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(Feature.Voice, allowVoice) {
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = ChatPreference(allow = it)))
}
SectionSpacer()
@@ -80,7 +88,7 @@ private fun PreferencesLayout(
}
@Composable
private fun FeatureSection(feature: Feature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllowed>, onSelected: (FeatureAllowed) -> Unit) {
SectionView {
SectionItemView {
ExposedDropDownSettingRow(
@@ -107,3 +115,13 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
}
}
}
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,
)
}

View File

@@ -5,13 +5,17 @@ import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import android.view.WindowManager
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
@@ -30,7 +34,17 @@ fun PrivacySettingsView(
SectionView(stringResource(R.string.settings_section_title_device)) {
ChatLockItem(chatModel.performLA, setPerformLA)
SectionDivider()
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen)
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()

View File

@@ -196,5 +196,5 @@ suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCf
server.copy(tested = false) to null
}
fun serverHostname(srv: ServerCfg): String =
parseServerAddress(srv.server)?.hostnames?.firstOrNull() ?: srv.server
fun serverHostname(srv: String): String =
parseServerAddress(srv)?.hostnames?.firstOrNull() ?: srv

View File

@@ -30,12 +30,17 @@ fun SMPServersView(m: ChatModel) {
}
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 ->
servers.isEmpty() ||
servers == m.userSMPServers.value ||
testing.value ||
!servers.all { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
}
} ||
allServersDisabled.value
}
}
@@ -66,10 +71,11 @@ fun SMPServersView(m: ChatModel) {
val scope = rememberCoroutineScope()
SMPServersLayout(
testing.value,
servers,
serversUnchanged.value,
saveDisabled.value,
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),
@@ -149,6 +155,7 @@ private fun SMPServersLayout(
servers: List<ServerCfg>,
serversUnchanged: Boolean,
saveDisabled: Boolean,
allServersDisabled: Boolean,
addServer: () -> Unit,
testServers: () -> Unit,
resetServers: () -> Unit,
@@ -185,8 +192,9 @@ private fun SMPServersLayout(
Text(stringResource(R.string.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else HighOrLowlight)
}
SectionDivider()
SectionItemView(testServers, disabled = testing) {
Text(stringResource(R.string.smp_servers_test_servers), color = if (!testing) MaterialTheme.colors.onBackground else HighOrLowlight)
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) {
@@ -293,7 +301,7 @@ private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpd
// toList() is important. Otherwise, Compose will not redraw the screen after first update
onUpdated(updatedServers.toList())
if (f != null) {
fs[serverHostname(updatedServer)] = f
fs[serverHostname(updatedServer.server)] = f
}
}
}

View File

@@ -116,7 +116,7 @@ fun SettingsLayout(
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
SectionDivider()
ChatPreferencesItem(showSettingsModal)
ChatPreferencesItem(showCustomModal)
}
SectionSpacer()
@@ -239,14 +239,14 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable fun ChatPreferencesItem(showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit))) {
SettingsActionItem(
Icons.Outlined.ToggleOn,
stringResource(R.string.chat_preferences),
click = {
withApi {
showSettingsModal {
PreferencesView(it, it.currentUser.value ?: return@showSettingsModal)
showCustomModal { m, close ->
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
}()
}
}
@@ -381,12 +381,18 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n
}
@Composable
fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: SharedPreference<Boolean>, prefState: MutableState<Boolean>? = null) {
fun SettingsPreferenceItem(
icon: ImageVector,
text: String,
pref: SharedPreference<Boolean>,
prefState: MutableState<Boolean>? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
SharedPreferenceToggle(text, pref, prefState)
SharedPreferenceToggle(text, pref, prefState, onChange)
}
}
}

View File

@@ -21,6 +21,7 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">Gelöscht</string>
<string name="marked_deleted_description">als gelöscht markiert</string>
<string name="sending_files_not_yet_supported">Das Senden von Dateien wird noch nicht unterstützt</string>
<string name="receiving_files_not_yet_supported">Der Empfang von Dateien wird noch nicht unterstützt</string>
<string name="sender_you_pronoun">Meine Daten</string>
@@ -60,7 +61,7 @@
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Verbindungszeitüberschreitung</string>
<string name="connection_error">Verbindungsfehler</string>
<string name="network_error_desc">Bitte überprüfen Sie Ihre Netzwerkverbindung 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_adding_members">Fehler beim Hinzufügen von Mitgliedern</string>
<string name="error_joining_group">Fehler beim Beitritt zur Gruppe</string>
@@ -114,7 +115,6 @@
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat Nachrichten</string>
<string name="ntf_channel_calls">SimpleX Chat Anrufe</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat Anrufe (Sperrbildschirm)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Benachrichtigungsdienst</string>
@@ -167,10 +167,13 @@
<string name="save_verb">Speichern</string>
<string name="edit_verb">Bearbeiten</string>
<string name="delete_verb">Löschen</string>
<string name="reveal_verb">Aufdecken</string>
<string name="hide_verb">Verbergen</string>
<string name="allow_verb">Erlauben</string>
<string name="delete_message__question">Die Nachricht löschen?</string>
<string name="delete_message_cannot_be_undone_warning">Nachricht wird gelöscht - dies kann nicht rückgängig gemacht werden!</string>
<string name="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>
<!-- CIMetaView.kt -->
@@ -230,6 +233,7 @@
<!-- 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 -->
@@ -471,9 +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="edit_image">Bild bearbeiten</string>
<string name="delete_image">Bild löschen</string>
<string name="save_and_notify_contact">Speichern (und Kontakt benachrichtigen)</string>
<string name="save_and_notify_contacts">Speichern (und Kontakte benachrichtigen)</string>
<string name="save_and_notify_group_members">Speichern (und Gruppenmitglieder benachrichtigen)</string>
<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 -->
<string name="you_control_your_chat">Sie haben volle Kontrolle über Ihren Chat!</string>
@@ -613,6 +619,7 @@
<string name="auto_accept_images">Bilder automatisch akzeptieren</string>
<string name="transfer_images_faster">Bilder schneller übertragen</string>
<string name="send_link_previews">Link-Vorschau senden</string>
<string name="full_backup">App-Datensicherung</string>
<!-- Settings sections -->
<string name="settings_section_title_you">MEINE DATEN</string>
@@ -826,6 +833,8 @@
<string name="new_member_role">Neue Mitgliedsrolle</string>
<string name="icon_descr_expand_role">Rollenauswahl erweitern</string>
<string name="invite_to_group_button">In Gruppe einladen</string>
<string name="skip_inviting_button">Mitgliedereinladungen überspringen</string>
<string name="select_contacts">Kontakte auswählen</string>
<string name="icon_descr_contact_checked">Kontakt geprüft</string>
<string name="clear_contacts_selection_button">Löschen</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> Kontakt(e) ausgewählt</string>
@@ -946,8 +955,10 @@
<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="full_deletion">Vollständige Löschung</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>
@@ -963,18 +974,22 @@
<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 diesem Chat ist das unwiederbringliche Löschen von Nachrichten untersagt.</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 diesem Chat ist das unwiederbringliche Löschen von Nachrichten verboten.</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 diesem Chat sind Sprachnachrichten untersagt.</string>
<string name="voice_messages_are_prohibited">In dieser Gruppe sind Sprachnachrichten untersagt.</string>
</resources>

View File

@@ -21,6 +21,7 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">удалено</string>
<string name="marked_deleted_description">помечено к удалению</string>
<string name="sending_files_not_yet_supported">отправка файлов не поддерживается</string>
<string name="receiving_files_not_yet_supported">получение файлов не поддерживается</string>
<string name="sender_you_pronoun">вы</string>
@@ -60,7 +61,7 @@
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Превышено время соединения</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_adding_members">Ошибка при добавлении членов группы</string>
<string name="error_joining_group">Ошибка при вступлении в группу</string>
@@ -114,7 +115,6 @@
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat звонки (экран блокировки)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
@@ -167,10 +167,13 @@
<string name="save_verb">Сохранить</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="reveal_verb">Показать</string>
<string name="hide_verb">Спрятать</string>
<string name="allow_verb">Разрешить</string>
<string name="delete_message__question">Удалить сообщение?</string>
<string name="delete_message_cannot_be_undone_warning">Сообщение будет удалено это действие нельзя отменить!</string>
<string name="for_me_only">Только для меня</string>
<string name="delete_message_mark_deleted_warning">Сообщение будет помечено на удаление. Получатель(и) сможет(смогут) посмотреть это сообщение.</string>
<string name="for_me_only">Удалить для меня</string>
<string name="for_everybody">Для всех</string>
<!-- CIMetaView.kt -->
@@ -230,6 +233,7 @@
<!-- 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 -->
@@ -468,9 +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="edit_image">Поменять аватар</string>
<string name="delete_image">Удалить аватар</string>
<string name="save_and_notify_contact">Сохранить (и уведомить контакт)</string>
<string name="save_and_notify_contacts">Сохранить (и послать обновление контактам)</string>
<string name="save_and_notify_group_members">Сохранить (и уведомить членов группы)</string>
<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 -->
<string name="you_control_your_chat">Вы котролируете ваш чат!</string>
@@ -613,6 +619,7 @@
<string name="auto_accept_images">Автоприем изображений</string>
<string name="transfer_images_faster">Передавать изображения быстрее</string>
<string name="send_link_previews">Отправлять картинки ссылок</string>
<string name="full_backup">Резервная копия данных</string>
<!-- Settings sections -->
<string name="settings_section_title_you">ВЫ</string>
@@ -826,6 +833,8 @@
<string name="new_member_role">Роль члена группы</string>
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
<string name="invite_to_group_button">Пригласить в группу</string>
<string name="skip_inviting_button">Не приглашать членов</string>
<string name="select_contacts">Выберите контакты</string>
<string name="icon_descr_contact_checked">Контакт выбран</string>
<string name="clear_contacts_selection_button">Очистить</string>
<string name="num_contacts_selected">Выбрано контактов: <xliff:g id="num_contacts">%1$s</xliff:g></string>
@@ -945,8 +954,10 @@
<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="full_deletion">Полное удаление</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>
@@ -962,18 +973,22 @@
<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="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="message_deletion_prohibited_in_chat">Необратимое удаление сообщений запрещено в этой группе.</string>
<string name="group_members_can_send_voice">Члены группы могут отправлять голосовые сообщения.</string>
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этом чате.</string>
<string name="voice_messages_are_prohibited">Голосовые сообщения запрещены в этой группе.</string>
</resources>

View File

@@ -21,6 +21,7 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">deleted</string>
<string name="marked_deleted_description">marked deleted</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="sender_you_pronoun">you</string>
@@ -60,7 +61,7 @@
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
<string name="connection_error">Connection error</string>
<string name="network_error_desc">Please check your network connection 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_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
@@ -114,7 +115,6 @@
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat messages</string>
<string name="ntf_channel_calls">SimpleX Chat calls</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat calls (lock screen)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Notification service</string>
@@ -167,10 +167,13 @@
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="delete_verb">Delete</string>
<string name="reveal_verb">Reveal</string>
<string name="hide_verb">Hide</string>
<string name="allow_verb">Allow</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="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>
<!-- CIMetaView.kt -->
@@ -230,6 +233,7 @@
<!-- 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 -->
@@ -471,9 +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="edit_image">Edit image</string>
<string name="delete_image">Delete image</string>
<string name="save_and_notify_contact">Save (and notify contact)</string>
<string name="save_and_notify_contacts">Save (and notify contacts)</string>
<string name="save_and_notify_group_members">Save (and notify group members)</string>
<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 -->
<string name="you_control_your_chat">You control your chat!</string>
@@ -613,6 +619,7 @@
<string name="auto_accept_images">Auto-accept images</string>
<string name="transfer_images_faster">Transfer images faster</string>
<string name="send_link_previews">Send link previews</string>
<string name="full_backup">App data backup</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
@@ -826,6 +833,8 @@
<string name="new_member_role">New member role</string>
<string name="icon_descr_expand_role">Expand role selection</string>
<string name="invite_to_group_button">Invite to group</string>
<string name="skip_inviting_button">Skip inviting members</string>
<string name="select_contacts">Select contacts</string>
<string name="icon_descr_contact_checked">Contact checked</string>
<string name="clear_contacts_selection_button">Clear</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
@@ -946,8 +955,10 @@
<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="full_deletion">Full deletion</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>
@@ -968,13 +979,17 @@
<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 chat.</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 chat.</string>
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this group.</string>
</resources>

View File

@@ -76,9 +76,9 @@ struct ContentView: View {
userAuthorized = true
} else {
dismissAllSheets(animated: false) {
chatModel.chatId = nil
justAuthenticate()
}
chatModel.chatId = nil
}
}
@@ -124,14 +124,14 @@ struct ContentView: View {
func notificationAlert() -> Alert {
Alert(
title: Text("Notifications are disabled!"),
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
}
}

View File

@@ -101,7 +101,7 @@ final class ChatModel: ObservableObject {
}
func updateContact(_ contact: Contact) {
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact && !contact.viaGroupLink)
updateChat(.direct(contact: contact), addMissing: contact.directContact)
}
func updateGroup(_ groupInfo: GroupInfo) {
@@ -222,8 +222,10 @@ final class ChatModel: ObservableObject {
withAnimation(.default) {
self.reversedChatItems[i] = cItem
self.reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
// arrives earlier than the response from API, and item remains without tick
if case .sndNew = cItem.meta.itemStatus {
self.reversedChatItems[i].meta = ci.meta
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
}
}
return false
@@ -234,16 +236,19 @@ final class ChatModel: ObservableObject {
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
decreaseUnreadCounter(cInfo)
}
// update previews
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
chat.chatItems = [cItem]
chat.chatItems = [ChatItem.deletedItemDummy()]
}
}
// remove from current chat
if chatId == cInfo.id {
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
if reversedChatItems[i].isRcvNew() == true {
if reversedChatItems[i].isRcvNew {
NtfManager.shared.decNtfBadgeCount()
}
_ = withAnimation {
@@ -340,9 +345,7 @@ final class ChatModel: ObservableObject {
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update preview
if let i = getChatIndex(cInfo.id) {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
}
decreaseUnreadCounter(cInfo)
// update current chat
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
reversedChatItems[j].meta.itemStatus = .rcvRead
@@ -350,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 {
chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount })
}
@@ -416,7 +425,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1
if reversedChatItems[i].isRcvNew() {
if reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1

View File

@@ -261,9 +261,9 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent
throw r
}
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem {
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)
if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem }
if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r
}
@@ -603,16 +603,16 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? {
func networkErrorAlert(_ r: ChatResponse) -> Bool {
let am = AlertManager.shared
switch r {
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
am.showAlertMsg(
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
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
am.showAlertMsg(
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
default:
@@ -917,7 +917,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .contactConnectionDeleted(connection):
m.removeChat(connection.id)
case let .contactConnected(contact, _):
if !contact.viaGroupLink {
if contact.directContact {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
@@ -925,7 +925,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(contact)
}
case let .contactConnecting(contact):
if !contact.viaGroupLink {
if contact.directContact {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
@@ -972,20 +972,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
m.addChatItem(cInfo, cItem)
if case .image = cItem.content.msgContent,
let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
}
} else if case .voice = cItem.content.msgContent, // TODO check inlineFileMode != IFMSent
let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND,
UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) {
Task {
await receiveFile(fileId: file.fileId)
if let file = cItem.file,
let mc = cItem.content.msgContent,
file.fileSize <= MAX_IMAGE_SIZE {
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
if (mc.isImage && acceptImages)
|| (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.showNotification {
@@ -1010,14 +1005,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
case let .chatItemUpdated(aChatItem):
chatItemSimpleUpdate(aChatItem)
case let .chatItemDeleted(_, toChatItem):
let cInfo = toChatItem.chatInfo
let cItem = toChatItem.chatItem
if cItem.meta.itemDeleted {
m.removeChatItem(cInfo, cItem)
case let .chatItemDeleted(deletedChatItem, toChatItem, _):
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = m.upsertChatItem(cInfo, cItem)
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
case let .receivedGroupInvitation(groupInfo, _, _):
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
@@ -1149,7 +1141,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
m.updateContact(contact)
var err: String
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"
default: err = String(describing: chatError)
}

View File

@@ -29,6 +29,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: .fullDelete, iconColor: enabled.iconColor)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
}
}

View File

@@ -149,16 +149,16 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 360))
}

View File

@@ -50,7 +50,7 @@ struct CIMetaView: View {
struct CIMetaView_Previews: PreviewProvider {
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, false, true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample())

View File

@@ -232,13 +232,13 @@ struct CIVoiceView_Previews: PreviewProvider {
playbackTime: TimeInterval(20)
)
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
.environmentObject(ChatModel())
}
.previewLayout(.fixed(width: 360, height: 360))

View File

@@ -60,15 +60,15 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
.environmentObject(ChatModel())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
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))
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."))
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)
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

View File

@@ -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))
}
}

View File

@@ -15,11 +15,42 @@ struct ChatItemView: View {
var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted && !revealed {
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
} else if ci.quotedItem == nil && !ci.meta.itemDeleted {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration)
} else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
} else {
framedItemView()
}
}
private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
}
}
struct ChatItemContentView<Content: View>: View {
var chatInfo: ChatInfo
var chatItem: ChatItem
var showMember: Bool
var msgContentView: () -> Content
var body: some View {
switch chatItem.content {
case .sndMsgContent: contentItemView()
case .rcvMsgContent: contentItemView()
case .sndMsgContent: msgContentView()
case .rcvMsgContent: msgContentView()
case .sndDeleted: deletedItemView()
case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration)
@@ -36,17 +67,7 @@ struct ChatItemView: View {
case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
}
}
@ViewBuilder private func contentItemView() -> some View {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else if chatItem.quotedItem == nil && chatItem.content.text.isEmpty,
case let .voice(_, duration) = chatItem.content.msgContent {
CIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration)
} else {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
}
}
@@ -74,12 +95,67 @@ struct ChatItemView: View {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample())
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 70))
}
}
struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
static var previews: some View {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false))
Group{
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, true, false, false),
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, true, false, false),
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, true, false, false),
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, true, false, false),
content: ciFeatureContent,
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
}
.previewLayout(.fixed(width: 360, height: 70))
}

View File

@@ -34,7 +34,7 @@ struct ChatView: View {
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil
@State private var memberConnectionStats: ConnectionStats?
var body: some View {
let cInfo = chat.chatInfo
return VStack(spacing: 0) {
@@ -48,9 +48,9 @@ struct ChatView: View {
floatingButtons(proxy)
}
}
Spacer(minLength: 0)
ComposeView(
chat: chat,
composeState: $composeState,
@@ -170,16 +170,16 @@ struct ChatView: View {
}
}
}
private func searchToolbar() -> some View {
HStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
.focused($searchFocussed)
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
Button {
searchText = ""
} label: {
@@ -190,7 +190,7 @@ struct ChatView: View {
.foregroundColor(.secondary)
.background(Color(.secondarySystemBackground))
.cornerRadius(10.0)
Button ("Cancel") {
searchText = ""
searchMode = false
@@ -204,37 +204,37 @@ struct ChatView: View {
.padding(.horizontal)
.padding(.vertical, 8)
}
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
let maxWidth =
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
LazyVStack(spacing: 5) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
chatItemView(ci, maxWidth)
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
itemsInView.insert(ci.viewId)
loadChatItems(cInfo, ci, proxy)
if ci.isRcvNew() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
Task {
await apiMarkChatItemRead(cInfo, ci)
NtfManager.shared.decNtfBadgeCount()
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
itemsInView.insert(ci.viewId)
loadChatItems(cInfo, ci, proxy)
if ci.isRcvNew {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) {
Task {
await apiMarkChatItemRead(cInfo, ci)
NtfManager.shared.decNtfBadgeCount()
}
}
}
}
}
}
.onDisappear {
itemsInView.remove(ci.viewId)
}
.onDisappear {
itemsInView.remove(ci.viewId)
}
}
}
}
@@ -258,7 +258,7 @@ struct ChatView: View {
}
.scaleEffect(x: 1, y: -1, anchor: .center)
}
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
return VStack {
@@ -300,7 +300,7 @@ struct ChatView: View {
}
.padding()
}
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
ZStack {
Circle()
@@ -309,7 +309,7 @@ struct ChatView: View {
content()
}
}
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
Button {
CallController.shared.startCall(contact, media)
@@ -317,7 +317,7 @@ struct ChatView: View {
Image(systemName: imageName)
}
}
private func searchButton() -> some View {
Button {
searchMode = true
@@ -327,7 +327,7 @@ struct ChatView: View {
Label("Search", systemImage: "magnifyingglass")
}
}
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
@@ -343,7 +343,7 @@ struct ChatView: View {
Image(systemName: "person.crop.circle.badge.plus")
}
}
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
if loadingItems || firstPage { return }
@@ -371,7 +371,7 @@ struct ChatView: View {
}
}
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
@@ -402,130 +402,208 @@ struct ChatView: View {
Rectangle().fill(.clear)
.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(.leading, 12)
} 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 {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
var menu: [UIAction] = []
if let mc = ci.content.msgContent {
menu.append(contentsOf: [
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
) { _ in
withAnimation {
if composeState.editing {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
private struct ChatItemWithMenu: View {
var chat: Chat
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState
@Binding var showDeleteMessage: Bool
@State private var revealed = false
var body: some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
.uiKitContextMenu(actions: menu())
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
}
if let di = deletingItem, di.meta.editable {
Button(broadcastDeleteButtonText, role: .destructive) {
deleteMessage(.cidmBroadcast)
}
}
},
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
},
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
}
private func menu() -> [UIAction] {
var menu: [UIAction] = []
if let mc = ci.content.msgContent, !ci.meta.itemDeleted || revealed {
if !ci.meta.itemDeleted {
menu.append(replyUIAction())
}
menu.append(shareUIAction())
menu.append(copyUIAction())
if let filePath = getLoadedFilePath(ci.file) {
if case .image = ci.content.msgContent, let image = UIImage(contentsOfFile: filePath) {
menu.append(saveImageAction(image))
} else {
UIPasteboard.general.string = ci.content.text
menu.append(saveFileAction(filePath))
}
}
])
if let filePath = getLoadedFilePath(ci.file) {
if case .image = ci.content.msgContent,
let image = UIImage(contentsOfFile: filePath) {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
)
if ci.meta.editable && !mc.isVoice {
menu.append(editAction())
}
if revealed {
menu.append(hideUIAction())
}
menu.append(deleteUIAction())
} else if ci.meta.itemDeleted {
menu.append(revealUIAction())
menu.append(deleteUIAction())
} else if ci.isDeletedContent {
menu.append(deleteUIAction())
}
return menu
}
private func replyUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
) { _ in
withAnimation {
if composeState.editing {
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
} else {
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
}
}
}
}
private func shareUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
}
}
private func copyUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
if case let .image(text, _) = ci.content.msgContent,
text == "",
let image = getLoadedImage(ci.file) {
UIPasteboard.general.image = image
} else {
menu.append(
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.down")
) { _ in
let fileURL = URL(fileURLWithPath: filePath)
showShareSheet(items: [fileURL])
}
)
UIPasteboard.general.string = ci.content.text
}
}
if ci.meta.editable,
!mc.isVoice {
menu.append(
UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil")
) { _ in
withAnimation {
composeState = ComposeState(editingItem: ci)
}
}
)
}
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)
}
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
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)
}
)
} else if ci.isDeletedContent {
menu.append(
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
)
}
}
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
.uiKitContextMenu(actions: menu)
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
}
if let di = deletingItem, di.meta.editable {
Button("Delete for everyone",role: .destructive) {
deleteMessage(.cidmBroadcast)
}
private func hideUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Hide", comment: "chat item action"),
image: UIImage(systemName: "eye.slash")
) { _ in
withAnimation {
revealed = false
}
}
.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 {
switch (prevItem?.chatDir) {
case .groupSnd: return true
@@ -533,26 +611,26 @@ struct ChatView: View {
default: return false
}
}
private func scrollToBottom(_ proxy: ScrollViewProxy) {
if let ci = chatModel.reversedChatItems.first {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
private func scrollUp(_ proxy: ScrollViewProxy) {
if let ci = chatModel.topItemInView(itemsInView: itemsInView) {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
let toItem = try await apiDeleteChatItem(
let (deletedItem, toItem) = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
@@ -560,7 +638,11 @@ struct ChatView: View {
)
DispatchQueue.main.async {
deletingItem = nil
let _ = chatModel.removeChatItem(chat.chatInfo, toItem)
if let toItem = toItem {
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
} else {
chatModel.removeChatItem(chat.chatInfo, deletedItem)
}
}
}
} catch {

View File

@@ -170,6 +170,10 @@ struct ComposeView: View {
@State private var audioRecorder: AudioRecorder?
@State private var voiceMessageRecordingTime: TimeInterval?
@State private var startingRecording: Bool = false
// for some reason voice message preview playback occasionally
// fails to stop on ComposeVoiceView.playbackMode().onDisappear,
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
var body: some View {
VStack(spacing: 0) {
@@ -242,7 +246,7 @@ struct ComposeView: View {
CameraImageListPicker(images: $chosenImages)
}
}
.appSheet(isPresented: $showImagePicker) {
.sheet(isPresented: $showImagePicker) {
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
showImagePicker = false
if itemsSelected {
@@ -300,7 +304,6 @@ struct ComposeView: View {
}
}
.onDisappear {
audioRecorder?.stop()
if let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
}
@@ -314,6 +317,12 @@ struct ComposeView: View {
startingRecording = false
}
}
.onChange(of: chat.chatInfo.voiceMessageAllowed) { vmAllowed in
if !vmAllowed && composeState.voicePreview,
let fileName = composeState.voiceMessageRecordingFileName {
cancelVoiceMessageRecording(fileName)
}
}
}
@ViewBuilder func previewView() -> some View {
@@ -336,7 +345,8 @@ struct ComposeView: View {
recordingTime: $voiceMessageRecordingTime,
recordingState: $composeState.voiceMessageRecordingState,
cancelVoiceMessage: { cancelVoiceMessageRecording($0) },
cancelEnabled: !composeState.editing
cancelEnabled: !composeState.editing,
stopPlayback: $stopPlayback
)
case let .filePreview(fileName: fileName):
ComposeFileView(
@@ -427,6 +437,7 @@ struct ComposeView: View {
await send(.text(composeState.message), quoted: quoted)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
await send(.voice(text: composeState.message, duration: duration), quoted: quoted, file: recordingFileName)
case .filePreview:
if let fileURL = chosenFile,
@@ -476,10 +487,16 @@ struct ComposeView: View {
if let recStartError = await audioRecorder?.start(fileName: fileName) {
switch recStartError {
case .permission:
AlertManager.shared.showAlertMsg(
title: "No permission to record voice message",
message: "To record voice message please grant permission to use Microphone."
)
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",
@@ -536,6 +553,8 @@ struct ComposeView: View {
}
private func cancelVoiceMessageRecording(_ fileName: String) {
stopPlayback.toggle()
audioRecorder?.stop()
removeFile(fileName)
clearState()
}
@@ -548,9 +567,9 @@ struct ComposeView: View {
cancelledLinks = []
chosenImages = []
chosenFile = nil
audioRecorder?.stop()
audioRecorder = nil
voiceMessageRecordingTime = nil
startingRecording = false
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {

View File

@@ -34,6 +34,7 @@ struct ComposeVoiceView: View {
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?
@@ -54,18 +55,6 @@ struct ComposeVoiceView: View {
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity)
.padding(.top, 8)
.onDisappear {
audioPlayer?.stop()
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
}
private func recordingMode() -> some View {
@@ -123,6 +112,21 @@ struct ComposeVoiceView: View {
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 {
@@ -183,7 +187,8 @@ struct ComposeVoiceView_Previews: PreviewProvider {
recordingTime: Binding.constant(TimeInterval(20)),
recordingState: Binding.constant(VoiceMessageRecordingState.recording),
cancelVoiceMessage: { _ in },
cancelEnabled: true
cancelEnabled: true,
stopPlayback: Binding.constant(false)
)
.environmentObject(ChatModel())
}

View File

@@ -16,10 +16,6 @@ struct ContextItemView: View {
let cancelContextItem: () -> Void
var body: some View {
let bgColor = contextItem.chatDir.sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
HStack {
Image(systemName: contextIcon)
.resizable()
@@ -45,7 +41,7 @@ struct ContextItemView: View {
.padding(12)
.frame(minHeight: 50)
.frame(maxWidth: .infinity)
.background(bgColor)
.background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
}
}

View File

@@ -10,29 +10,45 @@ 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(.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() }
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: Feature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding<ContactFeatureAllowed>) -> some View {
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

View File

@@ -13,8 +13,8 @@ struct AddGroupMembersView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var chat: Chat
var groupInfo: GroupInfo
var showSkip: Bool = false
@State var groupInfo: GroupInfo
var creatingGroup: Bool = false
var showFooterCounter: Bool = true
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
@State private var selectedContacts = Set<Int64>()
@@ -52,6 +52,9 @@ struct AddGroupMembersView: View {
} else {
let count = selectedContacts.count
Section {
if creatingGroup {
groupPreferencesButton($groupInfo, true)
}
rolePicker()
inviteMembersButton()
.disabled(count < 1)
@@ -78,14 +81,10 @@ struct AddGroupMembersView: View {
}
}
if (showSkip) {
if creatingGroup {
v.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if showSkip {
Button ("Skip") {
if let cb = addedMembersCb { cb(selectedContacts) }
}
}
Button ("Skip") { addedMembersCb?(selectedContacts) }
}
}
} else {
@@ -142,6 +141,7 @@ struct AddGroupMembersView: View {
}
}
}
.frame(height: 36)
}
private func contactCheckView(_ contact: Contact) -> some View {

View File

@@ -45,7 +45,7 @@ struct GroupChatInfoView: View {
if groupInfo.canEdit {
editGroupButton()
}
groupPreferencesButton()
groupPreferencesButton($groupInfo)
} header: {
Text("")
} footer: {
@@ -200,20 +200,6 @@ struct GroupChatInfoView: View {
}
}
func groupPreferencesButton() -> some View {
NavigationLink {
GroupPreferencesView(
groupInfo: $groupInfo,
preferences: groupInfo.fullGroupPreferences,
currentPreferences: groupInfo.fullGroupPreferences
)
.navigationBarTitle("Group preferences")
.navigationBarTitleDisplayMode(.large)
} label: {
Label("Group preferences", systemImage: "switch.2")
}
}
func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
@@ -310,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 {
Alert(
title: Text("Can't invite contacts!"),

View File

@@ -43,8 +43,15 @@ struct GroupMemberInfoView: View {
.listRowBackground(Color.clear)
if let contactId = member.memberContactId {
Section {
openDirectChatButton(contactId)
if let chat = chatModel.getContactChat(contactId),
chat.chatInfo.contact?.directContact ?? false {
Section {
knownDirectChatButton(chat)
}
} else if groupInfo.fullGroupPreferences.directMessages.on {
Section {
newDirectChatButton(contactId)
}
}
}
@@ -112,26 +119,30 @@ struct GroupMemberInfoView: View {
}
}
func openDirectChatButton(_ contactId: Int64) -> some View {
func knownDirectChatButton(_ chat: Chat) -> some View {
Button {
var chat = chatModel.getContactChat(contactId)
if chat == nil {
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))")
}
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
if let chat = chat {
} label: {
Label("Send direct message", systemImage: "message")
}
}
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")

View File

@@ -10,32 +10,53 @@ 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(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.voice, $preferences.voice.enable)
if groupInfo.canEdit {
Section {
Button("Reset") { preferences = currentPreferences }
Button("Save (and notify group members)") { savePreferences() }
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: Feature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
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(feature.icon) {
settingsRow(icon, color: color) {
Picker(feature.text, selection: enableFeature) {
ForEach(GroupFeatureEnabled.values) { enable in
Text(enable.text)
@@ -45,12 +66,12 @@ struct GroupPreferencesView: View {
}
}
else {
settingsRow(feature.icon) {
settingsRow(icon, color: color) {
infoRow(feature.text, enableFeature.wrappedValue.text)
}
}
} footer: {
Text(feature.enableGroupPrefDescription(enableFeature.wrappedValue, groupInfo.canEdit))
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.frame(height: 36, alignment: .topLeading)
}
}
@@ -78,7 +99,8 @@ struct GroupPreferencesView_Previews: PreviewProvider {
GroupPreferencesView(
groupInfo: Binding.constant(GroupInfo.sampleData),
preferences: FullGroupPreferences.sampleData,
currentPreferences: FullGroupPreferences.sampleData
currentPreferences: FullGroupPreferences.sampleData,
creatingGroup: false
)
}
}

View File

@@ -82,7 +82,7 @@ struct GroupProfileView: View {
CameraImagePicker(image: $chosenImage)
}
}
.appSheet(isPresented: $showImagePicker) {
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}

View File

@@ -402,10 +402,10 @@ struct ErrorAlert {
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
switch error as? ChatResponse {
case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection and try again.")
case .chatCmdError(.errorAgent(.BROKER(.NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection and try again.")
case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
default:
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
}

View File

@@ -101,8 +101,10 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
if let cItem = cItem {
let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
let itemFormattedText = !cItem.meta.itemDeleted ? cItem.formattedText : nil
ZStack(alignment: .topTrailing) {
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
(itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true))
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
@@ -204,6 +206,10 @@ struct ChatPreviewView_Previews: PreviewProvider {
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, true, false)]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],

View File

@@ -27,7 +27,7 @@ struct AddGroupView: View {
AddGroupMembersView(
chat: chat,
groupInfo: groupInfo,
showSkip: true,
creatingGroup: true,
showFooterCounter: false
) { _ in
dismiss()
@@ -136,7 +136,7 @@ struct AddGroupView: View {
CameraImagePicker(image: $chosenImage)
}
}
.appSheet(isPresented: $showImagePicker) {
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}

View File

@@ -53,7 +53,7 @@ struct SimpleXInfo: View {
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
}
.appSheet(isPresented: $showHowItWorks) {
.sheet(isPresented: $showHowItWorks) {
HowItWorks(onboarding: onboarding)
}
}

View File

@@ -18,7 +18,7 @@ struct PreferencesView: View {
var body: some View {
VStack {
List {
// featureSection(.fullDelete, $preferences.fullDelete.allow)
featureSection(.fullDelete, $preferences.fullDelete.allow)
featureSection(.voice, $preferences.voice.allow)
Section {
@@ -30,7 +30,7 @@ struct PreferencesView: View {
}
}
private func featureSection(_ feature: Feature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
Section {
settingsRow(feature.icon) {
Picker(feature.text, selection: allowFeature) {

View File

@@ -28,20 +28,10 @@ struct SMPServerView: View {
ProgressView().scaleEffect(2)
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
server = serverToEdit
dismiss()
} label: {
HStack {
Image(systemName: "chevron.left")
Text("Your SMP servers")
}
}
}
}
.modifier(BackButton(label: "Your SMP servers") {
server = serverToEdit
dismiss()
})
.alert(isPresented: $showTestFailure) {
Alert(
title: Text("Server test failed!"),
@@ -121,6 +111,26 @@ struct SMPServerView: View {
}
}
struct BackButton: ViewModifier {
var label: LocalizedStringKey = "Back"
var action: () -> Void
func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: action) {
HStack {
Image(systemName: "chevron.left")
Text(label)
}
}
}
}
}
}
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
switch server.tested {
case .some(true):
@@ -155,8 +165,8 @@ func testServerConnection(server: Binding<ServerCfg>) async -> SMPTestFailure? {
}
}
func serverHostname(_ srv: ServerCfg) -> String {
parseServerAddress(srv.server)?.hostnames.first ?? srv.server
func serverHostname(_ srv: String) -> String {
parseServerAddress(srv)?.hostnames.first ?? srv
}
struct SMPServerView_Previews: PreviewProvider {

View File

@@ -63,7 +63,7 @@ struct SMPServersView: View {
Button("Reset") { servers = m.userSMPServers ?? [] }
.disabled(servers == m.userSMPServers || testing)
Button("Test servers", action: testServers)
.disabled(testing)
.disabled(testing || allServersDisabled)
Button("Save servers", action: saveSMPServers)
.disabled(saveDisabled)
howToButton()
@@ -79,7 +79,7 @@ struct SMPServersView: View {
Button("Add preset servers", action: addAllPresets)
.disabled(hasAllPresets())
}
.appSheet(isPresented: $showScanSMPServer) {
.sheet(isPresented: $showScanSMPServer) {
ScanSMPServer(servers: $servers)
}
.alert(item: $alert) { a in
@@ -101,12 +101,20 @@ struct SMPServersView: View {
}
private var saveDisabled: Bool {
servers.count == 0 || servers == m.userSMPServers || testing || !servers.allSatisfy { srv in
servers.isEmpty ||
servers == m.userSMPServers ||
testing ||
!servers.allSatisfy { srv in
if let address = parseServerAddress(srv.server) {
return uniqueAddress(srv, address)
}
return false
}
} ||
allServersDisabled
}
private var allServersDisabled: Bool {
servers.allSatisfy { !$0.enabled }
}
private func smpServerView(_ server: Binding<ServerCfg>) -> some View {
@@ -214,7 +222,7 @@ struct SMPServersView: View {
for i in 0..<servers.count {
if servers[i].enabled {
if let f = await testServerConnection(server: $servers[i]) {
fs[serverHostname(servers[i])] = f
fs[serverHostname(servers[i].server)] = f
}
}
}

View File

@@ -16,7 +16,7 @@ struct SettingsButton: View {
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
.appSheet(isPresented: $showSettings, content: {
.sheet(isPresented: $showSettings, content: {
SettingsView(showSettings: $showSettings)
})
}

View File

@@ -100,7 +100,7 @@ struct UserProfile: View {
CameraImagePicker(image: $chosenImage)
}
}
.appSheet(isPresented: $showImagePicker) {
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}

View File

@@ -303,6 +303,11 @@
<target>Erlauben Sie das unwiederbringliche löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sending direct messages to members." xml:space="preserve">
<source>Allow sending direct messages to members.</source>
<target>Erlauben Sie das Senden von Direktnachrichten an Mitglieder</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
<source>Allow to irreversibly delete sent messages.</source>
<target>Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.</target>
@@ -383,6 +388,11 @@
<target>Automatisch</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Back" xml:space="preserve">
<source>Back</source>
<target>Zurück</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
<source>Both you and your contact can irreversibly delete sent messages.</source>
<target>Sowohl Ihr Kontakt, als auch Sie können gesendete Nachrichten unwiederbringlich löschen.</target>
@@ -879,7 +889,7 @@
<trans-unit id="Delete for everyone" xml:space="preserve">
<source>Delete for everyone</source>
<target>Für Alle löschen</target>
<note>No comment provided by engineer.</note>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Delete for me" xml:space="preserve">
<source>Delete for me</source>
@@ -981,6 +991,16 @@
<target>Die Geräteauthentifizierung ist deaktiviert. Sie können die SimpleX Sperre über die Einstellungen aktivieren, sobald Sie die Geräteauthentifizierung aktiviert haben.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Direct messages" xml:space="preserve">
<source>Direct messages</source>
<target>Direkte Nachrichten</target>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
<source>Direct messages between members are prohibited in this group.</source>
<target>In dieser Gruppe sind Direktnachrichten zwischen Mitgliedern nicht möglich.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
<source>Disable SimpleX Lock</source>
<target>SimpleX Sperre deaktivieren</target>
@@ -1291,6 +1311,11 @@
<target>Fehler: Keine Datenbankdatei</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Exit without saving" xml:space="preserve">
<source>Exit without saving</source>
<target>Beenden ohne Speichern</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Export database" xml:space="preserve">
<source>Export database</source>
<target>Datenbank exportieren</target>
@@ -1331,11 +1356,6 @@
<target>Für Konsole</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Full deletion" xml:space="preserve">
<source>Full deletion</source>
<target>Vollständige Löschung</target>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Full link" xml:space="preserve">
<source>Full link</source>
<target>Vollständiger Link</target>
@@ -1391,6 +1411,11 @@
<target>Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send direct messages." xml:space="preserve">
<source>Group members can send direct messages.</source>
<target>Gruppenmitglieder können Direktnachrichten versenden.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send voice messages." xml:space="preserve">
<source>Group members can send voice messages.</source>
<target>Gruppenmitglieder können Sprachnachrichten senden.</target>
@@ -1436,6 +1461,11 @@
<target>Verborgen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Hide" xml:space="preserve">
<source>Hide</source>
<target>Verbergen</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="How SimpleX works" xml:space="preserve">
<source>How SimpleX works</source>
<target>Wie SimpleX funktioniert</target>
@@ -1603,6 +1633,11 @@
<target>In diesem Chat ist das unwiederbringliche Löschen von Nachrichten untersagt.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
<source>Irreversible message deletion is prohibited in this group.</source>
<target>In dieser Gruppe ist das unwiederbringliche Löschen von Nachrichten verboten.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.</target>
@@ -1703,6 +1738,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Viele Menschen haben gefragt: *Wie kann SimpleX Nachrichten zustellen, wenn es keine Benutzerkennungen gibt?*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark deleted for everyone" xml:space="preserve">
<source>Mark deleted for everyone</source>
<target>Für Alle als gelöscht markieren</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark read" xml:space="preserve">
<source>Mark read</source>
<target>Als gelesen markieren</target>
@@ -2018,9 +2058,9 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Überprüfen Sie bitte, ob Sie den richtigen Link genutzt haben oder bitten Sie Ihren Kontakt nochmal darum, Ihnen einen Link zuzusenden.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please check your network connection and try again." xml:space="preserve">
<source>Please check your network connection and try again.</source>
<target>Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.</target>
<trans-unit id="Please check your network connection with %@ and try again." xml:space="preserve">
<source>Please check your network connection with %@ and try again.</source>
<target>Bitte überprüfen Sie Ihre Netzwerkverbindung mit %@ und versuchen Sie es erneut.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please check yours and your contact preferences." xml:space="preserve">
@@ -2088,6 +2128,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Unwiederbringliches Löschen von Nachrichten verbieten.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending direct messages to members." xml:space="preserve">
<source>Prohibit sending direct messages to members.</source>
<target>Verbieten Sie das Senden von Direktnachrichten an Mitglieder</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
<source>Prohibit sending voice messages.</source>
<target>Senden von Sprachnachrichten untersagen.</target>
@@ -2238,6 +2283,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Fehler bei der Wiederherstellung der Datenbank</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reveal" xml:space="preserve">
<source>Reveal</source>
<target>Aufdecken</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Revert" xml:space="preserve">
<source>Revert</source>
<target>Zurückkehren</target>
@@ -2258,19 +2308,19 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Speichern</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Save (and notify contact)" xml:space="preserve">
<source>Save (and notify contact)</source>
<target>Speichern (und Kontakt benachrichtigen)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source>
<target>Speichern (und Kontakte benachrichtigen)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify group members)" xml:space="preserve">
<source>Save (and notify group members)</source>
<target>Speichern (und Gruppenmitglieder benachrichtigen)</target>
<trans-unit id="Save and notify contact" xml:space="preserve">
<source>Save and notify contact</source>
<target>Speichern und Kontakt benachrichtigen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save and notify group members" xml:space="preserve">
<source>Save and notify group members</source>
<target>Speichern und Gruppenmitglieder benachrichtigen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save archive" xml:space="preserve">
@@ -2293,6 +2343,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Passwort im Schlüsselbund speichern</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save preferences?" xml:space="preserve">
<source>Save preferences?</source>
<target>Präferenzen speichern?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save servers" xml:space="preserve">
<source>Save servers</source>
<target>Server speichern</target>
@@ -2388,6 +2443,11 @@ Wir werden Serverredundanzen hinzufügen, um verloren gegangene Nachrichten zu v
<target>Kontaktname festlegen…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set group preferences" xml:space="preserve">
<source>Set group preferences</source>
<target>Gruppenpräferenzen einstellen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set passphrase to export" xml:space="preserve">
<source>Set passphrase to export</source>
<target>Passwort für den Export festlegen</target>
@@ -2897,6 +2957,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s
<target>In diesem Chat sind Sprachnachrichten untersagt.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
<source>Voice messages are prohibited in this group.</source>
<target>In dieser Gruppe sind Sprachnachrichten untersagt.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
<source>Voice messages prohibited!</source>
<target>Sprachnachrichten sind untersagt!</target>
@@ -3556,6 +3621,11 @@ SimpleX-Server können Ihr Profil nicht einsehen.</target>
<target>hat die Gruppe verlassen</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="marked deleted" xml:space="preserve">
<source>marked deleted</source>
<target>als gelöscht markiert</target>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="member" xml:space="preserve">
<source>member</source>
<target>Mitglied</target>

View File

@@ -303,6 +303,11 @@
<target>Allow irreversible message deletion only if your contact allows it to you.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sending direct messages to members." xml:space="preserve">
<source>Allow sending direct messages to members.</source>
<target>Allow sending direct messages to members.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
<source>Allow to irreversibly delete sent messages.</source>
<target>Allow to irreversibly delete sent messages.</target>
@@ -383,6 +388,11 @@
<target>Automatically</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Back" xml:space="preserve">
<source>Back</source>
<target>Back</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
<source>Both you and your contact can irreversibly delete sent messages.</source>
<target>Both you and your contact can irreversibly delete sent messages.</target>
@@ -879,7 +889,7 @@
<trans-unit id="Delete for everyone" xml:space="preserve">
<source>Delete for everyone</source>
<target>Delete for everyone</target>
<note>No comment provided by engineer.</note>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Delete for me" xml:space="preserve">
<source>Delete for me</source>
@@ -981,6 +991,16 @@
<target>Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Direct messages" xml:space="preserve">
<source>Direct messages</source>
<target>Direct messages</target>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
<source>Direct messages between members are prohibited in this group.</source>
<target>Direct messages between members are prohibited in this group.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
<source>Disable SimpleX Lock</source>
<target>Disable SimpleX Lock</target>
@@ -1291,6 +1311,11 @@
<target>Error: no database file</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Exit without saving" xml:space="preserve">
<source>Exit without saving</source>
<target>Exit without saving</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Export database" xml:space="preserve">
<source>Export database</source>
<target>Export database</target>
@@ -1331,11 +1356,6 @@
<target>For console</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Full deletion" xml:space="preserve">
<source>Full deletion</source>
<target>Full deletion</target>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Full link" xml:space="preserve">
<source>Full link</source>
<target>Full link</target>
@@ -1391,6 +1411,11 @@
<target>Group members can irreversibly delete sent messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send direct messages." xml:space="preserve">
<source>Group members can send direct messages.</source>
<target>Group members can send direct messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send voice messages." xml:space="preserve">
<source>Group members can send voice messages.</source>
<target>Group members can send voice messages.</target>
@@ -1436,6 +1461,11 @@
<target>Hidden</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Hide" xml:space="preserve">
<source>Hide</source>
<target>Hide</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="How SimpleX works" xml:space="preserve">
<source>How SimpleX works</source>
<target>How SimpleX works</target>
@@ -1603,6 +1633,11 @@
<target>Irreversible message deletion is prohibited in this chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
<source>Irreversible message deletion is prohibited in this group.</source>
<target>Irreversible message deletion is prohibited in this group.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>It allows having many anonymous connections without any shared data between them in a single chat profile.</target>
@@ -1703,6 +1738,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark deleted for everyone" xml:space="preserve">
<source>Mark deleted for everyone</source>
<target>Mark deleted for everyone</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark read" xml:space="preserve">
<source>Mark read</source>
<target>Mark read</target>
@@ -2018,9 +2058,9 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Please check that you used the correct link or ask your contact to send you another one.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please check your network connection and try again." xml:space="preserve">
<source>Please check your network connection and try again.</source>
<target>Please check your network connection and try again.</target>
<trans-unit id="Please check your network connection with %@ and try again." xml:space="preserve">
<source>Please check your network connection with %@ and try again.</source>
<target>Please check your network connection with %@ and try again.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please check yours and your contact preferences." xml:space="preserve">
@@ -2088,6 +2128,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Prohibit irreversible message deletion.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending direct messages to members." xml:space="preserve">
<source>Prohibit sending direct messages to members.</source>
<target>Prohibit sending direct messages to members.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
<source>Prohibit sending voice messages.</source>
<target>Prohibit sending voice messages.</target>
@@ -2238,6 +2283,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Restore database error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reveal" xml:space="preserve">
<source>Reveal</source>
<target>Reveal</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Revert" xml:space="preserve">
<source>Revert</source>
<target>Revert</target>
@@ -2258,19 +2308,19 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Save</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Save (and notify contact)" xml:space="preserve">
<source>Save (and notify contact)</source>
<target>Save (and notify contact)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source>
<target>Save (and notify contacts)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify group members)" xml:space="preserve">
<source>Save (and notify group members)</source>
<target>Save (and notify group members)</target>
<trans-unit id="Save and notify contact" xml:space="preserve">
<source>Save and notify contact</source>
<target>Save and notify contact</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save and notify group members" xml:space="preserve">
<source>Save and notify group members</source>
<target>Save and notify group members</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save archive" xml:space="preserve">
@@ -2293,6 +2343,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Save passphrase in Keychain</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save preferences?" xml:space="preserve">
<source>Save preferences?</source>
<target>Save preferences?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save servers" xml:space="preserve">
<source>Save servers</source>
<target>Save servers</target>
@@ -2388,6 +2443,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Set contact name…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set group preferences" xml:space="preserve">
<source>Set group preferences</source>
<target>Set group preferences</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set passphrase to export" xml:space="preserve">
<source>Set passphrase to export</source>
<target>Set passphrase to export</target>
@@ -2897,6 +2957,11 @@ To connect, please ask your contact to create another connection link and check
<target>Voice messages are prohibited in this chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
<source>Voice messages are prohibited in this group.</source>
<target>Voice messages are prohibited in this group.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
<source>Voice messages prohibited!</source>
<target>Voice messages prohibited!</target>
@@ -3556,6 +3621,11 @@ SimpleX servers cannot see your profile.</target>
<target>left</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="marked deleted" xml:space="preserve">
<source>marked deleted</source>
<target>marked deleted</target>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="member" xml:space="preserve">
<source>member</source>
<target>member</target>

View File

@@ -303,6 +303,11 @@
<target>Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sending direct messages to members." xml:space="preserve">
<source>Allow sending direct messages to members.</source>
<target>Разрешить посылать прямые сообщения членам группы.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages." xml:space="preserve">
<source>Allow to irreversibly delete sent messages.</source>
<target>Разрешить необратимо удалять отправленные сообщения.</target>
@@ -383,6 +388,11 @@
<target>Автоматически</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Back" xml:space="preserve">
<source>Back</source>
<target>Назад</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can irreversibly delete sent messages." xml:space="preserve">
<source>Both you and your contact can irreversibly delete sent messages.</source>
<target>Вы и ваш контакт можете необратимо удалять отправленные сообщения.</target>
@@ -879,7 +889,7 @@
<trans-unit id="Delete for everyone" xml:space="preserve">
<source>Delete for everyone</source>
<target>Удалить для всех</target>
<note>No comment provided by engineer.</note>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Delete for me" xml:space="preserve">
<source>Delete for me</source>
@@ -981,6 +991,16 @@
<target>Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Direct messages" xml:space="preserve">
<source>Direct messages</source>
<target>Прямые сообщения</target>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
<source>Direct messages between members are prohibited in this group.</source>
<target>Прямые сообщения между членами группы запрещены.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
<source>Disable SimpleX Lock</source>
<target>Отключить блокировку SimpleX</target>
@@ -1291,6 +1311,11 @@
<target>Ошибка: данные чата не найдены</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Exit without saving" xml:space="preserve">
<source>Exit without saving</source>
<target>Выйти без сохранения</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Export database" xml:space="preserve">
<source>Export database</source>
<target>Экспорт архива чата</target>
@@ -1331,11 +1356,6 @@
<target>Для консоли</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Full deletion" xml:space="preserve">
<source>Full deletion</source>
<target>Полное удаление</target>
<note>chat feature</note>
</trans-unit>
<trans-unit id="Full link" xml:space="preserve">
<source>Full link</source>
<target>Полная ссылка</target>
@@ -1391,6 +1411,11 @@
<target>Члены группы могут необратимо удалять отправленные сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send direct messages." xml:space="preserve">
<source>Group members can send direct messages.</source>
<target>Члены группы могут посылать прямые сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Group members can send voice messages." xml:space="preserve">
<source>Group members can send voice messages.</source>
<target>Члены группы могут отправлять голосовые сообщения.</target>
@@ -1436,6 +1461,11 @@
<target>Скрытое</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Hide" xml:space="preserve">
<source>Hide</source>
<target>Спрятать</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="How SimpleX works" xml:space="preserve">
<source>How SimpleX works</source>
<target>Как SimpleX работает</target>
@@ -1603,6 +1633,11 @@
<target>Необратимое удаление сообщений запрещено в этом чате.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
<source>Irreversible message deletion is prohibited in this group.</source>
<target>Необратимое удаление сообщений запрещено в этой группе.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
<source>It allows having many anonymous connections without any shared data between them in a single chat profile.</source>
<target>Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.</target>
@@ -1703,6 +1738,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark deleted for everyone" xml:space="preserve">
<source>Mark deleted for everyone</source>
<target>Пометить как удаленное для всех</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Mark read" xml:space="preserve">
<source>Mark read</source>
<target>Прочитано</target>
@@ -2018,9 +2058,9 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please check your network connection and try again." xml:space="preserve">
<source>Please check your network connection and try again.</source>
<target>Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</target>
<trans-unit id="Please check your network connection with %@ and try again." xml:space="preserve">
<source>Please check your network connection with %@ and try again.</source>
<target>Пожалуйста, проверьте ваше соединение с %@ и попробуйте еще раз.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please check yours and your contact preferences." xml:space="preserve">
@@ -2088,6 +2128,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Запретить необратимое удаление сообщений.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending direct messages to members." xml:space="preserve">
<source>Prohibit sending direct messages to members.</source>
<target>Запретить посылать прямые сообщения членам группы.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Prohibit sending voice messages." xml:space="preserve">
<source>Prohibit sending voice messages.</source>
<target>Запретить отправлять голосовые сообщений.</target>
@@ -2238,6 +2283,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Ошибка при восстановлении базы данных</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reveal" xml:space="preserve">
<source>Reveal</source>
<target>Показать</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Revert" xml:space="preserve">
<source>Revert</source>
<target>Отменить изменения</target>
@@ -2258,19 +2308,19 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Сохранить</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Save (and notify contact)" xml:space="preserve">
<source>Save (and notify contact)</source>
<target>Сохранить (и уведомить контакт)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify contacts)" xml:space="preserve">
<source>Save (and notify contacts)</source>
<target>Сохранить (и уведомить контакты)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save (and notify group members)" xml:space="preserve">
<source>Save (and notify group members)</source>
<target>Сохранить (и уведомить членов группы)</target>
<trans-unit id="Save and notify contact" xml:space="preserve">
<source>Save and notify contact</source>
<target>Сохранить и уведомить контакт</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save and notify group members" xml:space="preserve">
<source>Save and notify group members</source>
<target>Сохранить и уведомить членов группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save archive" xml:space="preserve">
@@ -2293,6 +2343,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Сохранить пароль в Keychain</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save preferences?" xml:space="preserve">
<source>Save preferences?</source>
<target>Сохранить предпочтения?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save servers" xml:space="preserve">
<source>Save servers</source>
<target>Сохранить серверы</target>
@@ -2388,6 +2443,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Имя контакта…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set group preferences" xml:space="preserve">
<source>Set group preferences</source>
<target>Предпочтения группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set passphrase to export" xml:space="preserve">
<source>Set passphrase to export</source>
<target>Установите пароль</target>
@@ -2897,6 +2957,11 @@ To connect, please ask your contact to create another connection link and check
<target>Голосовые сообщения запрещены в этом чате.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
<source>Voice messages are prohibited in this group.</source>
<target>Голосовые сообщения запрещены в этой группе.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
<source>Voice messages prohibited!</source>
<target>Голосовые сообщения запрещены!</target>
@@ -3556,6 +3621,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>покинул(а) группу</target>
<note>rcv group event chat item</note>
</trans-unit>
<trans-unit id="marked deleted" xml:space="preserve">
<source>marked deleted</source>
<target>помечено к удалению</target>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="member" xml:space="preserve">
<source>member</source>
<target>член группы</target>

View File

@@ -74,11 +74,6 @@
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; };
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; };
5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; };
5CA7DFD32933E16C00F7FDDE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA7DFCE2933E16B00F7FDDE /* libffi.a */; };
5CA7DFD42933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA7DFCF2933E16B00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W-ghc8.10.7.a */; };
5CA7DFD52933E16C00F7FDDE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA7DFD02933E16C00F7FDDE /* libgmp.a */; };
5CA7DFD62933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA7DFD12933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W.a */; };
5CA7DFD72933E16C00F7FDDE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA7DFD22933E16C00F7FDDE /* libgmpxx.a */; };
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; };
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; };
5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; };
@@ -141,6 +136,12 @@
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; };
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; };
644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */; };
644EFFF62941BD6900525D5B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF12941BD6800525D5B /* libffi.a */; };
644EFFF72941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF22941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a */; };
644EFFF82941BD6900525D5B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF32941BD6800525D5B /* libgmp.a */; };
644EFFF92941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF42941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a */; };
644EFFFA2941BD6900525D5B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644EFFF52941BD6900525D5B /* libgmpxx.a */; };
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
@@ -284,11 +285,6 @@
5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = "<group>"; };
5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = "<group>"; };
5CA7DFC229302AF000F7FDDE /* AppSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSheet.swift; sourceTree = "<group>"; };
5CA7DFCE2933E16B00F7FDDE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CA7DFCF2933E16B00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W-ghc8.10.7.a"; sourceTree = "<group>"; };
5CA7DFD02933E16C00F7FDDE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CA7DFD12933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W.a"; sourceTree = "<group>"; };
5CA7DFD22933E16C00F7FDDE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CADE79929211BB900072E13 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPreferencesView.swift; sourceTree = "<group>"; };
5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -351,6 +347,12 @@
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = "<group>"; };
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = "<group>"; };
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedDeletedItemView.swift; sourceTree = "<group>"; };
644EFFF12941BD6800525D5B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
644EFFF22941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a"; sourceTree = "<group>"; };
644EFFF32941BD6800525D5B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
644EFFF42941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a"; sourceTree = "<group>"; };
644EFFF52941BD6900525D5B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
@@ -396,13 +398,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
644EFFF72941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a in Frameworks */,
644EFFF92941BD6900525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a in Frameworks */,
644EFFF82941BD6900525D5B /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CA7DFD52933E16C00F7FDDE /* libgmp.a in Frameworks */,
5CA7DFD62933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W.a in Frameworks */,
5CA7DFD42933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W-ghc8.10.7.a in Frameworks */,
5CA7DFD32933E16C00F7FDDE /* libffi.a in Frameworks */,
644EFFF62941BD6900525D5B /* libffi.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5CA7DFD72933E16C00F7FDDE /* libgmpxx.a in Frameworks */,
644EFFFA2941BD6900525D5B /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -458,11 +460,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5CA7DFCE2933E16B00F7FDDE /* libffi.a */,
5CA7DFD02933E16C00F7FDDE /* libgmp.a */,
5CA7DFD22933E16C00F7FDDE /* libgmpxx.a */,
5CA7DFCF2933E16B00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W-ghc8.10.7.a */,
5CA7DFD12933E16C00F7FDDE /* libHSsimplex-chat-4.3.0-KONDmy6IJf0HwVSmQIx39W.a */,
644EFFF12941BD6800525D5B /* libffi.a */,
644EFFF32941BD6800525D5B /* libgmp.a */,
644EFFF52941BD6900525D5B /* libgmpxx.a */,
644EFFF22941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW-ghc8.10.7.a */,
644EFFF42941BD6800525D5B /* libHSsimplex-chat-4.3.1-5rS6G9uR2Qu1YtPYDlH5CW.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -679,6 +681,7 @@
6440C9FF288857A10062C672 /* CIEventView.swift */,
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */,
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */,
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */,
);
path = ChatItem;
sourceTree = "<group>";
@@ -1022,6 +1025,7 @@
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1255,7 +1259,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1276,7 +1280,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 4.3;
MARKETING_VERSION = 4.3.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1297,7 +1301,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1318,7 +1322,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 4.3;
MARKETING_VERSION = 4.3.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1376,7 +1380,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1389,7 +1393,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 4.3;
MARKETING_VERSION = 4.3.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1406,7 +1410,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1419,7 +1423,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 4.3;
MARKETING_VERSION = 4.3.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;

View File

@@ -332,7 +332,7 @@ public enum ChatResponse: Decodable, Error {
case newChatItem(chatItem: AChatItem)
case chatItemStatusUpdated(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool)
case contactsList(contacts: [Contact])
// group events
case groupCreated(groupInfo: GroupInfo)
@@ -538,7 +538,7 @@ public enum ChatResponse: Decodable, Error {
case let .newChatItem(chatItem): return String(describing: chatItem)
case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
case let .chatItemDeleted(deletedChatItem, toChatItem, byUser): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)"
case let .contactsList(contacts): return String(describing: contacts)
case let .groupCreated(groupInfo): return String(describing: groupInfo)
case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)"
@@ -734,7 +734,7 @@ public struct SMPTestFailure: Decodable, Error, Equatable {
switch testError {
case .SMP(.AUTH):
return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error")
case .BROKER(.NETWORK):
case .BROKER(_, .NETWORK):
return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error")
default:
return err
@@ -1081,7 +1081,7 @@ public enum AgentErrorType: Decodable {
case CONN(connErr: ConnectionErrorType)
case SMP(smpErr: ProtocolErrorType)
case NTF(ntfErr: ProtocolErrorType)
case BROKER(brokerErr: BrokerErrorType)
case BROKER(brokerAddress: String, brokerErr: BrokerErrorType)
case AGENT(agentErr: SMPAgentError)
case INTERNAL(internalErr: String)
}

View File

@@ -54,7 +54,7 @@ public struct Profile: Codable, NamedChat {
(fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))"
}
static let sampleData = Profile(
public static let sampleData = Profile(
displayName: "alice",
fullName: "Alice"
)
@@ -245,17 +245,21 @@ public enum ContactUserPref: Decodable {
}
}
public enum Feature: String, Decodable {
public protocol Feature {
var iconFilled: String { get }
}
public enum ChatFeature: String, Decodable, Feature {
case fullDelete
case voice
public var values: [Feature] { [.fullDelete, .voice] }
public var values: [ChatFeature] { [.fullDelete, .voice] }
public var id: Self { self }
public var text: String {
switch self {
case .fullDelete: return NSLocalizedString("Full deletion", comment: "chat feature")
case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature")
case .voice: return NSLocalizedString("Voice messages", comment: "chat feature")
}
}
@@ -311,10 +315,49 @@ public enum Feature: String, Decodable {
: "Voice messages are prohibited in this chat."
}
}
}
public func enableGroupPrefDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey {
public enum GroupFeature: String, Decodable, Feature {
case fullDelete
case voice
case directMessages
public var values: [GroupFeature] { [.directMessages, .fullDelete, .voice] }
public var id: Self { self }
public var text: String {
switch self {
case .directMessages: return NSLocalizedString("Direct messages", comment: "chat feature")
case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature")
case .voice: return NSLocalizedString("Voice messages", comment: "chat feature")
}
}
public var icon: String {
switch self {
case .directMessages: return "arrow.left.and.right.circle"
case .fullDelete: return "trash.slash"
case .voice: return "mic"
}
}
public var iconFilled: String {
switch self {
case .directMessages: return "arrow.left.and.right.circle.fill"
case .fullDelete: return "trash.slash.fill"
case .voice: return "mic.fill"
}
}
public func enableDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey {
if canEdit {
switch self {
case .directMessages:
switch enabled {
case .on: return "Allow sending direct messages to members."
case .off: return "Prohibit sending direct messages to members."
}
case .fullDelete:
switch enabled {
case .on: return "Allow to irreversibly delete sent messages."
@@ -328,15 +371,20 @@ public enum Feature: String, Decodable {
}
} else {
switch self {
case .directMessages:
switch enabled {
case .on: return "Group members can send direct messages."
case .off: return "Direct messages between members are prohibited in this group."
}
case .fullDelete:
switch enabled {
case .on: return "Group members can irreversibly delete sent messages."
case .off: return "Irreversible message deletion is prohibited in this chat."
case .off: return "Irreversible message deletion is prohibited in this group."
}
case .voice:
switch enabled {
case .on: return "Group members can send voice messages."
case .off: return "Voice messages are prohibited in this chat."
case .off: return "Voice messages are prohibited in this group."
}
}
}
@@ -443,31 +491,35 @@ public enum FeatureAllowed: String, Codable, Identifiable {
}
public struct FullGroupPreferences: Decodable, Equatable {
public var directMessages: GroupPreference
public var fullDelete: GroupPreference
public var voice: GroupPreference
public init(fullDelete: GroupPreference, voice: GroupPreference) {
public init(directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) {
self.directMessages = directMessages
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = FullGroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
public static let sampleData = FullGroupPreferences(directMessages: GroupPreference(enable: .off), fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
}
public struct GroupPreferences: Codable {
public var directMessages: GroupPreference?
public var fullDelete: GroupPreference?
public var voice: GroupPreference?
public init(fullDelete: GroupPreference?, voice: GroupPreference?) {
public init(directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) {
self.directMessages = directMessages
self.fullDelete = fullDelete
self.voice = voice
}
public static let sampleData = GroupPreferences(fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
public static let sampleData = GroupPreferences(directMessages: GroupPreference(enable: .off), fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on))
}
public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> GroupPreferences {
GroupPreferences(fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
GroupPreferences(directMessages: fullPreferences.directMessages, fullDelete: fullPreferences.fullDelete, voice: fullPreferences.voice)
}
public struct GroupPreference: Codable, Equatable {
@@ -643,7 +695,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
switch self {
case let .direct(contact): return contact.mergedPreferences.voice.enabled.forUser
case let .group(groupInfo): return groupInfo.fullGroupPreferences.voice.on
default: return true
default: return false
}
}
public var fullDeletionAllowed: Bool {
switch self {
case let .direct(contact): return contact.mergedPreferences.fullDelete.enabled.forUser
case let .group(groupInfo): return groupInfo.fullGroupPreferences.fullDelete.on
default: return false
}
}
@@ -742,6 +802,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var profile: LocalProfile
public var activeConn: Connection
public var viaGroup: Int64?
public var contactUsed: Bool
public var chatSettings: ChatSettings
public var userPreferences: Preferences
public var mergedPreferences: ContactUserPreferences
@@ -757,12 +818,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var image: String? { get { profile.image } }
public var localAlias: String { profile.localAlias }
public var isIndirectContact: Bool {
activeConn.connLevel > 0 || viaGroup != nil
}
public var viaGroupLink: Bool {
activeConn.viaGroupLink
public var directContact: Bool {
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
}
public var contactConnIncognito: Bool {
@@ -774,6 +831,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
localDisplayName: "alice",
profile: LocalProfile.sampleData,
activeConn: Connection.sampleData,
contactUsed: true,
chatSettings: ChatSettings.defaults,
userPreferences: Preferences.sampleData,
mergedPreferences: ContactUserPreferences.sampleData,
@@ -1322,7 +1380,7 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public func isRcvNew() -> Bool {
public var isRcvNew: Bool {
if case .rcvNew = meta.itemStatus { return true }
return false
}
@@ -1371,6 +1429,7 @@ public struct ChatItem: Identifiable, Decodable {
case .rcvGroupFeature: return false
case .sndGroupFeature: return showNtfDir
case .rcvChatFeatureRejected: return showNtfDir
case .rcvGroupFeatureRejected: return showNtfDir
}
}
@@ -1399,7 +1458,7 @@ public struct ChatItem: Identifiable, Decodable {
content: .sndMsgContent(msgContent: .text(text)),
quotedItem: quotedItem,
file: file
)
)
}
public static func getVoiceMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "voice.m4a", fileSize: Int64 = 65536, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
@@ -1409,7 +1468,7 @@ public struct ChatItem: Identifiable, Decodable {
content: .rcvMsgContent(msgContent: .voice(text: text, duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
)
)
}
public static func getFileMsgContentSample (id: Int64 = 1, text: String = "", fileName: String = "test.txt", fileSize: Int64 = 100, fileStatus: CIFileStatus = .rcvComplete) -> ChatItem {
@@ -1419,7 +1478,7 @@ public struct ChatItem: Identifiable, Decodable {
content: .rcvMsgContent(msgContent: .file(text)),
quotedItem: nil,
file: CIFile.getSample(fileName: fileName, fileSize: fileSize, fileStatus: fileStatus)
)
)
}
public static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem {
@@ -1429,7 +1488,7 @@ public struct ChatItem: Identifiable, Decodable {
content: .rcvDeleted(deleteMode: .cidmBroadcast),
quotedItem: nil,
file: nil
)
)
}
public static func getIntegrityErrorSample (_ status: CIStatus = .rcvRead, fromMsgId: Int64 = 1, toMsgId: Int64 = 2) -> ChatItem {
@@ -1439,7 +1498,7 @@ public struct ChatItem: Identifiable, Decodable {
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: fromMsgId, toMsgId: toMsgId)),
quotedItem: nil,
file: nil
)
)
}
public static func getGroupInvitationSample (_ status: CIGroupInvitationStatus = .pending) -> ChatItem {
@@ -1449,7 +1508,7 @@ public struct ChatItem: Identifiable, Decodable {
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: status), memberRole: .admin),
quotedItem: nil,
file: nil
)
)
}
public static func getGroupEventSample () -> ChatItem {
@@ -1459,10 +1518,10 @@ public struct ChatItem: Identifiable, Decodable {
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
)
)
}
public static func getChatFeatureSample(_ feature: Feature, _ enabled: FeatureEnabled) -> ChatItem {
public static func getChatFeatureSample(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> ChatItem {
let content = CIContent.rcvChatFeature(feature: feature, enabled: enabled)
return ChatItem(
chatDir: .directRcv,
@@ -1470,7 +1529,27 @@ public struct ChatItem: Identifiable, Decodable {
content: content,
quotedItem: nil,
file: nil
)
)
}
public static func deletedItemDummy() -> ChatItem {
ChatItem(
chatDir: CIDirection.directRcv,
meta: CIMeta(
itemId: -1,
itemTs: .now,
itemText: NSLocalizedString("deleted", comment: "deleted chat item"),
itemStatus: .rcvRead,
createdAt: .now,
updatedAt: .now,
itemDeleted: false,
itemEdited: false,
editable: false
),
content: .rcvDeleted(deleteMode: .cidmBroadcast),
quotedItem: nil,
file: nil
)
}
}
@@ -1534,7 +1613,7 @@ public enum CIStatus: Decodable {
case sndNew
case sndSent
case sndErrorAuth
case sndError(agentError: AgentErrorType)
case sndError(agentError: String)
case rcvNew
case rcvRead
@@ -1573,11 +1652,12 @@ public enum CIContent: Decodable, ItemContent {
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
case sndConnEvent(sndConnEvent: SndConnEvent)
case rcvChatFeature(feature: Feature, enabled: FeatureEnabled)
case sndChatFeature(feature: Feature, enabled: FeatureEnabled)
case rcvGroupFeature(feature: Feature, preference: GroupPreference)
case sndGroupFeature(feature: Feature, preference: GroupPreference)
case rcvChatFeatureRejected(feature: Feature)
case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled)
case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled)
case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference)
case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference)
case rcvChatFeatureRejected(feature: ChatFeature)
case rcvGroupFeatureRejected(groupFeature: GroupFeature)
public var text: String {
get {
@@ -1600,6 +1680,7 @@ public enum CIContent: Decodable, ItemContent {
case let .rcvGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)"
case let .sndGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)"
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
}
}
}
@@ -1711,6 +1792,13 @@ public enum MsgContent {
}
}
public var isText: Bool {
switch self {
case .text: return true
default: return false
}
}
public var isVoice: Bool {
switch self {
case .voice: return true
@@ -1718,6 +1806,13 @@ public enum MsgContent {
}
}
public var isImage: Bool {
switch self {
case .image: return true
default: return false
}
}
var cmdString: String {
switch self {
case let .text(text): return "text \(text)"

View File

@@ -203,6 +203,9 @@
/* No comment provided by engineer. */
"Allow irreversible message deletion only if your contact allows it to you." = "Erlauben Sie das unwiederbringliche löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt.";
/* No comment provided by engineer. */
"Allow sending direct messages to members." = "Erlauben Sie das Senden von Direktnachrichten an Mitglieder";
/* No comment provided by engineer. */
"Allow to irreversibly delete sent messages." = "Unwiederbringliches Löschen von gesendeten Nachrichten erlauben.";
@@ -257,6 +260,9 @@
/* No comment provided by engineer. */
"Automatically" = "Automatisch";
/* No comment provided by engineer. */
"Back" = "Zurück";
/* integrity error chat item */
"bad message hash" = "Ungültiger Nachrichten-Hash";
@@ -632,7 +638,7 @@
/* No comment provided by engineer. */
"Delete files and media?" = "Dateien und Medien löschen?";
/* No comment provided by engineer. */
/* chat feature */
"Delete for everyone" = "Für Alle löschen";
/* No comment provided by engineer. */
@@ -704,6 +710,12 @@
/* connection level description */
"direct" = "direkt";
/* chat feature */
"Direct messages" = "Direkte Nachrichten";
/* No comment provided by engineer. */
"Direct messages between members are prohibited in this group." = "In dieser Gruppe sind Direktnachrichten zwischen Mitgliedern nicht möglich.";
/* authentication reason */
"Disable SimpleX Lock" = "SimpleX Sperre deaktivieren";
@@ -914,6 +926,9 @@
/* No comment provided by engineer. */
"Error: URL is invalid" = "Fehler: URL ist ungültig";
/* No comment provided by engineer. */
"Exit without saving" = "Beenden ohne Speichern";
/* No comment provided by engineer. */
"Export database" = "Datenbank exportieren";
@@ -938,9 +953,6 @@
/* No comment provided by engineer. */
"For console" = "Für Konsole";
/* chat feature */
"Full deletion" = "Vollständige Löschung";
/* No comment provided by engineer. */
"Full link" = "Vollständiger Link";
@@ -977,6 +989,9 @@
/* No comment provided by engineer. */
"Group members can irreversibly delete sent messages." = "Gruppenmitglieder können gesendete Nachrichten unwiederbringlich löschen.";
/* No comment provided by engineer. */
"Group members can send direct messages." = "Gruppenmitglieder können Direktnachrichten versenden.";
/* No comment provided by engineer. */
"Group members can send voice messages." = "Gruppenmitglieder können Sprachnachrichten senden.";
@@ -1007,6 +1022,9 @@
/* No comment provided by engineer. */
"Hidden" = "Verborgen";
/* chat item action */
"Hide" = "Verbergen";
/* No comment provided by engineer. */
"How it works" = "Wie es funktioniert";
@@ -1139,6 +1157,9 @@
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this chat." = "In diesem Chat ist das unwiederbringliche Löschen von Nachrichten untersagt.";
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this group." = "In dieser Gruppe ist das unwiederbringliche Löschen von Nachrichten verboten.";
/* No comment provided by engineer. */
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Er ermöglicht mehrere anonyme Verbindungen in einem einzigen Chat-Profil ohne Daten zwischen diesen zu teilen.";
@@ -1202,12 +1223,18 @@
/* No comment provided by engineer. */
"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Viele Menschen haben gefragt: *Wie kann SimpleX Nachrichten zustellen, wenn es keine Benutzerkennungen gibt?*";
/* No comment provided by engineer. */
"Mark deleted for everyone" = "Für Alle als gelöscht markieren";
/* No comment provided by engineer. */
"Mark read" = "Als gelesen markieren";
/* No comment provided by engineer. */
"Markdown in messages" = "Markdowns in Nachrichten";
/* marked deleted chat item preview text */
"marked deleted" = "als gelöscht markiert";
/* member role */
"member" = "Mitglied";
@@ -1432,7 +1459,7 @@
"Please check that you used the correct link or ask your contact to send you another one." = "Überprüfen Sie bitte, ob Sie den richtigen Link genutzt haben oder bitten Sie Ihren Kontakt nochmal darum, Ihnen einen Link zuzusenden.";
/* No comment provided by engineer. */
"Please check your network connection and try again." = "Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.";
"Please check your network connection with %@ and try again." = "Bitte überprüfen Sie Ihre Netzwerkverbindung mit %@ und versuchen Sie es erneut.";
/* No comment provided by engineer. */
"Please check yours and your contact preferences." = "Bitte überprüfen sie sowohl Ihre, als auch die Präferenzen Ihres Kontakts.";
@@ -1473,6 +1500,9 @@
/* No comment provided by engineer. */
"Prohibit irreversible message deletion." = "Unwiederbringliches Löschen von Nachrichten verbieten.";
/* No comment provided by engineer. */
"Prohibit sending direct messages to members." = "Verbieten Sie das Senden von Direktnachrichten an Mitglieder";
/* No comment provided by engineer. */
"Prohibit sending voice messages." = "Senden von Sprachnachrichten untersagen.";
@@ -1581,6 +1611,9 @@
/* No comment provided by engineer. */
"Restore database error" = "Fehler bei der Wiederherstellung der Datenbank";
/* chat item action */
"Reveal" = "Aufdecken";
/* No comment provided by engineer. */
"Revert" = "Zurückkehren";
@@ -1590,14 +1623,14 @@
/* chat item action */
"Save" = "Speichern";
/* No comment provided by engineer. */
"Save (and notify contact)" = "Speichern (und Kontakt benachrichtigen)";
/* No comment provided by engineer. */
"Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)";
/* No comment provided by engineer. */
"Save (and notify group members)" = "Speichern (und Gruppenmitglieder benachrichtigen)";
"Save and notify contact" = "Speichern und Kontakt benachrichtigen";
/* No comment provided by engineer. */
"Save and notify group members" = "Speichern und Gruppenmitglieder benachrichtigen";
/* No comment provided by engineer. */
"Save archive" = "Archiv speichern";
@@ -1611,6 +1644,9 @@
/* No comment provided by engineer. */
"Save passphrase in Keychain" = "Passwort im Schlüsselbund speichern";
/* No comment provided by engineer. */
"Save preferences?" = "Präferenzen speichern?";
/* No comment provided by engineer. */
"Save servers" = "Server speichern";
@@ -1674,6 +1710,9 @@
/* No comment provided by engineer. */
"Set contact name…" = "Kontaktname festlegen…";
/* No comment provided by engineer. */
"Set group preferences" = "Gruppenpräferenzen einstellen";
/* No comment provided by engineer. */
"Set passphrase to export" = "Passwort für den Export festlegen";
@@ -2016,6 +2055,9 @@
/* No comment provided by engineer. */
"Voice messages are prohibited in this chat." = "In diesem Chat sind Sprachnachrichten untersagt.";
/* No comment provided by engineer. */
"Voice messages are prohibited in this group." = "In dieser Gruppe sind Sprachnachrichten untersagt.";
/* No comment provided by engineer. */
"Voice messages prohibited!" = "Sprachnachrichten sind untersagt!";

View File

@@ -203,6 +203,9 @@
/* No comment provided by engineer. */
"Allow irreversible message deletion only if your contact allows it to you." = "Разрешить необратимое удаление сообщений, только если ваш контакт разрешает это вам.";
/* No comment provided by engineer. */
"Allow sending direct messages to members." = "Разрешить посылать прямые сообщения членам группы.";
/* No comment provided by engineer. */
"Allow to irreversibly delete sent messages." = "Разрешить необратимо удалять отправленные сообщения.";
@@ -257,6 +260,9 @@
/* No comment provided by engineer. */
"Automatically" = "Автоматически";
/* No comment provided by engineer. */
"Back" = "Назад";
/* integrity error chat item */
"bad message hash" = "ошибка хэш сообщения";
@@ -632,7 +638,7 @@
/* No comment provided by engineer. */
"Delete files and media?" = "Удалить файлы и медиа?";
/* No comment provided by engineer. */
/* chat feature */
"Delete for everyone" = "Удалить для всех";
/* No comment provided by engineer. */
@@ -704,6 +710,12 @@
/* connection level description */
"direct" = "прямое";
/* chat feature */
"Direct messages" = "Прямые сообщения";
/* No comment provided by engineer. */
"Direct messages between members are prohibited in this group." = "Прямые сообщения между членами группы запрещены.";
/* authentication reason */
"Disable SimpleX Lock" = "Отключить блокировку SimpleX";
@@ -914,6 +926,9 @@
/* No comment provided by engineer. */
"Error: URL is invalid" = "Ошибка: неверная ссылка";
/* No comment provided by engineer. */
"Exit without saving" = "Выйти без сохранения";
/* No comment provided by engineer. */
"Export database" = "Экспорт архива чата";
@@ -938,9 +953,6 @@
/* No comment provided by engineer. */
"For console" = "Для консоли";
/* chat feature */
"Full deletion" = "Полное удаление";
/* No comment provided by engineer. */
"Full link" = "Полная ссылка";
@@ -977,6 +989,9 @@
/* No comment provided by engineer. */
"Group members can irreversibly delete sent messages." = "Члены группы могут необратимо удалять отправленные сообщения.";
/* No comment provided by engineer. */
"Group members can send direct messages." = "Члены группы могут посылать прямые сообщения.";
/* No comment provided by engineer. */
"Group members can send voice messages." = "Члены группы могут отправлять голосовые сообщения.";
@@ -1007,6 +1022,9 @@
/* No comment provided by engineer. */
"Hidden" = "Скрытое";
/* chat item action */
"Hide" = "Спрятать";
/* No comment provided by engineer. */
"How it works" = "Как это работает";
@@ -1139,6 +1157,9 @@
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this chat." = "Необратимое удаление сообщений запрещено в этом чате.";
/* No comment provided by engineer. */
"Irreversible message deletion is prohibited in this group." = "Необратимое удаление сообщений запрещено в этой группе.";
/* No comment provided by engineer. */
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
@@ -1202,12 +1223,18 @@
/* No comment provided by engineer. */
"Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*";
/* No comment provided by engineer. */
"Mark deleted for everyone" = "Пометить как удаленное для всех";
/* No comment provided by engineer. */
"Mark read" = "Прочитано";
/* No comment provided by engineer. */
"Markdown in messages" = "Форматирование сообщений";
/* marked deleted chat item preview text */
"marked deleted" = "помечено к удалению";
/* member role */
"member" = "член группы";
@@ -1432,7 +1459,7 @@
"Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку.";
/* No comment provided by engineer. */
"Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.";
"Please check your network connection with %@ and try again." = "Пожалуйста, проверьте ваше соединение с %@ и попробуйте еще раз.";
/* No comment provided by engineer. */
"Please check yours and your contact preferences." = "Проверьте предпочтения вашего контакта.";
@@ -1473,6 +1500,9 @@
/* No comment provided by engineer. */
"Prohibit irreversible message deletion." = "Запретить необратимое удаление сообщений.";
/* No comment provided by engineer. */
"Prohibit sending direct messages to members." = "Запретить посылать прямые сообщения членам группы.";
/* No comment provided by engineer. */
"Prohibit sending voice messages." = "Запретить отправлять голосовые сообщений.";
@@ -1581,6 +1611,9 @@
/* No comment provided by engineer. */
"Restore database error" = "Ошибка при восстановлении базы данных";
/* chat item action */
"Reveal" = "Показать";
/* No comment provided by engineer. */
"Revert" = "Отменить изменения";
@@ -1590,14 +1623,14 @@
/* chat item action */
"Save" = "Сохранить";
/* No comment provided by engineer. */
"Save (and notify contact)" = "Сохранить (и уведомить контакт)";
/* No comment provided by engineer. */
"Save (and notify contacts)" = "Сохранить (и уведомить контакты)";
/* No comment provided by engineer. */
"Save (and notify group members)" = "Сохранить (и уведомить членов группы)";
"Save and notify contact" = "Сохранить и уведомить контакт";
/* No comment provided by engineer. */
"Save and notify group members" = "Сохранить и уведомить членов группы";
/* No comment provided by engineer. */
"Save archive" = "Сохранить архив";
@@ -1611,6 +1644,9 @@
/* No comment provided by engineer. */
"Save passphrase in Keychain" = "Сохранить пароль в Keychain";
/* No comment provided by engineer. */
"Save preferences?" = "Сохранить предпочтения?";
/* No comment provided by engineer. */
"Save servers" = "Сохранить серверы";
@@ -1674,6 +1710,9 @@
/* No comment provided by engineer. */
"Set contact name…" = "Имя контакта…";
/* No comment provided by engineer. */
"Set group preferences" = "Предпочтения группы";
/* No comment provided by engineer. */
"Set passphrase to export" = "Установите пароль";
@@ -2016,6 +2055,9 @@
/* No comment provided by engineer. */
"Voice messages are prohibited in this chat." = "Голосовые сообщения запрещены в этом чате.";
/* No comment provided by engineer. */
"Voice messages are prohibited in this group." = "Голосовые сообщения запрещены в этой группе.";
/* No comment provided by engineer. */
"Voice messages prohibited!" = "Голосовые сообщения запрещены!";

View File

@@ -0,0 +1,139 @@
---
layout: layouts/article.html
title: "SimpleX Chat reviews and v4.3 released with instant voice messages, irreversible deletion of sent messages and improved server configuration."
date: 2022-12-06
image: images/20221206-voice.png
imageBottom: true
previewBody: blog_previews/20221206.html
permalink: "/blog/20221206-simplex-chat-v4.3-voice-messages.html"
---
# SimpleX Chat reviews and v4.3 released with instant voice messages, irreversible deletion of sent messages and improved server configuration.
**Published:** Dec 6, 2022
## SimpleX Chat reviews
Since we published [the security assessment of SimpleX Chat](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) completed by Trail of Bits in November, several sites published the reviews and included it in their recommendations:
- Privacy Guides added SimpleX Chat to [the recommended private and secure messengers](https://www.privacyguides.org/real-time-communication/#simplex-chat).
- Mike Kuketz a well-known security expert published [the review of SimpleX Chat](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) and added it to [the messenger matrix](https://www.messenger-matrix.de).
- Supernova published [the review](https://supernova.tilde.team/detailed_reviews.html#simplex) and increased [SimpleX Chat recommendation ratings](https://supernova.tilde.team/messengers.html).
## What's new in v4.3
- [instant voice messages!](#instant-voice-messages)
- [irreversible deletion of sent messages for all recipients](#irreversible-message-deletion)
- [improved SMP server configuration and support for server passwords](#smp-servers-configuration-and-password)
- [privacy and security improvements](#privacy-and-security-improvements):
- protect app screen in recent apps and prevent screenshots
- improved privacy and security of SimpleX invitation links in the app
- optional Android app data backup
- optionally allow direct messages between group members
### Instant voice messages
<img src="./images/20221206-voice.png" width="288">
Voice messages, unlike normal files, are sent instantly, in the existing connection with your contact and without acceptance from the recipient. For this reason we limited the size of voice messages to ~92.5kb (an equivalent of 6 messages), that limits the duration to 30 seconds on iOS and to ~42 seconds on Android (the size is different because of different encoders), with an average sound quality. The voice messages are sent in MP4AAC format that is natively supported both on iOS and on Android, and you can play voice message files outside of SimpleX Chat app.
Users who do not want to receive voice messages can disable them, either globally, for all contacts, or for each contact independently. Please note that the global preference change will only affect the contacts where you shared your main profile (not incognito contacts) and where you didn't change the preference for the particular contact. Groups have a separate policy that allows disabling voice messages for all members (they are allowed by default). The owner can set this policy when creating a group or later, via Group preferences page.
### Irreversible message deletion
<img src="./images/20221206-deleted1.png" width="288"> &nbsp;&nbsp; <img src="./images/20221206-deleted2.png" width="288">
When you receive email, you have full confidence that the sender cannot delete their email from your mailbox after you received it. And it seems correct in the end, this is your device, and nobody should be able to delete any data from it.
Most existing messengers made an opposite decision the senders can irreversibly delete their messages from the recipients' devices after they were delivered, whether recipients agree to that or not. And it seems correct too - this is your message, you should be able to delete it, at least for a limited time; that the message is on the recipient device doesn't change your ownership of this message.
While both these statements appear correct, at least to some people, they simply cannot both be correct at the same time, as they contradict each other - either one or both of them must be wrong. This appears to be a very polarising subject, and [the polls](https://mastodon.social/@simplex/109461879089268041) [I made](https://www.reddit.com/r/SimpleXChat/comments/zdam11/poll_irreversible_message_deletion_by_sender_what/) [yesterday](https://twitter.com/epoberezkin/status/1599797374389727233) [show it](https://www.linkedin.com/feed/update/urn:li:activity:7005564342502842368/) - the votes are split evenly.
You may want to be able to delete your messages even after they are received to protect your privacy and security, and you want the communication product you use to enforce it. But you may also have many reason to disagree to the deletion of messages on your device for several different reasons:
- it may be a business context, and either your organisation policy or a compliance requirement is that every message you receive must be preserved for some time.
- these messages may contain threat or abuse and you want to keep them as a proof.
- you may have paid for the the message (e.g., it can be a consulting report), and you don't want it to suddenly disappear before you had a chance to store it outside of the conversation.
Instead of taking any side in this choice, we decided to allow to change this behaviour either globally or separately for each contact or group. That makes SimpleX Chat unique, being suitable both for the communication contexts where email is traditionally used and in informal or privacy sensitive contexts, that would allow the senders to delete messages irreversibly, provided that the recipients agree to that.
In any case, the senders can never be 100% certain that the message is deleted from the recipient's device - recipient can be running a modified client that does not honour the conversation setting, and there is no way to ascertain which code your contact runs on their device.
If irreversible message deletion is not allowed in the conversation, the senders can still mark their messages as deleted, and it would show "mark deleted" placeholder in the conversation. The recipients can then both reveal the content of the original message and fully delete it on their devices.
### SMP servers configuration and password
<img src="./images/20221206-server1.png" width="288"> &nbsp;&nbsp; <img src="./images/20221206-server2.png" width="288"> &nbsp;&nbsp; <img src="./images/20221206-server3.png" width="288">
When you self-host your own SMP server you may want to make it public so that anybody can use it to receive messages. But many users want to host their private servers, so that only they and their friends can use them to receive the messages.
v4.0 of SMP server and the new version of the apps adds support for server passwords. It is chosen randomly when you initialize the new server, and if you already have a server you can change it. Anybody can still message you, it doesn't require knowing the password, and the links you share do not include it, but to be able to receive the messages you need to know a server address that includes the password. In a way, it is similar to how basic authentication works in HTTP, and how browsers support the URIs with included credentials.
The new server configuration section now allows to test your servers before you start using them, and you can also share your server address via QR code, so that your friends or your team can use them too, without the need to copy paste the addresses.
You can read how to install and configure SMP servers in [this guide](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SERVER.md).
### Privacy and security improvements
#### Protect app screen
<img src="./images/20221206-protect.png" width="330">
It is now enabled by default, but you can disable it via settings.
iOS app only hides the app screen in the recent apps, Android app in addition to that also prevents the screenshots.
This is not the security measure for the senders, and we made it optional, as the recipient could circumvent it anyway this is for you to protect your app screen when you give your phone to somebody.
#### Privacy and security of SimpleX invitation links
Previously, when you sent somebody an invitation link, a contact address or a group link, they would take half a screen in the chat and they could open in the browser in some cases. Also, as these links are quire large, it is not easy to see if the page domain is maliciously replaced, what SMP server the connection would go through or what kind of link it is.
This version instead of showing the full link shows a short description, and it replaces a public web address with an internal URI scheme that the app uses (simplex:/) such links open directly in the app. There is an option to show the full link, if you need it, and even to open it in the browser from the app, but in this case if this link is not using https://simplex.chat website it will show as red to highlight it.
### Optional Android app data backup
The previous version always backed up app data in the way it was configured by the system. Now you can override it from inside the app, preventing the backup even if it's enabled by the system settings. This version requires disabling it manually, we will make it disabled by default in the next release (v4.3.1).
### Direct messages between group members
The new version does not allow them by default, but it can be enabled by group owners in the group settings when the group is created or at any later moment.
## SimpleX platform
Some links to answer the most common questions:
[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers).
[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users).
[Technical details and limitations](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md#privacy-technical-details-and-limitations).
[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions).
Please also see the information on our [new website](https://simplex.chat) - it also answers all these questions.
## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat!
We are prioritizing users privacy and security - it would be impossible without your support.
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
Your donations help us raise more funds any amount, even the price of the cup of coffee, makes a big difference for us.
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in many crypto-currencies.
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- please let us know, via GitHub issue or chat, if you want to make a donation in some other cryptocurrency - we will add the address to the list.
Thank you,
Evgeny
SimpleX Chat founder

View File

@@ -1,5 +1,21 @@
# Blog
Dec 12, 2022 [SimpleX Chat reviews and v4.3 released]
November reviews:
- [Privacy Guides](https://www.privacyguides.org/real-time-communication/#simplex-chat) recommendations.
- [Review by Mike Kuketz](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/).
- [The messenger matrix](https://www.messenger-matrix.de).
- [Supernova review](https://supernova.tilde.team/detailed_reviews.html#simplex) and [messenger ratings](https://supernova.tilde.team/messengers.html).
v4.3 is released:
- instant voice messages!
- irreversible deletion of sent messages for all recipients
- improved SMP server configuration and support for server passwords
- privacy and security improvements: protect app screen, SimpleX links security, etc.
Nov 8, 2022 [Security audit by Trail of Bits, the new website and v4.2 released](./20221108-simplex-chat-v4.2-security-audit-new-website.md)
_"Have you been audited or should we just ignore you?"_

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: c2342cba057fa2333b5936a2254507b5b62e8de2
tag: fb21d9836e07706c7498baa967f932cb11b818e5
source-repository-package
type: git

View File

@@ -190,24 +190,24 @@ Stored messages, connections, statistics and server log are located in `/var/opt
SMP server address has the following format:
```
smp://<fingerprint>[:<password>]@hostname1,hostname2
smp://<fingerprint>[:<password>]@<public_hostname>[,<onion_hostname>]
```
- `<fingerprint>`
Your `smp-server` fingerprint of certificate. You can check your certificate fingerprint in `/etc/opt/simplex/fingerprint`.
- `<password>`
- **optional** `<password>`
Your configured password of `smp-server`. You can check your configured pasword in `/etc/opt/simplex/smp-server.ini`, under `[AUTH]` section in `create_password:` field.
- `@hostname1,hostname2`
- `<public_hostname>`, **optional** `<onion_hostname>`
Your configured hostname(s) of `smp-server`. You can check your configured hosts in `/etc/opt/simplex/smp-server.ini`, under `[TRANSPORT]` section in `host:` field.
### Systemd commands
To enable `smp-server` on server boot, run:
To start `smp-server` on host boot, run:
```sh
sudo systemctl enable smp-server.service
@@ -286,11 +286,11 @@ fromTime,qCreated,qSecured,qDeleted,msgSent,msgRecv,dayMsgQueues,weekMsgQueues,m
- `msgRecv` - int; received messages
- `dayMsgQueues` - int; created queues in a day
- `dayMsgQueues` - int; active queues in a day
- `weekMsgQueues` - int; created queues in a week
- `weekMsgQueues` - int; active queues in a week
- `monthMsgQueues` - int; created queues in a month
- `monthMsgQueues` - int; active queues in a month
To import `csv` to `Grafana` one should:

View File

@@ -0,0 +1,208 @@
# Ephemeral conversations with existing contacts
Ephemeral conversation inside existing conversation with stricter security properties.
- additional level of encryption for message bodies & text, chat items content & text (more?) inside chat db
- ephemeral conversation key not persisted - only stored in-memory of current chat session, cleared on exiting to background
- separate tables for chat items and messages to avoid gaps in ids? TBC db pages on deletion
- don't persist chat items and messages at all? if yes - how to support multiple ephemeral conversations with different contacts in the same session - holding chat items in memory may become expensive. though only "not yet seen" items may have to be held in memory - after opening ephemeral conversation no longer keep them in memory; in this case closing ephemeral conversation screen (not fully exiting but keeping it in list of current ephemeral conversations) and re-opening also does not restore chat items, though connection and key are preserved
- if multiple ephemeral conversations are allowed - how to know they have new messages - should there be notifications for them? only local or push notifications too? not a regular notification but indication in chat list?
- disabled features? e.g. "reply" if messages aren't persisted. voice messages, files, etc.? if files are supported they are deleted exiting, should also be a part of chat start cleanup process (see below)
- contact is required to be verified to start ephemeral conversation - improves guarantee that the key for ephemeral conversation is agreed in a secure context
- no visibility of contact profile in UI
- separated with a blank screen / transition from a main conversation to prevent them appearing in the same screen
- new entity - not a contact?
- new chat type & direction, or additional dimension?
- api to start new and open existing (limited by chat session lifespan) ephemeral conversation
- api to join - also requires verified connection? one party can have contact verified, second not - prohibit until verified?
- join via special chat item? join via same button that is used to start? allow both? chat item and negotiation messages should be automatically deleted on end or on cleanup
- api to end - any side can initiate, both sides client cooperate and delete?
- a new connection is created for the conversation, deleted upon end, incognito mode doesn't affect - no profile is shared at all
- on chat start - deletes ephemeral conversations that were not ended, due to crash or another reason (get synchronously before starting?)
- controller has state of all "active" ephemeral conversations, saved are not loaded - what if one party crashes and not ends, then creates a new ephemeral conversation - previous is ended for another party?
## Design
\***
Track current ephemeral conversations in ChatController.
``` haskell
data ChatController = ChatController {
...
currentECs :: TMap ContactId EphemeralConversation
...
}
data EphemeralConversation = EphemeralConversation
{ chatItemId :: Int64,
ecState :: ECState
}
deriving (Show)
data ECState
= ECInvitationSent { localDhPrivKey :: C.PrivateKeyX25519 }
| ECInvitationReceived { localDhPubKey :: C.PublicKeyX25519 }
| ECAcptSent { sharedKey :: Maybe C.Key }
| ECAcptReceived { sharedKey :: Maybe C.Key }
| ECNegotiated { sharedKey :: Maybe C.Key }
data ECStateTag
= ECSTInvitationSent
| ECSTInvitationReceived
| ECSTAcptSent
| ECSTAcptReceived
| ECSTNegotiated
ecStateTag :: ECState -> ECStateTag
```
\***
Protocol messages:
- `XECInv C.PublicKeyX25519` - invite to ephemeral conversation, other properties except key? ECInvitation type to contain properties?
- on send: add to Controller's `currentECs` in state `ECInvitationSent` and create `CIECInvitation` chat item.
- on receive: `processXECInv` - add to Controller's `currentECs` in state `ECInvitationReceived` and create `CIECInvitation` chat item.
- `XECAcpt C.PublicKeyX25519 ConnReqInvitation` - accept ephemeral conversation, send link to join.
- on send: update Controller's `currentECs` record to state `ECAcptSent`, update chat item.
- on receive: `processXECAcpt` - update Controller's `currentECs` record to state `ECAcptReceived`, update chat item.
- `XECEnd` - message to end ephemeral conversation. Send in main connection or new one? Main may be better as it may signal cancel as well if ephemeral conversation wasn't yet accepted/negotiated.
Race condition if both parties send `XECInv` simultaneously - if `XECInv` is received when there is ephemeral conversation in `currentECs` in state `ECInvitationSent`, just remove it and signal error `CEECNegotiationError`.
\***
APIs:
- `APIStartEC ContactId` - sends `XECInv`, in UI ephemeral chat view is opened, disabled/progress indicator until ephemeral conversation is negotiated.
- `APIJoinEC ContactId` - sends `XECAcpt`, in UI ephemeral chat view is opened, disabled/progress indicator until ephemeral conversation is negotiated.
- `APIOpenEC ContactId` - loads chat items (?) for current ephemeral conversation, opens ephemeral chat view.
- api to reject? or just allow to delete chat item?
- `APIEndEC ContactId` - sends `XECEnd`, deletes connection, ephemeral conversation entity and chat items, removes from `currentECs` state, deletes `CIECInvitation` chat item, in UI chat is closed.
- terminal counterparts
ChatResponses (mirroring chat item updates for terminal):
- `CRECInvitationSent {contact :: Contact}`
- `CRECInvitationReceived {contact :: Contact}`
- `CRECAccepted {contact :: Contact}`
- `CRECAcceptReceived {contact :: Contact}`
- `CRECEnded {contact :: Contact}`
ChatErrors:
- `CEECNegotiationError {contactId :: ContactId}` - both sent `XECInv`, failed to establish connection, etc.
- `CENoCurrentEC` - on trying to open, accept, end.
- `CEECState {currentECState :: ECStateTag}` - on state errors
\***
Chat item content:
``` haskell
data CIContent (d :: MsgDirection) where
...
CIRcvECInvitation ECStateTag -> CIContent 'MDRcv
CISndECInvitation ECStateTag -> CIContent 'MDSnd
...
```
is there a need for more detailed `CIECStatus` or state tag will suffice? (see CICallStatus)
\***
New chat type, direction, chat info:
``` haskell
data ChatType = ... | CTEphemeral ...
-- new ChatType requires processing cases on ChatRef in all APIs based on it, which may be a good thing
-- e.g. automatically requires separate APISendMessage api
data ChatInfo (c :: ChatType) where
...
EphemeralChat :: ChatInfo 'CTEphemeral -- no additional information required?
...
data CIDirection (c :: ChatType) (d :: MsgDirection) where
...
CIEphemeralSnd :: CIDirection 'CTEphemeral 'MDSnd
CIEphemeralRcv :: CIDirection 'CTEphemeral 'MDRcv
...
-- same for `data CIQDirection (c :: ChatType)`
-- same for `data SChatType (c :: ChatType)`
```
Maybe it should be a new dimension and not ChatType, though it may have to be drastically different for groups and easier expressed as a separate `CTEphemeralGroup` ChatType.
Pros of not having it as contact's flag/dimension:
- no special casing in loading chat previews
- no special casing in APIs, instead it's fully fledged ChatType
- separate table for entity - cleaner deletion, no gaps
- can have separate ConnectionEntity, though it may be a con
\***
New ConnectionEntity?
- `RcvDirectEphemeralMsgConnection {entityConnection :: Connection}`
can allow to easily prohibit many protocol messages, e.g. calls, groups, etc.
\***
Database changes:
```sql
CREATE TABLE ephemeral_conversations(
ephemeral_conversation_id INTEGER PRIMARY KEY,
contact_id INTEGER REFERENCES contacts(contact_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT CHECK(updated_at NOT NULL)
);
-- separate ec_messages and ec_chat_items tables?
-- or foreign_keys to ephemeral_conversations in existing messages and chat_items tables?
-- if files allowed: same question
-- don't save chat items and messages at all? see above. if it's separate ConnectionEntity it's not hard
ALTER TABLE connections ADD COLUMN ephemeral_conversation_id INTEGER DEFAULT NULL
REFERENCES ephemeral_conversations (ephemeral_conversation_id) ON DELETE CASCADE;
-- add logic on loading entities, e.g. for subscriptions
```
If tables for chat items and messages are separate - logic for saving encrypted message/item content, text, etc. doesn't affect existing queries and code. If it's a new connection entity type it's separate cases in api/processing anyway.
If tables for chat items and messages are reused - To/FromField don't auto convert using To/FromJSON, instead saved/loaded as string, decrypted based on flag?

View File

@@ -1,23 +1,38 @@
<h1>SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!</h1>
<p><strong>Full privacy of your identity, profile, contacts and metadata</strong>: unlike any other existing messaging platform, SimpleX uses no phone numbers or any other identifiers assigned to the users - not even random numbers. This protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers.</p>
<p><strong>Complete protection against spam and abuse</strong>: as you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Read more.</p>
<p><strong>Full ownership, control and security of your data</strong>: SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received.</p>
<p><strong>Decentralized network</strong>: you can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers.</p>
<p><a href="https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html" target="_blank">Security assessment</a> was done by Trail of Bits in November 2022.</p>
<p>SimpleX Chat features:</p>
<ul>
<li>end-to-end encrypted messages, with editing, replies and deletion of messages.</li>
<li>sending end-to-end encrypted images and files.</li>
<li>single-use and long-term user addresses.</li>
<li>secret chat groups - only group members know it exists and who is the member.</li>
<li>end-to-end encrypted audio and video calls.</li>
<li>private instant notifications.</li>
<li>portable chat profile - you can transfer your chat contacts and history to another device (terminal or mobile).</li>
</ul>
<p>SimpleX Chat advantages:</p>
<ul>
<li><strong>Full privacy of your identity, profile, contacts and metadata</strong>: unlike any other existing messaging platform, SimpleX uses no phone numbers or any other identifiers assigned to the users - not even random numbers. This protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers.</li>
<li><strong>Complete protection against spam and abuse</strong>: as you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address.</li>
<li><strong>Full ownership, control and security of your data</strong>: SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received.</li>
<li><strong>Decentralized network</strong>: you can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers.</li>
</ul>
<p>You can connect to anybody you know via link or scan QR code (in the video call or in person) and start sending messages instantly - no emails, phone numbers or passwords needed.</p>
<p>Your profile and contacts are only stored in the app on your device - our servers do not have access to this information.</p>
<p>All messages are end-to-end encrypted using open-source double-ratchet protocol; the messages are routed via our servers using open-source SimpleX Messaging Protocol.</p>
<p>The app sends local notifications only when messages or connection requests arrive - the app checks for the new messages every 10-15 min, but if you stop using the app it may stop checking for the new messages.</p>
<p>Please send us any questions via the app (connect to the team via settings!), <a href="mailto:chat@simplex.chat?subject=SimpleX Chat on F-Droid">email us</a> or submit an <a href="https://github.com/simplex-chat/simplex-chat/issues" target="_blank">issue on GitHub</a>.</p>
<p>Please send us any questions via the app (connect to the team via settings!), email chat@simplex.chat or submit issues on GitHub (https://github.com/simplex-chat/simplex-chat/issues)</p>
<p>Source code: https://github.com/simplex-chat/simplex-chat</p>
<p>Follow us on Twitter (@SimpleXChat) and Reddit (r/SimpleXChat/) for the latest updates.</p>
<p>Follow us on Mastodon (<a href="https://mastodon.social/@simplex" target="_blank">@simplex@mastodon.social</a>), Twitter (<a href="https://twitter.com/SimpleXChat" target="_blank">@SimpleXChat</a>) and Reddit (<a href="https://www.reddit.com/r/SimpleXChat/" target="_blank">r/SimpleXChat</a>) for the latest updates.</p>
<p>Once you install SimpleX Chat, join the group of users via <a href="https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" target="_blank">this link</a> to share your ideas and feedback.</p>
<p>Source code: <a href="https://github.com/simplex-chat/simplex-chat#readme" target="_blank">https://github.com/simplex-chat/simplex-chat</a></p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 955 KiB

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